Merge commit 'bbe840cd8bb72a4f854137948493784b3c4c2a06' as 'plugins/redmine_hourglass'

This commit is contained in:
2023-03-23 12:49:36 +01:00
208 changed files with 25031 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
ARG RUBY_VERSION=3.1
ARG REDMINE_VERSION=5-stable
FROM alpinelab/ruby-dev:${RUBY_VERSION} AS redmine
ARG REDMINE_VERSION
ENV REDMINE_VERSION=${REDMINE_VERSION}
RUN \
cd / \
&& mv /app /redmine \
&& chmod ugo+w /redmine
WORKDIR /redmine

View File

@@ -0,0 +1,14 @@
group :development do
# gem 'better_errors'
# gem 'binding_of_caller'
# gem 'meta_request' # support RailsPanel in Chrome
#
# gem 'pry'
# gem 'pry-byebug'
# gem 'pry-rails'
# gem "debase", "0.2.5.beta2", require: false
# gem "ruby-debug-ide", "~> 0.7.3"
gem "debug"
gem 'rufo', require: false
end

View File

@@ -0,0 +1,16 @@
development: &postgres
adapter: postgresql
encoding: utf8
database: redmine
username: postgres
password: postgres
host: postgres
port: 5432
test:
<<: *postgres
database: redmine_test
production:
<<: *postgres

View File

@@ -0,0 +1,58 @@
{
"name": "Redmine - Postgres",
"dockerComposeFile": "docker-compose.yml",
"service": "redmine",
"workspaceFolder": "/redmine",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"username": "vscode",
"userUid": "1000",
"userGid": "1000"
},
"ghcr.io/devcontainers/features/ruby:1": "none",
"ghcr.io/devcontainers/features/node:1": "none",
"ghcr.io/devcontainers/features/git:1": {
"version": "latest",
"ppa": "false"
}
},
"customizations": {
"vscode": {
"extensions": [
"rebornix.Ruby",
"mtxr.sqltools",
"mtxr.sqltools-driver-pg",
"craigmaslowski.erb",
"hridoy.rails-snippets",
"misogi.ruby-rubocop",
"jnbt.vscode-rufo",
"donjayamanne.git-extension-pack"
],
"settings": {
"sqltools.connections": [
{
"name": "Rails Development Database",
"driver": "PostgreSQL",
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"database": "redmine",
"username": "postgres"
},
{
"name": "Rails Test Database",
"driver": "PostgreSQL",
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"database": "redmine_test",
"username": "postgres"
}
]
}
}
},
"forwardPorts": [ 5000 ],
"postCreateCommand": "sh -x /redmine/post-create.sh",
"remoteUser": "vscode"
}

View File

@@ -0,0 +1,42 @@
version: '3.7'
services:
redmine:
build:
context: .
target: redmine
args:
RUBY_VERSION: "3.1.3"
REDMINE_VERSION: "5.0-stable"
NODE_VERSION: "lts/*"
volumes:
- redmine-data:/redmine/files
- node_modules:/redmine/node_modules
- bundle:/bundle
- ../..:/redmine/plugins/redmine_hourglass
- ./Gemfile.local:/redmine/Gemfile.local
- ./database.yml:/redmine/config/database.yml
- ./post-create.sh:/redmine/post-create.sh
environment:
RAILS_ENV: development
REDMINE_SECRET_KEY_BASE: supersecretkey
REDMINE_PLUGINS_MIGRATE: 'true'
command: sleep infinity
depends_on:
- postgres
postgres:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: redmine
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
postgres-data: null
redmine-data:
node_modules:
bundle:

View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -e
. ${NVM_DIR}/nvm.sh
nvm install --lts
sudo chown -R vscode:vscode .
sudo chmod ugo+w /bundle
git config --global --add safe.directory /redmine
git init
git remote add origin https://github.com/redmine/redmine.git
git fetch
git checkout -t origin/${REDMINE_VERSION} -f
git apply plugins/redmine_hourglass/.devcontainer/postgres/redmine5_i18n.patch
bundle
bundle exec rails config/initializers/secret_token.rb
bundle exec rails db:create
bundle exec rails db:migrate
bundle exec rails redmine:plugins
# RAILS_ENV=test bundle exec rails db:drop
# RAILS_ENV=test bundle exec rails db:create
# RAILS_ENV=test bundle exec rails db:migrate
# RAILS_ENV=test bundle exec rails redmine:plugins

View File

@@ -0,0 +1,13 @@
diff --git a/lib/redmine/i18n.rb b/lib/redmine/i18n.rb
index 805e3c61c..42b5ab23f 100644
--- a/lib/redmine/i18n.rb
+++ b/lib/redmine/i18n.rb
@@ -125,7 +125,7 @@ module Redmine
if options[:cache] == false
available_locales = ::I18n.backend.available_locales
valid_languages.
- select {|locale| available_locales.include?(locale)}.
+ select {|locale| available_locales.include?(locale) && (ll(locale.to_s, :general_lang_name) rescue false) }.
map {|lang| [ll(lang.to_s, :general_lang_name), lang.to_s]}.
sort_by(&:first)
else

View File

@@ -0,0 +1,10 @@
* text=auto
*.rb text eol=lf
*.sh text eol=lf
*.yml text eol=lf
*.slim text eol=lf
*.coffee text eol=lf
*.scss text eol=lf
*.rake text eol=lf
*.json text eol=lf
Gemfile text eol=lf

View File

@@ -0,0 +1,7 @@
test:
adapter: mysql2
host: 127.0.0.1
port: 3306
database: redmine_test
username: root
password: redmine_hourglass

View File

@@ -0,0 +1,7 @@
test:
adapter: postgresql
host: localhost
port: 5432
database: redmine_test
username: redmine_hourglass
password: redmine_hourglass

View File

@@ -0,0 +1,3 @@
test:
adapter: sqlite3
database: db/redmine_test.sqlite3

View File

@@ -0,0 +1,75 @@
name: Specs
on: [push, pull_request]
jobs:
specs:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
redmine: [ '5.0.4', '4.2.9' ]
ruby: [ '2.7.7', '2.6.8' ]
database: [ 'sqlite3', 'postgresql', 'mysql2' ]
include:
- redmine: '5.0.4'
ruby: '3.1.3'
database: 'postgresql'
- redmine: '5.0.4'
ruby: '3.1.3'
database: 'mysql2'
services:
postgresql:
image: postgres
ports:
- 5432:5432
env:
POSTGRES_DB: redmine_test
POSTGRES_USER: redmine_hourglass
POSTGRES_PASSWORD: redmine_hourglass
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
mysql:
image: mysql:5
ports:
- 3306:3306
env:
MYSQL_DATABASE: redmine_test
MYSQL_ROOT_PASSWORD: redmine_hourglass
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
- name: Install redmine
run: wget https://github.com/redmine/redmine/archive/${{ matrix.redmine }}.tar.gz -qO- | tar -C $GITHUB_WORKSPACE -xz --strip=1 --show-transformed -f -
- uses: actions/checkout@v3
with:
path: 'plugins/redmine_hourglass'
- name: Create database config
run: cp $GITHUB_WORKSPACE/plugins/redmine_hourglass/.github/data/${{ matrix.database }}_database.yml $GITHUB_WORKSPACE/config/database.yml
- name: Install dependencies
run: |
bundle config set --local without 'rmagick'
bundle install --jobs=3 --retry=3
- name: Setup database and plugin
run: |
bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake redmine:load_default_data REDMINE_LANG=en
bundle exec rake generate_secret_token
bundle exec rake redmine:plugins:migrate
env:
RAILS_ENV: test
- name: Run specs
run: bundle exec rake --trace redmine:plugins:hourglass:spec
env:
RAILS_ENV: test

11
plugins/redmine_hourglass/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
*.swp
*.user
*~
~*
.idea/*
Gemfile.lock
tmp/rubycritic/
/.bundle
.ruby-version

View File

@@ -0,0 +1,5 @@
Metrics/LineLength:
Max: 120
Style:
Enabled: false

View File

@@ -0,0 +1,125 @@
# Change Log
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [unreleased] - TBA
### Added
- [please add new features]
### Changed
- [please add noteworthy changes]
### Removed
- [please add dropped features]
### Fixed
- [please add bug fixes]
## [1.3.0] - TBA
### Added
- support for Redmine 5
- support for Ruby >2.6
- Added .devcontainer to ease development
### Changed
- Upgraded Gems to support Rails 6
- Fixed Swagger API documentation for mass update and create
### Fixed
- Asset compilation for Redmine images
## [1.2.0] - 2023-02-21
Non-Beta Release after proving that everything works.
### Changed
- added more time clamping options
- many bugfixes
- fixed styling
- fixed timer stops when brower tab in background
### Known issues
- no support for Redmine 5
## [1.2.0-beta] - 2021-06-27
Upgraded to support Redmine 4.2.1
### Changed
- resolved blockers for current Redmine versions
- dropped support for older Redmine versions
### Fixed
- issue filter for administrators works as expected
- update for issue, project and activity work normal
## [1.1.2] - 2019-04-18
Bugfix release.
### Changed
- enhanced activity display in report by adding a dash
### Fixed
- fixed issue where changes to the project filter in time bookings queries would corrupt activity and user filters
## [1.1.1] - 2019-04-04
Bugfix release.
### Fixed
- creating & updating saved queries for time logs, time bookings and tracker
- position of the edit & delete buttons for saved queries for hourglass views
## [1.1.0] - 2019-03-25
First release with Redmine 4.0 support.
Redmine versions 3.x are still supported, but will be removed in future releases.
### Added
- support for Redmine version 4.0.0 and above
- backend and frontend validation of settings
### Changed
- improved testing of multiple databases
### Fixed
- removal of database specific tests that led to errors
- enhanced datetime parsing
- enhanced filters of time logs
## [1.0.0] - 2019-02-26
This is the first mature release after a long beta testing period.
We added plenty of new features which were long time overdue.
Some of the things were requested several years before and were finally possible.
### Added
- functionality is available as an API, so desktop and mobile clients are possible
- rounding can be configured to only affect sums
- proper support for grouping entries in list views
- project specific plugin settings
- time trackers can now be queried
- direct links from running time tracker to issue and project
- error messages for client side validations
- a qr code on the overview page intended to help integrating the upcoming companion app
- there is now version filter for time trackers
- you can now set up activity default per user, which will be automatically used for time trackers and bookings
### Changed
- rounding is now only for time bookings instead of time logs
- enhanced access rights
- changed data structure massively, if anyone relied on the database tables, please update your code
- we improved the timezone handling cause there was a synchronisation problem between client and server
### Removed
- dropped support for Redmine below version 3.2
- dropped support for Ruby below 2.0.0
- removed the extra report tab, it's now merged in the time bookings tab
- ability to remove time logs with time bookings, you now need to remove the time booking first

View File

@@ -0,0 +1,74 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at conduct@hicknhack-software.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

View File

@@ -0,0 +1,39 @@
# How to contribute to Redmine Hourglass
First off all, thanks for your interest in contributing to Redmine Hourglass.
The following is a set of guidelines for contributing to this project. These are just guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request.
But please notice that all of your interactions in the project are
expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
#### Reporting Issues
Please make sure your issue wasn't already reported or even fixed by searching through our [issues](https://github.com/hicknhack-software/redmine_hourglass/issues).
Otherwise create a new one, but add a proper title and a clear description.
If you report a bug, please include as much information as you can provide, for example:
* Redmine version
* Plugin version
* Other plugins installed
* Gem versions
* Operating system
If you report a feature request, please add some use cases for what that feature could be used, so we can elaborate better about it.
Avoid opening new issues to ask questions in our issues tracker. Please go through
the project wiki, documentation and source code first. If you really can't understand something, maybe we missed documenting it properly. Feel free to add a documentation feature request for that.
#### Pull Requests
We appreciate every bug fix or enhancement because we sometimes don't have the time besides day work to do everything we would like to do with this plugin.
So you can easily speed things up by creating Pull Requests adding that functionality you are craving for.
We also like to have this plugin translated in more languages than the few we are capable of speaking ourselves.
In any ways, please make sure that your Pull Request has a proper title and a clear description what it adds. Also make sure that your addition works in all our supported redmine versions.
Thanks,<br>
HicknHack Software Team

View File

@@ -0,0 +1,19 @@
Hourglass is a Redmine plugin intended to aid in time tracking on issues and projects with reports and management supporting tools.
Authors: Robert Kranz
Copyright (C) 2017 HicknHack Software GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

View File

@@ -0,0 +1,5 @@
FROM redmine:4.1.1
RUN apt-get update && apt-get install -y build-essential libffi-dev
RUN rm /usr/src/redmine/Gemfile.lock.mysql2
RUN touch /usr/src/redmine/Gemfile.lock.mysql2

View File

@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@@ -0,0 +1,45 @@
source 'https://rubygems.org'
# asset pipeline
gem 'uglifier'
gem 'coffee-script', '~> 2.4.1'
gem 'sass', '~> 3.5.1'
gem 'sprockets', '~> 3.7.2', require: 'sprockets/railtie'
# access control
gem 'pundit', '~> 1.1.0'
# this is useful for unix based systems which don't have a js runtime installed
# if you are on windows and this makes problems, simply remove the line
# gem 'therubyracer', :platform => :ruby
# views
gem 'slim', '~> 3.0.8'
gem 'js-routes', '~> 2.2.4'
gem 'momentjs-rails', '>= 2.10.7'
gem 'rswag', '~> 2.5.1' # api docs
gem 'rspec-core'
gem 'rqrcode' unless dependencies.any? { |d| d.name == 'rqrcode' }
group :development, :test do
gem 'rspec-rails', '~> 5.1.2'
gem 'factory_bot_rails'
gem 'zonebie'
gem 'database_cleaner'
gem 'faker'
end
if RUBY_VERSION < "2.1"
group :development, :test do
gem 'rubycritic', '<2.9.0', require: false
end
elsif RUBY_VERSION < "2.3"
group :development, :test do
gem 'rubycritic', '<4.0.0', require: false
end
else
group :development, :test do
gem 'rubycritic', require: false
end
end

View File

@@ -0,0 +1,150 @@
# Redmine Hourglass Plugin
[![Code Climate](https://codeclimate.com/github/hicknhack-software/redmine_hourglass.png)](https://codeclimate.com/github/hicknhack-software/redmine_hourglass)
[![Build Status](https://github.com/hicknhack-software/redmine_hourglass/workflows/Specs/badge.svg)](https://github.com/hicknhack-software/redmine_hourglass/actions?query=workflow%3ASpecs)
[![Join the chat at https://gitter.im/hicknhack-software/redmine_hourglass](https://badges.gitter.im/hicknhack-software/redmine_hourglass.svg)](https://gitter.im/hicknhack-software/redmine_hourglass?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
Hourglass is a Redmine plugin to aid in tracking spent time on projects and issues. It allows users to start / stop a timer with an optional reference to what they are working on.
It allows various queries for time entries as well as possibilities to update existing entries.
Hourglass can be configured on a global base as well as per project.
See [CHANGELOG.md](CHANGELOG.md) for the latest features.
## Migrate from old Time Tracker plugin
_Note: This is a complete rewrite of the [Redmine Time Tracker Plugin](https://github.com/hicknhack-software/redmine_time_tracker). While it has feature parity (atleast we hope we didn't forget anything), the code base has changed positively, so further additions are no longer a pain to do._
___To ease migrating we added a function to import time entries from the redmine_time_tracker. You can find this in the plugin settings and as a rake task. For more information about migrating from the old time tracker take a look on the [Migration Guide](https://github.com/hicknhack-software/redmine_hourglass/wiki/Migration-Guide)___
## Companion App
We made an app to ease use of the time tracker on mobile (android only for now), check it out:
- [Hourglass for Android](https://play.google.com/store/apps/details?id=hnh.software.hourglass)
The Binaries are made available as downloads in the [Releases](https://github.com/hicknhack-software/redmine_hourglass/releases) section
## Features
- Per user time tracking
- Integrates well with Redmine by reusing time entries
- Overview of spent time for users
- Track project unrelated time
- Book tracked time on issues
- Detailed statistics for team management
- Status monitor of currently running trackers
- Detailed list views with Redmine queries integrated
- Report generation for projects with graphical time representation with customizable company logo
- Project specific settings
## Requirements
* Ruby 2.6.8
* Redmine 4.2.1 (The only version fully tested! - Try older versions of the plugin for older versions of Redmine.)
* An [ExecJS](https://github.com/sstephenson/execjs) compatible runtime, the gemfile includes [therubyracer](https://github.com/cowboyd/therubyracer) for unix based systems and windows ships with a default js interpreter (from Win 7 upwards), so most people should be set. If you happen to have problems like for example [#29](https://github.com/hicknhack-software/redmine_hourglass/issues/29), take a look on the linked ExecJS and install one of the mentioned runtimes.
See [.github/workflows/main.yml](.github/workflows/main.yml) for details about supported versions.
If a newer version doesn't appear in there, feel free to open an issue and report your experience with that Redmine or Ruby version.
## Installation
1. If you use Mysql please make sure you have the timezone details loaded, otherwise you miss a lot of nice features.
```bash
sudo mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql
```
1. Place the source code of this plugin at the `plugins/redmine_hourglass` folder.
Either by:
- Download a release from [Releases](https://github.com/hicknhack-software/redmine_hourglass/releases) and extract it into your Redmine.
- Or clone the repository:
```bash
git clone https://github.com/hicknhack-software/redmine_hourglass.git plugins/redmine_hourglass
```
1. Install required Gems by:
```bash
bundle install
```
1. Update the database schema by running the following command from the root of Redmine:
```bash
bundle exec rake redmine:plugins:migrate RAILS_ENV=production
```
1. Precompile the assets.
- If your Redmine is accessed with a path on your domain, like `www.example.com/redmine` use this option:
```bash
bundle exec rake redmine:plugins:assets RAILS_ENV=production RAILS_RELATIVE_URL_ROOT=/redmine
```
- If your Redmine is on the root you might simply run:
```bash
bundle exec rake redmine:plugins:assets RAILS_ENV=production
```
1. (Re)start your Redmine
1. Done. *Please read the "First time usage" section below.*
## Update
The process is roughly the same as installing. Make sure you have the desired version in the `plugins` directory and run the steps above (except 1).
If you had it installed via git before, the first step is simply doing `git pull` in the `plugins/redmine_hourglass` directory.
## First time usage
1. Login as an administrator and setup the permissions for your roles
- developers should have the rights to track time
![Developer Rights](doc/images/DeveloperRights.png)
- managers should have the rights to fix times
![Manager Rights](doc/images/ManagerRights.png)
1. Enable the "Hourglass" module for your project
- works well in combination with the built in `Time tracking`
![Project Modules](doc/images/ProjectModules.png)
1. You should now see the Time Tracking link in the top menu.
To track time directly on an issue, you can use the context menu (right click in the issues list) in
the issue list to start or stop the timer or press the "Start Tracking" button on the top right, next to the default "Book Time" Redmine button.
### What's what?
The plugin is intended to help us create invoices for customers. This requires the separation of time that was spent and time that is booked. Only booked times can be billed.
More information are available in the [wiki](http://github.com/hicknhack-software/redmine_hourglass/wiki).
#### Time Tracker
The stop watch. Time you spent gets "generated" by the trackers.
#### Time Log
A time log is a spent amount of time. If you stop the tracker, a time log is created. A time log has nothing attached to it. To add this time to issues or projects, you **book** time.
Role permissions can be edited to disable logging. This might be useful for reviewers, that do not generate time on their own but want to look up statistics on a project or user.
#### Time Booking
A booking is time that is actually connected to a task (project or issue). To create a booking, you book time from a time log. You are not limited to spent the whole time of a single booking, you can divide as you wish. You however aren't able book more time than what was actually logged. The role you have on projects and their settings determine if you are able to edit bookings or are just allowed to create them.
#### Settings
The plugin offers a list of settings at the Redmine roles and permission settings page. Also you can set the size and file for a logo to be displayed at the report in the Redmine plugin settings, enable rounding behaviour and interval as well as snapping percentage. You can also refine this settings per project if you have different accounting rules per project.
![Project Settings](doc/images/ProjectSettings.png)
## Contributing
Bug reports and pull requests are welcome on [GitHub](https://github.com/hicknhack-software/redmine_hourglass). Please check the [contribution guide](CONTRIBUTING.md).This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to our [code of conduct](CODE_OF_CONDUCT.md).
## License
The plugin is available released under the terms of [GPL](https://www.gnu.org/licenses/gpl).

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

View File

@@ -0,0 +1,6 @@
#= require moment
#= require timeFields
#= require forms
#= require lists
#= require timer
#= require time_tracker_form

View File

@@ -0,0 +1,189 @@
initIssueAutoCompletion = ->
$issueField = $(@)
$projectField = $issueField.closest('form').find("[name*='[project_id]']")
$issueField.autocomplete
source: (request, response) ->
$.ajax
url: hourglassRoutes.hourglass_completion_issues(project_id: $projectField.val()),
dataType: 'json',
data: request
success: response
minLength: 1,
autoFocus: true,
response: (event, ui) ->
$(event.target).next().val('')
select: (event, ui) ->
event.preventDefault()
$issueField
.val(ui.item.label)
.next().val(ui.item.issue_id)
.trigger('change')
$projectField.val(ui.item.project_id).trigger('changefromissue') if $projectField.val() isnt ui.item.project_id
focus: (event, ui) ->
event.preventDefault()
updateActivityField = ($activityField, $projectField) ->
$selected_activity = $activityField.find("option:selected")
$.ajax
url: hourglassRoutes.hourglass_completion_activities()
data:
project_id: $projectField.val()
success: (activities) ->
$activityField.find('option[value!=""]').remove()
for {id, name, isDefault} in activities
do ->
$activityField.append $('<option/>', value: id).text(name)
if $projectField.val() is ''
$activityField.val null
$activityField.trigger('change')
else if $selected_activity.text() is name or ($selected_activity.val() is '' and isDefault)
$activityField.val id
$activityField.trigger('change')
hourglass.FormValidator.validateField $activityField
updateUserField = ($userField, $projectField) ->
selected_user = $userField.find("option:selected").text()
$.ajax
url: hourglassRoutes.hourglass_completion_users()
data:
project_id: $projectField.val()
success: (users) ->
$userField.find('option[value!=""]').remove()
for {id, name} in users
do ->
$userField.append $('<option/>', value: id).text(name)
$userField.val id if selected_user is name
hourglass.FormValidator.validateField $userField
updateDurationField = ($startField, $stopField) ->
start = moment $startField.val(), moment.ISO_8601
stop = moment $stopField.val(), moment.ISO_8601
$startField.closest('form').find('.js-duration').val hourglass.Utils.formatDuration moment.duration stop.diff(start)
updateLink = ($field) ->
$link = $field.closest('.form-field').find('label + a')
if $link.length
$link.toggleClass 'hidden', $field.val() is ''
$link.attr('href', $link.attr('href').replace(/\/([^/]*)$/, "/#{$field.val()}"))
formFieldChanged = (event) ->
$target = $(event.target)
$target = $target.next() if $target.hasClass('js-linked-with-hidden')
hourglass.FormValidator.validateField $target
$target.trigger 'formfieldchanged'
startFieldChanged = (event) ->
$startField = $(event.target)
return if $startField.hasClass('invalid')
$stopField = $startField.closest('form').find('[name*=stop]')
if $stopField.length > 0
hourglass.FormValidator.validateField $stopField
updateDurationField $startField, $stopField
stopFieldChanged = (event) ->
$stopField = $(event.target)
return if $stopField.hasClass('invalid')
$startField = $stopField.closest('form').find('[name*=start]')
hourglass.FormValidator.validateField $startField
updateDurationField $startField, $stopField
durationFieldChanged = (event) ->
$durationField = $(event.target)
return if $durationField.hasClass('invalid')
$startField = $durationField.closest('form').find('[name*=start]')
$stopField = $durationField.closest('form').find('[name*=stop]')
duration = hourglass.Utils.parseDuration $durationField.val()
hourglass.timeField.setValue $stopField, moment($startField.val()).add(duration)
hourglass.FormValidator.validateField $stopField
projectFieldChanged = (event) ->
$projectField = $(@)
$form = $projectField.closest('form')
$issueTextField = $form.find('.js-issue-autocompletion')
$activityField = $form.find("[name*='[activity_id]']")
$userField = $form.find("[name*='[user_id]']")
round = $projectField.find(':selected').data('round-default')
$form.find('[type=checkbox][name*=round]').prop('checked', round) unless round is null
sumsOnly = $projectField.find(':selected').data('round-sums-only')
roundingDisabled = if sumsOnly is undefined then $projectField.val() is '' else sumsOnly
$form.find('[type=checkbox][name*=round]').prop('disabled', roundingDisabled)
.closest('.form-field').toggleClass('hidden', roundingDisabled)
$issueTextField.val('').trigger('change') unless $issueTextField.val() is '' or event.type is 'changefromissue'
hourglass.FormValidator.validateField $projectField if event.type is 'changefromissue'
updateActivityField $activityField, $projectField
updateUserField $userField, $projectField if $userField.length > 0
updateLink $projectField
issueFieldChanged = ->
$issueTextField = $(@)
$issueField = $issueTextField.next()
$issueField.val('') if $issueTextField.val() is ''
updateLink $issueField
split = (timeLogId, mSplitAt, insertNewBefore, round) ->
$.ajax
url: hourglassRoutes.split_hourglass_time_log timeLogId
method: 'post'
data:
split_at: mSplitAt.toJSON()
insert_new_before: insertNewBefore
round: round
submit_without_split_checking = ($form, e) ->
$form.removeClass('js-check-splitting')
$.rails.handleRemote.call($form[0], e)
addSplittingFailedHandler = (xhr) ->
xhr.fail ({responseJSON}) ->
hourglass.Utils.showErrorMessage responseJSON.message
checkSplitting = (e)->
$form = $(@)
timeLogId = $form.data('timeLogId')
$startField = $form.find('[name*=start]')
$stopField = $form.find('[name*=stop]')
mStart = moment $startField.val(), moment.ISO_8601
mStop = moment $stopField.val(), moment.ISO_8601
round = $form.find('[type=checkbox][name*=round]').prop('checked')
next = ->
submit_without_split_checking $form, e
any = false
if mStart.isAfter $startField.data('mLimit')
startNext = next
any = true
next = ->
xhr = split timeLogId, mStart, true, round
addSplittingFailedHandler(xhr)
xhr.done startNext
if mStop.isBefore $stopField.data('mLimit')
stopNext = next
any = true
next = ->
xhr = split timeLogId, mStop, false, round
addSplittingFailedHandler(xhr)
xhr.done stopNext
if any
next()
return false
return true
$ ->
$(document)
.on 'focus', '.js-issue-autocompletion:not(.ui-autocomplete-input)', initIssueAutoCompletion
.on 'change', '.js-validate-form', formFieldChanged
.on 'change changefromissue', '[name*=project_id]', projectFieldChanged
.on 'change changefromissue', '#cb_project_id', projectFieldChanged
.on 'change', '.js-issue-autocompletion', issueFieldChanged
.on 'formfieldchanged', '[name*=start]', startFieldChanged
.on 'formfieldchanged', '[name*=stop]', stopFieldChanged
.on 'formfieldchanged', '.js-duration', durationFieldChanged
.on 'submit ajax:before', '.js-validate-form', (event) ->
isFormValid = hourglass.FormValidator.validateForm $(@)
unless isFormValid
event.preventDefault()
event.stopPropagation()
return isFormValid
.on 'ajax:before', '.js-check-splitting', checkSplitting
$('.js-issue-autocompletion:focus:not(.ui-autocomplete-input)').each(initIssueAutoCompletion)

View File

@@ -0,0 +1,4 @@
#= require js-routes
#= require utils
#= require validators
#= require redmine_integrations

View File

@@ -0,0 +1,42 @@
#= require jqplot/jquery.jqplot
#= require jqplot/jqplot.barRenderer
#= require jqplot/jqplot.categoryAxisRenderer
#= require jqplot/jqplot.highlighter
$ ->
if hourglass.jqplotData.data.length > 0
$chartContainer = $('#chart-container').addClass('has-data')
plot = $.jqplot 'chart-container', hourglass.jqplotData.data,
seriesColors: (['#777', '#AAA'] if $chartContainer.hasClass('print'))
stackSeries: true,
seriesDefaults:
renderer: $.jqplot.BarRenderer
rendererOptions:
fillToZero: true
shadow: false
barMargin: 2,
varyBarColor: true,
axes:
xaxis:
renderer: $.jqplot.CategoryAxisRenderer
ticks: hourglass.jqplotData.ticks
yaxis:
min: 0
pad: 1.2
tickInterval: Math.max(Math.ceil(Math.max.apply(null, hourglass.jqplotData.data[0]) / 8), 1)
tickOptions: {formatString: "%d #{hourglass.jqplotData.hourSign}"}
grid:
background: "#ffffff"
shadow: false
highlighter:
tooltipContentEditor: (str, seriesIndex, pointIndex, plot) ->
hourglass.jqplotData.highlightData[seriesIndex][pointIndex]
show: true
showMarker: false
timeout = null
$(window).resize ->
clearTimeout timeout
timeout = setTimeout ->
plot.replot()
, 250

View File

@@ -0,0 +1,153 @@
toggleAllCheckBoxes = (event) ->
event.preventDefault()
$boxes = $(@).closest('table').find('input[type=checkbox]')
all_checked = true
$boxes.each -> all_checked = all_checked && $(@).prop('checked')
$boxes.each ->
$(@)
.prop('checked', !all_checked)
.parents('tr')
.toggleClass('context-menu-selection', !all_checked)
multiFormParameters = ($form) ->
entries = {}
type = $form.data('formType')
$form.closest('table').find(".#{type}-form").each (i) ->
$form = $(@)
entry = {}
for param in $form.find('.form-field').find('input, select, textarea').serializeArray()
entry[param.name.replace /[a-z_]*\[([a-z_]*)]/, '$1'] = param.value
entries[$form.data('id-for-bulk-edit') || "new#{i}"] = entry
entries
submitMultiForm = (event) ->
event.preventDefault()
$button = $(@)
$form = $button.closest('form')
entries = multiFormParameters $form
url = $button.data('url')
if url?
data = {}
data[$button.data('name')] = entries
$.ajax
url: url
method: 'post'
data: data
success: ->
location.reload()
error: ({responseJSON}) ->
hourglass.Utils.showErrorMessage responseJSON.message
else
alert 'Not yet implemented'
checkForMultiForm = ($row, $formRow)->
type = $formRow.find('form').data('formType')
$table = $row.closest('table')
$visibleForms = $table.find(".#{type}-form")
if $visibleForms.length > 1
$visibleForms.find('[name=commit]').addClass('hidden')
$visibleForms.find('.js-bulk-edit').addClass('hidden').last().removeClass('hidden')
$visibleForms.find('.js-not-in-multi').prop('disabled', true)
else
$visibleForms.find('[name=commit]').removeClass('hidden')
$visibleForms.find('.js-bulk-edit').addClass('hidden')
$visibleForms.find('.js-not-in-multi').prop('disabled', false)
showInlineForm = (event, response) ->
responseText = response
if !responseText?
[_data, _status, xhr] = event.detail
responseText = xhr.responseText
$row = $(@).closest 'tr'
$row.addClass('hidden')
$formRow = $row.clone().removeClass('hidden')
tdCount = $formRow.find('td').toArray().reduce((total, elem) ->
total + (parseInt(elem.colSpan) || 1)
, 0) - 1
$formRow
.removeClass 'hascontextmenu context-menu-selection'
.empty()
.append $('<td/>', class: 'hide-when-print')
.append $('<td/>', colspan: tdCount).append responseText
.insertAfter $row
$formRow.find('.js-validate-limit').each addStartStopLimitMoments
$durationField = $formRow.find('.js-duration')
$durationField.val hourglass.Utils.formatDuration parseFloat($durationField.val()), 'hours' if $durationField
checkForMultiForm $row, $formRow
showInlineFormMulti = (event) ->
[_data, _status, xhr] = event.detail
$(xhr.response).each ->
showInlineForm.call $("##{$(@).data('id-for-bulk-edit')} .js-show-inline-form").get(), event, @
window.contextMenuHide()
showInlineFormCreate = (event) ->
showInlineForm.call $('.js-create-form-anchor').get(), event
hideInlineForm = (event) ->
event.preventDefault()
$formRow = $(@).closest('tr')
$row = $formRow.prev()
$formRow.remove()
$row.removeClass('hidden')
checkForMultiForm $row, $formRow
processErrorPageResponse = (event) ->
[responseText, status, xhr] = event.detail
if responseText
$response = $(responseText)
message = "#{$response.filter('h2').text()} - #{$response.filter('#errorExplanation').text()}"
hourglass.Utils.showErrorMessage message
addStartStopLimitMoments = ->
$field = $(@)
$field.data 'mLimit', moment $field.val(), moment.ISO_8601 unless moment.isMoment($field.data('mLimit'))
# this is only needed for redmine > 3.4, but it doesn't hurt to have it in lower version too
window.oldContextMenuShow = window.contextMenuShow
window.contextMenuShow = (event) ->
event.target = $('<div/>').appendTo $('<form/>', data: {'cm-url': hourglassRoutes.hourglass_ui_context_menu()})
window.oldContextMenuShow event
$ ->
$list = $('.hourglass-list')
$list
.on 'click', '.checkbox a', toggleAllCheckBoxes
.on 'ajax:success', '.js-show-inline-form', showInlineForm
.on 'ajax:error', '.js-show-inline-form', processErrorPageResponse
.on 'click', '.js-hide-inline-form', hideInlineForm
.on 'click', '.js-bulk-edit', submitMultiForm
$(document)
.on 'ajax:success', '.js-show-inline-form-multi', showInlineFormMulti
.on 'ajax:success', '.js-create-record', showInlineFormCreate
.on 'ajax:error', '.js-show-inline-form-multi, .js-create-record', processErrorPageResponse
.on 'ajax:before', '.disabled[data-remote]', ->
window.contextMenuHide()
return false
$list.find '.group'
.on 'click', '.expander', (event) ->
event.preventDefault()
toggleRowGroup @
.on 'click', 'a', (event) ->
event.preventDefault()
toggleAllRowGroups @
$queryForm = $('#query_form')
$queryForm
.on 'click', 'legend', (event) ->
event.preventDefault()
toggleFieldset @
$queryForm.find '.buttons'
.on 'click', '.js-query-apply', (event) ->
event.preventDefault()
$queryForm.submit()
.on 'click', '.js-query-save', (event) ->
event.preventDefault()
$this = $(@)
$queryForm
.attr 'action', $this.data('url')
.append $('<input/>', type: 'hidden', name: 'query_class').val($this.data('query-class'))
.submit()

View File

@@ -0,0 +1,145 @@
startNewTracker = (link) ->
$(link).addClass('js-skip-dialog').first().click()
timeTrackerAjax = (args) ->
$.ajax
url: args.url
type: args.type || 'post'
data: $.extend {_method: args.method}, args.data or {}
success: args.success
error: ({responseJSON}) ->
hourglass.Utils.showErrorMessage responseJSON.message
stopDialogApplyHandler = (link) ->
$stopDialog = $(@)
$activityField = $stopDialog.find('[name*=activity_id]')
return unless hourglass.FormValidator.isFieldValid $activityField
$stopDialog.dialog 'close'
timeTrackerAjax
url: $(link).attr('href')
type: 'delete'
data:
time_tracker:
activity_id: $activityField.val()
success: -> location.reload()
startDialogApplyHandler = (link) ->
$startDialog = $(@)
switch $startDialog.find('input[type=radio]:checked').val()
when 'log'
$activityField = $startDialog.find('[name*=activity_id]')
if $activityField.length
return unless hourglass.FormValidator.isFieldValid $activityField
$startDialog.dialog 'close'
timeTrackerAjax
url: hourglassRoutes.start_hourglass_time_trackers()
method: 'post'
data:
$.extend Object.fromEntries(new URLSearchParams($(link).data('params'))),
id: 'current'
current_action: 'stop'
current_update:
activity_id: $activityField.val()
success: -> location.reload()
else
$startDialog.dialog 'close'
timeTrackerAjax
url: hourglassRoutes.start_hourglass_time_trackers()
method: 'post'
data:
$.extend Object.fromEntries(new URLSearchParams($(link).data('params'))),
id: 'current'
current_action: 'stop'
success: -> location.reload()
when 'discard'
$startDialog.dialog 'close'
timeTrackerAjax
url: hourglassRoutes.start_hourglass_time_trackers()
method: 'post'
data:
$.extend Object.fromEntries(new URLSearchParams($(link).data('params'))),
id: 'current'
current_action: 'destroy'
success: -> location.reload()
when 'takeover'
$startDialog.dialog 'close'
timeTrackerAjax
url: hourglassRoutes.hourglass_time_tracker 'current'
type: 'put'
data: Object.fromEntries(new URLSearchParams($(link).data('params')))
success: ->
location.reload()
showStartDialog = (e) ->
return true if $(@).hasClass('js-skip-dialog')
$startDialog = $('.js-start-dialog')
if $startDialog.length is 0
$startDialogContent = $('.js-start-dialog-content')
if $startDialogContent.length isnt 0
e.preventDefault()
e.stopPropagation()
hourglass.Utils.showDialog 'js-start-dialog', $startDialogContent, [
{
text: $startDialogContent.data('button-ok-text')
click: -> startDialogApplyHandler.call(@, e.target)
}
{
text: $startDialogContent.data('button-cancel-text')
click: -> $(@).dialog 'close'
}
]
else
e.preventDefault()
e.stopPropagation()
$startDialog.dialog 'open'
showStopDialog = (e) ->
return true if $(@).hasClass('js-skip-dialog')
$stopDialog = $('.js-stop-dialog')
if $stopDialog.length is 0
$stopDialogContent = $('.js-stop-dialog-content')
if $stopDialogContent.length isnt 0
e.preventDefault()
e.stopPropagation()
hourglass.Utils.showDialog 'js-stop-dialog', $stopDialogContent, [
{
text: $stopDialogContent.data('button-ok-text')
click: -> stopDialogApplyHandler.call(@, e.target)
}
{
text: $stopDialogContent.data('button-cancel-text')
click: -> $(@).dialog 'close'
}
]
$stopDialogContent.on 'change', '[name*=activity_id]', ->
hourglass.FormValidator.validateField $(@)
else
e.preventDefault()
e.stopPropagation()
$stopDialog.dialog 'open'
window.oldToggleOperator = window.toggleOperator
window.toggleOperator = (field) ->
operator = $("#operators_" + field.replace('.', '_')).val()
return enableValues(field, []) if operator is 'q' or operator is 'lq'
window.oldToggleOperator field
$ ->
$('#content > .contextual >:nth-child(2)').after $('.js-issue-action').removeClass('hidden')
$('.hourglass-quick').replaceWith $('.js-account-menu-link').removeClass('hidden')
$('#content, #top-menu')
.on 'click', '.js-start-tracker', showStartDialog
.on 'click', '.js-stop-tracker', showStopDialog
$contextMenuTarget = null
$(document).on 'contextmenu', '.hourglass-list', (e) ->
$contextMenuTarget = $(@)
$.ajaxPrefilter (options) ->
return unless options.url.endsWith 'hourglass/ui/context_menu'
options.data = $.param list_type: $contextMenuTarget.data('list-type')
$contextMenuTarget.find('.context-menu-selection').each ->
options.data += "&ids[]=#{@id}"

View File

@@ -0,0 +1,12 @@
@hourglass ?= {}
@hourglass.timeField =
setValue: ($field, mValue) ->
$field.val(mValue.toISOString()).change()
$field.prev().val mValue.utcOffset(window.hourglass.UtcOffset).format(window.hourglass.DateTimeFormat)
$ ->
$(document)
.on 'change', '.js-time-field', () ->
$field = $(@)
$field.next().val(moment("#{$field.val()} #{window.hourglass.UtcOffset}",
"#{window.hourglass.DateTimeFormat} ZZ").toISOString()).change()

View File

@@ -0,0 +1,96 @@
valueTarget = ($target) ->
if $target.get(0).type is 'checkbox' and not $target.prop('checked')
$target.prev()
else
$target
formData = {}
putData = {}
putTimer = 0
updateLink = ($field) ->
$link = $field.closest('.form-field').find('label + a')
if $link.length
$link.toggleClass 'hidden', $field.val() is ''
$link.attr('href', $link.attr('href').replace(/\/([^/]*)$/, "/#{$field.val()}"))
refreshForm = () ->
$.ajax
url: hourglassRoutes.hourglass_time_tracker('current')
type: 'get'
success: (data) ->
oldData = formData
formData = data
if oldData.issue_id != data.issue_id
$issueField = $('#time_tracker_issue_id')
$issueField.val(data.issue_id)
updateLink($issueField)
if oldData.project_id != data.project_id
$projectField = $('#time_tracker_project_id')
$projectField.val(data.project_id)
updateLink($projectField)
if oldData.activity_id != data.activity_id
$activityField = $('#time_tracker_activity_id')
$activityField.val(data.activity_id)
return
error: ({responseJSON}) ->
hourglass.Utils.showErrorMessage responseJSON.message
putForm = () ->
putTimer = 0
data = putData
putData = {}
hourglass.Utils.clearFlash()
$.ajax
url: hourglassRoutes.hourglass_time_tracker('current')
type: 'put'
data: data
success: () ->
refreshForm()
error: ({responseJSON}) ->
hourglass.Utils.showErrorMessage responseJSON.message
formFieldChanged = (event) ->
$target = $(event.target)
attribute = $target.attr('name')
key = attribute.replace(/^\w+\[(\w+)\]$/, '$1')
value = valueTarget($target).val()
return if value == formData[key]?.toString() or value == putData[attribute]
putData[attribute] = value
if attribute.indexOf('project_id') > -1
$issueField = $(@).find('.js-issue-autocompletion').next()
putData[$issueField.attr('name')] = $issueField.val()
unless $target.hasClass('invalid') || putTimer != 0
putTimer = setTimeout(putForm, 1);
$ ->
$timeTrackerControl = $('.time-tracker-control')
hourglass.Timer.start() if $timeTrackerControl.length > 0
$timeTrackerEditForm = $timeTrackerControl.find('.edit-time-tracker-form')
$timeTrackerNewForm = $timeTrackerControl.find('.new-time-tracker-form')
hourglass.FormValidator.validateForm $timeTrackerEditForm
$timeTrackerEditForm.on 'formfieldchanged', formFieldChanged
.find('#time_tracker_start')
.on 'change', ->
hourglass.Timer.start()
$timeTrackerEditForm.find('.js-stop-new').on 'click', ->
$timeTrackerEditForm.data('start-new', true)
$timeTrackerEditForm.on 'ajax:success', (event) ->
if $timeTrackerEditForm.data('start-new')
event.stopPropagation()
$timeTrackerEditForm.data('start-new', false)
$.ajax
url: hourglassRoutes.start_hourglass_time_trackers()
type: 'post'
complete: () ->
location.reload()
$timeTrackerNewForm.on 'submit', ->
value = if $('#time_tracker_issue_id').val()
''
else
$('#time_tracker_task').val()
$('#time_tracker_comments').val value

View File

@@ -0,0 +1,30 @@
timeTrackerTimerInterval = null
startTimeTrackerTimer = ->
startTimestamp = moment $('.time-tracker-control [name*=start]').val(), moment.ISO_8601
duration = moment.duration moment() - startTimestamp
numberToString = (number)->
result = (Math.floor Math.abs number).toString()
result = '0' + result if Math.abs(number) < 10
result
displayTime = ->
durationString = [
duration.asHours(),
duration.asMinutes() % 60,
duration.asSeconds() % 60
].map(numberToString).join(':')
$('.time-tracker-control .input.js-running-time').html("#{if duration < 0 then '-' else ''} #{durationString}")
displayTime()
clearInterval timeTrackerTimerInterval if timeTrackerTimerInterval?
timeTrackerTimerInterval = setInterval ->
duration = moment.duration moment() - startTimestamp
displayTime()
, 1000
@hourglass ?= {}
@hourglass.Timer = {
start: startTimeTrackerTimer
}

View File

@@ -0,0 +1,55 @@
clearFlash = ->
$('#content').find('.flash').remove()
showMessage = (message, type) ->
clearFlash()
if $.isArray message
$('#content').prepend $('<div/>', class: "flash #{type}").html $('<ul/>').html $.map message, (msg) ->
$('<li/>').text msg
else
$('#content').prepend $('<div/>', class: "flash #{type}").text message
showNotice = (message) ->
showMessage message, 'notice'
showErrorMessage = (message) ->
showMessage message, 'error'
showDialog = (className, $content, buttons = []) ->
$('<div/>', class: className, title: $content.data('dialog-title'))
.append $content.removeClass('hidden')
.appendTo 'body'
.dialog
autoOpen: true
resizable: false
draggable: false
modal: true
width: 300
buttons: buttons
formatDuration = (duration, unit = null) ->
duration = moment.duration duration, unit unless moment.isDuration duration
moment("1900-01-01 00:00:00").add(duration).format('HH:mm')
parseDuration = (durationString) ->
[hours, minutes] = durationString.split(':')
moment.duration(hours: hours, minutes: minutes)
@hourglass ?= {}
@hourglass.Utils =
clearFlash: clearFlash
formatDuration: formatDuration
parseDuration: parseDuration
showDialog: showDialog
showErrorMessage: showErrorMessage
showNotice: showNotice
$ ->
$(document)
.on 'ajax:success', '.js-hourglass-remote', ->
location.reload()
.on 'ajax:error', '.js-hourglass-remote', (event) ->
[responseJSON, status, xhr] = event.detail
hourglass.Utils.showErrorMessage responseJSON.message
.on 'click', '.js-toggle', ->
$($(@).data('target')).toggleClass 'hidden'

View File

@@ -0,0 +1,82 @@
addError = ($field, msg) ->
errors = getErrors $field
errors.push "[#{$field.closest('.form-field').find('label').text()}]: #{window.hourglass.errorMessages[msg] || msg}"
$field.data 'errors', errors
getErrors = ($field) ->
$field.data('errors') || []
clearErrors = ($field) ->
$field.data 'errors', null
isEmpty = ($field) ->
$field.val() is ''
validatePresence = ($field) ->
addError $field, 'empty' if isEmpty $field
validateByType = (type, $field, $form) ->
switch type
when 'activity_id'
validatePresence $field unless isEmpty $form.find('[name*=project_id]')
when 'issue_id'
validatePresence $field unless isEmpty $form.find('#issue_text')
when 'start'
mStart = moment $field.val(), moment.ISO_8601
addError $field, 'invalid' unless mStart.isValid()
addError $field, 'exceedsLimit' if $field.hasClass('js-validate-limit') and mStart.isBefore $field.data('mLimit')
$stopField = $form.find('[name*=stop]')
break if $stopField.length is 0
mStop = moment $stopField.val(), moment.ISO_8601
if $field.hasClass('js-allow-zero-duration')
addError $field, 'invalidDuration' if mStart.isAfter mStop
else
addError $field, 'invalidDuration' if mStart.isSameOrAfter mStop
when 'stop'
mStop = moment $field.val(), moment.ISO_8601
addError $field, 'invalid' unless mStop.isValid()
addError $field, 'exceedsLimit' if $field.hasClass('js-validate-limit') and mStop.isAfter $field.data('mLimit')
$startField = $form.find('[name*=start]')
break if $startField.length is 0
mStart = moment $startField.val(), moment.ISO_8601
if $field.hasClass('js-allow-zero-duration')
addError $field, 'invalidDuration' if mStart.isAfter mStop
else
addError $field, 'invalidDuration' if mStart.isSameOrAfter mStop
validateField = ($field, $form) ->
clearErrors $field
validatePresence $field if $field.prop('required')
name = $field.attr('name')
validateByType name.replace(/[a-z_]*\[([a-z_]*)]/, '$1'), $field, $form if name?
hasErrors = getErrors($field).length > 0
$field.toggleClass('invalid', hasErrors)
$field.prev().toggleClass('invalid', hasErrors) if $field.attr('type') is 'hidden'
all_form_fields = ($form, filter = null) ->
$fields = $form.find('input, select, textarea')
if filter? then $fields.filter filter else $fields
processValidation = ($form) ->
hourglass.Utils.clearFlash()
$invalidFields = all_form_fields $form, '.invalid'
hourglass.Utils.showErrorMessage $invalidFields.map( -> getErrors $(@)).get() if $invalidFields.length > 0
$form.find(':submit').attr('disabled', $invalidFields.length > 0)
validateSingleField = ($field, $form = $field.closest('form')) ->
validateField $field, $form
processValidation $form
validateForm = ($form) ->
all_form_fields($form, '[name]').each ->
validateField $(@), $form
processValidation $form
@hourglass ?= {}
@hourglass.FormValidator =
validateField: validateSingleField
isFieldValid: ($field, args...) ->
validateSingleField $field, args...
getErrors($field).length is 0
validateForm: validateForm

View File

@@ -0,0 +1,252 @@
@mixin form-row {
.form-row {
width: 100%;
float: left;
.form-field {
float: left;
margin-right: 5px;
.label, >label {
float: left;
overflow: hidden;
font-size: 9px;
}
>input {
display: block;
}
.input, >input {
margin-top: 15px;
input ~ a {
margin-left: 10px;
}
.invalid {
border: 2px solid #D00;
background-color: #FFE3E3;
color: #800;
}
}
}
}
}
@mixin inline-block {
display: inline-block;
vertical-align: middle;
}
.controller-hourglass_ui {
.hasDatepicker {
width: 13em;
}
.time-tracker-control {
margin-bottom: 30px;
form {
display: inline-block;
@include form-row;
}
}
.hourglass-list {
tr {
white-space: nowrap;
text-align: center;
&:hover {
td.actions a {
visibility: visible;
}
}
&.warning:hover {
background-color: #FFEBC1;
}
td {
text-align: center;
@include form-row;
&.comments, &.issue {
text-align: left;
}
&.actions {
text-align: right;
a {
padding: 2px 16px;
background-repeat: no-repeat;
background-position: 10px 0;
visibility: hidden;
}
}
}
th {
&.actions {
width: 1px;
}
}
&.group {
td {
text-align: left;
.expander {
margin-right: 5px;
}
.totals > span {
margin-left: 5px;
}
}
}
}
}
#query_form {
#filters-table {
tr.filter {
td.field {
white-space: nowrap;
}
}
}
}
#chart-container.has-data {
height: 400px;
margin-bottom: 10px;
}
//everything inside of action report are print specific styles
&.action-report {
color: #484848;
width: 210mm;
padding: 0;
* {
font-family: "Myriad Pro", Verdana, sans-serif;
}
.header {
h1 {
font-weight: bold;
font-size: 13pt;
width: 50%;
@include inline-block;
}
.logo {
width: 50%;
text-align: right;
@include inline-block;
}
}
.list {
width: 100%;
border-collapse: collapse;
* {
font-size: 11pt;
}
thead {
display: table-header-group;
tr {
background-color: #90D2E8;
th {
border-bottom-color: #666;
}
}
}
tbody {
display: table-row-group;
}
tr {
page-break-inside: avoid;
&.odd {
background-color: #f6f7f8;
}
&.even {
background-color: #fff;
}
td, th {
padding-right: 1em;
border-bottom: 1px solid #aaa;
text-align: left;
vertical-align: top;
&:first-child {
padding-left: 1em;
}
&:last-child {
padding-right: 1em;
}
&.description {
white-space: normal;
}
.project {
position: relative;
overflow: hidden;
font-size: 8pt;
}
.count {
margin-left: 5px;
}
}
}
}
.chart {
.query-totals {
@include inline-block;
width: 15%;
font-weight: bold;
font-size: 11pt;
}
#chart-container {
@include inline-block;
height: 300px;
width: 85%;
overflow: visible;
.jqplot-yaxis {
font-size: 75%;
text-align: right;
padding-right: 10px;
}
.jqplot-xaxis {
font-size: 75%;
}
.jqplot-xaxis-tick {
white-space: nowrap;
}
}
}
}
label + a.icon {
margin-left: 5px;
}
.icon-link {
background: url(/images/link.png) no-repeat 0% 70%;
}
}
.swagger-ui {
input,
select,
button {
height: auto;
}
}
.swagger-note {
margin-top: 10px;
.swagger-link {
font-size: 1.3em;
font-weight: bold;
text-decoration: none;
.logo__img {
display: block;
float: left;
margin-top: 2px;
}
.logo__title {
display: inline-block;
padding: 5px 0 0 10px;
}
}
}

View File

@@ -0,0 +1,48 @@
.hidden {
display: none;
}
.hourglass-dialog {
p {
margin: 5px 0;
}
.center {
text-align: center;
.invalid {
border: 2px solid #D00;
background-color: #FFE3E3;
color: #800;
}
}
}
.icon-hourglass-start {
background-image: image-url('icons/time_start.png');
}
.icon-hourglass-stop {
background-image: image-url('icons/time_stop.png');
}
.icon-hourglass-continue {
background-image: image-url('icons/time_continue.png');
}
.icon-hourglass-join {
background-image: image-url('icons/arrow_join.png');
}
.flash {
ul {
margin: 0;
list-style: none;
padding: 0;
}
}
label .hint {
font-size: xx-small;
font-weight: normal;
color: #666666;
}

View File

@@ -0,0 +1,30 @@
module AuthorizationConcern
extend ActiveSupport::Concern
included do
include Pundit
rescue_from(Pundit::NotAuthorizedError) do |e|
render_403 message: e.policy.message, no_halt: true
end
def pundit_user
User.current
end
def authorize(record, query = nil)
super
record
end
def authorize_update(record, params)
authorize record
record.transaction do
record.with_before_save proc { authorize record } do
record.update params
end
end
record
end
end
end

View File

@@ -0,0 +1,15 @@
module BooleanParsing
extend ActiveSupport::Concern
def parse_boolean(keys, params)
keys = [keys] if keys.is_a? Symbol
keys.each do |key|
if Rails::VERSION::MAJOR <= 4
params[key] = ActiveRecord::Type::Boolean.new.type_cast_from_user(params[key])
else
params[key] = ActiveRecord::Type::Boolean.new.cast(params[key])
end
end
params
end
end

View File

@@ -0,0 +1,34 @@
module HourglassUi
module Overview
extend ActiveSupport::Concern
included do
menu_item :hourglass_overview, only: :index
end
def index
authorize :'hourglass/ui', :view?
@time_tracker = User.current.hourglass_time_tracker || Hourglass::TimeTracker.new
@time_log_list_arguments = index_page_list_arguments :time_logs do |time_log_query|
time_log_query.add_filter 'booked', '!', [true]
time_log_query.column_names = time_log_query.default_columns_names - [:booked?]
end
@time_booking_list_arguments = index_page_list_arguments :time_bookings
end
private
def index_page_list_arguments(query_identifier)
query = query_class_map[query_identifier].new name: '_'
query.group_by = :start
query.add_filter 'date', 'w+lw', [true]
query.add_filter 'user_id', '=', [User.current.id.to_s]
yield query if block_given?
params[:sort] = params["#{query_identifier}_sort"]
@sort_default = [%w(date desc)]
sort_update query.sortable_columns, "#{sort_name}_#{query_identifier}"
query.sort_criteria = @sort_criteria.to_a
list_arguments(query, per_page: 15, page_param: "#{query_identifier}_page").merge action_name: query_identifier.to_s, hide_per_page_links: true, sort_param_name: "#{query_identifier}_sort"
end
end
end

View File

@@ -0,0 +1,39 @@
module HourglassUi
module TimeBookings
extend ActiveSupport::Concern
included do
menu_item :hourglass_time_bookings, only: :time_bookings
end
def time_bookings
list_records Hourglass::TimeBooking
build_chart_query
end
def new_time_bookings
authorize Hourglass::TimeBooking, :create?
now = Time.now.change(sec: 0)
duration = Hourglass::DateTimeCalculations.in_hours Hourglass::DateTimeCalculations.round_minimum
time_booking = Hourglass::TimeBooking.new start: now, stop: now + duration.hours,
time_entry_attributes: {hours: duration}
render 'hourglass_ui/time_bookings/new', locals: {time_booking: time_booking}, layout: false
end
def edit_time_bookings
record_form Hourglass::TimeBooking
end
def bulk_edit_time_bookings
bulk_record_form Hourglass::TimeBooking
end
def report
@query_identifier = :time_bookings
list_records Hourglass::TimeBooking
@list_arguments[:entries] = @list_arguments[:entries].offset(nil).limit(nil)
build_chart_query
render layout: false
end
end
end

View File

@@ -0,0 +1,36 @@
module HourglassUi
module TimeLogs
extend ActiveSupport::Concern
included do
menu_item :hourglass_time_logs, only: :time_logs
end
def time_logs
list_records Hourglass::TimeLog
end
def new_time_logs
authorize Hourglass::TimeLog, :create?
now = Time.now.change(sec: 0)
time_log = Hourglass::TimeLog.new start: now, stop: now + Hourglass::DateTimeCalculations.round_minimum
render 'hourglass_ui/time_logs/new', locals: {time_log: time_log}, layout: false
end
def edit_time_logs
record_form Hourglass::TimeLog
end
def bulk_edit_time_logs
bulk_record_form Hourglass::TimeLog
end
def book_time_logs
record_form Hourglass::TimeLog, action: :book?, template: :book
end
def bulk_book_time_logs
bulk_record_form Hourglass::TimeLog, action: :book?, template: :book
end
end
end

View File

@@ -0,0 +1,22 @@
module HourglassUi
module TimeTrackers
extend ActiveSupport::Concern
included do
menu_item :hourglass_time_trackers, only: :time_trackers
end
def time_trackers
list_records Hourglass::TimeTracker
render 'hourglass_ui/query_view'
end
def edit_time_trackers
record_form Hourglass::TimeTracker
end
def bulk_edit_time_trackers
bulk_record_form Hourglass::TimeTracker
end
end
end

View File

@@ -0,0 +1,50 @@
module ListConcern
include Redmine::Pagination
extend ActiveSupport::Concern
private
def list_arguments(query = @query, options = {})
list_arguments = {query: query, action_name: action_name, sort_criteria: @sort_criteria}
if query.valid?
scope = query.results_scope order: sort_clause
count = scope.count
paginator = Paginator.new count, options[:per_page] || per_page_option, params[options[:page_param] || :page], options[:page_param]
entries = scope.offset(paginator.offset).limit(paginator.per_page)
list_arguments.merge! count: count, paginator: paginator, entries: entries
end
list_arguments
end
def list_records(klass)
authorize klass, :view?
retrieve_query
init_sort
@list_arguments = list_arguments
end
def record_form(klass, action: :change?, template: :edit)
record = authorize find_record(klass), action
render_forms get_type(klass), [record], template
end
def bulk_record_form(klass, action: :change?, template: :edit)
records = params[:ids].map do |id|
record = klass.find_by(id: id)
policy(record).send(action) ? record : next
end.compact
render_404 if records.empty?
render_forms get_type(klass), records, template
end
def find_record(klass)
klass.find_by(id: params[:id]) or render_404
end
def render_forms(type, records, template)
render "hourglass_ui/#{type}/#{template}", locals: {"#{type}".to_sym => records}, layout: false unless performed?
end
def get_type(klass)
klass.name.demodulize.tableize
end
end

View File

@@ -0,0 +1,72 @@
module QueryConcern
extend ActiveSupport::Concern
included do
helper_method :query_class, :query_identifier
end
private
def query_class_map
{
time_logs: Hourglass::TimeLogQuery,
time_bookings: Hourglass::TimeBookingQuery,
time_trackers: Hourglass::TimeTrackerQuery
}.with_indifferent_access
end
def query_class
@query_identifier ||= params[:query_class] || action_name
@query_class ||= query_class_map[@query_identifier]
end
def query_identifier
@query_identifier
end
def retrieve_query(force_new: params[:set_filter] == '1')
@query = if force_new || session[session_query_var_name].nil?
new_query
elsif params[:query_id].present?
query_from_id
elsif session[session_query_var_name]
query_from_session
end
@query.project = @project
@query.add_filter 'user_id', '=', ['me'] unless policy(@query_class.queried_class).allowed_to?(:view_foreign)
end
def session_query_var_name
query_class.name.underscore.to_sym
end
def query_from_id
query = Query.where(project: [nil, @project]).find(params[:query_id])
#raise ::Unauthorized unless query.visible?
session[session_query_var_name] = {id: query.id}
sort_clear
query
rescue ActiveRecord::RecordNotFound
render_404
end
def new_query
query = query_class.build_from_params params, name: '_'
session[session_query_var_name] = {
filters: query.filters,
group_by: query.group_by,
column_names: query.column_names,
options: {
totalable_names: query.totalable_names
}
}
query
end
def query_from_session
query_class.find_by(id: session[session_query_var_name][:id]) || query_class.new(session[session_query_var_name].merge name: '_')
end
def build_chart_query
@chart_query = Hourglass::ChartQuery.new name: '_', filters: @query.filters, group_by: :date, main_query: @query
end
end

View File

@@ -0,0 +1,11 @@
module SortConcern
include SortHelper
extend ActiveSupport::Concern
private
def init_sort(query = @query)
sort_init query.sort_criteria.empty? ? [%w(date desc)] : query.sort_criteria
sort_update query.sortable_columns
query.sort_criteria = @sort_criteria.to_a
end
end

View File

@@ -0,0 +1,156 @@
module Hourglass
class ApiBaseController < ApplicationController
include QueryConcern
include SortConcern
include BooleanParsing
around_action :catch_halt
before_action :require_login
rescue_from StandardError, with: :internal_server_error
rescue_from ActionController::ParameterMissing, with: :missing_parameters
rescue_from(ActiveRecord::RecordNotFound) { render_404 no_halt: true }
rescue_from Query::StatementInvalid, with: :query_statement_invalid
rescue_from Hourglass::TimeLog::AlreadyBookedException, with: :already_booked
include ::AuthorizationConcern
private
# use only these codes:
# :ok (200)
# :not_modified (304)
# :bad_request (400)
# :unauthorized (401)
# :forbidden (403)
# :not_found (404)
# :internal_server_error (500)
def respond_with_error(status, message, **options)
render json: {
message: message.is_a?(Array) && options[:array_mode] == :sentence ? message.to_sentence : message,
status: Rack::Utils.status_code(status)
},
status: status
throw :halt unless options[:no_halt]
end
def respond_with_success(response_obj = nil)
if response_obj
render json: response_obj
else
head :no_content
end
throw :halt
end
def render_403(options = {})
respond_with_error :forbidden, options[:message] || t('hourglass.api.errors.forbidden'), no_halt: options[:no_halt]
end
def render_404(options = {})
respond_with_error :not_found, options[:message] || t("hourglass.api.#{controller_name}.errors.not_found", default: t('hourglass.api.errors.not_found')), no_halt: options[:no_halt]
end
def catch_halt
catch :halt do
yield
end
end
def do_update(record, params_hash)
record = authorize_update record, params_hash
if record.errors.empty?
respond_with_success
else
respond_with_error :bad_request, record.errors.full_messages, array_mode: :sentence
end
end
def list_records(klass)
authorize klass
@query_identifier = klass.name.demodulize.tableize
retrieve_query force_new: true
init_sort
scope = @query.results_scope order: sort_clause
offset, limit = api_offset_and_limit
respond_with_success(
count: scope.count,
offset: offset,
limit: limit,
records: scope.offset(offset).limit(limit).to_a
)
end
def bulk(params_key = controller_name, &block)
@bulk_success = []
@bulk_errors = []
entries = params[params_key]
entries = entries.to_unsafe_h if Rails::VERSION::MAJOR >= 5 && entries.instance_of?(ActionController::Parameters)
entries.each_with_index do |(id, params), index|
if Rails::VERSION::MAJOR <= 4
id, params = "new#{index}", id if id.is_a?(Hash)
else
id, params = "new#{index}", id if id.instance_of?(ActionController::Parameters)
params = ActionController::Parameters.new(params) if params.is_a?(Hash)
end
error_preface = id.start_with?('new') ? bulk_error_preface(index, mode: :create) : bulk_error_preface(id)
evaluate_entry bulk_entry(id, params, &block), error_preface
end
if @bulk_success.length > 0
flash_array :error, @bulk_errors if @bulk_errors.length > 0 && !api_request?
respond_with_success success: @bulk_success, errors: @bulk_errors
else
respond_with_error :bad_request, @bulk_errors
end
end
def evaluate_entry(entry, error_preface)
if entry
if entry.is_a? String
@bulk_errors.push "#{error_preface} #{entry}"
elsif entry.errors.empty?
@bulk_success.push entry
else
@bulk_errors.push "#{error_preface} #{entry.errors.full_messages.to_sentence}"
end
else
@bulk_errors.push "#{error_preface} #{t("hourglass.api.#{controller_name}.errors.not_found")}"
end
end
def bulk_entry(id, params)
yield id, params
rescue ActiveRecord::RecordNotFound
nil
rescue Pundit::NotAuthorizedError => e
e.policy.message || t('hourglass.api.errors.forbidden')
end
def bulk_error_preface(id, mode: nil)
"[#{t("hourglass.api.#{controller_name}.errors.bulk_#{'create_' if mode == :create}error_preface", id: id)}:]"
end
def missing_parameters(_e)
respond_with_error :bad_request, t('hourglass.api.errors.missing_parameters'), no_halt: true
end
def internal_server_error(e)
messages = [e.message] + e.backtrace
Rails.logger.error messages.join("\n")
respond_with_error :internal_server_error, Rails.env.production? ? t('hourglass.api.errors.internal_server_error') : messages, no_halt: true
end
def already_booked(_e)
respond_with_error :bad_request, t('hourglass.api.time_logs.errors.already_booked'), no_halt: true
end
def flash_array(type, messages)
flash[type] = render_to_string partial: 'hourglass_ui/flash_array', locals: { messages: messages }
end
def custom_field_keys(params_hash)
return {} unless params_hash[:custom_field_values]
params_hash[:custom_field_values].keys
end
end
end

View File

@@ -0,0 +1,82 @@
module Hourglass
class TimeBookingsController < ApiBaseController
accept_api_auth :index, :show, :create, :bulk_create, :update, :bulk_update, :destroy, :bulk_destroy
def index
list_records Hourglass::TimeBooking
end
def show
respond_with_success authorize time_booking_from_id
end
def create
time_log, time_booking = nil
ActiveRecord::Base.transaction do
time_log = Hourglass::TimeLog.create create_time_log_params
raise ActiveRecord::Rollback unless time_log.persisted?
time_booking = authorize time_log.book time_entry_params
raise ActiveRecord::Rollback unless time_booking.persisted?
respond_with_success time_log: time_log, time_booking: time_booking
end
error_messages = time_log.errors.full_messages
error_messages += time_booking.errors.full_messages if time_booking
respond_with_error :bad_request, error_messages, array_mode: :sentence
end
def bulk_create
bulk do |_, params|
result = nil
ActiveRecord::Base.transaction do
result = Hourglass::TimeLog.create create_time_log_params params
raise ActiveRecord::Rollback unless result.persisted?
result = authorize result.book time_entry_params(params).except(:user_id)
raise ActiveRecord::Rollback unless result.persisted?
result.include_time_log!
end
result
end
end
def update
attributes = {time_entry_attributes: time_entry_params}
attributes[:time_log_attributes] = attributes[:time_entry_attributes].slice(:user_id) if attributes[:time_entry_attributes][:user_id]
do_update time_booking_from_id, attributes
end
def bulk_update
authorize Hourglass::TimeBooking
bulk do |id, params|
attributes = {time_entry_attributes: time_entry_params(params)}
attributes[:time_log_attributes] = attributes[:time_entry_attributes].slice(:user_id) if attributes[:time_entry_attributes][:user_id]
authorize_update time_booking_from_id(id), attributes
end
end
def destroy
authorize(time_booking_from_id).destroy
respond_with_success
end
def bulk_destroy
authorize Hourglass::TimeBooking
bulk do |id|
authorize(time_booking_from_id id).destroy
end
end
private
def create_time_log_params(params_hash = params.require(:time_booking))
params_hash.permit(:start, :stop, :comments, :user_id)
end
def time_entry_params(params_hash = params.require(:time_booking))
params_hash.permit(:comments, :project_id, :issue_id, :activity_id, :user_id,
custom_field_values: custom_field_keys(params_hash))
end
def time_booking_from_id(id = params[:id])
Hourglass::TimeBooking.find id
end
end
end

View File

@@ -0,0 +1,137 @@
module Hourglass
class TimeLogsController < ApiBaseController
accept_api_auth :index, :show, :update, :create, :bulk_create, :bulk_update, :split, :join, :book, :bulk_book, :destroy, :bulk_destroy
def index
list_records Hourglass::TimeLog
end
def show
respond_with_success authorize time_log_from_id
end
def create
time_log = authorize TimeLog.new create_time_log_params
if time_log.save
respond_with_success time_log: time_log
else
respond_with_error :bad_request, time_log.errors.full_messages, array_mode: :sentence
end
end
def bulk_create
authorize Hourglass::TimeLog
bulk do |_, params|
time_log = authorize TimeLog.new create_time_log_params params
time_log.save
time_log
end
end
def update
do_update time_log_from_id, time_log_params
end
def bulk_update
authorize Hourglass::TimeLog
bulk do |id, params|
authorize_update time_log_from_id(id), time_log_params(params)
end
end
def split
time_log = authorize time_log_from_id
new_time_log = time_log.split split_params
if new_time_log
respond_with_success time_log: time_log, new_time_log: new_time_log
else
respond_with_error :bad_request, t('hourglass.api.time_logs.errors.split_failed')
end
end
def join
authorize Hourglass::TimeLog
ids = params[:ids].uniq
time_logs = Hourglass::TimeLog.where(id: ids).order start: :asc
raise ActiveRecord::RecordNotFound if time_logs.length != ids.length
time_log = time_logs.transaction do
time_logs.reduce do |joined, tl|
authorize tl
raise ActiveRecord::Rollback unless joined.join_with tl
joined
end
end
if time_log && time_log.persisted?
respond_with_success time_log
else
respond_with_error :bad_request, t('hourglass.api.time_logs.errors.join_failed')
end
end
def book
time_log = time_log_from_id
time_booking = time_log.transaction do
time_booking = time_log.book time_booking_params
authorize time_log, :booking_allowed?
time_booking
end
if time_booking.persisted?
respond_with_success time_booking
else
respond_with_error :bad_request, time_booking.errors.full_messages, array_mode: :sentence
end
end
def bulk_book
authorize Hourglass::TimeLog
bulk :time_bookings do |id, booking_params|
time_log = authorize time_log_from_id id
time_log.transaction do
time_booking = time_log.book time_booking_params booking_params
authorize time_log, :booking_allowed?
time_booking
end
end
end
def destroy
authorize(time_log_from_id).destroy
respond_with_success
end
def bulk_destroy
authorize Hourglass::TimeLog
bulk do |id|
authorize(time_log_from_id id).destroy
end
end
private
def time_log_params(params_hash = params.require(:time_log))
parse_boolean :round, params_hash.permit(:start, :stop, :comments, :round, :user_id)
end
def create_time_log_params(params_hash = params.require(:time_log))
params_hash.permit(:start, :stop, :comments, :user_id)
end
def split_params
parse_boolean [:round, :insert_new_before],
{
split_at: Time.parse(params[:split_at]),
insert_new_before: params[:insert_new_before],
round: params[:round]
}
end
def time_booking_params(params_hash = params.require(:time_booking))
parse_boolean :round, params_hash.permit(:comments, :project_id, :issue_id, :activity_id, :round,
custom_field_values: custom_field_keys(params_hash))
end
def time_log_from_id(id = params[:id])
Hourglass::TimeLog.find id
end
end
end

View File

@@ -0,0 +1,124 @@
module Hourglass
class TimeTrackersController < ApiBaseController
accept_api_auth :index, :show, :start, :update, :bulk_update, :stop, :destroy, :bulk_destroy
def index
list_records Hourglass::TimeTracker
end
def show
respond_with_success authorize get_time_tracker
end
def start
process_current_action
time_tracker = authorize Hourglass::TimeTracker.new time_tracker_params? ? time_tracker_params.except(:start) : {}
if time_tracker.save
respond_with_success time_tracker
else
respond_with_error :bad_request, time_tracker.errors.full_messages, array_mode: :sentence
end
end
def update
do_update get_time_tracker, time_tracker_params
end
def bulk_update
authorize Hourglass::TimeTracker
bulk do |id, params|
authorize_update time_tracker_from_id(id), time_tracker_permit(params)
end
end
def stop
time_tracker = authorize get_time_tracker
time_tracker.assign_attributes time_tracker_params if time_tracker_params?
time_log, time_booking = stop_time_tracker(time_tracker)
if time_tracker.destroyed?
respond_with_success({time_log: time_log, time_booking: time_booking}.compact)
else
error_messages = time_log&.errors&.full_messages || []
error_messages += time_booking&.errors&.full_messages || []
error_messages += time_tracker&.errors&.full_messages || []
respond_with_error :bad_request, error_messages, array_mode: :sentence
end
end
def destroy
authorize(get_time_tracker).destroy
respond_with_success
end
def bulk_destroy
authorize Hourglass::TimeTracker
bulk do |id|
authorize(time_tracker_from_id id).destroy
end
end
private
def time_tracker_params?
params[:time_tracker].present?
end
def time_tracker_params
time_tracker_permit params.require(:time_tracker)
end
def current_action_param
params[:current_action]
end
def current_update_params?
params[:current_update].present?
end
def current_update_params
time_tracker_permit params.require(:current_update)
end
def time_tracker_permit(hash)
hash.permit(:start, :comments, :round, :project_id, :issue_id, :activity_id, :user_id,
custom_field_values: custom_field_keys(hash))
end
def get_time_tracker(id = params[:id])
id == 'current' ? current_time_tracker : time_tracker_from_id(id)
end
def current_time_tracker
User.current.hourglass_time_tracker or raise ActiveRecord::RecordNotFound
end
def time_tracker_from_id(id)
Hourglass::TimeTracker.find id
end
def stop_time_tracker(time_tracker)
time_tracker.transaction do
time_log = time_tracker.stop
authorize time_log, :booking_allowed? if time_log && time_tracker.project
[time_log, time_log&.time_booking]
end if time_tracker.valid?
end
def process_current_action
case current_action_param
when 'destroy'
authorize(get_time_tracker).destroy
true
when 'stop'
time_tracker = authorize get_time_tracker
time_tracker.assign_attributes current_update_params if current_update_params?
time_log, time_booking = stop_time_tracker(time_tracker)
if time_tracker.destroyed?
true
else
error_messages = time_log&.errors&.full_messages || []
error_messages += time_booking&.errors&.full_messages || []
error_messages += time_tracker&.errors&.full_messages || []
respond_with_error :bad_request, error_messages, array_mode: :sentence
false
end
else
true
end
end
end
end

View File

@@ -0,0 +1,49 @@
class HourglassCompletionController < Hourglass::ApiBaseController
include Hourglass::ApplicationHelper
accept_api_auth :issues, :activities
def issues
issue_arel = Issue.arel_table
id_as_text = case Issue.connection.adapter_name
when 'PostgreSQL', 'SQLite'
Arel::Nodes::NamedFunction.new("CAST", [ issue_arel[:id].as("VARCHAR") ])
when 'Mysql2', 'MySQL'
Arel::Nodes::NamedFunction.new("CAST", [ issue_arel[:id].as("CHAR(50)") ])
else
issue_arel[:id] # unknown
end
was_admin = User.current.admin?
User.current.admin = false # prevent Redmine from ignoring permissions for admins, like we do later anyways
project = params[:project_id].present? ? Project.find(params[:project_id]) : nil
issues = Issue.cross_project_scope(project).visible
issues = issues.joins(:project).where(Project.allowed_to_one_of_condition User.current, Hourglass::AccessControl.permissions_from_action(controller: 'hourglass/time_logs', action: 'book')).where(
issue_arel[:id].eq(params[:term].to_i)
.or(id_as_text.matches("%#{params[:term]}%"))
.or(issue_arel[:subject].matches("%#{params[:term]}%"))
)
issue_list = issues.map do |issue|
{
label: "##{issue.id} #{issue.subject}",
issue_id: "#{issue.id}",
project_id: issue.project.id}
end
User.current.admin = was_admin
respond_with_success issue_list
end
def activities
activities = TimeEntryActivity.applicable(User.current.projects.find_by id: params[:project_id])
default_activity = User.current.default_activity activities
activities_result = activities.map do |activity|
{id: activity.id, name: activity.name, isDefault: default_activity && activity.name == default_activity.name}
end
respond_with_success activities_result
end
def users
project = User.current.projects.find_by id: params[:project_id]
users = project.nil? || User.current.allowed_to?(:hourglass_edit_booked_time, project) ? user_collection(project) : [User.current]
respond_with_success users.map { |user| {id: user.id, name: user.name} }
end
end

View File

@@ -0,0 +1,13 @@
class HourglassImportController < ApplicationController
def redmine_time_tracker_plugin
Hourglass::RedmineTimeTrackerImport.start!
flash[:notice] = I18n::t('hourglass.settings.import.success.redmine_time_tracker')
rescue => e
messages = [e.message] + e.backtrace
Rails.logger.error messages.join("\n")
flash[:error] = I18n::t('hourglass.settings.import.error.redmine_time_tracker')
ensure
redirect_to plugin_settings_path Hourglass::PLUGIN_NAME
end
end

View File

@@ -0,0 +1,24 @@
class HourglassProjectsController < ApplicationController
helper :application
def settings
find_project
deny_access unless User.current.allowed_to? :select_project_modules, @project
@settings = Hourglass::ProjectSettings.load(@project)
if request.post?
if @settings.update(hourglass_settings_params)
flash[:notice] = l(:notice_successful_update)
render js: "window.location='#{settings_project_path @project, tab: Hourglass::PLUGIN_NAME}'"
return
end
end
end
private
def hourglass_settings_params
params.require(:hourglass_project_settings).permit(:round_sums_only, :round_minimum, :round_limit,
:round_default, :round_carry_over_due, :clamp_limit)
end
end

View File

@@ -0,0 +1,82 @@
class HourglassQueriesController < ApplicationController
include QueriesHelper
include QueryConcern
before_action :find_query, only: [:edit, :update, :destroy]
before_action :find_project, :build_query, only: [:new, :create]
helper QueriesHelper
def new
@query.project = @project
@query.build_from_params(params)
end
def create
update_query_from_params
save
end
def edit
end
def update
update_query_from_params
save action: :update
end
def destroy
@query.destroy
redirect_to redirect_path set_filter: 1
end
private
def save(action: :create)
if @query.save
flash[:notice] = l(:"notice_successful_#{action}")
redirect_to redirect_path query_id: @query.id
else
render action: action == :create ? 'new' : 'edit'
end
end
def build_query
@query = query_class.new
@query.user = User.current
end
def update_query_from_params
@query.project = params[:query_is_for_all] ? nil : @project
@query.build_from_params(params)
@query.column_names = nil if params[:default_columns]
@query.sort_criteria = (params[:query] && params[:query][:sort_criteria]) || @query.sort_criteria
@query.name = params[:query] && params[:query][:name]
if User.current.allowed_to?(:manage_public_queries, @query.project) || User.current.admin?
@query.visibility = (params[:query] && params[:query][:visibility]) || Query::VISIBILITY_PRIVATE
@query.role_ids = params[:query] && params[:query][:role_ids]
else
@query.visibility = Query::VISIBILITY_PRIVATE
end
end
def find_query
@query = Query.find(params[:id])
@project = @query.project
render_403 unless @query.editable_by? User.current
rescue ActiveRecord::RecordNotFound
render_404
end
def find_project
@project = Project.visible.find(params[:project_id]) if params[:project_id]
render_403 unless User.current.allowed_to?(:save_queries, @project, global: true)
rescue ActiveRecord::RecordNotFound
render_404
end
def redirect_path(options = {})
uri = URI params[:request_referer].presence || request.referer || hourglass_ui_root_path
uri.query = URI.encode_www_form(URI.decode_www_form(uri.query || '') << options.flatten)
uri.to_s
end
end

View File

@@ -0,0 +1,41 @@
class HourglassUiController < ApplicationController
helper QueriesHelper
helper IssuesHelper
helper SortHelper
helper ContextMenusHelper
helper CustomFieldsHelper
helper Hourglass::ApplicationHelper
helper Hourglass::UiHelper
helper Hourglass::ListHelper
helper Hourglass::ChartHelper
helper Hourglass::ReportHelper
include AuthorizationConcern
include SortConcern
include QueryConcern
include ListConcern
include HourglassUi::Overview
include HourglassUi::TimeLogs
include HourglassUi::TimeBookings
include HourglassUi::TimeTrackers
before_action :require_login
def context_menu
list_type = get_list_type
@records = Hourglass.const_get(list_type.classify).find params[:ids]
render "hourglass_ui/#{list_type}/context_menu", layout: false
end
def api_docs
end
private
def get_list_type
list_type = %w(time_bookings time_logs time_trackers).select {|val| val == params[:list_type]}.first
render_403 unless list_type
list_type
end
end

View File

@@ -0,0 +1,80 @@
module Hourglass
module ApplicationHelper
def hourglass_asset_paths(type, sources)
options = sources.extract_options!
if options[:plugin] == Hourglass::PLUGIN_NAME && Rails.env.production?
plugin = options.delete(:plugin)
sources.map! do |source|
extname = compute_asset_extname source, options.merge(type: type)
source = "#{source}#{extname}" if extname.present?
source = File.join Hourglass::Assets.asset_directory_map[type], source
"/plugin_assets/#{plugin}/#{Hourglass::Assets.manifest.assets[source] || source}"
end
end
sources.push options
end
def javascript_include_tag(*sources)
super(*hourglass_asset_paths(:javascript, sources))
end
def stylesheet_link_tag(*sources)
super(*hourglass_asset_paths(:stylesheet, sources))
end
def form_field(field, form, object, options = {})
render partial: "hourglass_ui/forms/fields/#{field}", locals: {form: form, entry: object}.merge(options)
end
def issue_label_for(issue)
"##{issue.id} #{issue.subject}" if issue
end
def projects_for_project_select(selected = nil)
projects = User.current.projects.allowed_to_one_of(*(Hourglass::AccessControl.permissions_from_action(controller: 'hourglass/time_logs', action: 'book') + Hourglass::AccessControl.permissions_from_action(controller: 'hourglass/time_bookings', action: 'change')).flatten)
project_tree_options_for_select projects, selected: selected do |project|
{data: {
round_default: Hourglass::SettingsStorage[:round_default, project: project],
round_sums_only: Hourglass::SettingsStorage[:round_sums_only, project: project]
}}
end
end
def user_collection(project = nil)
project.present? ? project.users : User.active
end
def localized_hours_in_units(hours)
h, min = Hourglass::DateTimeCalculations.hours_in_units hours || 0
"#{h}#{t('hourglass.ui.chart.hour_sign')} #{min}#{t('hourglass.ui.chart.minute_sign')}"
end
def in_user_time_zone(time)
zone = User.current.time_zone
if zone
time.in_time_zone zone
else
time.utc? ? time.localtime : time
end
end
def css_classes(*args)
args.compact.join(' ')
end
def format_identifier_to_js(format)
{
'%b' => 'MMM',
'%B' => 'MMMM',
'%d' => 'DD',
'%m' => 'MM',
'%M' => 'mm',
'%H' => 'HH',
'%I' => 'hh',
'%p' => 'A',
'%P' => 'a',
'%Y' => 'YYYY'
}.inject(format) { |str, (k, v)| str.gsub(k, v) }
end
end
end

View File

@@ -0,0 +1,59 @@
module Hourglass
module ChartHelper
# works only for time bookings for now
def chart_data(chart_query)
data = Hash.new([].freeze)
ticks = []
tooltips = Hash.new([].freeze)
if chart_query.valid?
hours_per_date_without_column = hours_per_date chart_query
dates = hours_per_date_without_column.keys.compact.sort
if dates.present?
group_key_is_string = dates.first.is_a?(String)
date_range = group_key_is_string ? (Date.parse(dates.first)..Date.parse(dates.last)) : (dates.first..dates.last)
hours_per_column_per_date(hours_per_date_without_column).each do |column, hours_per_date|
date_range.each do |date|
hours = hours_per_date[group_key_is_string ? date.to_s : date]
data[column] += [hours || 0.0]
tooltips[column] += ["#{format_date date.to_time}, #{localized_hours_in_units hours}"]
end
end
ticks = calculate_ticks date_range
end
end
[data.values, ticks, tooltips.values]
end
private
# to get readable labels, we have to blank out some of them if there are to many
def calculate_ticks(date_range)
gap = [(date_range.count / 8.to_f).ceil, 1].max
date_range.each_with_index.map { |date, i| i % gap == 0 ? format_date(date.to_time) : '' }
end
def hours_per_date(query)
query.total_by_group_for(:hours).transform_values do |totals_by_column|
totals_by_column = {default: totals_by_column} unless query.main_query.grouped?
totals_by_column.transform_keys! { |_| :default } if query.main_query.group_by == 'date'
Hash[totals_by_column.map { |column, total| [column, unrounded_total(total)] }]
end
end
def unrounded_total(total)
total.reduce(0.0) do |sum, total_by_project|
sum + total_by_project[1].to_f.round(2)
end
end
def hours_per_column_per_date(hours_per_date)
hours_per_date.each_with_object({}) do |(date, hours_per_column), hours_per_column_per_date|
hours_per_column.each do |project_id, hours|
hours_per_column_per_date[project_id] ||= {}
hours_per_column_per_date[project_id][date] = hours
end
end
end
end
end

View File

@@ -0,0 +1,110 @@
module Hourglass
module ListHelper
def column_header(_query, column, options={})
return super if Hourglass.redmine_has_advanced_queries?
if column.sortable && options[:sort_param].present?
params[:sort] = params.delete options[:sort_param]
result = super column
result.gsub!(/(?<!_)sort(?==)/, options[:sort_param])
params[options[:sort_param]] = params.delete :sort
result.html_safe
else
super column
end
end
def grouped_entry_list(entries, query, &block)
return entry_list entries, &block unless query.grouped?
totals_by_group = query.totals_by_group
count_by_group = query.count_by_group
grouped_entries(entries, query).each do |group, group_entries|
yield nil, {
name: group_name(group, query, group_entries.first),
totals: transform_totals(extract_group_value(group, totals_by_group)),
count: extract_group_value(group, count_by_group)
}
entry_list group_entries, &block
end
end
def entry_list(entries)
entries.each { |entry| yield entry }
end
def render_query_totals(query)
return unless query.totalable_columns.present?
content_tag 'p', class: 'query-totals' do
totals_sum(query).each do |column, total|
concat total_tag(column, total)
end
end
end
def date_content(entry)
format_date entry.start
end
def start_content(entry)
format_time entry.start, false
end
def stop_content(entry)
format_time entry.stop, false
end
private
def grouped_entries(entries, query)
entries.group_by { |entry| query.column_value query.group_by_column, entry }
end
def extract_group_value(group, values_by_group)
values_by_group[group] || values_by_group[group.to_s] || (group.respond_to?(:id) && values_by_group[group.id]) || nil
end
def transform_totals(totals)
return {} if totals.nil?
Hash[totals.map do |column, total|
[
column,
column.name == :hours && total.is_a?(Hash) ? time_booking_total(total) : total
]
end]
end
def totals_sum(query)
if query.grouped?
query.totals_by_group.each_with_object(query.totalable_columns.map { |column| [column, 0.00] }.to_h) do |(_, totals), sum|
transform_totals(totals).each do |column, total|
sum[column] += total
end
end
else
query.totalable_columns.each_with_object(Hash.new(0)) do |column, sum|
total = query.total_for column
sum[column] += column.name == :hours && total.is_a?(Hash) ? time_booking_total(total) : total
end
end
end
def group_name(group, query, first_entry)
if group.blank? && group != false
"(#{l(:label_blank_value)})"
else
column_content query.group_by_column, first_entry
end
end
def time_booking_total(total)
total.reduce(0.0) do |sum, total_by_project|
sum + rounded_total(*total_by_project).to_f.round(2)
end
end
def rounded_total(project_id, total)
return total unless Hourglass::SettingsStorage[:round_sums_only, project: project_id]
Hourglass::DateTimeCalculations.in_hours(Hourglass::DateTimeCalculations.round_interval total.hours, project: project_id)
end
end
end

View File

@@ -0,0 +1,54 @@
module Hourglass
module ReportHelper
def report_column_map
@report_column_map ||= {
date: [:start, :stop],
description: [:activity, :issue, :comments, :project, :fixed_version],
duration: [:hours, :start, :stop]
}
end
def combined_column_names(column)
report_column_map.select { |_key, array| array.include? column.name }.keys
end
def combined_columns
columns = []
@query.columns.each do |column|
combined_names = combined_column_names(column)
columns.push column if combined_names.empty?
combined_names.reject { |name| columns.find { |col| col.name == name } }.each do |name|
columns.push QueryColumn.new name
end
end
columns.sort_by! do |column|
report_column_map.keys.index(column.name) || Float::INFINITY
end
end
def description_content(entry)
output = ActiveSupport::SafeBuffer.new
if entry.issue.present?
output.concat [entry.activity, entry.issue].compact.join(' - ')
else
output.concat [entry.activity, entry.comments].compact.join(': ')
end
version = content_tag :div, class: 'project' do
[entry.project, entry.fixed_version].compact.join(' / ')
end
output.concat version
output
end
def duration_content(entry)
output = ActiveSupport::SafeBuffer.new
output.concat localized_hours_in_units entry.hours
time = content_tag :div, class: 'start-stop' do
[format_time(entry.start, false), format_time(entry.stop, false)].compact.join(' - ')
end
output.concat time
output
end
end
end

View File

@@ -0,0 +1,64 @@
module Hourglass
module UiHelper
def title_for_query_view
title @query.persisted? ? h(@query.name) : t("hourglass.ui.#{action_name}.title")
end
def render_main_menu(_project)
render_menu :hourglass_menu
end
def display_main_menu?(_project)
Redmine::MenuManager.items(:hourglass_menu).children.present?
end
def query_links(title, queries)
params.delete :set_filter
super
end
unless Hourglass.redmine_has_advanced_queries?
def render_sidebar_queries(_klass, _project)
super()
end
def sidebar_queries
@sidebar_queries ||= query_class.visible.where(project: [nil, @project]).order(name: :asc)
end
end
def column_content(column, entry, use_html = true)
content_method = "#{column.name}_content".to_sym
if respond_to? content_method
send content_method, entry
elsif use_html
super column, entry
else
csv_content column, entry
end
end
def format_date(time)
return nil unless time
super in_user_time_zone(time).to_date
end
def date_time_format
date = Setting.date_format.blank? ? I18n.t('date.formats.default') : Setting.date_format
time = Setting.time_format.blank? ? I18n.t('time.formats.time') : Setting.time_format
"#{date} #{time}"
end
def utc_offset
user_time_zone = User.current.time_zone
return user_time_zone.now.formatted_offset if user_time_zone
time = Time.now
return time.localtime.formatted_offset if time.utc?
time.formatted_offset
end
def date_strings_lookup(key)
I18n.t(key, scope: :date).compact.to_json.html_safe
end
end
end

View File

@@ -0,0 +1,7 @@
module Hourglass::Namespace
extend ActiveSupport::Concern
included do
self.table_name_prefix = 'hourglass_'
end
end

View File

@@ -0,0 +1,11 @@
module Hourglass::ProjectIssueSyncing
extend ActiveSupport::Concern
included do
before_save :sync_issue_and_project
end
def sync_issue_and_project
self.project_id = issue.project_id if issue.present?
end
end

View File

@@ -0,0 +1,222 @@
Query # workaround: loading Query loads QueryColumn
module Hourglass::QueryBase
extend ActiveSupport::Concern
class NestedGroupableQueryColumn < QueryColumn
def groupable?
not @groupable.nil?
end
def group_by_statement
@groupable
end
end
included do
# copied from issue query, without the view_issues right check
scope :visible, lambda { |*args|
user = args.shift || User.current
scope = joins("LEFT OUTER JOIN #{Project.table_name} ON #{table_name}.project_id = #{Project.table_name}.id").
where("#{table_name}.project_id IS NULL")
if user.admin?
scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", Query::VISIBILITY_PRIVATE, user.id)
elsif user.memberships.any?
scope.where("#{table_name}.visibility = ?" +
" OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
"SELECT DISTINCT q.id FROM #{table_name} q" +
" INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
" INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
" INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
" WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
" OR #{table_name}.user_id = ?",
Query::VISIBILITY_PUBLIC, Query::VISIBILITY_ROLES, user.id, user.id)
elsif user.logged?
scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", Query::VISIBILITY_PUBLIC, user.id)
else
scope.where("#{table_name}.visibility = ?", Query::VISIBILITY_PUBLIC)
end
}
end
def build_from_params(params)
super
self.totalable_names = self.default_totalable_names unless params[:t] || (params[:query] && params[:query][:totalable_names])
self
end
def queried_class
self.class.queried_class
end
def base_scope
queried_class.where statement
end
def is_private?
visibility == Query::VISIBILITY_PRIVATE
end
def is_public?
!is_private?
end
def results_scope(options = {})
order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
base_scope.
order(order_option).
joins(joins_for_order_statement(order_option.join(',')))
end
def default_totalable_names
@default_totalable_names ||= [:hours]
end
def count_by_group
grouped_query do |scope|
scope.count
end
end
def totals_by_group
totalable_columns.each_with_object({}) do |column, result|
total_by_group_for(column).each do |group, total|
result[group] ||= {}
result[group][column] = total
end
end
end
def column_value(column, entry)
content_method = "#{column.name}_value".to_sym
if respond_to? content_method
send content_method, entry
else
column.value entry
end
end
def date_value(entry)
User.current.time_to_date(entry.start)
end
def sql_for_date_field(field, operator, value)
sql_for_field(field, operator, value, queried_class.table_name, 'start')
end
def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
sql = ''
case operator
when 'w+lw'
# = this and last week
first_day_of_week = l(:general_first_day_of_week).to_i
day_of_week = Date.today.cwday
days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
sql = relative_date_clause(db_table, db_field, -days_ago - 7, -days_ago + 6, is_custom_filter)
when 'q'
# = current quarter
date = User.current.today
sql = date_clause(db_table, db_field, date.beginning_of_quarter, date.end_of_quarter, is_custom_filter)
when 'lq'
# = last quarter
date = User.current.today - 3.months
sql = date_clause(db_table, db_field, date.beginning_of_quarter, date.end_of_quarter, is_custom_filter)
else
sql = super
end
sql
end
private
def add_date_filter
add_available_filter 'date', type: :date
end
def add_comments_filter
add_available_filter 'comments', type: :text
end
def add_user_filter
principals = []
if project
principals += project.principals.visible.sort
unless project.leaf?
sub_projects = project.descendants.visible.to_a
principals += Principal.member_of(sub_projects).visible
end
else
if all_projects.any?
principals += Principal.member_of(all_projects).visible
end
end
principals.uniq!
principals.sort!
users = principals.select { |p| p.is_a?(User) }
values = []
values << ["<< #{l(:label_me)} >>", 'me'] if User.current.logged?
values += users.collect { |s| [s.name, s.id.to_s] }
add_available_filter 'user_id', type: :list, values: values if values.any?
end
def add_project_filter
values = []
if User.current.logged? && User.current.memberships.any?
values << ["<< #{l(:label_my_projects).downcase} >>", 'mine']
end
values += all_projects_values
add_available_filter 'project_id', type: :list, values: values if values.any?
end
def add_sub_project_filter
sub_projects = project.descendants.visible.to_a
values = sub_projects.collect { |s| [s.name, s.id.to_s] }
add_available_filter 'subproject_id', type: :list_subprojects, values: values if values.any?
end
def add_issue_filter
issues = Issue.visible.all
values = issues.collect { |s| [s.subject, s.id.to_s] }
add_available_filter 'issue_id', type: :list, values: values if values.any?
add_available_filter 'issue_subject', type: :text if issues.any?
end
def add_activity_filter
activities = project ? project.activities : TimeEntryActivity.shared
values = activities.map { |a| [a.name, a.id.to_s] }
add_available_filter 'activity_id', type: :list, values: values if values.any?
end
def add_fixed_version_filter
versions = if project
project.shared_versions.to_a
else
Version.visible.to_a
end
values = versions.uniq.sort.collect { |s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
add_available_filter 'fixed_version_id', type: :list_optional, values: values
end
def associated_custom_field_columns(association, custom_fields, options = {})
custom_fields.visible.map do |custom_field|
QueryAssociationCustomFieldColumn.new(association, custom_field, options)
end
end
def has_through_associations
[]
end
def sql_for_custom_field(*args)
result = super
result.gsub(/#{queried_table_name}\.(#{has_through_associations.join('|')})_id/) do
groupable_columns.select { |c| c.name == Regexp.last_match[1].to_sym }.first.group_by_statement
end
end
# this is a fix for redmine 3.2.7
def issue_custom_fields
return super if defined? super
IssueCustomField.all
end
end

View File

@@ -0,0 +1,23 @@
module Hourglass::TypeParsing
def parse_type(type, attribute)
if Rails::VERSION::MAJOR <= 4
case type
when :boolean
ActiveRecord::Type::Boolean.new.type_cast_from_user(attribute)
when :integer
ActiveRecord::Type::Integer.new.type_cast_from_user(attribute)
when :float
ActiveRecord::Type::Float.new.type_cast_from_user(attribute)
end
else
case type
when :boolean
ActiveRecord::Type::Boolean.new.cast(attribute)
when :integer
ActiveRecord::Type::Integer.new.cast(attribute)
when :float
ActiveRecord::Type::Float.new.cast(attribute)
end
end
end
end

View File

@@ -0,0 +1,25 @@
module Hourglass
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
before_save :execute_temporary_proc
def serializable_hash(options)
super(options).select { |_, v| v }
end
def with_before_save(proc)
@temporary_proc = proc
result = yield
@temporary_proc = nil
result
end
private
attr_accessor :temporary_proc
def execute_temporary_proc
temporary_proc.call if temporary_proc.is_a? Proc
end
end
end

View File

@@ -0,0 +1,24 @@
module Hourglass
class ChartQuery < TimeBookingQuery
attr_accessor :main_query
def initialize(attributes = nil, *args)
self.main_query = attributes.delete :main_query
super
end
def total_for_hours(scope)
scope = scope.group(main_query.group_by_statement) if main_query.group_by_statement
scope.group("#{TimeEntry.table_name}.project_id").sum("#{TimeEntry.table_name}.hours").each_with_object({}) do |((date, column, project_id), total), totals|
totals[date] ||= {}
totals[date][column] ||= {}
if project_id
totals[date][column][project_id] = total
else
totals[date][column] = total
end
end
end
end
end

View File

@@ -0,0 +1,77 @@
module Hourglass
class GlobalSettings
include TypeParsing
include ActiveModel::Model
attr_accessor :round_sums_only,
:round_minimum,
:round_limit,
:round_default,
:round_carry_over_due,
:report_title,
:report_logo_url,
:report_logo_width,
:global_tracker,
:clamp_limit
validates :round_sums_only, inclusion: { in: ['true', 'false', '1', '0', true, false] }
validates :round_minimum, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 24 }
validates :round_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
validates :round_default, inclusion: { in: ['true', 'false', '1', '0', true, false] }
validates :round_carry_over_due, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 24 }
validates :report_title, length: { maximum: 255 }, presence: true
validates :report_logo_url, length: { maximum: 4096 }
validates :report_logo_width, numericality: { only_integer: true, greater_than_or_equal_to: 0,
less_than_or_equal_to: 9999 }
validates :global_tracker, inclusion: { in: ['true', 'false', '1', '0', true, false] }
validates :clamp_limit, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 24 }
def initialize
from_hash Hourglass::SettingsStorage
end
def update(attributes)
from_hash attributes
if valid?
resolve_types
Hourglass::SettingsStorage[] = to_hash
end
valid?
end
private
def from_hash(attributes)
self.round_sums_only = attributes[:round_sums_only]
self.round_minimum = attributes[:round_minimum]
self.round_limit = attributes[:round_limit]
self.round_default = attributes[:round_default]
self.round_carry_over_due = attributes[:round_carry_over_due]
self.report_title = attributes[:report_title]
self.report_logo_url = attributes[:report_logo_url]
self.report_logo_width = attributes[:report_logo_width]
self.global_tracker = attributes[:global_tracker]
self.clamp_limit = attributes[:clamp_limit]
end
def to_hash
{
round_sums_only: round_sums_only, round_minimum: round_minimum, round_limit: round_limit,
round_default: round_default, round_carry_over_due: round_carry_over_due, report_title: report_title,
report_logo_url: report_logo_url, report_logo_width: report_logo_width, global_tracker: global_tracker,
clamp_limit: clamp_limit
}
end
def resolve_types
self.round_sums_only = parse_type :boolean, @round_sums_only
self.round_minimum = parse_type :float, @round_minimum
self.round_limit = parse_type :integer, @round_limit
self.round_default = parse_type :boolean, @round_default
self.round_carry_over_due = parse_type :float, @round_carry_over_due
self.report_logo_width = parse_type :integer, @report_logo_width
self.global_tracker = parse_type :boolean, @global_tracker
self.clamp_limit = parse_type :float, @clamp_limit
end
end
end

View File

@@ -0,0 +1,72 @@
module Hourglass
class ProjectSettings
include TypeParsing
include ActiveModel::Model
attr_accessor :round_sums_only,
:round_minimum,
:round_limit,
:round_default,
:round_carry_over_due,
:clamp_limit
validates :round_sums_only, inclusion: { in: ['true', 'false', true, false] }, allow_blank: true
validates :round_minimum, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 24 }, allow_blank: true
validates :round_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0,
less_than_or_equal_to: 100 }, allow_blank: true
validates :round_default, inclusion: { in: ['true', 'false', true, false] }, allow_blank: true
validates :round_carry_over_due, numericality: { greater_than_or_equal_to: 0,
less_than_or_equal_to: 24 }, allow_blank: true
validates :clamp_limit, numericality: { greater_than_or_equal_to: 0,
less_than_or_equal_to: 24 }, allow_blank: true
def initialize(project = nil)
@project = project
from_hash Hourglass::SettingsStorage.project(@project)
end
def self.load(project = nil)
self.new project
end
def update(attributes)
from_hash attributes
if valid?
resolve_types
Hourglass::SettingsStorage[project: @project] = to_hash
end
valid?
end
private
def from_hash(attributes)
self.round_sums_only = attributes[:round_sums_only]
self.round_minimum = attributes[:round_minimum]
self.round_limit = attributes[:round_limit]
self.round_default = attributes[:round_default]
self.round_carry_over_due = attributes[:round_carry_over_due]
self.clamp_limit = attributes[:clamp_limit]
end
def to_hash
{
round_sums_only: round_sums_only,
round_minimum: round_minimum,
round_limit: round_limit,
round_default: round_default,
round_carry_over_due: round_carry_over_due,
clamp_limit: clamp_limit
}
end
def resolve_types
self.round_sums_only = parse_type :boolean, @round_sums_only
self.round_minimum = parse_type :float, @round_minimum
self.round_limit = parse_type :integer, @round_limit
self.round_default = parse_type :boolean, @round_default
self.round_carry_over_due = parse_type :float, @round_carry_over_due
self.clamp_limit = parse_type :float, @clamp_limit
end
end
end

View File

@@ -0,0 +1,88 @@
module Hourglass
class TimeBooking < ApplicationRecord
include Namespace
include ProjectIssueSyncing
belongs_to :time_log
belongs_to :time_entry, dependent: :destroy
has_one :user, through: :time_log
has_one :project, through: :time_entry
has_one :issue, through: :time_entry
has_one :activity, through: :time_entry
has_one :fixed_version, through: :issue
has_many :custom_values, through: :time_entry
accepts_nested_attributes_for :time_entry
accepts_nested_attributes_for :time_log
after_initialize :fix_nil_hours
after_validation :filter_time_entry_invalid_error
after_save :save_custom_field_values
validates_presence_of :time_log, :time_entry, :start, :stop
validate :stop_is_valid
validates_associated :time_entry
delegate :id, to: :issue, prefix: true, allow_nil: true
delegate :id, to: :activity, prefix: true, allow_nil: true
delegate :id, to: :project, prefix: true, allow_nil: true
delegate :id, to: :user, prefix: true, allow_nil: true
delegate :comments, :comments=, :hours, :project_id=, :save_custom_field_values, to: :time_entry, allow_nil: true
scope :visible, lambda { |*args| joins(:project).where(projects: {id: visible_condition(args.shift || User.current, *args)})
}
def update(args = {})
if args[:time_entry_attributes].present? && time_entry.present?
args[:time_entry_attributes].merge! id: time_entry_id
end
if args[:time_log_attributes].present? && time_log.present?
args[:time_log_attributes].merge! id: time_log_id
end
super args
end
def rounding_carry_over
(stop - time_log.stop).to_i
end
def as_json(args = {})
includes = [:time_entry]
includes << :time_log if include_time_log?
super({include: includes}.deep_merge args)
end
def include_time_log!
@include_time_log = true
end
def time_tracker_params
{issue_id: issue_id, project_id: project_id, activity_id: activity_id, comments: comments}
end
private
def self.visible_condition(user, _options = {})
project_ids = Project.allowed_to(user, :hourglass_view_booked_time).pluck :id
project_ids += Project.allowed_to(user, :hourglass_view_own_booked_time).pluck :id
project_ids.uniq
end
def fix_nil_hours
time_entry.hours ||= 0 if time_entry && time_entry.activity_id.blank? #redmine sets hours to nil, if it's 0 on initializing
end
def filter_time_entry_invalid_error
self.errors.delete(:time_entry)
# self.errors.messages.transform_keys! {|k| k == :'time_entry.base' ? :base : k}
end
def stop_is_valid
#this is different from the stop validation of time log
errors.add :stop, :invalid if stop.present? && start.present? && stop < start
end
def include_time_log?
@include_time_log
end
end
end

View File

@@ -0,0 +1,109 @@
module Hourglass
class TimeBookingQuery < Query
include QueryBase
self.queried_class = TimeBooking
self.available_columns = [
TimestampQueryColumn.new(:date, sortable: "#{TimeBooking.table_name}.start", groupable: true),
QueryColumn.new(:start),
QueryColumn.new(:stop),
QueryColumn.new(:hours, totalable: true),
QueryColumn.new(:comments),
NestedGroupableQueryColumn.new(:user, sortable: lambda { User.fields_for_order_statement }, groupable: "#{User.table_name}.id"),
NestedGroupableQueryColumn.new(:project, sortable: "#{Project.table_name}.name", groupable: "#{Project.table_name}.id"),
NestedGroupableQueryColumn.new(:activity, sortable: "#{TimeEntryActivity.table_name}.position", groupable: "#{TimeEntryActivity.table_name}.id"),
NestedGroupableQueryColumn.new(:issue, sortable: "#{Issue.table_name}.subject", groupable: "#{Issue.table_name}.id"),
NestedGroupableQueryColumn.new(:fixed_version, sortable: lambda { Version.fields_for_order_statement }, groupable: "#{Issue.table_name}.fixed_version_id"),
]
def initialize(attributes=nil, *args)
super attributes
self.filters ||= {'date' => {:operator => "m", :values => [""]}}
end
def initialize_available_filters
add_user_filter
add_date_filter
add_issue_filter
if project
add_sub_project_filter unless project.leaf?
else
add_project_filter if all_projects.any?
end
add_activity_filter
add_fixed_version_filter
add_comments_filter
add_associations_custom_fields_filters :user, :project, :activity, :fixed_version
add_custom_fields_filters issue_custom_fields, :issue
add_custom_fields_filters time_entry_custom_fields, :time_entry
end
def available_columns
@available_columns ||= self.class.available_columns.dup.tap do |available_columns|
available_columns.push *associated_custom_field_columns(:time_entry, time_entry_custom_fields)
available_columns.push *associated_custom_field_columns(:issue, issue_custom_fields, totalable: false)
available_columns.push *associated_custom_field_columns(:project, project_custom_fields, totalable: false)
# 2021-07-06 arBmind: custom fields for users cannot be properly authorized
# available_columns.push *associated_custom_field_columns(:user, UserCustomField, totalable: false)
available_columns.push *associated_custom_field_columns(:fixed_version, VersionCustomField, totalable: false)
end
end
def default_columns_names
@default_columns_names ||= [:date, :start, :stop, :hours, :project, :issue, :activity, :comments]
end
def base_scope
super.visible.eager_load(:time_entry, :activity, :user, :project, issue: :fixed_version)
end
def sql_for_user_id_field(field, operator, value)
sql_for_field(field, operator, value, User.table_name, 'id')
end
def sql_for_project_id_field(field, operator, value)
sql_for_field(field, operator, value, Project.table_name, 'id')
end
def sql_for_issue_id_field(field, operator, value)
sql_for_field(field, operator, value, Issue.table_name, 'id')
end
def sql_for_issue_subject_field(field, operator, value)
sql_for_field(field, operator, value, Issue.table_name, 'subject')
end
def sql_for_fixed_version_id_field(field, operator, value)
sql_for_field(field, operator, value, Issue.table_name, 'fixed_version_id')
end
def sql_for_comments_field(field, operator, value)
sql_for_field(field, operator, value, TimeEntry.table_name, 'comments', true)
end
def sql_for_activity_id_field(field, operator, value)
condition_on_id = sql_for_field(field, operator, value, Enumeration.table_name, 'id')
condition_on_parent_id = sql_for_field(field, operator, value, Enumeration.table_name, 'parent_id')
if operator == '='
"(#{condition_on_id} OR #{condition_on_parent_id})"
else
"(#{condition_on_id} AND #{condition_on_parent_id})"
end
end
def total_for_hours(scope)
scope.group("#{TimeEntry.table_name}.project_id").sum("#{TimeEntry.table_name}.hours").each_with_object({}) do |((column, project_id), total), totals|
totals[column] ||= {}
if project_id
totals[column][project_id] = total
else
totals[column] = total
end
end
end
def has_through_associations
%i(user issue project activity fixed_version)
end
end
end

View File

@@ -0,0 +1,138 @@
module Hourglass
class TimeLog < ApplicationRecord
include Namespace
class AlreadyBookedException < StandardError
end
belongs_to :user
has_one :time_booking, dependent: :destroy
has_one :time_entry, through: :time_booking
before_save :remove_seconds
validates_presence_of :user, :start, :stop
validates_length_of :comments, maximum: 1024, allow_blank: true
validate :stop_is_valid
validate :does_not_overlap_with_other, if: [:user, :start?, :stop?]
delegate :project, to: :time_booking, allow_nil: true
scope :booked_on_project, lambda { |project_id|
joins(:time_entry).where(time_entries: {project_id: project_id})
}
scope :with_start_in_interval, lambda { |floor, ceiling|
where(arel_table[:start].gt(floor).and(arel_table[:start].lt(ceiling)))
}
scope :overlaps_with, lambda { |start, stop|
where(arel_table[:start].lt(stop).and(arel_table[:stop].gt(start)))
}
def build_time_booking(args = {})
super time_booking_arguments default_booking_arguments.merge args
end
def update(attributes)
round = attributes.delete :round
ActiveRecord::Base.transaction do
result = super attributes
if booked?
DateTimeCalculations.booking_process user, start: start, stop: stop, project_id: time_booking.project_id, round: round do |options|
time_booking.update start: options[:start], stop: options[:stop], time_entry_attributes: {hours: DateTimeCalculations.time_diff_in_hours(options[:start], options[:stop])}
time_booking
end
raise ActiveRecord::Rollback unless time_booking.persisted?
end
result
end
end
def book(attributes)
raise AlreadyBookedException if booked?
DateTimeCalculations.booking_process user, default_booking_arguments.merge(attributes.except(:start, :stop)) do |options|
create_time_booking time_booking_arguments options
end
end
def split(args)
split_at = args[:split_at].change(sec: 0)
insert_new_before, round = args.values_at :insert_new_before, :round
return if start >= split_at || split_at >= stop
old_time = insert_new_before ? start : stop
ActiveRecord::Base.transaction do
update insert_new_before ? {start: split_at, round: round} : {stop: split_at, round: round}
new_time_log_args = insert_new_before ? {start: old_time, stop: split_at} : {start: split_at, stop: old_time}
self.class.create new_time_log_args.merge user: user, comments: comments
end
end
def join_with(other)
return false unless joinable? other
new_stop = other.stop
ActiveRecord::Base.transaction do
other.destroy
update stop: new_stop
end
true
end
def hours
DateTimeCalculations.time_diff_in_hours start, stop
end
def booked?
time_booking.present? && time_booking.persisted?
end
def bookable?
!booked?
end
def as_json(args = {})
super args.deep_merge methods: :hours
end
def joinable?(other)
user_id == other.user_id && stop == other.start && bookable? && other.bookable?
end
def self.joinable?(*ids)
where(id: ids).order(start: :asc).reduce do |previous, time_log|
return false unless previous.joinable?(time_log)
time_log
end
true
end
private
def default_booking_arguments
{start: start, stop: stop, comments: comments, time_log_id: id, user: user}.with_indifferent_access
end
def time_booking_arguments(options)
options
.slice(:start, :stop, :time_log_id)
.merge time_entry_attributes: time_entry_arguments(options)
end
def time_entry_arguments(options)
options
.slice(:project_id, :issue_id, :comments, :activity_id, :user, :custom_field_values)
.merge spent_on: User.current.time_to_date(options[:start]), hours: DateTimeCalculations.time_diff_in_hours(options[:start], options[:stop]), author: options[:user]
end
def stop_is_valid
errors.add :stop, :invalid if stop.present? && start.present? && stop <= start
end
def does_not_overlap_with_other
errors.add :base, :overlaps unless user.hourglass_time_logs.where.not(id: id).overlaps_with(start, stop).empty?
end
def remove_seconds
self.start = start.change(sec: 0) if start
self.stop = stop.change(sec: 0) if stop
end
end
end

View File

@@ -0,0 +1,75 @@
module Hourglass
class TimeLogQuery < Query
include QueryBase
self.queried_class = TimeLog
self.available_columns = [
QueryColumn.new(:comments),
QueryColumn.new(:user, sortable: lambda { User.fields_for_order_statement }, groupable: true),
TimestampQueryColumn.new(:date, sortable: "#{TimeLog.table_name}.start", groupable: true),
QueryColumn.new(:start),
QueryColumn.new(:stop),
QueryColumn.new(:hours, totalable: true),
QueryColumn.new(:booked?),
]
def initialize(attributes=nil, *args)
super attributes
self.filters ||= {'date' => {:operator => "m", :values => [""]}}
end
def initialize_available_filters
add_user_filter
add_date_filter
add_comments_filter
add_available_filter 'booked', label: :field_booked?, type: :list, values: [[I18n.t(:general_text_Yes), true]]
add_associations_custom_fields_filters :user
end
def available_columns
@available_columns ||= self.class.available_columns.dup.tap do |available_columns|
# 2021-07-06 arBmind: custom fields for users cannot be properly authorized
# available_columns.push *associated_custom_field_columns(:user, UserCustomField, totalable: false)
end
end
def default_columns_names
@default_columns_names ||= [:booked?, :date, :start, :stop, :hours, :comments]
end
def base_scope
super.eager_load(:user, time_booking: :project)
end
def sql_for_booked_field(field, operator, _value)
operator_to_use = operator == '=' ? '*' : '!*'
sql_for_field(field, operator_to_use, nil, TimeBooking.table_name, 'id')
end
def sql_for_comments_field(field, operator, value)
sql_for_field(field, operator, value, TimeLog.table_name, 'comments', true)
end
def total_for_hours(scope)
map_total(
scope.sum db_datetime_diff "#{queried_class.table_name}.start", "#{queried_class.table_name}.stop"
) { |t| Hourglass::DateTimeCalculations.in_hours(t).round(2) }
end
private
def db_datetime_diff(datetime1, datetime2)
case ActiveRecord::Base.connection.adapter_name.downcase.to_sym
when :mysql2
"TIMESTAMPDIFF(SECOND, #{datetime1}, #{datetime2})"
when :sqlite
"(strftime('%s', #{datetime2}) - strftime('%s', #{datetime1}))"
when :postgresql
"EXTRACT(EPOCH FROM (#{datetime2} - #{datetime1}))"
else
"(#{datetime2} - #{datetime1})"
end
end
end
end

View File

@@ -0,0 +1,98 @@
module Hourglass
class TimeTracker < ApplicationRecord
include Namespace
include ProjectIssueSyncing
belongs_to :user
belongs_to :project
belongs_to :issue
belongs_to :activity, class_name: 'TimeEntryActivity', foreign_key: 'activity_id'
has_one :fixed_version, through: :issue
acts_as_customizable
after_initialize :init
before_update if: :project_id_changed? do
update_round project
true
end
validates_uniqueness_of :user_id
validates_presence_of :user, :start
validates_presence_of :project, if: Proc.new { |tt| tt.project_id.present? }
validates_presence_of :issue, if: Proc.new { |tt| tt.issue_id.present? }
validates_presence_of :activity, if: Proc.new { |tt| tt.activity_id.present? }
validates_length_of :comments, maximum: 255, allow_blank: true
validate :does_not_overlap_with_other, if: [:user, :start?], on: :update
class << self
alias_method :start, :create
end
def stop
stop = DateTimeCalculations.calculate_stoppable_time start, project: project
time_log = nil
transaction(requires_new: true) do
if start < stop
time_log = TimeLog.create time_log_params.merge stop: stop
raise ActiveRecord::Rollback unless time_log.persisted?
time_booking = bookable? ? time_log.book(time_booking_params) : nil
raise ActiveRecord::Rollback if time_booking && !time_booking.persisted?
end
destroy
end
time_log
end
def hours
DateTimeCalculations.time_diff_in_hours start, Time.now.change(sec: 0) + 1.minute
end
def available_custom_fields
CustomField.where("type = 'TimeEntryCustomField'").sorted.to_a
end
def validate_custom_field_values
super unless new_record?
end
def clamp?
DateTimeCalculations.clamp? start, project: project
end
private
def init
now = Time.now.change sec: 0
self.user ||= User.current
previous_time_log = user.hourglass_time_logs.find_by(stop: now + 1.minute)
self.project_id ||= issue && issue.project_id
update_round project_id unless round.present?
self.start ||= previous_time_log && previous_time_log.stop || now
self.activity ||= user.default_activity(TimeEntryActivity.applicable(user.projects.find_by id: project_id)) if project_id
end
def time_log_params
attributes.with_indifferent_access.slice :start, :user_id, :comments
end
def time_booking_params
attributes.with_indifferent_access.slice(:project_id, :issue_id, :activity_id, :round)
.merge custom_field_values: custom_field_values.inject({}) { |h, v| h[v.custom_field_id.to_s] = v.value; h }
end
def bookable?
project.present?
end
def update_round(project = nil)
self.round = !Hourglass::SettingsStorage[:round_sums_only, project: project] &&
Hourglass::SettingsStorage[:round_default, project: project]
end
def does_not_overlap_with_other
errors.add :base, :overlaps if user.hourglass_time_logs.overlaps_with(start, Time.now).any?
end
end
end

View File

@@ -0,0 +1,77 @@
module Hourglass
class TimeTrackerQuery < Query
include QueryBase
self.queried_class = TimeTracker
self.available_columns = [
QueryColumn.new(:comments),
QueryColumn.new(:user, sortable: lambda { User.fields_for_order_statement }, groupable: true),
TimestampQueryColumn.new(:date, sortable: "#{TimeTracker.table_name}.start", groupable: true),
QueryColumn.new(:start),
QueryColumn.new(:hours),
QueryColumn.new(:project, sortable: "#{Project.table_name}.name", groupable: true),
QueryColumn.new(:activity, sortable: "#{TimeEntryActivity.table_name}.position", groupable: true),
QueryColumn.new(:issue, sortable: "#{Issue.table_name}.subject", groupable: true),
QueryColumn.new(:fixed_version, sortable: lambda { Version.fields_for_order_statement }, groupable: true),
]
def initialize(attributes=nil, *args)
super attributes
self.filters ||= {}
end
def initialize_available_filters
add_user_filter
add_date_filter
add_issue_filter
if project
add_sub_project_filter unless project.leaf?
elsif all_projects.any?
add_project_filter
end
add_activity_filter
add_fixed_version_filter
add_comments_filter
add_associations_custom_fields_filters :user, :project, :activity, :fixed_version
add_custom_fields_filters issue_custom_fields, :issue
end
def available_columns
@available_columns ||= self.class.available_columns.dup.tap do |available_columns|
available_columns.push *associated_custom_field_columns(:issue, issue_custom_fields, totalable: false)
available_columns.push *associated_custom_field_columns(:project, project_custom_fields, totalable: false)
# 2021-07-06 arBmind: custom fields for users cannot be properly authorized
# available_columns.push *associated_custom_field_columns(:user, UserCustomField, totalable: false)
available_columns.push *associated_custom_field_columns(:fixed_version, VersionCustomField, totalable: false)
end
end
def default_columns_names
@default_columns_names ||= [:user, :date, :start, :hours, :project, :issue, :activity, :comments]
end
def base_scope
super.eager_load(:user, :project, :activity, issue: :fixed_version)
end
def sql_for_fixed_version_id_field(field, operator, value)
sql_for_field(field, operator, value, Issue.table_name, 'fixed_version_id')
end
def sql_for_custom_field(*args)
result = super
result.gsub! /#{queried_table_name}\.(fixed_version)_id/ do
groupable_columns.select { |c| c.name === $1.to_sym }.first.groupable
end
result
end
def sql_for_comments_field(field, operator, value)
sql_for_field(field, operator, value, TimeTracker.table_name, 'comments', true)
end
def has_through_associations
%i(fixed_version)
end
end
end

View File

@@ -0,0 +1,40 @@
module RedmineAuthorization
def allowed_to?(action, controller_name = nil)
controller = controller_name || self.class.name.gsub('::Scope', '').demodulize.gsub('Policy', '').tableize
action_args = {controller: "hourglass/#{controller}", action: action}
project.blank? ? user.allowed_to_globally?(action_args) : user.allowed_to?(action_args, project)
end
private
def authorized?(action)
return foreign_authorized? action if foreign_entry?
allowed_to? action
end
def foreign_authorized?(action)
foreign_forbidden_message and return false unless allowed_to? "#{action}_foreign"
true
end
def foreign_entry?
record_user && record_user != user
end
def unsafe_attributes?
return false unless record.respond_to? :changed
unsafe_attributes = record.changed.map(&:to_sym).select { |attr| protected_parameters.include? attr }
if record.new_record?
unsafe_attributes.delete :user_id
unsafe_attributes.delete :start
end
unsafe_attributes.length > 0
end
def foreign_forbidden_message
@message ||= I18n.t('hourglass.api.errors.change_others_forbidden')
end
def update_all_forbidden_message
@message ||= I18n.t('hourglass.api.errors.update_all_forbidden')
end
end

View File

@@ -0,0 +1,56 @@
module Hourglass
class ApplicationPolicy
include RedmineAuthorization
attr_reader :user, :record, :message
def initialize(user, record)
@user = user
@record = record
end
def project
record.project if record.respond_to? :project
end
def record_user
record.user if record.respond_to? :user
end
def view?
authorized? :view
end
def create?
if unsafe_attributes?
update_all_forbidden_message and return false unless authorized? :change_all
end
authorized? :create
end
def protected_parameters
%i(start stop user user_id)
end
def change?(param = nil)
condition = param ? protected_parameters.include?(param) : unsafe_attributes?
if condition
update_all_forbidden_message and return false unless authorized? :change_all
end
authorized? :change
end
def destroy?
authorized? :destroy
end
alias_method :index?, :view?
alias_method :show?, :view?
alias_method :new?, :create?
alias_method :bulk_create?, :create?
alias_method :edit?, :change?
alias_method :update?, :change?
alias_method :bulk_update?, :change?
alias_method :bulk_destroy?, :destroy?
end
end

View File

@@ -0,0 +1,4 @@
module Hourglass
class TimeBookingPolicy < ApplicationPolicy
end
end

View File

@@ -0,0 +1,26 @@
module Hourglass
class TimeLogPolicy < ApplicationPolicy
def book?
@message = I18n.t('hourglass.api.time_logs.errors.already_booked') and return false if booked?
booking_allowed?
end
def booking_allowed?
authorized? :book
end
def destroy?
@message = I18n.t('hourglass.api.time_logs.errors.delete_booked') and return false if booked?
super
end
alias_method :bulk_book?, :book?
alias_method :split?, :change?
alias_method :join?, :change?
private
def booked?
record.respond_to?(:booked?) && record.booked?
end
end
end

View File

@@ -0,0 +1,31 @@
module Hourglass
class TimeTrackerPolicy < ApplicationPolicy
def create?
return false if booking_parameters_forbidden?
super
end
def update?
return false if booking_parameters_forbidden?
super
end
alias_method :start?, :create?
alias_method :stop?, :create? # it's easy, if you are able to start it, you should be able to stop it
private
def booking_parameters_forbidden?
booking_attributes? && !allowed_to?(:book, :time_logs)
end
def booking_parameters
%i(project_id issue_id activity_id)
end
def booking_attributes?
return false unless record.respond_to? :changed
booking_attributes = record.changed.map(&:to_sym).select { |attr| booking_parameters.include? attr }
booking_attributes.length > 0
end
end
end

View File

@@ -0,0 +1,12 @@
module Hourglass
class UiPolicy < Struct.new(:user, :ui)
attr_reader :record, :record_user, :project, :message
def view?
Pundit.policy!(user, Hourglass::TimeTracker).start? ||
Pundit.policy!(user, Hourglass::TimeBooking).view? ||
Pundit.policy!(user, Hourglass::TimeLog).view?
end
end
end

View File

@@ -0,0 +1,8 @@
- if Hourglass::SettingsStorage[:global_tracker]
- time_tracker = User.current.hourglass_time_tracker
- if time_tracker
= render partial: 'hooks/time_tracker/stop_link', locals: {time_tracker: time_tracker, issue: nil}
- if time_tracker.project.present? && time_tracker.activity.blank?
= render partial: 'hooks/time_tracker/activity_dialog_content', locals: {time_tracker: time_tracker}
- elsif Pundit.policy!(User.current, Hourglass::TimeTracker).start?
= render partial: 'hooks/time_tracker/start_link', locals: {time_tracker: time_tracker, issue: nil, params: nil}

View File

@@ -0,0 +1,11 @@
- time_tracker = User.current.hourglass_time_tracker
- if @issue.nil?
-# operations on multiple issues
- elsif time_tracker && time_tracker.issue_id == @issue.id
= render partial: 'hooks/time_tracker/stop_link', locals: { time_tracker: time_tracker, issue: @issue }
- if !Hourglass::SettingsStorage[:global_tracker] && time_tracker.project.present? && time_tracker.activity.blank?
= render partial: 'hooks/time_tracker/activity_dialog_content', locals: { time_tracker: time_tracker }
- elsif Pundit.policy!(User.current, Hourglass::TimeTracker.new(issue_id: @issue.id)).start?
= render partial: 'hooks/time_tracker/start_link', locals: { time_tracker: time_tracker, issue: @issue, params: { time_tracker: { issue_id: @issue.id } } }
- if time_tracker
= render partial: 'hooks/time_tracker/start_dialog_content', locals: { time_tracker: time_tracker, issue: nil }

View File

@@ -0,0 +1 @@
li = render partial: 'hooks/issue_actions'

View File

@@ -0,0 +1,8 @@
javascript:
window.hourglass = window.hourglass || {};
window.hourglass.errorMessages = {
empty: "#{t('hourglass.ui.forms.errors.empty')}",
invalid: "#{t('hourglass.ui.forms.errors.invalid')}",
exceedsLimit: "#{t('hourglass.ui.forms.errors.exceedsLimit')}",
invalidDuration: "#{t('hourglass.ui.forms.errors.invalidDuration')}"
};

View File

@@ -0,0 +1,4 @@
/ this is used by my account and by users edit, so only use the user variable here instead of User.current
h3 = t('hourglass.user_settings.title')
= labelled_fields_for :pref, user.pref do |pref_fields|
p = pref_fields.select :default_activity, TimeEntryActivity.shared.active.map{ |activity| activity.name }, include_blank: true

View File

@@ -0,0 +1,4 @@
.hidden.hourglass-dialog.js-stop-dialog-content data-dialog-title=t('hourglass.ui.issues.stop_dialog.title') data-button-ok-text=t(:button_apply) data-button-cancel-text=t(:button_cancel)
p = t('hourglass.ui.issues.stop_dialog.description')
.center
= collection_select :time_tracker, :activity_id, TimeEntryActivity.applicable(time_tracker.project), :id, :name, {required: true, include_blank: true}

View File

@@ -0,0 +1,18 @@
.hidden.hourglass-dialog.js-start-dialog-content data-dialog-title=t('hourglass.ui.issues.start_dialog.title') data-button-ok-text=t(:button_apply) data-button-cancel-text=t(:button_cancel)
- description = time_tracker.issue_id && format_object(time_tracker.issue) || time_tracker.project_id && format_object(time_tracker.project) || time_tracker.comments
p = raw t('hourglass.ui.issues.start_dialog.description', time_tracker: description)
p
= radio_button_tag 'running_time', 'log', true
- unless time_tracker.activity.blank?
= label_tag 'running_time_log', t('hourglass.ui.issues.start_dialog.options.log')
- else
= label_tag 'running_time_log', t('hourglass.ui.issues.start_dialog.options.log_activity')
.center
= collection_select :time_tracker, :activity_id, TimeEntryActivity.applicable(time_tracker.project), :id, :name, {required: true, include_blank: true}
- if Pundit.policy!(User.current, time_tracker).destroy?
p
= radio_button_tag 'running_time', 'discard', false
= label_tag 'running_time_discard', t('hourglass.ui.issues.start_dialog.options.discard')
p
= radio_button_tag 'running_time', 'takeover', false
= label_tag 'running_time_takeover', t('hourglass.ui.issues.start_dialog.options.takeover')

View File

@@ -0,0 +1,4 @@
= link_to t('hourglass.ui.issues.start'), start_hourglass_time_trackers_path,
class: "icon icon-hourglass-start js-hourglass-remote js-start-tracker hidden #{issue.present? ? 'js-issue-action' : 'js-account-menu-link'}",
remote: true, method: 'post',
data: ({params: params.to_param} unless params.nil?)

View File

@@ -0,0 +1,3 @@
= link_to t('hourglass.ui.issues.stop'), stop_hourglass_time_tracker_path(time_tracker),
class: "icon icon-hourglass-stop js-stop-tracker js-hourglass-remote hidden #{issue.present? ? 'js-issue-action' : 'js-account-menu-link'}",
remote: true, method: 'delete', data: time_tracker.clamp? ? {confirm: t('hourglass.ui.forms.confirmations.stop_clamping', 'duration': localized_hours_in_units(Hourglass::DateTimeCalculations.in_hours(Hourglass::DateTimeCalculations.clamp_limit(project: time_tracker.project))))} : nil

View File

@@ -0,0 +1,37 @@
= form_for @settings, url: { controller: 'hourglass_projects', action: 'settings', id: @project }, remote: true, method: :post do |f|
= hidden_field_tag :tab, Hourglass::PLUGIN_NAME
fieldset.box.tabular
legend = t('hourglass.project_settings.override_hint_html', url: plugin_settings_path(Hourglass::PLUGIN_NAME))
h3 = t('hourglass.settings.rounding.title')
= error_messages_for @settings
p
= render partial: 'hourglass_projects/label_with_global_value_tag', locals: {name: 'settings[round_minimum]', label_text: t('hourglass.settings.rounding.fields.minimum'), global_value: Hourglass::SettingsStorage[:round_minimum]}
= f.number_field :round_minimum, {min: 0, max: 24, step: :any}
= " (#{t(:field_hours)})"
p
= render partial: 'hourglass_projects/label_with_global_value_tag', locals: {name: 'settings[round_limit]', label_text: t('hourglass.settings.rounding.fields.limit'), global_value: Hourglass::SettingsStorage[:round_limit]}
= f.number_field :round_limit, {min: 0, max: 100}
| (%)
p
= render partial: 'hourglass_projects/label_with_global_value_tag', locals: {name: 'settings[round_carry_over_due]', label_text: t('hourglass.settings.rounding.fields.carry_over_due'), global_value: Hourglass::SettingsStorage[:round_carry_over_due]}
= f.number_field :round_carry_over_due, {min: 0, max: 24, step: :any}
= " (#{t(:field_hours)})"
p
= render partial: 'hourglass_projects/label_with_global_value_tag', locals: {name: 'settings[round_default]', label_text: t('hourglass.settings.rounding.fields.default'), global_value: Hourglass::SettingsStorage[:round_default] ? t(:general_text_yes) : t(:general_text_no)}
- p @settings.round_default
= f.select :round_default, [[t('hourglass.project_settings.use_global'), nil], [t(:general_text_Yes), true], [t(:general_text_no), false]]
p
= render partial: 'hourglass_projects/label_with_global_value_tag', locals: {name: 'settings[round_sums_only]', label_text: t('hourglass.settings.rounding.fields.sums_only'), global_value: Hourglass::SettingsStorage[:round_sums_only] ? t(:general_text_yes) : t(:general_text_no)}
= f.select :round_sums_only, [[t('hourglass.project_settings.use_global'), nil], [t(:general_text_Yes), true], [t(:general_text_no), false]]
h3 = t('hourglass.settings.clamping.title')
p
= render partial: 'hourglass_projects/label_with_global_value_tag', locals: {name: 'settings[clamp_limit]', label_text: t('hourglass.settings.clamping.fields.limit'), global_value: Hourglass::SettingsStorage[:clamp_limit]}
= f.number_field :clamp_limit, {min: 0, max: 24, step: :any}
= " (#{t(:field_hours)})"
= submit_tag l(:button_save)

View File

@@ -0,0 +1,4 @@
= label_tag name do
= label_text
br
span.hint = t('hourglass.project_settings.global_value', value: global_value)

View File

@@ -0,0 +1,2 @@
|
$('#tab-content-redmine_hourglass').html('#{escape_javascript(render partial: 'hourglass_settings')}');

View File

@@ -0,0 +1,6 @@
h2 = t(:label_query)
= form_tag hourglass_query_path(@query), method: :put do
= hidden_field_tag 'request_referer', params[:request_referer] || URI(request.referer || '').path
= hidden_field_tag 't[]'
= render partial: 'queries/form', locals: {query: @query}
= submit_tag t(:button_save)

View File

@@ -0,0 +1,7 @@
h2 = t("hourglass.queries.#{query_identifier}.title_new")
= form_tag @project ? project_hourglass_queries_path(@project) : hourglass_queries_path do
= hidden_field_tag 'request_referer', params[:request_referer] || URI(request.referer || '').path
= hidden_field_tag 'query_class', query_identifier
= hidden_field_tag 't[]'
= render partial: 'queries/form', locals: {query: @query}
= submit_tag t(:button_save)

View File

@@ -0,0 +1,3 @@
ul
- messages.each do |msg|
li = msg

View File

@@ -0,0 +1,41 @@
.time-tracker-control
- unless @time_tracker.persisted?
h3 = t('hourglass.ui.index.time_tracker_control.heading')
= form_for @time_tracker, url: start_hourglass_time_trackers_path, as: 'time_tracker', remote: true, html: {class: 'new-time-tracker-form js-hourglass-remote'} do |f|
.form-row
.form-field
= text_field_tag :time_tracker_task, nil, size: '30', maxlength: 1024, disabled: !policy(@time_tracker).change?, class: ('js-issue-autocompletion' if policy(Hourglass::TimeLog).book?)
= f.hidden_field :issue_id if policy(Hourglass::TimeLog).book?
= f.hidden_field :comments
.form-field
= f.submit t('hourglass.ui.index.time_tracker_control.button_start')
- else
h3 = t('hourglass.ui.index.time_tracker_control.tracking_heading')
= form_for @time_tracker, url: stop_hourglass_time_tracker_path(@time_tracker), method: :delete, as: 'time_tracker', remote: true, html: {class: 'edit-time-tracker-form js-validate-form js-hourglass-remote'} do |f|
.form-row
= form_field :issue, f, @time_tracker, disabled: !policy(Hourglass::TimeLog).book?, with_link: true
= form_field :comments, f, @time_tracker
- rounding_disabled = !@time_tracker.project || Hourglass::SettingsStorage[:round_sums_only, project: @time_tracker.project]
.form-field class=('hidden' if rounding_disabled)
.label
= f.label :round
.input
= f.check_box :round, disabled: rounding_disabled
.form-field
.label
= t('hourglass.ui.index.time_tracker_control.label_running_time')
.input.js-running-time
.form-row
= form_field :project, f, @time_tracker, disabled: !policy(Hourglass::TimeLog).book?, with_link: true
= form_field :activity, f, @time_tracker, disabled: !policy(Hourglass::TimeLog).book?
= form_field :start, f, @time_tracker, disabled: !policy(@time_tracker).change?(:start)
.form-field
.input
= f.submit t('hourglass.ui.index.time_tracker_control.button_stop'), data: @time_tracker.clamp? ? {confirm: t('hourglass.ui.forms.confirmations.stop_clamping', 'duration': localized_hours_in_units(Hourglass::DateTimeCalculations.in_hours(Hourglass::DateTimeCalculations.clamp_limit(project: @time_tracker.project))))} : {}
= f.submit t('hourglass.ui.index.time_tracker_control.button_stop_new'), class: 'js-stop-new', data: @time_tracker.clamp? ? {confirm: t('hourglass.ui.forms.confirmations.stop_clamping', 'duration': localized_hours_in_units(Hourglass::DateTimeCalculations.in_hours(Hourglass::DateTimeCalculations.clamp_limit(project: @time_tracker.project))))} : {}
- if policy(@time_tracker).destroy?
= link_to '', hourglass_time_tracker_path(@time_tracker), class: 'icon icon-del js-hourglass-remote', title: t(:button_delete), remote: true, method: :delete, data: {confirm: t(:text_are_you_sure)}
.form-row
- @time_tracker.custom_field_values.each do |value|
.form-field = custom_field_tag_with_label :'time_tracker', value

View File

@@ -0,0 +1,45 @@
- api_enabled = Setting.rest_api_enabled == '1'
- content_for :header_tags do
= stylesheet_link_tag 'swagger-ui', plugin: Hourglass::PLUGIN_NAME, media: 'screen'
= javascript_include_tag 'swagger-ui-bundle', plugin: Hourglass::PLUGIN_NAME
javascript:
$(function () {
var initiated = false;
window.swaggerUi = new SwaggerUIBundle({
url: "#{hourglass_rswag_api_path}/v1/swagger.json",
dom_id: "#swagger-ui-container",
presets: [SwaggerUIBundle.presets.apis],
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
onFailure: function () {
hourglass.Utils.showErrorMessage("#{t('hourglass.ui.api_docs.error_json_missing', task: 'redmine:plugins:hourglass:api_docs')}");
},
onComplete: function() {
window.swaggerUi.preauthorizeApiKey("api_key", "#{User.current.api_key}");
},
docExpansion: 'list'
});
});
= render layout: 'hourglass_ui/layouts/hourglass' do
- html_title t('hourglass.ui.api_docs.title')
p = t('hourglass.ui.api_docs.description')
- if api_enabled
.swagger-section
#message-bar.swagger-ui-wrap
#auth_container
#swagger-ui-container.swagger-ui-wrap
.swagger-note
= t('hourglass.ui.api_docs.swagger_note')
| :
.swagger-link
a href="http://swagger.io"
img.logo__img alt="" height="30" width="30" src=Hourglass::Assets.path('swagger.png', type: 'image')
span.logo__title Swagger
- else
= t('hourglass.ui.api_docs.error_api_disabled')
' :
= link_to t('hourglass.ui.api_docs.api_settings'), settings_path(tab: 'api')

View File

@@ -0,0 +1,5 @@
.form-field
.label
= form.label :activity_id
.input
= form.collection_select :activity_id, TimeEntryActivity.applicable(entry.project), :id, :name, { include_blank: true }, disabled: local_assigns[:disabled], required: local_assigns[:required]

Some files were not shown because too many files have changed in this diff Show More