Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85a35a3063 | ||
|
|
e0e391efbe | ||
|
|
9eff89963c | ||
|
|
352fac26f3 | ||
|
|
8c16bf126a | ||
|
|
42de968f8f | ||
|
|
ed8f024e42 | ||
|
|
e534369c5b | ||
|
|
fed797df93 | ||
|
|
6a44dbfe83 | ||
|
|
28c5357f11 | ||
|
|
a8824f92a1 | ||
|
|
e57b69973f | ||
|
|
d6798eed1e | ||
|
|
50383673ca | ||
|
|
022dbe32e5 | ||
|
|
baac4eb698 | ||
|
|
8aa8f452d5 | ||
|
|
54255cf923 | ||
|
|
bd3d82d224 | ||
|
|
b14ad9f3ab | ||
|
|
261b4646e4 | ||
|
|
d62e1c4468 | ||
|
|
5b0266c462 | ||
|
|
06118dcabb | ||
|
|
984c852ab6 | ||
|
|
47414cfd0e | ||
|
|
fdf4fdfb6a | ||
|
|
5ee0396a02 | ||
|
|
724eb9c658 | ||
|
|
936ab05882 | ||
|
|
817a43b178 | ||
|
|
0f16f42887 | ||
|
|
641c199c8c | ||
|
|
04dd6b900f | ||
|
|
536a05210e | ||
|
|
60d16016e8 | ||
|
|
9222075c7a | ||
|
|
641dcdff85 | ||
|
|
668649be7a | ||
|
|
0de936d474 | ||
|
|
47bd8b0ce7 | ||
|
|
2287c9951c | ||
|
|
94c28ebbc5 | ||
|
|
b4739f8f03 | ||
|
|
c32cd36861 | ||
|
|
945562b34d | ||
|
|
af0f89774e | ||
|
|
c6c4098759 | ||
|
|
fa1c79e15c | ||
|
|
0705c26513 | ||
|
|
140fa4b7c0 | ||
|
|
cd88901868 | ||
|
|
8eec97deb5 | ||
|
|
4331a7c9f4 | ||
|
|
f4fcfeba9d | ||
|
|
48aefb1862 | ||
|
|
bda9584346 | ||
|
|
ed1154af67 | ||
|
|
c368eacd87 | ||
|
|
949c59230f | ||
|
|
1114538f41 | ||
|
|
b0f83291ee | ||
|
|
e91e4ff430 | ||
|
|
4ad290d4bd | ||
|
|
37c6e2c0af | ||
|
|
b4a45afce9 | ||
|
|
330ff6eb47 | ||
|
|
c681eb1bdb | ||
|
|
de533e7031 | ||
|
|
ae6ed3f23f |
@@ -3,7 +3,8 @@ root = true
|
||||
|
||||
; Unix-style newlines
|
||||
[*]
|
||||
end_of_line = LF
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
|
||||
[*.php]
|
||||
indent_style = space
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
unreleased=true
|
||||
future-release=1.4
|
||||
future-release=1.7
|
||||
exclude-labels=duplicate,question,invalid,wontfix,release
|
||||
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt
|
||||
issues-wo-labels=false
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,11 +1,13 @@
|
||||
# ========== KIMAI ==========
|
||||
# IDE
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
/bin/*
|
||||
!bin/console
|
||||
/config/packages/local.yaml
|
||||
/config/packages/*-local.yaml
|
||||
/config/packages/*/local.yaml
|
||||
/config/packages/*/*-local.yaml
|
||||
|
||||
public/avatars/*.png
|
||||
|
||||
@@ -20,6 +22,8 @@ templates/invoice/renderer/.~lock*
|
||||
|
||||
/var/invoices/*
|
||||
|
||||
/var/packages/*
|
||||
!var/packages/.gitkeep
|
||||
/var/plugins/*
|
||||
!var/plugins/.gitkeep
|
||||
|
||||
@@ -36,10 +40,6 @@ templates/invoice/renderer/.~lock*
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> symfony/web-server-bundle ###
|
||||
.web-server-pid
|
||||
###< symfony/web-server-bundle ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
/.phpunit.result.cache
|
||||
|
||||
24
.travis.yml
24
.travis.yml
@@ -9,11 +9,12 @@ matrix:
|
||||
include:
|
||||
- stage: Code quality
|
||||
env: CODECOVERAGE=0 DB=sqlite
|
||||
php: '7.2'
|
||||
php: '7.3'
|
||||
script:
|
||||
- composer validate --no-check-all --strict
|
||||
- composer kimai:codestyle
|
||||
- composer kimai:phpstan
|
||||
- composer kimai:code-lint
|
||||
- stage: Test
|
||||
php: '7.2'
|
||||
env: CODECOVERAGE=0 DB=mysql
|
||||
@@ -26,13 +27,21 @@ matrix:
|
||||
sudo: required
|
||||
services:
|
||||
- mysql
|
||||
- stage: Test
|
||||
php: '7.4'
|
||||
env: CODECOVERAGE=0 DB=mysql
|
||||
sudo: required
|
||||
services:
|
||||
- mysql
|
||||
- stage: Test
|
||||
php: '7.2'
|
||||
env: CODECOVERAGE=0 DB=sqlite
|
||||
- stage: Test
|
||||
#php: '7.4snapshot'
|
||||
php: '7.3'
|
||||
env: CODECOVERAGE=0 DB=sqlite
|
||||
- stage: Test
|
||||
php: '7.4'
|
||||
env: CODECOVERAGE=0 DB=sqlite
|
||||
- stage: Test
|
||||
php: '7.2'
|
||||
env: CODECOVERAGE=0 DB=mariadb
|
||||
@@ -51,6 +60,15 @@ matrix:
|
||||
- mysql
|
||||
before_script:
|
||||
- mysql -u root -e 'CREATE USER IF NOT EXISTS travis@localhost; GRANT ALL ON *.* TO travis@localhost;'
|
||||
- stage: Test
|
||||
php: '7.4'
|
||||
env: CODECOVERAGE=0 DB=mariadb
|
||||
addons:
|
||||
mariadb: '10.2'
|
||||
services:
|
||||
- mysql
|
||||
before_script:
|
||||
- mysql -u root -e 'CREATE USER IF NOT EXISTS travis@localhost; GRANT ALL ON *.* TO travis@localhost;'
|
||||
- stage: Code coverage
|
||||
php: '7.3'
|
||||
env: CODECOVERAGE=1 DB=sqlite
|
||||
@@ -68,7 +86,7 @@ install:
|
||||
- composer require zendframework/zend-ldap
|
||||
|
||||
script:
|
||||
- if [[ $CODECOVERAGE == 1 ]]; then vendor/bin/phpunit tests/ --coverage-clover=coverage.xml; fi;
|
||||
- if [[ $CODECOVERAGE == 1 ]]; then TEST_WITH_BUNDLES=1 vendor/bin/phpunit tests/ --coverage-clover=coverage.xml; fi;
|
||||
- if [[ $CODECOVERAGE == 0 ]]; then vendor/bin/phpunit tests/; fi;
|
||||
- if [[ $DB == 'sqlite' ]]; then cp tests/.env.dist.sqlite .env; fi;
|
||||
- if [[ $DB == "mysql" || $DB == "mariadb" ]]; then cp tests/.env.dist.mysql .env; fi;
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,8 +1,45 @@
|
||||
# Change Log
|
||||
|
||||
## [1.7](https://github.com/kevinpapst/kimai2/tree/1.7) (2020-01-19)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.6.2...1.7)
|
||||
|
||||
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.7)
|
||||
|
||||
## [1.6.2](https://github.com/kevinpapst/kimai2/tree/1.6.2) (2019-12-02)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.6.1...1.6.2)
|
||||
|
||||
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2)
|
||||
|
||||
## [1.6.1](https://github.com/kevinpapst/kimai2/tree/1.6.1) (2019-11-26)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.6...1.6.1)
|
||||
|
||||
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1)
|
||||
|
||||
## [1.6](https://github.com/kevinpapst/kimai2/tree/1.6) (2019-11-24)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.5...1.6)
|
||||
|
||||
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.6)
|
||||
|
||||
## [1.5](https://github.com/kevinpapst/kimai2/tree/1.5) (2019-11-03)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4.2...1.5)
|
||||
|
||||
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.5)
|
||||
|
||||
## [1.4.2](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-14)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4.1...1.4.2)
|
||||
|
||||
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.4.2)
|
||||
|
||||
## [1.4.1](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-07)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4...1.4.1)
|
||||
|
||||
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.4.1)
|
||||
|
||||
## [1.4](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-03)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.3...1.4)
|
||||
|
||||
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.4)
|
||||
|
||||
## [1.3](https://github.com/kevinpapst/kimai2/tree/1.3) (2019-09-11)
|
||||
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.2...1.3)
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ RUN apt update && \
|
||||
zip && \
|
||||
apt remove -y wget && \
|
||||
apt -y autoremove && \
|
||||
apt clean
|
||||
apt clean && \
|
||||
cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
|
||||
|
||||
FROM tmp_kimai2_base
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Do
|
||||
### Requirements
|
||||
|
||||
- PHP 7.2 or higher
|
||||
- Database (MySQL, MariaDB, SQLite)
|
||||
- Database (MySQL/MariaDB with timezone data, SQLite for development)
|
||||
- Webserver (nginx, Apache)
|
||||
- A modern browser
|
||||
- [Other libraries](https://www.kimai.org/download/)
|
||||
|
||||
### About
|
||||
|
||||
This is new version of the open source timetracker Kimai. It is stable and production ready, ships
|
||||
This is the new version of the open source timetracker Kimai. It is stable and production ready, ships
|
||||
with most advanced features from Kimai 1 and many new ones, including but not limited to:
|
||||
|
||||
JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones,
|
||||
@@ -59,9 +59,9 @@ You can see a rough development roadmap in the [Milestones](https://github.com/k
|
||||
It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome.
|
||||
|
||||
> Kimai 2 uses a rolling release concept for delivering updates.
|
||||
> You can upgrade Kimai at any time, you don't need to wait for the next official release.
|
||||
> You can upgrade Kimai at any time , you don't need to wait for the next official release.
|
||||
|
||||
Release versions will be created on a regular base (approx. one release per month) and you can use these tags if you are familiar with git.
|
||||
Release versions will be created on a regular base (approx. one release per month) and you can should use these tags if you are not familiar with git.
|
||||
Every code change, whether it's a new feature or a bug fix, will be done on the master branch.
|
||||
I have to do it this way, as I develop Kimai in my free time and want to put my effort into the software instead of backporting changes for old versions.
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ As announced in the [README](README.md) I only support the latest available rele
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| master | :white_check_mark: |
|
||||
| 1.4 | :white_check_mark: |
|
||||
| < 1.4 | :x: |
|
||||
| 1.7 | :white_check_mark: |
|
||||
| < 1.7 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
79
UPGRADING.md
79
UPGRADING.md
@@ -8,52 +8,101 @@ you can upgrade your Kimai installation to the latest stable release.
|
||||
Check below if there are more version specific steps required, which need to be executed after the normal update process.
|
||||
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
|
||||
|
||||
## [1.7](https://github.com/kevinpapst/kimai2/releases/tag/1.7)
|
||||
|
||||
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
|
||||
|
||||
New permissions:
|
||||
|
||||
- `comments_customer` - show comment list on customer detail page (new feature)
|
||||
- `details_customer` - show detail information for customers (customer number, vat, rates, meta-fields, assigned teams ...)
|
||||
- `comments_project` - show comment list on project detail page (new feature)
|
||||
- `details_project` - show detail information for projects (rates, meta-fields, assigned teams ...)
|
||||
|
||||
If you are using teams, please read on: The following list of permissions are now also available in the UI and they (can) replace the `X_project` and `X_customer` permissions.
|
||||
They are more strict, as they allow only access to team specific items, the older permissions without `_teamlead_`/`_team_` work on a global level instead.
|
||||
|
||||
- `view_teamlead_customer`, `edit_teamlead_customer`, `budget_teamlead_customer`, `permissions_teamlead_customer`, `comments_teamlead_customer`, `details_teamlead_customer` - allows access to customer data when user is teamlead of a team assigned to the customer (replaces more global permission like `view_customer` for teamleads)
|
||||
- `view_team_customer`, `edit_team_customer`, `budget_team_customer`, `comments_team_customer`, `details_team_customer` - allows access to customer data when user is member of a team assigned to the customer (replaces more global permission like `view_customer` for users)
|
||||
- `view_teamlead_project`, `edit_teamlead_project`, `budget_teamlead_project`, `permissions_teamlead_project`, `comments_teamlead_project`, `details_teamlead_project` - allows access to customer data when user is teamlead of a team assigned to the project (replaces more global permission like `view_project` for teamleads)
|
||||
- `view_team_project`, `edit_team_project`, `budget_team_project`, `comments_team_project`, `details_team_project` - allows access to customer data when user is member of a team assigned to the project (replaces more global permission like `view_project` for users)
|
||||
|
||||
### ExpenseBundle
|
||||
|
||||
**ATTENTION** due to incompatibilities in the underlying frameworks users of the ExpenseBundle need to do one more step:
|
||||
|
||||
You need to delete the bundle before updating: `rm -r var/plugins/ExpenseBundle`, otherwise you will run into errors during the update.
|
||||
|
||||
After the Kimai update was successful, you have to re-install the latest bundle version, which is compatible with Kimai 1.7 only.
|
||||
|
||||
### Developer
|
||||
|
||||
- Projects now have a start and end date and the API will only return those, which are either unconfigured or currently active, you might want to reload the list of projects once the user entered begin and end datetime OR use the new `ignoreDates` parameter.
|
||||
- Doctrine bundle was updated to v2, check your code for [the usage of RegistryInterface and ObjectManager](https://github.com/doctrine/DoctrineBundle/blob/master/UPGRADE-2.0.md)
|
||||
- Removed the webserver bundle and the command `server:run` - see [docs](https://www.kimai.org/documentation/developers.html)
|
||||
|
||||
## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6), [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1), [1.6.2](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2)
|
||||
|
||||
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
|
||||
|
||||
- Invoice changes:
|
||||
- Moved CSV, ODS and XSLX invoice templates to [another repository](https://github.com/Keleo/kimai2-invoice-templates). Using them? Install them manually (see [invoice documentation](https://www.kimai.org/documentation/invoices.html)).
|
||||
- Added new invoice fields (VAT, contact, payment details) and customer field (VAT). Used the twig settings before? Move them to the respective invoice template settings.
|
||||
- Permissions can be managed via Admin UI. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html).
|
||||
- Important permission change: regular users with the `view_other_timesheet` permission could see all timesheets. This was a legacy from the time before team permissions were introduced. If you rely on this behavior, you need to create a team with all users and the teamlead being the user who needs access to all timesheets.
|
||||
|
||||
### Developer
|
||||
|
||||
Please add default permissions to your [plugin](https://www.kimai.org/documentation/plugins.html).
|
||||
|
||||
## [1.5](https://github.com/kevinpapst/kimai2/releases/tag/1.5)
|
||||
|
||||
[Update as usual](https://www.kimai.org/documentation/updates.html)
|
||||
|
||||
## [1.4](https://github.com/kevinpapst/kimai2/releases/tag/1.4)
|
||||
|
||||
**Attention**: There is a new directory, which needs to be writable by the webserver: `public/avatars/`.
|
||||
**There is a new directory, which needs to be writable by the webserver: `public/avatars/`.**
|
||||
|
||||
New permission (used in new dashboard widget):
|
||||
- `view_team_member` - display team assignments (names, teamleads and members) for the current user
|
||||
|
||||
### Possible BC breaks
|
||||
Activated Javascript select component by default (check mobile devices).
|
||||
|
||||
- Activated Javascript select component by default
|
||||
|
||||
### BC breaks for devs
|
||||
### Developer: BC breaks
|
||||
|
||||
- Dashboard widgets and rows need to define their `type` by FQCN
|
||||
- Switched to Symfony 4.3 event types, this could fail in plugins only if they didn't use the official constants for event names
|
||||
- Switched to Symfony 4.3 event types, this could fail in plugins, but only if they didn't use the official constants for event names
|
||||
|
||||
## [1.3](https://github.com/kevinpapst/kimai2/releases/tag/1.3)
|
||||
|
||||
### Possible BC breaks
|
||||
Added `manage_tag` permission for new tag features
|
||||
|
||||
### Developer: BC breaks
|
||||
|
||||
- Refactored toolbars and search, plugins needs to be checked
|
||||
- Added `manage_tag` permission for new tag features
|
||||
- Invoices now supports multiple repositories, some method signatures had to be changed (eg. `calculateSumIdentifier()`)
|
||||
|
||||
## [1.2](https://github.com/kevinpapst/kimai2/releases/tag/1.2)
|
||||
|
||||
### Possible BC breaks
|
||||
**If you are still using 0.7 or below, you need to upgrade to 1.1 before upgrading to this version.**
|
||||
|
||||
- Deleted timezone conversion command. If you are still using 0.7 or below, you need to upgrade to 1.1 before upgrading to this version.
|
||||
- Deleted timezone conversion command.
|
||||
- Minimum password length raised from 5 to 8 character (applies only for password changes and new users)
|
||||
- Maximum customer name length lowered to 150 character
|
||||
- Maximum project name length lowered to 150 character
|
||||
- Maximum activity name length lowered to 150 character
|
||||
- Added new permission: `manage_invoice_template`
|
||||
- Removed permissions: `view_invoice_template`, `create_invoice_template`, `edit_invoice_template`, `delete_invoice_template`
|
||||
- Removed permission: `create_export` (using `create_export` only)
|
||||
- Removed permission: `view_export` (using `create_export` only)
|
||||
|
||||
### Developer: BC breaks
|
||||
|
||||
- Custom export renderer need to check for usage of `Timesheet::getEnd()` as running entries can now be exported as well
|
||||
|
||||
## [1.1](https://github.com/kevinpapst/kimai2/releases/tag/1.1)
|
||||
|
||||
[Update as usual](https://www.kimai.org/documentation/updates.html), nothing special for this release if you upgrade from 1.0 / 1.0.1.
|
||||
|
||||
## [1.0.1](https://github.com/kevinpapst/kimai2/releases/tag/1.0.1)
|
||||
|
||||
[Update as usual](https://www.kimai.org/documentation/updates.html), nothing special for this release if you upgrade from 1.0.
|
||||
|
||||
## [1.0](https://github.com/kevinpapst/kimai2/releases/tag/1.0)
|
||||
|
||||
This release contains several changes, as I still have the goal to stabilize the code base to prevent
|
||||
|
||||
@@ -11,39 +11,54 @@ global.$ = global.jQuery = $;
|
||||
|
||||
require('bootstrap-sass');
|
||||
require('jquery-slimscroll');
|
||||
|
||||
require('select2');
|
||||
require('select2/dist/js/i18n/ar');
|
||||
require('select2/dist/js/i18n/cs');
|
||||
require('select2/dist/js/i18n/da');
|
||||
require('select2/dist/js/i18n/de');
|
||||
require('select2/dist/js/i18n/es');
|
||||
require('select2/dist/js/i18n/eu');
|
||||
require('select2/dist/js/i18n/fr');
|
||||
require('select2/dist/js/i18n/hu');
|
||||
require('select2/dist/js/i18n/it');
|
||||
require('select2/dist/js/i18n/ja');
|
||||
require('select2/dist/js/i18n/ko');
|
||||
require('select2/dist/js/i18n/nl');
|
||||
require('select2/dist/js/i18n/pt-BR');
|
||||
require('select2/dist/js/i18n/ru');
|
||||
require('select2/dist/js/i18n/sk');
|
||||
require('select2/dist/js/i18n/sv');
|
||||
require('select2/dist/js/i18n/tr');
|
||||
require('select2/dist/js/i18n/zh-CN');
|
||||
|
||||
const Moment = require('moment');
|
||||
global.moment = Moment;
|
||||
require('moment/locale/ar');
|
||||
require('moment/locale/cs');
|
||||
require('moment/locale/da');
|
||||
require('moment/locale/de');
|
||||
require('moment/locale/de-ch');
|
||||
require('moment/locale/es');
|
||||
require('moment/locale/eu');
|
||||
require('moment/locale/fr');
|
||||
require('moment/locale/hu');
|
||||
require('moment/locale/it');
|
||||
require('moment/locale/ja');
|
||||
require('moment/locale/ko');
|
||||
require('moment/locale/nl');
|
||||
require('moment/locale/pt-br');
|
||||
require('moment/locale/ru');
|
||||
require('moment/locale/sk');
|
||||
require('moment/locale/sv');
|
||||
require('moment/locale/tr');
|
||||
require('moment/locale/zh-cn');
|
||||
|
||||
require('daterangepicker');
|
||||
|
||||
const Sortable = require('sortablejs/Sortable.min');
|
||||
global.Sortable = Sortable;
|
||||
|
||||
// ------ AdminLTE framework ------
|
||||
require('./sass/admin-lte.scss');
|
||||
require('admin-lte/dist/css/AdminLTE.min.css');
|
||||
@@ -60,6 +75,8 @@ require('./sass/app.scss');
|
||||
|
||||
// ------ Kimai itself ------
|
||||
require('./js/KimaiWebLoader.js');
|
||||
global.KimaiPaginatedBoxWidget = require('./js/widgets/KimaiPaginatedBoxWidget').default;
|
||||
global.KimaiReloadPageWidget = require('./js/widgets/KimaiReloadPageWidget').default;
|
||||
|
||||
// ------ Autocomplete for tags only ------
|
||||
require('jquery-ui/ui/widgets/autocomplete');
|
||||
require('jquery-ui/ui/widgets/autocomplete');
|
||||
|
||||
@@ -4,16 +4,22 @@ require('fullcalendar/dist/gcal.min');
|
||||
|
||||
require('fullcalendar/dist/locale/ar');
|
||||
require('fullcalendar/dist/locale/cs');
|
||||
require('fullcalendar/dist/locale/da');
|
||||
require('fullcalendar/dist/locale/de');
|
||||
require('fullcalendar/dist/locale/de-ch');
|
||||
require('fullcalendar/dist/locale/es');
|
||||
require('fullcalendar/dist/locale/eu');
|
||||
require('fullcalendar/dist/locale/fr');
|
||||
require('fullcalendar/dist/locale/hu');
|
||||
require('fullcalendar/dist/locale/it');
|
||||
require('fullcalendar/dist/locale/ja');
|
||||
require('fullcalendar/dist/locale/ko');
|
||||
require('fullcalendar/dist/locale/nl');
|
||||
require('fullcalendar/dist/locale/pt-br');
|
||||
require('fullcalendar/dist/locale/ru');
|
||||
require('fullcalendar/dist/locale/sk');
|
||||
require('fullcalendar/dist/locale/sv');
|
||||
require('fullcalendar/dist/locale/tr');
|
||||
require('fullcalendar/dist/locale/zh-cn');
|
||||
|
||||
require('fullcalendar/dist/fullcalendar.min.css');
|
||||
|
||||
@@ -33,12 +33,14 @@ import KimaiAutocomplete from "./plugins/KimaiAutocomplete";
|
||||
import KimaiFormSelect from "./plugins/KimaiFormSelect";
|
||||
import KimaiForm from "./plugins/KimaiForm";
|
||||
import KimaiDatePicker from "./plugins/KimaiDatePicker";
|
||||
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
|
||||
import KimaiMultiUpdateTable from "./plugins/KimaiMultiUpdateTable";
|
||||
|
||||
export default class KimaiLoader {
|
||||
|
||||
constructor(configurations, translations) {
|
||||
// set the current locale for all javascript components
|
||||
moment.locale(configurations['locale']);
|
||||
moment.locale(configurations['locale'].replace('_', '-').toLowerCase());
|
||||
|
||||
const kimai = new KimaiContainer(
|
||||
new KimaiConfiguration(configurations),
|
||||
@@ -49,6 +51,7 @@ export default class KimaiLoader {
|
||||
kimai.registerPlugin(new KimaiAPI());
|
||||
kimai.registerPlugin(new KimaiAlert());
|
||||
kimai.registerPlugin(new KimaiFormSelect('.selectpicker'));
|
||||
kimai.registerPlugin(new KimaiConfirmationLink('confirmation-link'));
|
||||
kimai.registerPlugin(new KimaiActiveRecordsDuration('[data-since]'));
|
||||
kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility'));
|
||||
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]'));
|
||||
@@ -65,6 +68,7 @@ export default class KimaiLoader {
|
||||
kimai.registerPlugin(new KimaiAutocomplete('.js-autocomplete'));
|
||||
kimai.registerPlugin(new KimaiForm());
|
||||
kimai.registerPlugin(new KimaiThemeInitializer());
|
||||
kimai.registerPlugin(new KimaiMultiUpdateTable());
|
||||
//kimai.registerPlugin(new KimaiPauseRecord('li.messages-menu ul.menu li'));
|
||||
|
||||
// notify all listeners that Kimai plugins can now be registered
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import KimaiClickHandlerReducedInTableRow from "./KimaiClickHandlerReducedInTableRow";
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
/**
|
||||
* Needs to be initialized with a class name.
|
||||
@@ -18,7 +18,7 @@ import KimaiClickHandlerReducedInTableRow from "./KimaiClickHandlerReducedInTabl
|
||||
*
|
||||
* @param selector
|
||||
*/
|
||||
export default class KimaiAPILink extends KimaiClickHandlerReducedInTableRow {
|
||||
export default class KimaiAPILink extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
|
||||
@@ -197,6 +197,10 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
|
||||
err = '[' + xhr.status +'] ' + xhr.statusText;
|
||||
}
|
||||
alert.error(message, err);
|
||||
// this is useful for changing form fields and retrying to save (and in development to test form changes)
|
||||
setTimeout(function() {
|
||||
btn.button('reset');
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export default class KimaiAlert extends KimaiPlugin {
|
||||
message = translation.get(message);
|
||||
}
|
||||
Swal.fire({
|
||||
type: 'error',
|
||||
icon: 'error',
|
||||
title: title.replace('%reason%', ''),
|
||||
text: message,
|
||||
});
|
||||
@@ -42,10 +42,11 @@ export default class KimaiAlert extends KimaiPlugin {
|
||||
|
||||
Swal.fire({
|
||||
timer: 2000,
|
||||
timerProgressBar: true,
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
showConfirmButton: false,
|
||||
type: 'success',
|
||||
icon: 'success',
|
||||
title: message,
|
||||
});
|
||||
}
|
||||
@@ -65,7 +66,7 @@ export default class KimaiAlert extends KimaiPlugin {
|
||||
|
||||
Swal.fire({
|
||||
title: message,
|
||||
type: 'question',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: translation.get('confirm'),
|
||||
cancelButtonText: translation.get('cancel')
|
||||
|
||||
70
assets/js/plugins/KimaiConfirmationLink.js
Normal file
70
assets/js/plugins/KimaiConfirmationLink.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
/**
|
||||
* Needs to be initialized with a class name.
|
||||
*
|
||||
* Allows to assign the given selector to any element, which then is used as click-handler
|
||||
* calling an API method and trigger the event from data-event attribute afterwards.
|
||||
*
|
||||
* @param selector
|
||||
*/
|
||||
export default class KimaiConfirmationLink extends KimaiPlugin {
|
||||
|
||||
constructor(selector) {
|
||||
super();
|
||||
this.selector = selector;
|
||||
}
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
document.addEventListener('click', function(event) {
|
||||
let target = event.target;
|
||||
while (target !== null && !target.matches('body')) {
|
||||
if (target.classList.contains(self.selector)) {
|
||||
const attributes = target.dataset;
|
||||
|
||||
// is this a link?
|
||||
let url = attributes['href'];
|
||||
// or another HTML element with a custom href
|
||||
if (!url) {
|
||||
url = target.getAttribute('href');
|
||||
}
|
||||
|
||||
// or is this a button?
|
||||
let form = null;
|
||||
if (target.type === 'submit' && target.form !== undefined) {
|
||||
form = target.form;
|
||||
}
|
||||
|
||||
if (attributes.question !== undefined) {
|
||||
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
|
||||
if (value) {
|
||||
if (form === null) {
|
||||
document.location = url;
|
||||
} else {
|
||||
if (url !== null) {
|
||||
form.action = url;
|
||||
}
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
target = target.parentNode;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -61,6 +61,8 @@ export default class KimaiDateRangePicker extends KimaiPlugin {
|
||||
|
||||
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
|
||||
jQuery(this).val(picker.startDate.format(localeFormat) + ' - ' + picker.endDate.format(localeFormat));
|
||||
jQuery(this).data('begin', picker.startDate.format(localeFormat));
|
||||
jQuery(this).data('end', picker.endDate.format(localeFormat));
|
||||
jQuery(this).trigger("change");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,26 +24,108 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
}
|
||||
|
||||
activateSelectPicker(selector, container) {
|
||||
const elementSelector = this.selector;
|
||||
let options = {};
|
||||
if (container !== undefined) {
|
||||
options = {
|
||||
dropdownParent: $(container),
|
||||
};
|
||||
}
|
||||
|
||||
// Function to match the name of the parent and not only the names of the children
|
||||
// Based on the original matcher function of Select2: https://github.com/select2/select2/blob/5765090318c4d382ae56463cfa25ba8ca7bdd495/src/js/select2/defaults.js#L272
|
||||
// More information: https://select2.org/searching | https://github.com/select2/docs/blob/develop/pages/11.searching/docs.md
|
||||
function matcher(params, data) {
|
||||
// Always return the object if there is nothing to compare
|
||||
if (jQuery.trim(params.term) === '') {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Check whether options has children
|
||||
let hasChildren = data.children && data.children.length > 0;
|
||||
|
||||
// Split search param by space to search for all terms and convert all to uppercase
|
||||
let terms = params.term.toUpperCase().split(' ');
|
||||
let original = data.text.toUpperCase();
|
||||
|
||||
// Always return the parent option including its children, when the name matches one of the params
|
||||
// Check if the text contains all or at least one of the terms
|
||||
let foundAll = true;
|
||||
let foundOne = false;
|
||||
let missingTerms = [];
|
||||
terms.forEach(function(item, index) {
|
||||
if (original.indexOf(item) > -1) {
|
||||
foundOne = true;
|
||||
} else {
|
||||
foundAll = false;
|
||||
missingTerms.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// If the option element contains all terms, return it
|
||||
if(foundAll) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Do a recursive check for options with children
|
||||
if (hasChildren) {
|
||||
// If the parent already contains one or more search terms, proceed only with the missing ones
|
||||
// First: Clone the original params object...
|
||||
let newParams = jQuery.extend(true, {}, params);
|
||||
if (foundOne) {
|
||||
newParams.term = missingTerms.join(' ');
|
||||
} else {
|
||||
newParams.term = params.term;
|
||||
}
|
||||
|
||||
// Clone the data object if there are children
|
||||
// This is required as we modify the object to remove any non-matches
|
||||
let match = jQuery.extend(true, {}, data);
|
||||
|
||||
// Check each child of the option
|
||||
for (let c = data.children.length - 1; c >= 0; c--) {
|
||||
let child = data.children[c];
|
||||
|
||||
let matches = matcher(newParams, child);
|
||||
|
||||
// If there wasn't a match, remove the object in the array
|
||||
if (matches === null) {
|
||||
match.children.splice(c, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If any children matched, return the new object
|
||||
if (match.children.length > 0) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
// If the option or its children do not contain the term, don't return anything
|
||||
return null;
|
||||
}
|
||||
|
||||
options = {...options, ...{
|
||||
language: this.getContainer().getConfiguration().get('locale'),
|
||||
theme: "bootstrap"
|
||||
language: this.getContainer().getConfiguration().get('locale').replace('_', '-'),
|
||||
theme: "bootstrap",
|
||||
matcher: matcher
|
||||
}};
|
||||
jQuery(selector + ' ' + this.selector).select2(options);
|
||||
jQuery(selector + ' ' + elementSelector).select2(options);
|
||||
|
||||
jQuery('body').on('reset', 'form', function(event){
|
||||
setTimeout(function() {
|
||||
jQuery(event.target).find(elementSelector).trigger('change');
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
destroySelectPicker(selector) {
|
||||
jQuery(selector + ' ' + this.selector).select2('destroy');
|
||||
}
|
||||
|
||||
|
||||
updateOptions(selectIdentifier, data) {
|
||||
let select = jQuery(selectIdentifier);
|
||||
let emptyOption = jQuery(selectIdentifier + ' option[value=""]');
|
||||
const selectedValue = select.val();
|
||||
|
||||
select.find('option').remove().end().find('optgroup').remove().end();
|
||||
|
||||
@@ -72,6 +154,9 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
select.append(htmlOptions);
|
||||
select.append(emptyOptions);
|
||||
|
||||
// if available, re-select the previous selected option (mostly usable for global activities)
|
||||
select.val(selectedValue);
|
||||
|
||||
// if we don't trigger the change, the other selects won't be resetted
|
||||
select.trigger('change');
|
||||
|
||||
|
||||
73
assets/js/plugins/KimaiMultiUpdateTable.js
Normal file
73
assets/js/plugins/KimaiMultiUpdateTable.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiMultiUpdateForm: handle the multi update checkbox list and form
|
||||
*/
|
||||
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiMultiUpdateTable extends KimaiPlugin {
|
||||
|
||||
init() {
|
||||
const self = this;
|
||||
|
||||
jQuery('body').
|
||||
on('change', '#multi_update_all', function(event) {
|
||||
jQuery('.multi_update_single').prop('checked', jQuery(event.target).prop('checked'));
|
||||
self.toggleForm();
|
||||
})
|
||||
.on('change', '.multi_update_single', function(event) {
|
||||
self.toggleForm();
|
||||
})
|
||||
.on('change', '#multi_update_table_action', function(event) {
|
||||
const selectedItem = jQuery('#multi_update_table_action option:selected');
|
||||
const selectedVal = selectedItem.val();
|
||||
|
||||
if (selectedVal === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = jQuery('#multi_update_form form');
|
||||
const selectedText = selectedItem.text();
|
||||
const ids = self.getSelectedIds();
|
||||
const question = form.attr('data-question').replace(/%action%/, selectedText).replace(/%count%/, ids.length);
|
||||
|
||||
self.getContainer().getPlugin('alert').question(question, function(value) {
|
||||
if (value) {
|
||||
form.attr('action', selectedVal).submit();
|
||||
} else {
|
||||
jQuery('#multi_update_table_action').val('').trigger('change');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getSelectedIds()
|
||||
{
|
||||
let ids = [];
|
||||
jQuery('.multi_update_single:checked').each(function(i){
|
||||
ids[i] = $(this).val();
|
||||
});
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
toggleForm()
|
||||
{
|
||||
const ids = this.getSelectedIds();
|
||||
jQuery('#multi_update_table_entities').val(ids.join(','));
|
||||
|
||||
if (ids.length > 0) {
|
||||
jQuery('#multi_update_form').show();
|
||||
} else {
|
||||
jQuery('#multi_update_form').hide();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import jQuery from 'jquery';
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
import moment from 'moment';
|
||||
|
||||
export default class KimaiSelectDataAPI extends KimaiPlugin {
|
||||
|
||||
@@ -32,37 +33,85 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
|
||||
const API = this.getContainer().getPlugin('api');
|
||||
|
||||
jQuery('body').on('change', selector, function(event) {
|
||||
let apiUrl = jQuery(this).attr('data-api-url').replace('-s-', jQuery(this).val());
|
||||
const targetSelect = '#' + jQuery(this).attr('data-related-select');
|
||||
const targetSelect = '#' + this.dataset['relatedSelect'];
|
||||
|
||||
// if the related target select does not exist, we do not need to load the related data
|
||||
if (jQuery(targetSelect).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let formPrefix = jQuery(this).parents('form').first().attr('name');
|
||||
if (formPrefix === undefined || formPrefix === null) {
|
||||
formPrefix = '';
|
||||
} else {
|
||||
formPrefix += '_';
|
||||
}
|
||||
|
||||
let newApiUrl = self._buildUrlWithFormFields(this.dataset['apiUrl'], formPrefix);
|
||||
|
||||
if (jQuery(this).val() === '') {
|
||||
if (jQuery(this).attr('data-empty-url') === undefined) {
|
||||
if (this.dataset['emptyUrl'] === undefined) {
|
||||
self._updateSelect(targetSelect, {});
|
||||
jQuery(targetSelect).attr('disabled', 'disabled');
|
||||
return;
|
||||
}
|
||||
apiUrl = jQuery(this).attr('data-empty-url').replace('-s-', jQuery(this).val());
|
||||
newApiUrl = self._buildUrlWithFormFields(this.dataset['emptyUrl'], formPrefix);
|
||||
}
|
||||
|
||||
jQuery(targetSelect).removeAttr('disabled');
|
||||
|
||||
API.get(apiUrl, {}, function(data){
|
||||
API.get(newApiUrl, {}, function(data){
|
||||
self._updateSelect(targetSelect, data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_buildUrlWithFormFields(apiUrl, formPrefix) {
|
||||
let newApiUrl = apiUrl;
|
||||
|
||||
apiUrl.split('?')[1].split('&').forEach(item => {
|
||||
let [key, value] = item.split('=');
|
||||
let decoded = decodeURIComponent(value);
|
||||
let test = decoded.match(/%(.*)%/);
|
||||
if (test !== null) {
|
||||
let targetField = jQuery('#' + formPrefix + test[1]);
|
||||
let newValue = '';
|
||||
if (targetField.length === 0) {
|
||||
// debug: this case for example happens in duration only mode, when the end field is not found
|
||||
//console.log('ERROR: Cannot find field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
|
||||
} else {
|
||||
if (targetField.val() !== null) {
|
||||
newValue = targetField.val();
|
||||
|
||||
if (newValue !== '') {
|
||||
// having that special case here is far from being perfect... but for now it works ;-)
|
||||
if (targetField.data('daterangepicker') !== undefined) {
|
||||
if (key === 'begin' || key === 'start' || targetField.data('daterangepicker').singleDatePicker) {
|
||||
newValue = targetField.data('daterangepicker').startDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
} else if (key === 'end') {
|
||||
newValue = targetField.data('daterangepicker').endDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
}
|
||||
} else if (targetField.data('format') !== undefined) {
|
||||
if (moment(newValue, targetField.data('format')).isValid()) {
|
||||
newValue = moment(newValue, targetField.data('format')).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newApiUrl = newApiUrl.replace(value, newValue);
|
||||
}
|
||||
});
|
||||
|
||||
return newApiUrl;
|
||||
}
|
||||
|
||||
_updateSelect(selectName, data) {
|
||||
const options = {};
|
||||
for (const apiData of data) {
|
||||
let title = apiData.parentTitle;
|
||||
if (title === null) {
|
||||
title = '__empty__';
|
||||
let title = '__empty__';
|
||||
if (apiData.hasOwnProperty('parentTitle') && apiData.parentTitle !== null) {
|
||||
title = apiData.parentTitle;
|
||||
}
|
||||
if (!options.hasOwnProperty(title)) {
|
||||
options[title] = [];
|
||||
|
||||
97
assets/js/widgets/KimaiPaginatedBoxWidget.js
Normal file
97
assets/js/widgets/KimaiPaginatedBoxWidget.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiPaginatedBoxWidget: handles box widgets that have a pagination
|
||||
*/
|
||||
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiPaginatedBoxWidget {
|
||||
|
||||
constructor(boxId) {
|
||||
this.selector = boxId;
|
||||
this.overlay = jQuery('<div class="overlay"><div class="fas fa-sync fa-spin"></div></div>');
|
||||
this.widget = jQuery(this.selector);
|
||||
this.href = this.widget.data('href');
|
||||
this.events = this.widget.data('reload').split(' ');
|
||||
|
||||
const self = this;
|
||||
|
||||
const reloadPage = function (event) {
|
||||
const page = jQuery(self.selector + ' .box-tools').data('page');
|
||||
const url = self._buildUrl(page);
|
||||
self.loadPage(url);
|
||||
};
|
||||
|
||||
for (const eventName of this.events) {
|
||||
document.addEventListener(eventName, reloadPage);
|
||||
}
|
||||
|
||||
this.widget.on('click', '.box-tools a', function (event) {
|
||||
event.preventDefault();
|
||||
self.loadPage(jQuery(event.target).attr('href'));
|
||||
});
|
||||
}
|
||||
|
||||
static create(elementId) {
|
||||
return new KimaiPaginatedBoxWidget(elementId);
|
||||
}
|
||||
|
||||
_showOverlay() {
|
||||
this.widget.append(this.overlay);
|
||||
}
|
||||
|
||||
_hideOverlay() {
|
||||
jQuery(this.overlay).remove();
|
||||
}
|
||||
|
||||
loadPage(url) {
|
||||
const self = this;
|
||||
const selector = this.selector;
|
||||
|
||||
self._showOverlay();
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
data: {},
|
||||
success: function (response) {
|
||||
const html = jQuery(response);
|
||||
jQuery(selector + ' .box-tools').replaceWith(html.find('.box-tools'));
|
||||
jQuery(selector + ' .box-body').replaceWith(html.find('.box-body'));
|
||||
jQuery(selector + ' .box-title').replaceWith(html.find('.box-title'));
|
||||
if (jQuery(selector + ' .box-footer').length > 0) {
|
||||
jQuery(selector + ' .box-footer').replaceWith(html.find('.box-footer'));
|
||||
}
|
||||
jQuery(selector + ' .box-body [data-toggle="tooltip"]').tooltip();
|
||||
self.widget.removeData('error-retry');
|
||||
self._hideOverlay();
|
||||
},
|
||||
dataType: 'html',
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
if (jqXHR.status !== undefined && jqXHR.status === 500) {
|
||||
if (self.widget.data('error-retry') !== undefined) {
|
||||
// TODO show error message ?
|
||||
return;
|
||||
}
|
||||
const page = jQuery(selector + ' .box-tools').data('page');
|
||||
if (page > 1) {
|
||||
self.widget.data('error-retry', 1);
|
||||
var url = self._buildUrl(page - 1);
|
||||
self.loadPage(url);
|
||||
}
|
||||
}
|
||||
self._hideOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_buildUrl(page) {
|
||||
return this.href.replace('1', page);
|
||||
}
|
||||
|
||||
}
|
||||
72
assets/js/widgets/KimaiReloadPageWidget.js
Normal file
72
assets/js/widgets/KimaiReloadPageWidget.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
|
||||
*/
|
||||
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiReloadPageWidget {
|
||||
|
||||
constructor(events, fullReload) {
|
||||
this.overlay = jQuery('<div class="overlay-wrapper"><div class="overlay"><div class="fas fa-sync fa-spin"></div></div></div>');
|
||||
this.widget = jQuery('div.content-wrapper');
|
||||
|
||||
const self = this;
|
||||
|
||||
const reloadPage = function (event) {
|
||||
if (fullReload) {
|
||||
document.location.reload(true);
|
||||
} else {
|
||||
self.loadPage(document.location);
|
||||
}
|
||||
};
|
||||
|
||||
for (const eventName of events.split(' ')) {
|
||||
document.addEventListener(eventName, reloadPage);
|
||||
}
|
||||
}
|
||||
|
||||
static create(events, fullReload) {
|
||||
if (fullReload === undefined || fullReload === null) {
|
||||
fullReload = false;
|
||||
}
|
||||
return new KimaiReloadPageWidget(events, fullReload);
|
||||
}
|
||||
|
||||
_showOverlay() {
|
||||
this.widget.append(this.overlay);
|
||||
}
|
||||
|
||||
_hideOverlay() {
|
||||
jQuery(this.overlay).remove();
|
||||
}
|
||||
|
||||
loadPage(url) {
|
||||
const self = this;
|
||||
|
||||
self._showOverlay();
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
data: {},
|
||||
success: function (response) {
|
||||
jQuery('section.content').replaceWith(
|
||||
jQuery(response).find('section.content')
|
||||
);
|
||||
self._hideOverlay();
|
||||
},
|
||||
dataType: 'html',
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
self._hideOverlay();
|
||||
document.location = url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -48,4 +48,9 @@ td {
|
||||
|
||||
.label-gray {
|
||||
background-color: $gray-lte;
|
||||
}
|
||||
|
||||
/* Delete link in action dropdowns */
|
||||
.dropdown-menu > li.delete > a {
|
||||
color: #dd4b39;
|
||||
}
|
||||
@@ -23,20 +23,21 @@ span.label-customer {
|
||||
table.dataTable {
|
||||
/* action column */
|
||||
.actions {
|
||||
width: 90px;
|
||||
li.delete a {
|
||||
color: #dd4b39;
|
||||
}
|
||||
width: 40px;
|
||||
}
|
||||
tr {
|
||||
/* summary row - provided in the users own timesheet table */
|
||||
&.summary td {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #ccc;
|
||||
background-color: #eaeaea;
|
||||
}
|
||||
&.open-edit {
|
||||
cursor: pointer;
|
||||
}
|
||||
th.multiCheckbox {
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
td {
|
||||
/*
|
||||
|
||||
@@ -23,7 +23,7 @@ section.content .overlay > .fas,
|
||||
section.content > .overlay,
|
||||
.box > .loading-img,
|
||||
.overlay-wrapper > .loading-img {
|
||||
position: absolute;
|
||||
position: fixed; /* see #1330 */
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
@@ -279,5 +279,8 @@ table.dataTable.table > tbody > tr > td {
|
||||
.img-circle {
|
||||
max-width: 35px;
|
||||
margin-right: 10px;
|
||||
&.teamlead {
|
||||
box-shadow: 0 0 5px 3px $gray-lte;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,301 +5,256 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
.invoice {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
body.invoice_print {
|
||||
background-color: #eee;
|
||||
|
||||
.invoice {
|
||||
margin: 25px;
|
||||
margin: 105px auto 30px auto;
|
||||
padding: 50px 65px;
|
||||
min-height: 297mm;
|
||||
position: relative;
|
||||
max-width: 210mm;
|
||||
box-shadow: 0 0 20px rgba(80,80,80,0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-small{
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
div.invoice-address {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
table.invoice-records {}
|
||||
table.invoice-meta th {
|
||||
padding-right: 40px;
|
||||
}
|
||||
table.invoice-sum th {
|
||||
width: 70%;
|
||||
}
|
||||
table.invoice-sum th, table.invoice-sum td {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ================================ FREELANCER INVOICE ================================ */
|
||||
|
||||
#freelancer-invoice {
|
||||
font: 15px/1 $font-family-sans-serif;
|
||||
box-sizing: border-box;
|
||||
|
||||
h1 {
|
||||
font: bold 100% sans-serif;
|
||||
letter-spacing: 0.5em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
|
||||
.page-header h3 {
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font: bold 1.5em sans-serif;
|
||||
margin-bottom: 1em;
|
||||
|
||||
div.invoice-address {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* table */
|
||||
|
||||
table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
|
||||
table.invoice-meta th {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table.invoice-sum th {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
/* header */
|
||||
|
||||
header {
|
||||
}
|
||||
|
||||
header:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
header address {
|
||||
float: left;
|
||||
font-style: normal;
|
||||
line-height: 1.25;
|
||||
margin: 0 1em 1em 0;
|
||||
font-weight: bold;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
article address {
|
||||
font-size: 1em;
|
||||
float: left;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
article.address {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
article.footer {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
/* article */
|
||||
|
||||
article, table.meta, table.inventory {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
article:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
article h1 {
|
||||
clip: rect(0 0 0 0);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
article p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* table meta & balance */
|
||||
|
||||
table.meta:after, table.balance:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
/* table meta */
|
||||
|
||||
table.meta {
|
||||
float: right;
|
||||
width: 50%;
|
||||
font-size: 85%;
|
||||
|
||||
th {
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* table balance */
|
||||
|
||||
table.balance {
|
||||
float: right;
|
||||
th {
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 140px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* table items */
|
||||
|
||||
table.inventory {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
margin-top: 2em;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
table.inventory th {
|
||||
font-weight: bold;
|
||||
border-width: 0 0 1px 0;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
table.inventory th,
|
||||
table.inventory td {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
table.inventory td:nth-child(1),
|
||||
table.inventory th:nth-child(1) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table.inventory td:nth-child(2),
|
||||
table.inventory th:nth-child(2) {
|
||||
text-align: center;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
table.inventory td:nth-child(3),
|
||||
table.inventory th:nth-child(3) {
|
||||
text-align: center;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
table.inventory td:nth-child(4),
|
||||
table.inventory th:nth-child(4) {
|
||||
table.invoice-sum th, table.invoice-sum td {
|
||||
text-align: right;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.invoice-items {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 3em;
|
||||
|
||||
.table {
|
||||
thead th {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 15px
|
||||
}
|
||||
tfoot {
|
||||
border-top: 1px solid #ddd;
|
||||
|
||||
tr:first-child th,
|
||||
tr:first-child td {
|
||||
padding-top: 15px
|
||||
}
|
||||
tr {
|
||||
th,td {
|
||||
border:none;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* footer */
|
||||
|
||||
footer {
|
||||
.footer {
|
||||
border-top: 1px solid #ccc;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
footer table {
|
||||
border-top: 1px solid #000;
|
||||
font-size: 0.8em;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
footer th {
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
footer table th:nth-child(1) {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
footer table th:nth-child(3) {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
footer td {
|
||||
text-align: left;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* aside */
|
||||
|
||||
aside {
|
||||
position: absolute;
|
||||
top: 10in;
|
||||
width: 7.5in;
|
||||
}
|
||||
|
||||
aside h1 {
|
||||
border: none;
|
||||
border-width: 0 0 1px;
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
aside h1 {
|
||||
border-color: #999;
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
aside p {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
aside div {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
aside div p {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
aside p span {
|
||||
width: 130px;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
body, .wrapper, #freelancer-invoice {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ================================ FREELANCER INVOICE ================================ */
|
||||
|
||||
#freelancer-invoice {
|
||||
margin: 1em 2em;
|
||||
|
||||
span:empty {
|
||||
display: none;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
h1 {
|
||||
font: bold 100% sans-serif;
|
||||
letter-spacing: 0.5em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font: bold 1.5em sans-serif;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* table */
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* header */
|
||||
|
||||
header {
|
||||
&:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
address {
|
||||
float: left;
|
||||
font-style: normal;
|
||||
line-height: 1.25;
|
||||
margin: 0 1em 1em 0;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
}
|
||||
|
||||
article address {
|
||||
font-size: 1em;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.add, .cut {
|
||||
display: none;
|
||||
article.address {
|
||||
margin:1em 0 1.5em 0;
|
||||
}
|
||||
|
||||
/* article */
|
||||
|
||||
article:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
article h1 {
|
||||
clip: rect(0 0 0 0);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
article p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* table meta & balance */
|
||||
|
||||
table.meta:after, table.balance:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
/* table meta */
|
||||
|
||||
table.meta {
|
||||
float: right;
|
||||
width: 50%;
|
||||
font-size: 85%;
|
||||
|
||||
th {
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* table balance */
|
||||
|
||||
table.balance {
|
||||
float: right;
|
||||
margin-top: 1em;
|
||||
th {
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 140px;
|
||||
text-align: right;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* table items */
|
||||
|
||||
table.inventory {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
margin-top: 2em;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
table.inventory th,
|
||||
table.inventory td {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
table.inventory thead th {
|
||||
font-weight: bold;
|
||||
border-width: 0 0 1px 0;
|
||||
border-bottom: 1px solid #000;
|
||||
padding-bottom: 15px
|
||||
}
|
||||
|
||||
table.inventory tbody tr:first-child td {
|
||||
padding-top: 15px
|
||||
}
|
||||
|
||||
table.inventory tbody tr:last-child td {
|
||||
padding-bottom: 15px
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.invoice {
|
||||
width: unset;
|
||||
/* ================================ PRINTING RULES ================================ */
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
body, .wrapper {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: white;
|
||||
}
|
||||
#freelancer-invoice {
|
||||
span:empty {
|
||||
display: none;
|
||||
}
|
||||
.add, .cut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.invoice {
|
||||
margin: 1em 2em;
|
||||
font-size: 90%;
|
||||
width: unset;
|
||||
padding: 0;
|
||||
min-height: unset;
|
||||
position: unset;
|
||||
max-width: unset;
|
||||
box-shadow: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,3 +39,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Simplify focus detection, when using keyboard tab navigation
|
||||
Deactivated, as it looks weird when the link is clicked
|
||||
*/
|
||||
/*
|
||||
.sidebar-menu > li > a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
*/
|
||||
19
bin/console
19
bin/console
@@ -1,23 +1,24 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
set_time_limit(0);
|
||||
|
||||
if (!file_exists(__DIR__.'/../vendor/autoload.php')) {
|
||||
echo 'Warning: You need to run "composer install" before you can use the console.' . \PHP_EOL;
|
||||
die(1);
|
||||
}
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
use Symfony\Component\Debug\Debug;
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
set_time_limit(0);
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
if (!class_exists(Application::class)) {
|
||||
throw new \RuntimeException('You need to add "symfony/framework-bundle" as a Composer dependency.');
|
||||
}
|
||||
|
||||
if (!isset($_SERVER['APP_ENV'])) {
|
||||
if (!class_exists(Dotenv::class)) {
|
||||
throw new \RuntimeException('APP_ENV environment variable is not defined. You need to define environment variables for configuration or add "symfony/dotenv" as a Composer dependency to load variables from a .env file.');
|
||||
throw new \RuntimeException('APP_ENV environment variable is not defined. You need to define environment variables or change the variables in your .env file.');
|
||||
}
|
||||
(new Dotenv(true))->load(__DIR__.'/../.env');
|
||||
}
|
||||
|
||||
@@ -27,12 +27,13 @@
|
||||
"kevinpapst/adminlte-bundle": "~3.0",
|
||||
"kimai/kimai2-composer": "^0.1",
|
||||
"laravolt/avatar": "^3.0",
|
||||
"mpdf/mpdf": "^7.1",
|
||||
"league/csv": "^9.4",
|
||||
"mpdf/mpdf": "^8.0",
|
||||
"nelmio/api-doc-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^1.5",
|
||||
"ocramius/proxy-manager": "2.1.1",
|
||||
"phpoffice/phpspreadsheet": "^1.4",
|
||||
"phpoffice/phpword": "^0.15.0",
|
||||
"ocramius/proxy-manager": "^2.1.1",
|
||||
"phpoffice/phpspreadsheet": "^1.10",
|
||||
"phpoffice/phpword": "^0.17",
|
||||
"psr/log": "^1.1",
|
||||
"sensio/framework-extra-bundle": "^5.2",
|
||||
"symfony/asset": "^4.0",
|
||||
@@ -63,19 +64,23 @@
|
||||
"doctrine/doctrine-fixtures-bundle": "^3.2",
|
||||
"friendsofphp/php-cs-fixer": "^2.10",
|
||||
"fzaninotto/faker": "^1.8",
|
||||
"phpstan/phpstan": "^0.11.7",
|
||||
"phpstan/phpstan-doctrine": "^0.11.4",
|
||||
"phpstan/phpstan-phpunit": "^0.11.2",
|
||||
"phpstan/phpstan-symfony": "^0.11.6",
|
||||
"phpunit/phpunit": "^7.0",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpstan/phpstan-doctrine": "^0.12",
|
||||
"phpstan/phpstan-phpunit": "^0.12",
|
||||
"phpstan/phpstan-symfony": "^0.12",
|
||||
"phpunit/phpunit": "^8.0",
|
||||
"symfony/browser-kit": "^4.0",
|
||||
"symfony/css-selector": "^4.0",
|
||||
"symfony/maker-bundle": "^1.12",
|
||||
"symfony/phpunit-bridge": "^4.0",
|
||||
"symfony/profiler-pack": "^1.0",
|
||||
"symfony/thanks": "^1.0",
|
||||
"symfony/web-server-bundle": "^4.0"
|
||||
"symfony/profiler-pack": "^1.0"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "artifact",
|
||||
"url": "var/packages/"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "7.2"
|
||||
@@ -117,12 +122,22 @@
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"kimai:code-check": [
|
||||
"kimai:pre-commit": [
|
||||
"@kimai:codestyle",
|
||||
"@kimai:phpstan",
|
||||
"@kimai:tests-unit",
|
||||
"@kimai:code-lint",
|
||||
"@kimai:tests-unit"
|
||||
],
|
||||
"kimai:code-check": [
|
||||
"@kimai:pre-commit",
|
||||
"@kimai:tests-integration"
|
||||
],
|
||||
"kimai:code-lint": [
|
||||
"bin/console lint:yaml config --parse-tags",
|
||||
"bin/console lint:twig templates",
|
||||
"bin/console lint:xliff translations",
|
||||
"bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction"
|
||||
],
|
||||
"kimai:tests": "vendor/bin/phpunit tests/",
|
||||
"kimai:tests-unit": "vendor/bin/phpunit --exclude-group integration tests/",
|
||||
"kimai:tests-integration": "vendor/bin/phpunit --group integration tests/",
|
||||
|
||||
3175
composer.lock
generated
3175
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\WebServerBundle\WebServerBundle::class => ['dev' => true],
|
||||
Doctrine\Bundle\DoctrineCacheBundle\DoctrineCacheBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
framework:
|
||||
assets:
|
||||
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
|
||||
packages:
|
||||
avatars:
|
||||
base_path: 'avatars'
|
||||
|
||||
@@ -28,7 +28,7 @@ doctrine:
|
||||
entity_managers:
|
||||
default:
|
||||
connection: default
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
|
||||
119
config/packages/doctrine/mysql.yaml
Normal file
119
config/packages/doctrine/mysql.yaml
Normal file
@@ -0,0 +1,119 @@
|
||||
doctrine:
|
||||
orm:
|
||||
dql:
|
||||
datetime_functions:
|
||||
addtime: DoctrineExtensions\Query\Mysql\AddTime
|
||||
convert_tz: DoctrineExtensions\Query\Mysql\ConvertTz
|
||||
date: DoctrineExtensions\Query\Mysql\Date
|
||||
date_format: DoctrineExtensions\Query\Mysql\DateFormat
|
||||
dateadd: DoctrineExtensions\Query\Mysql\DateAdd
|
||||
datesub: DoctrineExtensions\Query\Mysql\DateSub
|
||||
datediff: DoctrineExtensions\Query\Mysql\DateDiff
|
||||
day: DoctrineExtensions\Query\Mysql\Day
|
||||
dayname: DoctrineExtensions\Query\Mysql\DayName
|
||||
dayofweek: DoctrineExtensions\Query\Mysql\DayOfWeek
|
||||
dayofyear: DoctrineExtensions\Query\Mysql\DayOfYear
|
||||
div: DoctrineExtensions\Query\Mysql\Div
|
||||
from_unixtime: DoctrineExtensions\Query\Mysql\FromUnixtime
|
||||
hour: DoctrineExtensions\Query\Mysql\Hour
|
||||
last_day: DoctrineExtensions\Query\Mysql\LastDay
|
||||
makedate: DoctrineExtensions\Query\Mysql\MakeDate
|
||||
minute: DoctrineExtensions\Query\Mysql\Minute
|
||||
now: DoctrineExtensions\Query\Mysql\Now
|
||||
month: DoctrineExtensions\Query\Mysql\Month
|
||||
monthname: DoctrineExtensions\Query\Mysql\MonthName
|
||||
period_diff: DoctrineExtensions\Query\Mysql\PeriodDiff
|
||||
second: DoctrineExtensions\Query\Mysql\Second
|
||||
sectotime: DoctrineExtensions\Query\Mysql\SecToTime
|
||||
strtodate: DoctrineExtensions\Query\Mysql\StrToDate
|
||||
time: DoctrineExtensions\Query\Mysql\Time
|
||||
timediff: DoctrineExtensions\Query\Mysql\TimeDiff
|
||||
timestampadd: DoctrineExtensions\Query\Mysql\TimestampAdd
|
||||
timestampdiff: DoctrineExtensions\Query\Mysql\TimestampDiff
|
||||
timetosec: DoctrineExtensions\Query\Mysql\TimeToSec
|
||||
week: DoctrineExtensions\Query\Mysql\Week
|
||||
weekday: DoctrineExtensions\Query\Mysql\WeekDay
|
||||
year: DoctrineExtensions\Query\Mysql\Year
|
||||
yearmonth: DoctrineExtensions\Query\Mysql\YearMonth
|
||||
yearweek: DoctrineExtensions\Query\Mysql\YearWeek
|
||||
unix_timestamp: DoctrineExtensions\Query\Mysql\UnixTimestamp
|
||||
utc_timestamp: DoctrineExtensions\Query\Mysql\UtcTimestamp
|
||||
extract: DoctrineExtensions\Query\Mysql\Extract
|
||||
|
||||
numeric_functions:
|
||||
acos: DoctrineExtensions\Query\Mysql\Acos
|
||||
asin: DoctrineExtensions\Query\Mysql\Asin
|
||||
atan2: DoctrineExtensions\Query\Mysql\Atan2
|
||||
atan: DoctrineExtensions\Query\Mysql\Atan
|
||||
bit_count: DoctrineExtensions\Query\Mysql\BitCount
|
||||
bit_xor: DoctrineExtensions\Query\Mysql\BitXor
|
||||
ceil: DoctrineExtensions\Query\Mysql\Ceil
|
||||
cos: DoctrineExtensions\Query\Mysql\Cos
|
||||
cot: DoctrineExtensions\Query\Mysql\Cot
|
||||
degrees: DoctrineExtensions\Query\Mysql\Degrees
|
||||
exp: DoctrineExtensions\Query\Mysql\Exp
|
||||
floor: DoctrineExtensions\Query\Mysql\Floor
|
||||
log: DoctrineExtensions\Query\Mysql\Log
|
||||
log10: DoctrineExtensions\Query\Mysql\Log10
|
||||
log2: DoctrineExtensions\Query\Mysql\Log2
|
||||
pi: DoctrineExtensions\Query\Mysql\Pi
|
||||
power: DoctrineExtensions\Query\Mysql\Power
|
||||
quarter: DoctrineExtensions\Query\Mysql\Quarter
|
||||
radians: DoctrineExtensions\Query\Mysql\Radians
|
||||
rand: DoctrineExtensions\Query\Mysql\Rand
|
||||
round: DoctrineExtensions\Query\Mysql\Round
|
||||
stddev: DoctrineExtensions\Query\Mysql\StdDev
|
||||
sin: DoctrineExtensions\Query\Mysql\Sin
|
||||
std: DoctrineExtensions\Query\Mysql\Std
|
||||
tan: DoctrineExtensions\Query\Mysql\Tan
|
||||
variance: DoctrineExtensions\Query\Mysql\Variance
|
||||
|
||||
string_functions:
|
||||
aes_decrypt: DoctrineExtensions\Query\Mysql\AesDecrypt
|
||||
aes_encrypt: DoctrineExtensions\Query\Mysql\AesEncrypt
|
||||
any_value: DoctrineExtensions\Query\Mysql\AnyValue
|
||||
ascii: DoctrineExtensions\Query\Mysql\Ascii
|
||||
binary: DoctrineExtensions\Query\Mysql\Binary
|
||||
cast: DoctrineExtensions\Query\Mysql\Cast
|
||||
char_length: DoctrineExtensions\Query\Mysql\CharLength
|
||||
collate: DoctrineExtensions\Query\Mysql\Collate
|
||||
concat_ws: DoctrineExtensions\Query\Mysql\ConcatWs
|
||||
countif: DoctrineExtensions\Query\Mysql\CountIf
|
||||
crc32: DoctrineExtensions\Query\Mysql\Crc32
|
||||
degrees: DoctrineExtensions\Query\Mysql\Degrees
|
||||
field: DoctrineExtensions\Query\Mysql\Field
|
||||
find_in_set: DoctrineExtensions\Query\Mysql\FindInSet
|
||||
format: DoctrineExtensions\Query\Mysql\Format
|
||||
greatest: DoctrineExtensions\Query\Mysql\Greatest
|
||||
group_concat: DoctrineExtensions\Query\Mysql\GroupConcat
|
||||
hex: DoctrineExtensions\Query\Mysql\Hex
|
||||
ifelse: DoctrineExtensions\Query\Mysql\IfElse
|
||||
ifnull: DoctrineExtensions\Query\Mysql\IfNull
|
||||
inet_aton: DoctrineExtensions\Query\Mysql\InetAton
|
||||
inet_ntoa: DoctrineExtensions\Query\Mysql\InetNtoa
|
||||
inet6_aton: DoctrineExtensions\Query\Mysql\Inet6Aton
|
||||
inet6_ntoa: DoctrineExtensions\Query\Mysql\Inet6Ntoa
|
||||
instr: DoctrineExtensions\Query\Mysql\Instr
|
||||
is_ipv4: DoctrineExtensions\Query\Mysql\IsIpv4
|
||||
is_ipv4_compat: DoctrineExtensions\Query\Mysql\IsIpv4Compat
|
||||
is_ipv4_mapped: DoctrineExtensions\Query\Mysql\IsIpv4Mapped
|
||||
is_ipv6: DoctrineExtensions\Query\Mysql\IsIpv6
|
||||
lag: DoctrineExtensions\Query\Mysql\Lag
|
||||
lead: DoctrineExtensions\Query\Mysql\Lead
|
||||
least: DoctrineExtensions\Query\Mysql\Least
|
||||
lpad: DoctrineExtensions\Query\Mysql\Lpad
|
||||
match: DoctrineExtensions\Query\Mysql\MatchAgainst
|
||||
md5: DoctrineExtensions\Query\Mysql\Md5
|
||||
nullif: DoctrineExtensions\Query\Mysql\NullIf
|
||||
over: DoctrineExtensions\Query\Mysql\Over
|
||||
radians: DoctrineExtensions\Query\Mysql\Radians
|
||||
regexp: DoctrineExtensions\Query\Mysql\Regexp
|
||||
replace: DoctrineExtensions\Query\Mysql\Replace
|
||||
rpad: DoctrineExtensions\Query\Mysql\Rpad
|
||||
sha1: DoctrineExtensions\Query\Mysql\Sha1
|
||||
sha2: DoctrineExtensions\Query\Mysql\Sha2
|
||||
soundex: DoctrineExtensions\Query\Mysql\Soundex
|
||||
str_to_date: DoctrineExtensions\Query\Mysql\StrToDate
|
||||
substring_index: DoctrineExtensions\Query\Mysql\SubstringIndex
|
||||
unhex: DoctrineExtensions\Query\Mysql\Unhex
|
||||
uuid_short: DoctrineExtensions\Query\Mysql\UuidShort
|
||||
56
config/packages/doctrine/sqlite.yaml
Normal file
56
config/packages/doctrine/sqlite.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
doctrine:
|
||||
orm:
|
||||
dql:
|
||||
datetime_functions:
|
||||
convert_tz: App\Doctrine\Query\Sqlite\ConvertTz
|
||||
date: DoctrineExtensions\Query\Sqlite\Date
|
||||
date_format: DoctrineExtensions\Query\Sqlite\DateFormat
|
||||
#dateadd: DoctrineExtensions\Query\Sqlite\DateAdd
|
||||
#datediff: DoctrineExtensions\Query\Sqlite\DateDiff
|
||||
day: DoctrineExtensions\Query\Sqlite\Day
|
||||
dayname: DoctrineExtensions\Query\Sqlite\DayName
|
||||
hour: DoctrineExtensions\Query\Sqlite\Hour
|
||||
julianday: DoctrineExtensions\Query\Sqlite\JulianDay
|
||||
minute: DoctrineExtensions\Query\Sqlite\Minute
|
||||
#month: DoctrineExtensions\Query\Sqlite\Month
|
||||
#monthname: DoctrineExtensions\Query\Sqlite\MonthName
|
||||
second: DoctrineExtensions\Query\Sqlite\Second
|
||||
month: DoctrineExtensions\Query\Sqlite\Month
|
||||
strftime: DoctrineExtensions\Query\Sqlite\StrfTime
|
||||
#strtodate: DoctrineExtensions\Query\Sqlite\StrToDate
|
||||
#time: DoctrineExtensions\Query\Sqlite\Time
|
||||
#timestampadd: DoctrineExtensions\Query\Sqlite\TimestampAdd
|
||||
#timestampdiff: DoctrineExtensions\Query\Sqlite\TimestampDiff
|
||||
week: DoctrineExtensions\Query\Sqlite\Week
|
||||
weekday: DoctrineExtensions\Query\Sqlite\WeekDay
|
||||
year: DoctrineExtensions\Query\Sqlite\Year
|
||||
|
||||
|
||||
string_functions:
|
||||
# binary: DoctrineExtensions\Query\Sqlite\Binary
|
||||
# char_length: DoctrineExtensions\Query\Sqlite\CharLength
|
||||
concat_ws: DoctrineExtensions\Query\Sqlite\ConcatWs
|
||||
# countif: DoctrineExtensions\Query\Sqlite\CountIf
|
||||
# crc32: DoctrineExtensions\Query\Sqlite\Crc32
|
||||
# degrees: DoctrineExtensions\Query\Sqlite\Degrees
|
||||
# field: DoctrineExtensions\Query\Sqlite\Field
|
||||
# find_in_set: DoctrineExtensions\Query\Sqlite\FindInSet
|
||||
# group_concat: DoctrineExtensions\Query\Sqlite\GroupConcat
|
||||
greatest: DoctrineExtensions\Query\Sqlite\Greatest
|
||||
ifelse: DoctrineExtensions\Query\Sqlite\IfElse
|
||||
ifnull: DoctrineExtensions\Query\Sqlite\IfNull
|
||||
least: DoctrineExtensions\Query\Sqlite\Least
|
||||
# match: DoctrineExtensions\Query\Sqlite\MatchAgainst
|
||||
# md5: DoctrineExtensions\Query\Sqlite\Md5
|
||||
# nullif: DoctrineExtensions\Query\Sqlite\NullIf
|
||||
# radians: DoctrineExtensions\Query\Sqlite\Radians
|
||||
random: DoctrineExtensions\Query\Sqlite\Random
|
||||
# regexp: DoctrineExtensions\Query\Sqlite\Regexp
|
||||
replace: DoctrineExtensions\Query\Sqlite\Replace
|
||||
# sha1: DoctrineExtensions\Query\Sqlite\Sha1
|
||||
# sha2: DoctrineExtensions\Query\Sqlite\Sha2
|
||||
# soundex: DoctrineExtensions\Query\Sqlite\Soundex
|
||||
# uuid_short: DoctrineExtensions\Query\Sqlite\UuidShort
|
||||
|
||||
numeric_functions:
|
||||
round: DoctrineExtensions\Query\Sqlite\Round
|
||||
@@ -29,6 +29,3 @@ framework:
|
||||
#validation:
|
||||
# possible values: 'loose', 'strict', 'html5'
|
||||
#email_validation_mode: 'html5'
|
||||
|
||||
assets:
|
||||
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
|
||||
|
||||
@@ -21,11 +21,13 @@ kimai:
|
||||
# --------------------------------------------------------------------------------
|
||||
# TIME-TRACKING
|
||||
# All configs related to time-tracking, timesheets and record management
|
||||
# Most settings can be configured in the System configuration screen
|
||||
# --------------------------------------------------------------------------------
|
||||
timesheet:
|
||||
|
||||
# renders timesheet descriptions with markdown
|
||||
markdown_content: false
|
||||
# Allows to render timesheet descriptions with markdown
|
||||
# This setting can be changed through the Administration screen
|
||||
# markdown_content: false
|
||||
|
||||
# The time-tracking mode that should be used.
|
||||
# See https://www.kimai.org/documentation/timesheet.html#tracking-modes
|
||||
@@ -42,36 +44,36 @@ kimai:
|
||||
# Rounding rules are used to round the begin & end dates and the duration for timesheet records.
|
||||
# The "default" rule will round "begin" down and "end" up to the full minute, the "duration" will not be rounded.
|
||||
# Find out more about rounding rules at https://www.kimai.org/documentation/timesheet.html
|
||||
rounding:
|
||||
default:
|
||||
days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']
|
||||
begin: 1
|
||||
end: 1
|
||||
duration: 0
|
||||
mode: default
|
||||
# rounding:
|
||||
# default:
|
||||
# days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']
|
||||
# begin: 1
|
||||
# end: 1
|
||||
# duration: 0
|
||||
# mode: default
|
||||
|
||||
# # If you want to apply different hourly rates for specific weekdays, you can uncomment the "rates" configuration.
|
||||
# # The "weekend" rule will add 50% to each timesheet entry that will be recorded on "saturdays" or "sundays".
|
||||
# # See documentation at: https://www.kimai.org/documentation/timesheet.html#rate-calculation
|
||||
# rates:
|
||||
# weekend:
|
||||
# days: ['saturday','sunday']
|
||||
# factor: 1.5
|
||||
#
|
||||
# # If you want to limit the max. active entries per user, you can do it here.
|
||||
# # The soft_limit is used as theme setting and displays a warning color if the user has reached X active recordings
|
||||
# # The hard_limit is used to detect how many active records are allowed per user:
|
||||
# # - by default a user can only have one active time-record: it is automatically stopped when a new one is started
|
||||
# # - when hard_limit is > 1 and the user is trying to start a new entry after reaching the limit, a warning is shown
|
||||
# # and the user has to stop an active entry first
|
||||
# active_entries:
|
||||
# soft_limit: 1
|
||||
# hard_limit: 3
|
||||
#
|
||||
# # Rules that define timesheet validation and behaviour
|
||||
# rules:
|
||||
# # whether records in the future can be created
|
||||
# allow_future_times: true
|
||||
# If you want to apply different hourly rates for specific weekdays, you can uncomment the "rates" configuration.
|
||||
# The "weekend" rule will add 50% to each timesheet entry that will be recorded on "saturdays" or "sundays".
|
||||
# See documentation at: https://www.kimai.org/documentation/timesheet.html#rate-calculation
|
||||
# rates:
|
||||
# weekend:
|
||||
# days: ['saturday','sunday']
|
||||
# factor: 1.5
|
||||
|
||||
# If you want to limit the max. active entries per user, you can do it here.
|
||||
# The soft_limit is used as theme setting and displays a warning color if the user has reached X active recordings
|
||||
# The hard_limit is used to detect how many active records are allowed per user:
|
||||
# - by default a user can only have one active time-record: it is automatically stopped when a new one is started
|
||||
# - when hard_limit is > 1 and the user is trying to start a new entry after reaching the limit, a warning is shown
|
||||
# and the user has to stop an active entry first
|
||||
# active_entries:
|
||||
# soft_limit: 1
|
||||
# hard_limit: 3
|
||||
|
||||
# Rules that define timesheet validation and behaviour
|
||||
# allow_future_times: whether records in the future can be created
|
||||
# rules:
|
||||
# allow_future_times: true
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -84,17 +86,21 @@ kimai:
|
||||
# mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and sets"])
|
||||
sets:
|
||||
ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity']
|
||||
ACTIVITIES_TEAM: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity']
|
||||
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project','permissions_project']
|
||||
PROJECTS_TEAM: ['view_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project']
|
||||
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer']
|
||||
CUSTOMERS_TEAM: ['view_customer','edit_teamlead_customer','budget_teamlead_customer']
|
||||
ACTIVITIES_TEAMLEAD: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity']
|
||||
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project','permissions_project','comments_project','details_project']
|
||||
PROJECTS_ALL_TEAMLEAD: ['view_teamlead_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project','comments_teamlead_project','details_teamlead_project']
|
||||
PROJECTS_ALL_TEAM: ['view_team_project','edit_team_project','budget_team_project','comments_team_project','details_team_project']
|
||||
PROJECTS_TEAMLEAD: ['view_teamlead_project','edit_teamlead_project','budget_teamlead_project','comments_teamlead_project','details_teamlead_project']
|
||||
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer','comments_customer','details_customer']
|
||||
CUSTOMERS_ALL_TEAMLEAD: ['view_teamlead_customer','edit_teamlead_customer','budget_teamlead_customer','permissions_teamlead_customer','comments_teamlead_customer','details_teamlead_customer']
|
||||
CUSTOMERS_ALL_TEAM: ['view_team_customer','edit_team_customer','budget_team_customer','comments_team_customer','details_team_customer']
|
||||
CUSTOMERS_TEAMLEAD: ['view_teamlead_customer','edit_teamlead_customer','budget_teamlead_customer','comments_teamlead_customer','details_teamlead_customer']
|
||||
INVOICE: ['view_invoice','create_invoice']
|
||||
INVOICE_TEMPLATE: ['manage_invoice_template']
|
||||
TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet']
|
||||
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
|
||||
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
|
||||
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile']
|
||||
PROFILE_OTHER: ['view_other_profile','edit_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile']
|
||||
TAGS: ['view_tag','manage_tag','delete_tag']
|
||||
USER: ['view_user','create_user','delete_user','role_permissions']
|
||||
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
|
||||
@@ -105,10 +111,10 @@ kimai:
|
||||
SINGLE_USER: ['view_team_member','budget_team_project']
|
||||
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
|
||||
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member']
|
||||
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member']
|
||||
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member']
|
||||
# link above sets to one complete set for each user role
|
||||
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
|
||||
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
|
||||
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
|
||||
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
|
||||
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
|
||||
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
|
||||
@@ -117,6 +123,8 @@ kimai:
|
||||
ROLE_TEAMLEAD: ['ROLE_TEAMLEAD']
|
||||
ROLE_ADMIN: ['ROLE_ADMIN']
|
||||
ROLE_SUPER_ADMIN: ['ROLE_SUPER_ADMIN']
|
||||
# only here to register the (partially) unused permissions in the UI
|
||||
ROLE_FAKE: ['CUSTOMERS_ALL_TEAMLEAD','CUSTOMERS_ALL_TEAM','PROJECTS_ALL_TEAMLEAD','PROJECTS_ALL_TEAM']
|
||||
# add or remove single permissions
|
||||
roles:
|
||||
ROLE_USER: []
|
||||
@@ -173,7 +181,7 @@ kimai:
|
||||
user_duration:
|
||||
title: stats.yourWorkingHours
|
||||
order: 10
|
||||
permission: ROLE_USER
|
||||
permission: view_own_timesheet
|
||||
type: '\App\Widget\Type\CompoundChart'
|
||||
widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
|
||||
user_rates:
|
||||
@@ -209,24 +217,26 @@ kimai:
|
||||
# Locale specific settings, like date formats
|
||||
# --------------------------------------------------------------------------------
|
||||
languages:
|
||||
ar:
|
||||
date_time_type: 'yyyy-MM-dd HH:mm'
|
||||
date_type: 'yyyy-MM-dd'
|
||||
date: 'Y-m-d'
|
||||
date_time: 'm-d H:i'
|
||||
duration: '%%h:%%m h'
|
||||
cs:
|
||||
date_time_type: 'dd.MM.yyyy HH:mm'
|
||||
date_type: 'dd.MM.yyyy'
|
||||
date: 'd.m.Y'
|
||||
date_time: 'd.m H:i'
|
||||
duration: '%%h:%%m h'
|
||||
da:
|
||||
date_time_type: 'dd.MM.yyyy HH:mm'
|
||||
date_type: 'dd.MM.yyyy'
|
||||
date: 'd.m.Y'
|
||||
date_time: 'd.m. H:i'
|
||||
de:
|
||||
date_time_type: 'dd.MM.yyyy HH:mm'
|
||||
date_type: 'dd.MM.yyyy'
|
||||
date: 'd.m.Y'
|
||||
date_time: 'd.m. H:i'
|
||||
duration: '%%h:%%m h'
|
||||
de_CH:
|
||||
date_time_type: 'dd.MM.yyyy HH:mm'
|
||||
date_type: 'dd.MM.yyyy'
|
||||
date: 'd.m.Y'
|
||||
date_time: 'd.m. H:i'
|
||||
en:
|
||||
date_time_type: 'yyyy-MM-dd HH:mm'
|
||||
date_type: 'yyyy-MM-dd'
|
||||
@@ -238,6 +248,11 @@ kimai:
|
||||
date_type: 'dd.MM.yyyy'
|
||||
date: 'd.m.Y'
|
||||
date_time: 'd.m. H:i'
|
||||
eu:
|
||||
date_time_type: 'yyyy-MM-dd HH:mm'
|
||||
date_type: 'yyyy-MM-dd'
|
||||
date: 'Y-m-d'
|
||||
date_time: 'm-d H:i'
|
||||
duration: '%%h:%%m h'
|
||||
fr:
|
||||
date_time_type: 'dd/MM/yyyy HH:mm'
|
||||
@@ -250,48 +265,33 @@ kimai:
|
||||
date_type: 'yyyy.MM.dd.'
|
||||
date: 'Y.m.d.'
|
||||
date_time: 'm.d. H:i'
|
||||
duration: '%%h:%%m h'
|
||||
it:
|
||||
date_time_type: 'dd.MM.yyyy HH:mm'
|
||||
date_type: 'dd.MM.yyyy'
|
||||
date: 'd.m.Y'
|
||||
date_time: 'd.m. H:i'
|
||||
duration: '%%h:%%m h'
|
||||
ja:
|
||||
date_time_type: 'yyyy-MM-dd HH:mm'
|
||||
date_type: 'yyyy-MM-dd'
|
||||
date: 'Y-m-d'
|
||||
date_time: 'm-d H:i'
|
||||
duration: '%%h:%%m tim'
|
||||
ko:
|
||||
date_time_type: 'yyyy-MM-dd HH:mm'
|
||||
date_type: 'yyyy-MM-dd'
|
||||
date: 'Y-m-d'
|
||||
date_time: 'm-d H:i'
|
||||
duration: '%%h:%%m h'
|
||||
nl:
|
||||
date_time_type: 'dd.MM.yyyy HH:mm'
|
||||
date_type: 'dd.MM.yyyy'
|
||||
date: 'd.m.Y'
|
||||
date_time: 'd.m. H:i'
|
||||
duration: '%%hu%%m'
|
||||
pt_BR:
|
||||
date_time_type: 'dd-MM-yyyy HH:mm'
|
||||
date_type: 'dd-MM-yyyy'
|
||||
date: 'd-m-Y'
|
||||
date_time: 'd-m H:i'
|
||||
duration: '%%h:%%m h'
|
||||
ru:
|
||||
date_time_type: 'dd.MM.yyyy HH:mm'
|
||||
date_type: 'dd.MM.yyyy'
|
||||
date: 'd.m.Y'
|
||||
date_time: 'd.m. H:i'
|
||||
duration: '%%h:%%m h'
|
||||
sk:
|
||||
date_time_type: 'dd. MM. yyyy HH:mm'
|
||||
date_type: 'dd. MM. yyyy'
|
||||
date: 'd. m. Y'
|
||||
date_time: 'd. m. H:i'
|
||||
duration: '%%h:%%m h'
|
||||
sv:
|
||||
date_time_type: 'yyyy-MM-dd HH:mm'
|
||||
date_type: 'yyyy-MM-dd'
|
||||
date: 'Y-m-d'
|
||||
date_time: 'm-d H:i'
|
||||
duration: '%%h:%%m tim'
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -21,11 +21,12 @@ nelmio_api_doc:
|
||||
- { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] }
|
||||
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] }
|
||||
- { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] }
|
||||
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
|
||||
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User, User_Entity] }
|
||||
- { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] }
|
||||
- { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team] }
|
||||
- { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team, Team_Entity] }
|
||||
- { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] }
|
||||
- { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] }
|
||||
- { alias: TimesheetConfig, type: App\API\Model\TimesheetConfig, groups: [Default] }
|
||||
areas:
|
||||
path_patterns:
|
||||
- ^/api(?!/doc)
|
||||
@@ -39,7 +40,7 @@ nelmio_api_doc:
|
||||
description: |
|
||||
JSON API for the Kimai 2 time-tracking software. Read more about its usage in the [API documentation](https://www.kimai.org/documentation/rest-api.html) and then download a [Swagger file](doc.json) for import e.g. in Postman.
|
||||
Be aware: it is not yet considered stable and BC breaks might happen, but we try to avoid them.
|
||||
version: '0.3'
|
||||
version: '0.4'
|
||||
securityDefinitions:
|
||||
apiUser:
|
||||
type: apiKey
|
||||
|
||||
@@ -48,6 +48,7 @@ security:
|
||||
allow_if_all_abstain: false
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_USER: ~
|
||||
ROLE_TEAMLEAD: ROLE_USER
|
||||
ROLE_ADMIN: ROLE_TEAMLEAD
|
||||
ROLE_SUPER_ADMIN: ROLE_ADMIN
|
||||
|
||||
@@ -7,7 +7,10 @@ home:
|
||||
|
||||
homeLocale:
|
||||
path: /{_locale}
|
||||
requirements:
|
||||
_locale: '%app_locales%'
|
||||
defaults:
|
||||
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
|
||||
_locale: '%locale%'
|
||||
route: homepage
|
||||
permanent: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
app.test.sidebar_settings:
|
||||
path: /{_locale}/sidebar/settings
|
||||
controller: App\Controller\SidebarController::settingsAction
|
||||
app.test.active_entries:
|
||||
path: /{_locale}/layou/active_entries
|
||||
controller: App\Controller\LayoutController::activeEntries
|
||||
requirements:
|
||||
_locale: '%app_locales%'
|
||||
defaults:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
App\Entity\Activity:
|
||||
exclusion_policy: All
|
||||
custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate, color, budget, timeBudget, metaFields]
|
||||
custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, parentTitle]
|
||||
properties:
|
||||
id:
|
||||
include: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
App\Entity\Project:
|
||||
exclusion_policy: All
|
||||
custom_accessor_order: [id, name, comment, visible, orderNumber, customer, fixedRate, hourlyRate, color, budget, timeBudget, metaFields]
|
||||
custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, start, end, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, parentTitle]
|
||||
properties:
|
||||
id:
|
||||
include: true
|
||||
@@ -20,6 +20,15 @@ App\Entity\Project:
|
||||
orderNumber:
|
||||
include: true
|
||||
groups: [Entity]
|
||||
orderDate:
|
||||
include: true
|
||||
groups: [Entity]
|
||||
start:
|
||||
include: true
|
||||
groups: [Project]
|
||||
end:
|
||||
include: true
|
||||
groups: [Project]
|
||||
fixedRate:
|
||||
include: true
|
||||
groups: [Project]
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
App\Entity\Team:
|
||||
exclusion_policy: All
|
||||
custom_accessor_order: [id, name, users]
|
||||
custom_accessor_order: [id, name, teamlead, users]
|
||||
properties:
|
||||
id:
|
||||
include: true
|
||||
name:
|
||||
include: true
|
||||
teamlead:
|
||||
include: true
|
||||
groups: [Team_Entity]
|
||||
users:
|
||||
exclude: true
|
||||
include: true
|
||||
groups: [Team_Entity]
|
||||
customers:
|
||||
exclude: true
|
||||
projects:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
App\Entity\User:
|
||||
exclusion_policy: All
|
||||
custom_accessor_order: [id, alias, title, avatar, language, timezone]
|
||||
custom_accessor_order: [id, alias, title, avatar, language, timezone, teams]
|
||||
properties:
|
||||
id:
|
||||
include: true
|
||||
@@ -10,18 +10,21 @@ App\Entity\User:
|
||||
groups: [Default]
|
||||
title:
|
||||
include: true
|
||||
groups: [Entity]
|
||||
groups: [User_Entity]
|
||||
avatar:
|
||||
include: true
|
||||
groups: [Entity]
|
||||
groups: [User_Entity]
|
||||
teams:
|
||||
include: true
|
||||
groups: [User_Entity]
|
||||
virtual_properties:
|
||||
getLanguage:
|
||||
serialized_name: language
|
||||
exp: "object.getPreferenceValue('language') === null ? null : object.getPreferenceValue('language')"
|
||||
type: string
|
||||
groups: [Entity]
|
||||
groups: [User_Entity]
|
||||
getTimezone:
|
||||
serialized_name: timezone
|
||||
exp: "object.getPreferenceValue('timezone') === null ? null : object.getPreferenceValue('timezone')"
|
||||
type: string
|
||||
groups: [Entity]
|
||||
groups: [User_Entity]
|
||||
|
||||
@@ -10,7 +10,7 @@ FOS\UserBundle\Model\User:
|
||||
roles:
|
||||
type: array<string>
|
||||
include: true
|
||||
groups: [Entity]
|
||||
groups: [User_Entity]
|
||||
groups:
|
||||
exclude: true
|
||||
email:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
|
||||
parameters:
|
||||
locale: en
|
||||
app_locales: ar|cs|de|en|es|fr|hu|it|ja|ko|pt_BR|ru|sk|sv
|
||||
app_locales: ar|cs|de|de_CH|da|en|es|eu|fr|hu|it|ja|ko|nl|pt_BR|ru|sk|sv|tr|zh_CN
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@@ -74,9 +74,6 @@ services:
|
||||
App\Plugin\PluginManager:
|
||||
arguments: [!tagged kimai.plugin]
|
||||
|
||||
App\Export\ServiceExport:
|
||||
arguments: [!tagged export.renderer]
|
||||
|
||||
App\Widget\WidgetService:
|
||||
arguments:
|
||||
$renderer: !tagged widget.renderer
|
||||
@@ -121,18 +118,25 @@ services:
|
||||
# TIMESHEET RECORD CALCULATOR
|
||||
# ================================================================================
|
||||
|
||||
App\Timesheet\Calculator\DurationCalculator:
|
||||
arguments: ['%kimai.timesheet.rounding%']
|
||||
App\Timesheet\RoundingService:
|
||||
arguments:
|
||||
$roundingModes: !tagged timesheet.rounding_mode
|
||||
$rules: '%kimai.timesheet.rounding%'
|
||||
|
||||
App\Timesheet\Calculator\RateCalculator:
|
||||
arguments: ['%kimai.timesheet.rates%']
|
||||
|
||||
App\Timesheet\TrackingModeService:
|
||||
arguments:
|
||||
$modes: !tagged timesheet.tracking_mode
|
||||
|
||||
# ================================================================================
|
||||
# SECURITY & VOTER
|
||||
# ================================================================================
|
||||
|
||||
App\Security\RoleService:
|
||||
arguments: ['%security.role_hierarchy.roles%']
|
||||
arguments:
|
||||
$roles: '%security.role_hierarchy.roles%'
|
||||
|
||||
App\Security\RolePermissionManager:
|
||||
arguments:
|
||||
@@ -195,6 +199,16 @@ services:
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Configuration']
|
||||
|
||||
App\Repository\RoleRepository:
|
||||
class: Doctrine\ORM\EntityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Role']
|
||||
|
||||
App\Repository\RolePermissionRepository:
|
||||
class: Doctrine\ORM\EntityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\RolePermission']
|
||||
|
||||
App\Repository\InvoiceDocumentRepository:
|
||||
class: App\Repository\InvoiceDocumentRepository
|
||||
arguments: ['%kimai.invoice.documents%']
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
parameters:
|
||||
locale: en
|
||||
app_locales: ar|cs|de|de_CH|da|en|es|fr|hu|it|ja|ko|nl|pt_BR|ru|sk|sv|tr|zh_CN
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
public: true
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"sass-loader": "^7.0.2",
|
||||
"select2": "^4.0.10",
|
||||
"select2-bootstrap-theme": "^0.1.0-beta.10",
|
||||
"sweetalert2": "^8.11.7",
|
||||
"sortablejs": "^1.10.1",
|
||||
"sweetalert2": "^9.3",
|
||||
"webpack-notifier": "^1.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
10
phpstan.neon
10
phpstan.neon
@@ -1,6 +1,7 @@
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-symfony/extension.neon
|
||||
- vendor/phpstan/phpstan-doctrine/extension.neon
|
||||
- vendor/phpstan/phpstan-symfony/rules.neon
|
||||
|
||||
parameters:
|
||||
tmpDir: %rootDir%/../../../var/cache/phpstan
|
||||
@@ -10,14 +11,15 @@ parameters:
|
||||
# container_xml_path: '%rootDir%/../../../var/cache/dev/srcApp_KernelDevDebugContainer.xml'
|
||||
ignoreErrors:
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::scalarNode\(\).#'
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\).#'
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::useAttributeAsKey\(\).#'
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::integerNode\(\).#'
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::booleanNode\(\).#'
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::end\(\).#'
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::defaultValue\(\).#'
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::addDefaultsIfNotSet\(\).#'
|
||||
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::requiresAtLeastOneElement\(\).#'
|
||||
- '#Access to an undefined property Faker\\Generator::\$stateAbbr.#'
|
||||
- '#Access to an undefined property Faker\\Generator::\$catchPhrase.#'
|
||||
- '#Access to an undefined property Faker\\Generator::\$bs.#'
|
||||
- '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#'
|
||||
- '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#'
|
||||
excludes_analyse:
|
||||
- %rootDir%/../../../src/Command/KimaiImporterCommand.php
|
||||
- %rootDir%/../../../src/Ldap/LdapDriver.php
|
||||
|
||||
1
public/build/0.1a6ffb43.js
Normal file
1
public/build/0.1a6ffb43.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/1.16767f92.js
Normal file
1
public/build/1.16767f92.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/2.dad56560.js
Normal file
1
public/build/2.dad56560.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/app.8240996d.css
Normal file
1
public/build/app.8240996d.css
Normal file
File diff suppressed because one or more lines are too long
1
public/build/app.8b409f35.js
Normal file
1
public/build/app.8b409f35.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/calendar.13664a8f.js
Normal file
1
public/build/calendar.13664a8f.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,44 +2,44 @@
|
||||
"entrypoints": {
|
||||
"app": {
|
||||
"js": [
|
||||
"build/runtime.4ee6be68.js",
|
||||
"build/0.a87622f3.js",
|
||||
"build/1.c1bee41f.js",
|
||||
"build/app.a361daf0.js"
|
||||
"build/runtime.6ca1eea5.js",
|
||||
"build/0.1a6ffb43.js",
|
||||
"build/1.16767f92.js",
|
||||
"build/app.8b409f35.js"
|
||||
],
|
||||
"css": [
|
||||
"build/app.4bdd4f2d.css"
|
||||
"build/app.8240996d.css"
|
||||
]
|
||||
},
|
||||
"chart": {
|
||||
"js": [
|
||||
"build/runtime.4ee6be68.js",
|
||||
"build/0.a87622f3.js",
|
||||
"build/2.7be60d8d.js",
|
||||
"build/chart.0af3f813.js"
|
||||
"build/runtime.6ca1eea5.js",
|
||||
"build/0.1a6ffb43.js",
|
||||
"build/2.dad56560.js",
|
||||
"build/chart.7f4d7f29.js"
|
||||
]
|
||||
},
|
||||
"calendar": {
|
||||
"js": [
|
||||
"build/runtime.4ee6be68.js",
|
||||
"build/0.a87622f3.js",
|
||||
"build/1.c1bee41f.js",
|
||||
"build/calendar.c55aa90f.js"
|
||||
"build/runtime.6ca1eea5.js",
|
||||
"build/0.1a6ffb43.js",
|
||||
"build/1.16767f92.js",
|
||||
"build/calendar.13664a8f.js"
|
||||
],
|
||||
"css": [
|
||||
"build/calendar.36ef2a02.css"
|
||||
"build/calendar.ade7bcdf.css"
|
||||
]
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
|
||||
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
|
||||
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
|
||||
"build/app.a361daf0.js": "sha384-gIDdRGs/guN+3m3fK/QhrAX8s3aSKfwwwE2VQBKDP4RtEYK3Uf/7WqEfqwrsT7HZ",
|
||||
"build/app.4bdd4f2d.css": "sha384-XpqGJV9ny/Fk0uJHisAZrAZfWfkzDvm5RJQOaa9ozf6nJstSXyEHziVAza9fIifm",
|
||||
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
|
||||
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
|
||||
"build/calendar.c55aa90f.js": "sha384-72C6gaIwYSeErJb6b/UbbzgnNHAlqQW/4/J0tXisV6Qdd78PeF7VT3xpfb1r1YOW",
|
||||
"build/calendar.36ef2a02.css": "sha384-ta519W5+NY1a8FBh7rD4zORYTfUXCYTLci0Z25nf4BfodmuXqyPVNEe7dSe2/fRd"
|
||||
"build/runtime.6ca1eea5.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
|
||||
"build/0.1a6ffb43.js": "sha384-vuVCyLVj2b4h3jpKl+JSANRCacmXeLk+ihK5T5NZBO7+SJ5Y+o6d4qju116hTPSD",
|
||||
"build/1.16767f92.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
|
||||
"build/app.8b409f35.js": "sha384-KLjCz4QplvRgAqezJx4OSJp38bRW0WHel8mmbmGgDtYTKIHBahoGY72PQwD+UU4m",
|
||||
"build/app.8240996d.css": "sha384-ZaS4ro6HGbDfLyaJc8cVXJzTd8plX3mRzhHhiiUEErSzPlRKdZfViDk23BNvAaz+",
|
||||
"build/2.dad56560.js": "sha384-oKsefSmRq0GL+Qa6f6jExXvFay3cM6YZFHQRKoAHi2p54UOCtjJfjF2eAB1qykoL",
|
||||
"build/chart.7f4d7f29.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
|
||||
"build/calendar.13664a8f.js": "sha384-CDVRjmMRhA0KU90taIf50/DQOr1gbxGmdxVW8uj+qcwzKE843XPCz40fYQudopGU",
|
||||
"build/calendar.ade7bcdf.css": "sha384-ta519W5+NY1a8FBh7rD4zORYTfUXCYTLci0Z25nf4BfodmuXqyPVNEe7dSe2/fRd"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"build/0.a87622f3.js": "build/0.a87622f3.js",
|
||||
"build/1.c1bee41f.js": "build/1.c1bee41f.js",
|
||||
"build/2.7be60d8d.js": "build/2.7be60d8d.js",
|
||||
"build/app.css": "build/app.4bdd4f2d.css",
|
||||
"build/app.js": "build/app.a361daf0.js",
|
||||
"build/calendar.css": "build/calendar.36ef2a02.css",
|
||||
"build/calendar.js": "build/calendar.c55aa90f.js",
|
||||
"build/chart.js": "build/chart.0af3f813.js",
|
||||
"build/runtime.js": "build/runtime.4ee6be68.js",
|
||||
"build/0.1a6ffb43.js": "build/0.1a6ffb43.js",
|
||||
"build/1.16767f92.js": "build/1.16767f92.js",
|
||||
"build/2.dad56560.js": "build/2.dad56560.js",
|
||||
"build/app.css": "build/app.8240996d.css",
|
||||
"build/app.js": "build/app.8b409f35.js",
|
||||
"build/calendar.css": "build/calendar.ade7bcdf.css",
|
||||
"build/calendar.js": "build/calendar.13664a8f.js",
|
||||
"build/chart.js": "build/chart.7f4d7f29.js",
|
||||
"build/runtime.js": "build/runtime.6ca1eea5.js",
|
||||
"build/fonts/fa-brands-400.eot": "build/fonts/fa-brands-400.8e49b728.eot",
|
||||
"build/fonts/fa-brands-400.woff": "build/fonts/fa-brands-400.9f018d10.woff",
|
||||
"build/fonts/fa-brands-400.woff2": "build/fonts/fa-brands-400.9f4ce3dc.woff2",
|
||||
|
||||
@@ -15,7 +15,7 @@ if (!isset($_SERVER['APP_ENV'])) {
|
||||
(new Dotenv(true))->load(__DIR__.'/../.env');
|
||||
}
|
||||
|
||||
$env = $_SERVER['APP_ENV'] ?? 'dev';
|
||||
$env = $_SERVER['APP_ENV'] ?? 'prod';
|
||||
$debug = $_SERVER['APP_DEBUG'] ?? ('prod' !== $env);
|
||||
|
||||
if ($debug) {
|
||||
|
||||
@@ -16,11 +16,13 @@ use App\Event\ActivityMetaDefinitionEvent;
|
||||
use App\Form\API\ActivityApiEditForm;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
@@ -31,7 +33,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
/**
|
||||
* @RouteResource("Activity")
|
||||
*
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class ActivityController extends BaseApiController
|
||||
{
|
||||
@@ -72,10 +74,12 @@ class ActivityController extends BaseApiController
|
||||
* @Rest\QueryParam(name="globalsFirst", requirements="true|false", strict=true, nullable=true, description="Deprecated parameter, value is not used any more")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|name|project", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, project (default: name)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
|
||||
*
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher)
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$query = new ActivityQuery();
|
||||
|
||||
@@ -103,6 +107,10 @@ class ActivityController extends BaseApiController
|
||||
$query->setVisibility($visible);
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$data = $this->repository->getActivitiesForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Collection', 'Activity']);
|
||||
@@ -126,12 +134,11 @@ class ActivityController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction($id)
|
||||
public function getAction(int $id): Response
|
||||
{
|
||||
/** @var Activity $data */
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
@@ -162,13 +169,10 @@ class ActivityController extends BaseApiController
|
||||
* @SWG\Schema(ref="#/definitions/ActivityEditForm")
|
||||
* )
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws \App\Repository\RepositoryException
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postAction(Request $request)
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
if (!$this->isGranted('create_activity')) {
|
||||
throw new AccessDeniedHttpException('User cannot create activities');
|
||||
@@ -223,13 +227,10 @@ class ActivityController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $id
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, string $id)
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
{
|
||||
$activity = $this->repository->find($id);
|
||||
|
||||
@@ -282,13 +283,10 @@ class ActivityController extends BaseApiController
|
||||
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
|
||||
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
|
||||
*
|
||||
* @param int $id
|
||||
* @param ParamFetcherInterface $paramFetcher
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function metaAction($id, ParamFetcherInterface $paramFetcher)
|
||||
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$activity = $this->repository->find($id);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\API;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
|
||||
@@ -18,4 +19,12 @@ abstract class BaseApiController extends AbstractController
|
||||
{
|
||||
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
|
||||
public const DATE_FORMAT_PHP = 'Y-m-d\TH:m:s';
|
||||
|
||||
/**
|
||||
* @return User|null
|
||||
*/
|
||||
protected function getUser()
|
||||
{
|
||||
return parent::getUser();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,36 +12,46 @@ declare(strict_types=1);
|
||||
namespace App\API;
|
||||
|
||||
use App\API\Model\I18n;
|
||||
use App\API\Model\TimesheetConfig;
|
||||
use App\Configuration\LanguageFormattings;
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\User;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class ConfigurationController extends BaseApiController
|
||||
final class ConfigurationController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
protected $viewHandler;
|
||||
private $viewHandler;
|
||||
/**
|
||||
* @var LanguageFormattings
|
||||
*/
|
||||
protected $formats;
|
||||
private $formats;
|
||||
/**
|
||||
* @var TimesheetConfiguration
|
||||
*/
|
||||
private $timesheetConfiguration;
|
||||
|
||||
/**
|
||||
* @param ViewHandlerInterface $viewHandler
|
||||
* @param LanguageFormattings $formats
|
||||
* @param TimesheetConfiguration $timesheetConfiguration
|
||||
*/
|
||||
public function __construct(ViewHandlerInterface $viewHandler, LanguageFormattings $formats)
|
||||
public function __construct(ViewHandlerInterface $viewHandler, LanguageFormattings $formats, TimesheetConfiguration $timesheetConfiguration)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->formats = $formats;
|
||||
$this->timesheetConfiguration = $timesheetConfiguration;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,8 +64,11 @@ class ConfigurationController extends BaseApiController
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/config/i18n")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function i18nAction()
|
||||
public function i18nAction(): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
@@ -77,4 +90,35 @@ class ConfigurationController extends BaseApiController
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the instance specific timesheet configuration
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the instance specific timesheet configuration",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetConfig")
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/config/timesheet")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function timesheetConfigAction(): Response
|
||||
{
|
||||
$model = new TimesheetConfig();
|
||||
$model
|
||||
->setTrackingMode($this->timesheetConfiguration->getTrackingMode())
|
||||
->setDefaultBeginTime($this->timesheetConfiguration->getDefaultBeginTime())
|
||||
->setActiveEntriesHardLimit($this->timesheetConfiguration->getActiveEntriesHardLimit())
|
||||
->setActiveEntriesSoftLimit($this->timesheetConfiguration->getActiveEntriesSoftLimit())
|
||||
->setIsAllowFutureTimes($this->timesheetConfiguration->isAllowFutureTimes())
|
||||
;
|
||||
|
||||
$view = new View($model, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Config']);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ use App\Event\CustomerMetaDefinitionEvent;
|
||||
use App\Form\API\CustomerApiEditForm;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
@@ -31,7 +33,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
/**
|
||||
* @RouteResource("Customer")
|
||||
*
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class CustomerController extends BaseApiController
|
||||
{
|
||||
@@ -69,10 +71,12 @@ class CustomerController extends BaseApiController
|
||||
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|name", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name (default: name)")
|
||||
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
|
||||
*
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher)
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$query = new CustomerQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -89,6 +93,10 @@ class CustomerController extends BaseApiController
|
||||
$query->setVisibility($visible);
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$data = $this->repository->getCustomersForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Collection', 'Customer']);
|
||||
@@ -105,12 +113,11 @@ class CustomerController extends BaseApiController
|
||||
* @SWG\Schema(ref="#/definitions/CustomerEntity"),
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction($id)
|
||||
public function getAction(int $id): Response
|
||||
{
|
||||
/** @var Customer $data */
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
@@ -141,13 +148,10 @@ class CustomerController extends BaseApiController
|
||||
* @SWG\Schema(ref="#/definitions/CustomerEditForm")
|
||||
* )
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws \App\Repository\RepositoryException
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postAction(Request $request)
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
if (!$this->isGranted('create_customer')) {
|
||||
throw new AccessDeniedHttpException('User cannot create customers');
|
||||
@@ -202,13 +206,10 @@ class CustomerController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $id
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, string $id)
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
{
|
||||
$customer = $this->repository->find($id);
|
||||
|
||||
@@ -261,13 +262,10 @@ class CustomerController extends BaseApiController
|
||||
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
|
||||
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
|
||||
*
|
||||
* @param int $id
|
||||
* @param ParamFetcherInterface $paramFetcher
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function metaAction($id, ParamFetcherInterface $paramFetcher)
|
||||
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$customer = $this->repository->find($id);
|
||||
|
||||
|
||||
@@ -11,49 +11,49 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\API\Model;
|
||||
|
||||
class I18n
|
||||
final class I18n
|
||||
{
|
||||
/**
|
||||
* Format used for 'begin' and 'end'
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $formDateTime = '';
|
||||
private $formDateTime = '';
|
||||
/**
|
||||
* Format used for toolbar queries
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $formDate = '';
|
||||
private $formDate = '';
|
||||
/**
|
||||
* Format used to display date-time values (see PHP function date_format)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $dateTime = '';
|
||||
private $dateTime = '';
|
||||
/**
|
||||
* Format used to display date values (see PHP function date_format)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $date = '';
|
||||
private $date = '';
|
||||
/**
|
||||
* Format used to display times (see PHP function date_format)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $time = '';
|
||||
private $time = '';
|
||||
/**
|
||||
* Format used to display durations (replace: %h with hours, %m with minutes, %s with seconds)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $duration = '';
|
||||
private $duration = '';
|
||||
/**
|
||||
* Whether a twenty-four hour format is used (true) or 12-hours AM/PM format (false)
|
||||
* @var bool
|
||||
*/
|
||||
protected $is24hours = true;
|
||||
private $is24hours = true;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
|
||||
101
src/API/Model/TimesheetConfig.php
Normal file
101
src/API/Model/TimesheetConfig.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\API\Model;
|
||||
|
||||
final class TimesheetConfig
|
||||
{
|
||||
/**
|
||||
* See here: https://www.kimai.org/documentation/timesheet.html#tracking-modes
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $trackingMode = 'default';
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $defaultBeginTime = 'now';
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $activeEntriesHardLimit = 1;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $activeEntriesSoftLimit = 1;
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $isAllowFutureTimes = true;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getTrackingMode(): string
|
||||
{
|
||||
return $this->trackingMode;
|
||||
}
|
||||
|
||||
public function setTrackingMode(string $trackingMode): TimesheetConfig
|
||||
{
|
||||
$this->trackingMode = $trackingMode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDefaultBeginTime(): string
|
||||
{
|
||||
return $this->defaultBeginTime;
|
||||
}
|
||||
|
||||
public function setDefaultBeginTime(string $defaultBeginTime): TimesheetConfig
|
||||
{
|
||||
$this->defaultBeginTime = $defaultBeginTime;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActiveEntriesHardLimit(): int
|
||||
{
|
||||
return $this->activeEntriesHardLimit;
|
||||
}
|
||||
|
||||
public function setActiveEntriesHardLimit(int $activeEntriesHardLimit): TimesheetConfig
|
||||
{
|
||||
$this->activeEntriesHardLimit = $activeEntriesHardLimit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getActiveEntriesSoftLimit(): int
|
||||
{
|
||||
return $this->activeEntriesSoftLimit;
|
||||
}
|
||||
|
||||
public function setActiveEntriesSoftLimit(int $activeEntriesSoftLimit): TimesheetConfig
|
||||
{
|
||||
$this->activeEntriesSoftLimit = $activeEntriesSoftLimit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isAllowFutureTimes(): bool
|
||||
{
|
||||
return $this->isAllowFutureTimes;
|
||||
}
|
||||
|
||||
public function setIsAllowFutureTimes(bool $isAllowFutureTimes): TimesheetConfig
|
||||
{
|
||||
$this->isAllowFutureTimes = $isAllowFutureTimes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -16,22 +16,26 @@ use App\Event\ProjectMetaDefinitionEvent;
|
||||
use App\Form\API\ProjectApiEditForm;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Timesheet\UserDateTimeFactory;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
|
||||
/**
|
||||
* @RouteResource("Project")
|
||||
*
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class ProjectController extends BaseApiController
|
||||
{
|
||||
@@ -47,16 +51,21 @@ class ProjectController extends BaseApiController
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var UserDateTimeFactory
|
||||
*/
|
||||
private $dateTime;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher, UserDateTimeFactory $dateTime)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->dateTime = $dateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of projects
|
||||
* Returns a collection of projects.
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
@@ -67,14 +76,18 @@ class ProjectController extends BaseApiController
|
||||
* )
|
||||
* )
|
||||
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects")
|
||||
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects (1=visible, 2=hidden, 3=both)")
|
||||
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects. Allowed values: 1=visible, 2=hidden, 3=both (default; 1)")
|
||||
* @Rest\QueryParam(name="start", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only projects that started before this date will be included. Allowed format: HTML5 (default: now, if end is also empty)")
|
||||
* @Rest\QueryParam(name="end", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only projects that ended after this date will be included. Allowed format: HTML5 (default: now, if start is also empty)")
|
||||
* @Rest\QueryParam(name="ignoreDates", requirements="1", strict=true, nullable=true, description="If set, start and end are completely ignored. Allowed values: 1 (default: off)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|name|customer", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, customer (default: name)")
|
||||
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
|
||||
*
|
||||
* @param ParamFetcherInterface $paramFetcher
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher)
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -95,6 +108,31 @@ class ProjectController extends BaseApiController
|
||||
$query->setVisibility($visible);
|
||||
}
|
||||
|
||||
$ignoreDates = false;
|
||||
if (null !== $paramFetcher->get('ignoreDates')) {
|
||||
$ignoreDates = intval($paramFetcher->get('ignoreDates')) === 1;
|
||||
}
|
||||
|
||||
if (!$ignoreDates) {
|
||||
if (null !== ($begin = $paramFetcher->get('start')) && !empty($begin)) {
|
||||
$query->setProjectStart($this->dateTime->createDateTime($begin));
|
||||
}
|
||||
|
||||
if (null !== ($end = $paramFetcher->get('end')) && !empty($end)) {
|
||||
$query->setProjectEnd($this->dateTime->createDateTime($end));
|
||||
}
|
||||
|
||||
if (empty($begin) && empty($end)) {
|
||||
$now = $this->dateTime->createDateTime();
|
||||
$query->setProjectStart($now);
|
||||
$query->setProjectEnd($now);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$data = $this->repository->getProjectsForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Collection', 'Project']);
|
||||
@@ -111,12 +149,11 @@ class ProjectController extends BaseApiController
|
||||
* @SWG\Schema(ref="#/definitions/ProjectEntity"),
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction($id)
|
||||
public function getAction(int $id): Response
|
||||
{
|
||||
/** @var Project $data */
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
@@ -147,13 +184,10 @@ class ProjectController extends BaseApiController
|
||||
* @SWG\Schema(ref="#/definitions/ProjectEditForm")
|
||||
* )
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws \App\Repository\RepositoryException
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postAction(Request $request)
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
if (!$this->isGranted('create_project')) {
|
||||
throw new AccessDeniedHttpException('User cannot create projects');
|
||||
@@ -208,13 +242,10 @@ class ProjectController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $id
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, string $id)
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
{
|
||||
$project = $this->repository->find($id);
|
||||
|
||||
@@ -267,13 +298,10 @@ class ProjectController extends BaseApiController
|
||||
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
|
||||
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
|
||||
*
|
||||
* @param int $id
|
||||
* @param ParamFetcherInterface $paramFetcher
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function metaAction($id, ParamFetcherInterface $paramFetcher)
|
||||
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$project = $this->repository->find($id);
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StatusController extends BaseApiController
|
||||
{
|
||||
@@ -43,8 +45,11 @@ class StatusController extends BaseApiController
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/ping")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function pingAction()
|
||||
public function pingAction(): Response
|
||||
{
|
||||
$view = new View(['message' => 'pong'], 200);
|
||||
|
||||
@@ -61,8 +66,11 @@ class StatusController extends BaseApiController
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/version")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function versionAction()
|
||||
public function versionAction(): Response
|
||||
{
|
||||
return $this->viewHandler->handle(new View(new Version(), 200));
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ namespace App\API;
|
||||
|
||||
use App\Entity\Tag;
|
||||
use App\Form\API\TagApiEditForm;
|
||||
use App\Form\TagEditForm;
|
||||
use App\Repository\TagRepository;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -28,6 +28,8 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* @RouteResource("Tag")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class TagController extends BaseApiController
|
||||
{
|
||||
@@ -65,9 +67,10 @@ class TagController extends BaseApiController
|
||||
*
|
||||
* @Rest\QueryParam(name="name", strict=true, nullable=true, description="Search term to filter tag list")
|
||||
*
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher)
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$filter = $paramFetcher->get('name');
|
||||
|
||||
@@ -97,13 +100,10 @@ class TagController extends BaseApiController
|
||||
* @SWG\Schema(ref="#/definitions/TagEditForm")
|
||||
* )
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws \App\Repository\RepositoryException
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postAction(Request $request)
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
if (!$this->isGranted('manage_tag')) {
|
||||
throw new AccessDeniedHttpException('User cannot create tags');
|
||||
@@ -149,10 +149,10 @@ class TagController extends BaseApiController
|
||||
*
|
||||
* @Security("is_granted('delete_tag')")
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteAction($id)
|
||||
public function deleteAction(int $id): Response
|
||||
{
|
||||
$tag = $this->repository->find($id);
|
||||
|
||||
|
||||
@@ -17,12 +17,15 @@ use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @RouteResource("Team")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class TeamController extends BaseApiController
|
||||
{
|
||||
@@ -55,9 +58,10 @@ class TeamController extends BaseApiController
|
||||
*
|
||||
* @Security("is_granted('view_team')")
|
||||
*
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher)
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$data = $this->repository->findAll();
|
||||
|
||||
@@ -76,12 +80,13 @@ class TeamController extends BaseApiController
|
||||
* @SWG\Schema(ref="#/definitions/TeamEntity"),
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @Security("is_granted('view_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction($id)
|
||||
public function getAction(int $id): Response
|
||||
{
|
||||
/** @var Team $data */
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
@@ -89,7 +94,7 @@ class TeamController extends BaseApiController
|
||||
}
|
||||
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'Team']);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'Team', 'Team_Entity']);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
@@ -113,10 +118,10 @@ class TeamController extends BaseApiController
|
||||
*
|
||||
* @Security("is_granted('delete_team')")
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteAction($id)
|
||||
public function deleteAction(int $id): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
|
||||
@@ -19,15 +19,19 @@ use App\Form\API\TimesheetApiEditForm;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\RoundingService;
|
||||
use App\Timesheet\TimesheetService;
|
||||
use App\Timesheet\TrackingMode\TrackingModeInterface;
|
||||
use App\Timesheet\TrackingModeService;
|
||||
use App\Timesheet\UserDateTimeFactory;
|
||||
use App\Utils\SearchTerm;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
@@ -42,7 +46,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
/**
|
||||
* @RouteResource("Timesheet")
|
||||
*
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class TimesheetController extends BaseApiController
|
||||
{
|
||||
@@ -74,6 +78,14 @@ class TimesheetController extends BaseApiController
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var RoundingService
|
||||
*/
|
||||
private $roundingService;
|
||||
/**
|
||||
* @var TimesheetService
|
||||
*/
|
||||
private $service;
|
||||
|
||||
public function __construct(
|
||||
ViewHandlerInterface $viewHandler,
|
||||
@@ -82,7 +94,9 @@ class TimesheetController extends BaseApiController
|
||||
TimesheetConfiguration $configuration,
|
||||
TagRepository $tagRepository,
|
||||
TrackingModeService $trackingModeService,
|
||||
EventDispatcherInterface $dispatcher
|
||||
EventDispatcherInterface $dispatcher,
|
||||
RoundingService $roundingService,
|
||||
TimesheetService $service
|
||||
) {
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
@@ -91,6 +105,8 @@ class TimesheetController extends BaseApiController
|
||||
$this->tagRepository = $tagRepository;
|
||||
$this->trackingModeService = $trackingModeService;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->roundingService = $roundingService;
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
protected function getTrackingMode(): TrackingModeInterface
|
||||
@@ -116,7 +132,7 @@ class TimesheetController extends BaseApiController
|
||||
* @Rest\QueryParam(name="activity", requirements="\d+", strict=true, nullable=true, description="Activity ID to filter timesheets")
|
||||
* @Rest\QueryParam(name="page", requirements="\d+", strict=true, nullable=true, description="The page to display, renders a 404 if not found (default: 1)")
|
||||
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries for each page (default: 50)")
|
||||
* @Rest\QueryParam(name="tags", requirements="[a-zA-Z0-9 \-,]+", strict=true, nullable=true, description="The name of tags which are in the datasets")
|
||||
* @Rest\QueryParam(name="tags", strict=true, nullable=true, description="The name of tags which are in the datasets")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: DESC)")
|
||||
* @Rest\QueryParam(name="begin", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records after this date will be included (format: HTML5)")
|
||||
@@ -124,14 +140,14 @@ class TimesheetController extends BaseApiController
|
||||
* @Rest\QueryParam(name="exported", requirements="0|1", strict=true, nullable=true, description="Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)")
|
||||
* @Rest\QueryParam(name="active", requirements="0|1", strict=true, nullable=true, description="Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)")
|
||||
* @Rest\QueryParam(name="full", requirements="true", strict=true, nullable=true, description="Allows to fetch fully serialized objects including subresources (TimesheetSubCollection). Allowed values: true (default: false)")
|
||||
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
|
||||
*
|
||||
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
|
||||
*
|
||||
* @param ParamFetcherInterface $paramFetcher
|
||||
* @return Response
|
||||
* @throws \Exception
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher)
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$query = new TimesheetQuery();
|
||||
$query->setUser($this->getUser());
|
||||
@@ -204,6 +220,10 @@ class TimesheetController extends BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
/** @var Pagerfanta $data */
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
$data = (array) $data->getCurrentPageResults();
|
||||
@@ -236,12 +256,11 @@ class TimesheetController extends BaseApiController
|
||||
*
|
||||
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction($id)
|
||||
public function getAction(int $id): Response
|
||||
{
|
||||
/** @var Timesheet $data */
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
@@ -278,46 +297,37 @@ class TimesheetController extends BaseApiController
|
||||
*
|
||||
* @Security("is_granted('create_own_timesheet')")
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws \App\Repository\RepositoryException
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postAction(Request $request)
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setUser($this->getUser());
|
||||
$timesheet->setBegin($this->dateTime->createDateTime());
|
||||
|
||||
$event = new TimesheetMetaDefinitionEvent($timesheet);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$timesheet = $this->service->createNewTimesheet($this->getUser(), $request);
|
||||
|
||||
$mode = $this->getTrackingMode();
|
||||
|
||||
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
|
||||
'include_rate' => $this->isGranted('edit_rate', $timesheet),
|
||||
'include_exported' => $this->isGranted('edit_export', $timesheet),
|
||||
'include_user' => $this->isGranted('create_other_timesheet'),
|
||||
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
'date_format' => self::DATE_FORMAT,
|
||||
]);
|
||||
|
||||
$form->submit($request->request->all());
|
||||
$form->submit($request->request->all(), false);
|
||||
|
||||
if ($form->isValid()) {
|
||||
if (null === $timesheet->getEnd()) {
|
||||
if (!$this->isGranted('start', $timesheet)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to start this timesheet record');
|
||||
try {
|
||||
$this->service->saveNewTimesheet($timesheet);
|
||||
} catch (\Exception $ex) {
|
||||
if ($ex->getMessage() === 'timesheet.start.exceeded_limit') {
|
||||
throw new BadRequestHttpException('Too many active timesheets');
|
||||
} else {
|
||||
throw $ex;
|
||||
}
|
||||
$this->repository->stopActiveEntries(
|
||||
$timesheet->getUser(),
|
||||
$this->configuration->getActiveEntriesHardLimit()
|
||||
);
|
||||
}
|
||||
|
||||
$this->repository->save($timesheet);
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
|
||||
|
||||
@@ -355,11 +365,10 @@ class TimesheetController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param Request $request
|
||||
* @param int $id the timesheet to update
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, int $id)
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
{
|
||||
$timesheet = $this->repository->find($id);
|
||||
|
||||
@@ -379,6 +388,7 @@ class TimesheetController extends BaseApiController
|
||||
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
|
||||
'include_rate' => $this->isGranted('edit_rate', $timesheet),
|
||||
'include_exported' => $this->isGranted('edit_export', $timesheet),
|
||||
'include_user' => $this->isGranted('edit', $timesheet),
|
||||
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
'date_format' => self::DATE_FORMAT,
|
||||
@@ -419,10 +429,10 @@ class TimesheetController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteAction($id)
|
||||
public function deleteAction(int $id): Response
|
||||
{
|
||||
$timesheet = $this->repository->find($id);
|
||||
|
||||
@@ -458,10 +468,11 @@ class TimesheetController extends BaseApiController
|
||||
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries (default: 10)")
|
||||
*
|
||||
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\Query\QueryException
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function recentAction(ParamFetcherInterface $paramFetcher)
|
||||
public function recentAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$begin = $this->dateTime->createDateTime('-1 year');
|
||||
@@ -503,9 +514,11 @@ class TimesheetController extends BaseApiController
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('view_own_timesheet')")
|
||||
* @return Response
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function activeAction()
|
||||
public function activeAction(): Response
|
||||
{
|
||||
$data = $this->repository->getActiveEntries($this->getUser());
|
||||
|
||||
@@ -531,15 +544,11 @@ class TimesheetController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @throws \App\Repository\RepositoryException
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function stopAction($id)
|
||||
public function stopAction(int $id): Response
|
||||
{
|
||||
/** @var Timesheet $timesheet */
|
||||
$timesheet = $this->repository->find($id);
|
||||
|
||||
if (null === $timesheet) {
|
||||
@@ -550,7 +559,7 @@ class TimesheetController extends BaseApiController
|
||||
throw new AccessDeniedHttpException('You are not allowed to stop this timesheet');
|
||||
}
|
||||
|
||||
$this->repository->stopRecording($timesheet);
|
||||
$this->service->stopTimesheet($timesheet);
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
|
||||
@@ -576,15 +585,11 @@ class TimesheetController extends BaseApiController
|
||||
*
|
||||
* @Rest\RequestParam(name="copy", requirements="all|tags|rates|meta|description", strict=true, nullable=true, description="Whether data should be copied to the new entry. Allowed values: all, tags, rates, description, meta (default: nothing is copied)")
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @throws \App\Repository\RepositoryException
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function restartAction($id, ParamFetcherInterface $paramFetcher, ValidatorInterface $validator)
|
||||
public function restartAction(int $id, ParamFetcherInterface $paramFetcher, ValidatorInterface $validator): Response
|
||||
{
|
||||
/** @var Timesheet $timesheet */
|
||||
$timesheet = $this->repository->find($id);
|
||||
|
||||
if (null === $timesheet) {
|
||||
@@ -595,16 +600,14 @@ class TimesheetController extends BaseApiController
|
||||
throw new AccessDeniedHttpException('You are not allowed to re-start this timesheet');
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$copyTimesheet = $this->service->createNewTimesheet($this->getUser());
|
||||
|
||||
$copyTimesheet = new Timesheet();
|
||||
$copyTimesheet
|
||||
->setBegin($this->dateTime->createDateTime())
|
||||
->setUser($user)
|
||||
->setActivity($timesheet->getActivity())
|
||||
->setProject($timesheet->getProject())
|
||||
;
|
||||
$this->roundingService->roundBegin($copyTimesheet);
|
||||
|
||||
if (null !== ($copy = $paramFetcher->get('copy'))) {
|
||||
if (in_array($copy, ['rates', 'all'])) {
|
||||
@@ -636,12 +639,7 @@ class TimesheetController extends BaseApiController
|
||||
throw new BadRequestHttpException($errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage());
|
||||
}
|
||||
|
||||
$this->repository->stopActiveEntries(
|
||||
$user,
|
||||
$this->configuration->getActiveEntriesHardLimit()
|
||||
);
|
||||
|
||||
$this->repository->save($copyTimesheet);
|
||||
$this->service->saveNewTimesheet($copyTimesheet);
|
||||
|
||||
$view = new View($copyTimesheet, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
|
||||
@@ -665,14 +663,11 @@ class TimesheetController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function exportAction($id)
|
||||
public function exportAction(int $id): Response
|
||||
{
|
||||
/** @var Timesheet $timesheet */
|
||||
$timesheet = $this->repository->find($id);
|
||||
|
||||
if (null === $timesheet) {
|
||||
@@ -713,13 +708,10 @@ class TimesheetController extends BaseApiController
|
||||
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
|
||||
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
|
||||
*
|
||||
* @param int $id
|
||||
* @param ParamFetcherInterface $paramFetcher
|
||||
* @return Response
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function metaAction($id, ParamFetcherInterface $paramFetcher)
|
||||
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$timesheet = $this->repository->find($id);
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ namespace App\API;
|
||||
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Controller\Annotations\RouteResource;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -26,7 +28,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
/**
|
||||
* @RouteResource("User")
|
||||
*
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class UserController extends BaseApiController
|
||||
{
|
||||
@@ -65,12 +67,14 @@ class UserController extends BaseApiController
|
||||
* @Rest\QueryParam(name="visible", requirements="1|2|3", strict=true, nullable=true, description="Visibility status to filter users. Allowed values: 1=visible, 2=hidden, 3=all (default: 1)")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|username|alias|email", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
|
||||
*
|
||||
* @Security("is_granted('view_user')")
|
||||
*
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher)
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$query = new UserQuery();
|
||||
|
||||
@@ -86,6 +90,10 @@ class UserController extends BaseApiController
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$data = $this->repository->getUsersForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Collection', 'User']);
|
||||
@@ -98,7 +106,7 @@ class UserController extends BaseApiController
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Return one user entity. Required permission: view_user",
|
||||
* description="Return one user entity.",
|
||||
* @SWG\Schema(ref="#/definitions/UserEntity"),
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
@@ -109,10 +117,10 @@ class UserController extends BaseApiController
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @param int $id
|
||||
* @return Response
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction($id)
|
||||
public function getAction(int $id): Response
|
||||
{
|
||||
$user = $this->repository->find($id);
|
||||
|
||||
@@ -125,7 +133,29 @@ class UserController extends BaseApiController
|
||||
}
|
||||
|
||||
$view = new View($user, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'User']);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current user entity
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Return the current user entity.",
|
||||
* @SWG\Schema(ref="#/definitions/UserEntity"),
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/users/me")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function meAction(): Response
|
||||
{
|
||||
$view = new View($this->getUser(), 200);
|
||||
$view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
@@ -103,15 +103,12 @@ class CreateReleaseCommand extends Command
|
||||
$io->success('Prepare new packages for Kimai ' . $version . ' in ' . $tmpDir);
|
||||
|
||||
$gitCmd = sprintf(self::CLONE_CMD, $version);
|
||||
$tar = 'kimai-release-' . $version;
|
||||
$zip = 'kimai-release-' . $version;
|
||||
|
||||
if ($version === Constants::VERSION && Constants::STATUS !== 'stable') {
|
||||
$tar .= '_' . Constants::STATUS;
|
||||
$zip .= '_' . Constants::STATUS;
|
||||
}
|
||||
|
||||
$tar .= '.tar.gz';
|
||||
$zip .= '.zip';
|
||||
|
||||
$prefix = 'APP_ENV=prod DATABASE_URL=sqlite:///%kernel.project_dir%/var/data/kimai.sqlite';
|
||||
@@ -119,9 +116,7 @@ class CreateReleaseCommand extends Command
|
||||
$commands = [
|
||||
'Clone repository' => $gitCmd . ' ' . $tmpDir,
|
||||
'Install composer dependencies' => sprintf('cd %s && %s composer install --no-dev --optimize-autoloader', $tmpDir, $prefix),
|
||||
'Create database' => sprintf('cd %s && %s bin/console doctrine:database:create -n', $tmpDir, $prefix),
|
||||
'Create tables' => sprintf('cd %s && %s bin/console doctrine:schema:create -n', $tmpDir, $prefix),
|
||||
'Add all migrations' => sprintf('cd %s && %s bin/console doctrine:migrations:version --add --all -n', $tmpDir, $prefix),
|
||||
'Create database' => sprintf('cd %s && %s bin/console kimai:install -n', $tmpDir, $prefix),
|
||||
];
|
||||
|
||||
$filesToDelete = [
|
||||
@@ -137,7 +132,6 @@ class CreateReleaseCommand extends Command
|
||||
'phpunit.xml.dist',
|
||||
'webpack.config.js',
|
||||
'assets/',
|
||||
'bin/',
|
||||
'tests/',
|
||||
'var/cache/*',
|
||||
'var/data/kimai_test.sqlite',
|
||||
@@ -150,25 +144,24 @@ class CreateReleaseCommand extends Command
|
||||
}
|
||||
|
||||
$commands = array_merge($commands, [
|
||||
'Create tar' => 'cd ' . $tmpDir . ' && tar -czf ' . $directory . '/' . $tar . ' .',
|
||||
'Create zip' => 'cd ' . $tmpDir . ' && zip -r ' . $directory . '/' . $zip . ' .',
|
||||
'Create release zip' => 'cd ' . $tmpDir . ' && zip -q -r ' . $directory . '/' . $zip . ' .',
|
||||
'Remove tmp directory' => 'rm -rf ' . $tmpDir,
|
||||
]);
|
||||
|
||||
$exitCode = 0;
|
||||
foreach ($commands as $title => $command) {
|
||||
$io->success($title);
|
||||
passthru($command, $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
$io->error('Failed with command: ' . $command);
|
||||
|
||||
return -1;
|
||||
} else {
|
||||
$io->success($title);
|
||||
}
|
||||
}
|
||||
|
||||
$io->success(
|
||||
'New release packages available at: ' . PHP_EOL .
|
||||
$directory . '/' . $tar . PHP_EOL .
|
||||
'New release package available at: ' . PHP_EOL .
|
||||
$directory . '/' . $zip
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -21,34 +21,23 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Command used to create application user.
|
||||
*/
|
||||
class CreateUserCommand extends Command
|
||||
final class CreateUserCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var UserPasswordEncoderInterface
|
||||
*/
|
||||
protected $encoder;
|
||||
private $encoder;
|
||||
/**
|
||||
* @var RegistryInterface
|
||||
* @var ManagerRegistry
|
||||
*/
|
||||
protected $doctrine;
|
||||
private $doctrine;
|
||||
/**
|
||||
* @var ValidatorInterface
|
||||
*/
|
||||
protected $validator;
|
||||
private $validator;
|
||||
|
||||
/**
|
||||
* @param UserPasswordEncoderInterface $encoder
|
||||
* @param RegistryInterface $registry
|
||||
* @param ValidatorInterface $validator
|
||||
*/
|
||||
public function __construct(
|
||||
UserPasswordEncoderInterface $encoder,
|
||||
RegistryInterface $registry,
|
||||
ValidatorInterface $validator
|
||||
) {
|
||||
public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
|
||||
{
|
||||
$this->encoder = $encoder;
|
||||
$this->doctrine = $registry;
|
||||
$this->validator = $validator;
|
||||
@@ -130,14 +119,14 @@ class CreateUserCommand extends Command
|
||||
$entityManager->persist($user);
|
||||
$entityManager->flush();
|
||||
$io->success('Success! Created user: ' . $user->getUsername());
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to create user: ' . $user->getUsername());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
338
src/Command/ImportProjectCommand.php
Normal file
338
src/Command/ImportProjectCommand.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\FormConfiguration;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Importer\InvalidFieldsException;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use League\Csv\Reader;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* This command can change anytime, don't rely on its API for the future!
|
||||
*
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class ImportProjectCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'kimai:import:customer';
|
||||
|
||||
private static $requiredHeader = [
|
||||
'Name',
|
||||
'Customer',
|
||||
];
|
||||
|
||||
private static $supportedHeader = [
|
||||
'Name',
|
||||
'Customer',
|
||||
'Comment',
|
||||
'OrderNumber',
|
||||
'OrderDate',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
private $customers;
|
||||
/**
|
||||
* @var ProjectRepository
|
||||
*/
|
||||
private $projects;
|
||||
/**
|
||||
* @var TeamRepository
|
||||
*/
|
||||
private $teams;
|
||||
/**
|
||||
* @var UserRepository
|
||||
*/
|
||||
private $users;
|
||||
/**
|
||||
* @var FormConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
/**
|
||||
* @var Customer[]
|
||||
*/
|
||||
private $customerCache = [];
|
||||
/**
|
||||
* The datetime of this import as formatted string.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $dateTime = '';
|
||||
/**
|
||||
* Comment that will be added to new customers, projects and activities.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $comment = '';
|
||||
|
||||
public function __construct(CustomerRepository $customers, ProjectRepository $projects, TeamRepository $teams, UserRepository $users, FormConfiguration $configuration)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->customers = $customers;
|
||||
$this->projects = $projects;
|
||||
$this->teams = $teams;
|
||||
$this->users = $users;
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName(self::$defaultName)
|
||||
->setDescription('Import projects from CSV file')
|
||||
->setHelp(
|
||||
'This command allows to import projects from a CSV file, creating customers (if not existing) and optional empty teams for each project.' . PHP_EOL .
|
||||
'Imported customer will be matched by name and optionally created on the fly.' . PHP_EOL .
|
||||
'Required column names: ' . implode(', ', self::$requiredHeader) . PHP_EOL .
|
||||
'Supported column names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
|
||||
)
|
||||
->addOption('teamlead', null, InputOption::VALUE_REQUIRED, 'If you want to create empty teams for each project, give the username of the teamlead to be assigned')
|
||||
->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
|
||||
->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers and projects. %s will be replaced with the current datetime', 'Imported at %s')
|
||||
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai importer: Projects');
|
||||
|
||||
$csvFile = $input->getArgument('file');
|
||||
if (!file_exists($csvFile)) {
|
||||
$io->error('File not existing: ' . $csvFile);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!is_readable($csvFile)) {
|
||||
$io->error('File cannot be read: ' . $csvFile);
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$this->dateTime = (new \DateTime())->format('Y.m.d H:i');
|
||||
$this->comment = sprintf($input->getOption('comment'), $this->dateTime);
|
||||
|
||||
$csv = Reader::createFromPath($csvFile, 'r');
|
||||
$csv->setDelimiter($input->getOption('delimiter'));
|
||||
$csv->setHeaderOffset(0);
|
||||
$header = $csv->getHeader();
|
||||
|
||||
// validate teamlead
|
||||
$teamlead = $input->getOption('teamlead');
|
||||
if (null !== $teamlead) {
|
||||
$tmpUser = $this->users->findOneBy(['username' => $teamlead]);
|
||||
if (null === $tmpUser) {
|
||||
$tmpUser = $this->users->findOneBy(['email' => $teamlead]);
|
||||
if (null === $tmpUser) {
|
||||
$io->error(
|
||||
sprintf(
|
||||
'You requested to create empty teams for each project, but the given teamlead cannot be found.' . PHP_EOL .
|
||||
'Please create a user with the name (or email) %s first, before continuing.' . PHP_EOL,
|
||||
$teamlead
|
||||
)
|
||||
);
|
||||
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
$teamlead = $tmpUser;
|
||||
}
|
||||
|
||||
if (!$this->validateHeader($header)) {
|
||||
$io->error(
|
||||
sprintf(
|
||||
'Found invalid CSV header: %s' . PHP_EOL .
|
||||
'Required fields: %s' . PHP_EOL .
|
||||
'All supported fields: %s' . PHP_EOL,
|
||||
implode(', ', $header),
|
||||
implode(', ', self::$requiredHeader),
|
||||
implode(', ', self::$supportedHeader)
|
||||
)
|
||||
);
|
||||
|
||||
return 4;
|
||||
}
|
||||
|
||||
$records = $csv->getRecords();
|
||||
|
||||
$doImport = true;
|
||||
$row = 1;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
try {
|
||||
$this->validateRow($record);
|
||||
} catch (InvalidFieldsException $ex) {
|
||||
$io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
|
||||
$row++;
|
||||
}
|
||||
|
||||
if (!$doImport) {
|
||||
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
$row = 0;
|
||||
foreach ($records as $record) {
|
||||
$row++;
|
||||
try {
|
||||
$customer = $this->getCustomer($record['Customer']);
|
||||
$projectName = $record['Name'];
|
||||
|
||||
$project = new Project();
|
||||
$project->setName($projectName);
|
||||
$project->setCustomer($customer);
|
||||
|
||||
$comment = $this->comment;
|
||||
if (isset($record['Comment']) && !empty($record['Comment'])) {
|
||||
$comment = $record['Comment'];
|
||||
}
|
||||
$project->setComment($comment);
|
||||
|
||||
if (isset($record['OrderNumber']) && !empty($record['OrderNumber'])) {
|
||||
$project->setOrderNumber($record['OrderNumber']);
|
||||
}
|
||||
if (isset($record['OrderDate']) && !empty($record['OrderDate'])) {
|
||||
$project->setOrderDate($record['OrderDate']);
|
||||
}
|
||||
|
||||
$team = null;
|
||||
if (null !== $teamlead) {
|
||||
$team = new Team();
|
||||
$team->setName($projectName);
|
||||
$team->setTeamLead($teamlead);
|
||||
|
||||
$this->teams->saveTeam($team);
|
||||
}
|
||||
|
||||
$this->projects->saveProject($project);
|
||||
|
||||
if (null !== $team) {
|
||||
$project->addTeam($team);
|
||||
$team->addProject($project);
|
||||
|
||||
$this->teams->saveTeam($team);
|
||||
$this->projects->saveProject($project);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed importing project row %s with: %s', $row, $ex->getMessage()));
|
||||
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
$io->success(sprintf('Imported %s rows', $row));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getCustomer(string $customerName): Customer
|
||||
{
|
||||
if (!array_key_exists($customerName, $this->customerCache)) {
|
||||
$tmpCustomer = $this->customers->findBy(['name' => $customerName]);
|
||||
|
||||
if (count($tmpCustomer) > 1) {
|
||||
throw new \Exception(sprintf('Found multiple customers with the name: %s', $customerName));
|
||||
} elseif (count($tmpCustomer) === 1) {
|
||||
$tmpCustomer = $tmpCustomer[0];
|
||||
}
|
||||
|
||||
if ($tmpCustomer instanceof Customer) {
|
||||
$this->customerCache[$customerName] = $tmpCustomer;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists($customerName, $this->customerCache)) {
|
||||
return $this->customerCache[$customerName];
|
||||
}
|
||||
|
||||
$customer = new Customer();
|
||||
$customer->setName(sprintf($customerName, $this->dateTime));
|
||||
$customer->setComment($this->comment);
|
||||
$customer->setCountry($this->configuration->getCustomerDefaultCountry());
|
||||
$timezone = date_default_timezone_get();
|
||||
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
|
||||
$timezone = $this->configuration->getCustomerDefaultTimezone();
|
||||
}
|
||||
$customer->setTimezone($timezone);
|
||||
|
||||
$this->customers->saveCustomer($customer);
|
||||
|
||||
$this->customerCache[$customerName] = $customer;
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $row
|
||||
* @return bool
|
||||
* @throws InvalidFieldsException
|
||||
*/
|
||||
private function validateRow(array $row)
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
foreach (self::$requiredHeader as $headerName) {
|
||||
if (!isset($row[$headerName]) || empty($row[$headerName])) {
|
||||
$fields[] = $headerName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
throw new InvalidFieldsException($fields);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateHeader(array $header)
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
foreach (self::$requiredHeader as $headerName) {
|
||||
if (!in_array($headerName, $header)) {
|
||||
$fields[] = $headerName;
|
||||
}
|
||||
}
|
||||
|
||||
return empty($fields);
|
||||
}
|
||||
}
|
||||
517
src/Command/ImportTimesheetCommand.php
Normal file
517
src/Command/ImportTimesheetCommand.php
Normal file
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\FormConfiguration;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Importer\InvalidFieldsException;
|
||||
use App\Importer\UnknownUserException;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Utils\Duration;
|
||||
use League\Csv\Reader;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* This command can change anytime, don't rely on its API for the future!
|
||||
*
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class ImportTimesheetCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'kimai:import:timesheet';
|
||||
|
||||
// if we use 00:00 we might run into summer/winter time problems which happen between 02:00 and 03:00
|
||||
public const DEFAULT_BEGIN = '04:00';
|
||||
public const DEFAULT_CUSTOMER = 'Imported customer - %s';
|
||||
|
||||
private static $supportedHeader = [
|
||||
'Date',
|
||||
'From',
|
||||
'To',
|
||||
'Duration',
|
||||
'Rate',
|
||||
'User',
|
||||
'Customer',
|
||||
'Project',
|
||||
'Activity',
|
||||
'Description',
|
||||
'Exported',
|
||||
'Tags',
|
||||
'Hourly rate',
|
||||
'Fixed rate',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
private $customers;
|
||||
/**
|
||||
* @var ProjectRepository
|
||||
*/
|
||||
private $projects;
|
||||
/**
|
||||
* @var ActivityRepository
|
||||
*/
|
||||
private $activities;
|
||||
/**
|
||||
* @var UserRepository
|
||||
*/
|
||||
private $users;
|
||||
/**
|
||||
* @var TimesheetRepository
|
||||
*/
|
||||
private $timesheets;
|
||||
/**
|
||||
* @var FormConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
/**
|
||||
* @var Customer
|
||||
*/
|
||||
private $customerFallback;
|
||||
/**
|
||||
* @var Customer[]
|
||||
*/
|
||||
private $customerCache = [];
|
||||
/**
|
||||
* @var User[]
|
||||
*/
|
||||
private $userCache = [];
|
||||
/**
|
||||
* Comment that will be added to new customers, projects and activities.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $comment = '';
|
||||
/**
|
||||
* The datetime of this import as formatted string.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $dateTime = '';
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $begin = self::DEFAULT_BEGIN;
|
||||
|
||||
public function __construct(CustomerRepository $customers, ProjectRepository $projects, ActivityRepository $activities, UserRepository $users, TimesheetRepository $timesheets, FormConfiguration $configuration)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->customers = $customers;
|
||||
$this->projects = $projects;
|
||||
$this->activities = $activities;
|
||||
$this->users = $users;
|
||||
$this->timesheets = $timesheets;
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName(self::$defaultName)
|
||||
->setDescription('Import timesheets from CSV file')
|
||||
->setHelp(
|
||||
'This command allows to import timesheets from a CSV file, which are formatted like CSV exports.' . PHP_EOL .
|
||||
'Imported customer, projects and activities will be matched by name.' . PHP_EOL .
|
||||
'Supported columns names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
|
||||
)
|
||||
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'The timezone to be used. Supports: "valid timezone names", the string "user" (using the configured users timezone) and the string "server" (PHP default timezone)', 'user')
|
||||
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'A customer ID or name to assign for empty entries. Defaults to creating a new customer which is used for all un-linked projects')
|
||||
->addOption('activity', null, InputOption::VALUE_OPTIONAL, 'Whether new activities should be "global" or "project" specific. Allowed values are "global" and "project"', 'project')
|
||||
->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
|
||||
->addOption('begin', null, InputOption::VALUE_OPTIONAL, 'Default begin if none was provided in the format HH:MM', self::DEFAULT_BEGIN)
|
||||
->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers, projects and activities. %s will be replaced with the current datetime', 'Imported at %s')
|
||||
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai importer: Timesheets');
|
||||
|
||||
$csvFile = $input->getArgument('file');
|
||||
if (!file_exists($csvFile)) {
|
||||
$io->error('File not existing: ' . $csvFile);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!is_readable($csvFile)) {
|
||||
$io->error('File cannot be read: ' . $csvFile);
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$this->dateTime = (new \DateTime())->format('Y.m.d H:i');
|
||||
$this->comment = sprintf($input->getOption('comment'), $this->dateTime);
|
||||
$this->begin = $input->getOption('begin');
|
||||
|
||||
$timezone = $input->getOption('timezone');
|
||||
switch ($timezone) {
|
||||
case 'server':
|
||||
$timezone = new \DateTimeZone(date_default_timezone_get());
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
// null means fetch from user
|
||||
$timezone = null;
|
||||
break;
|
||||
|
||||
default:
|
||||
try {
|
||||
$timezone = new \DateTimeZone($timezone);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid timezone given, import canceled.');
|
||||
|
||||
return 3;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$activityType = $input->getOption('activity');
|
||||
$allowedActivityTypes = ['project', 'global'];
|
||||
if (!in_array($activityType, $allowedActivityTypes)) {
|
||||
$io->error(sprintf('Invalid activity type "%s" given, allowed values are: %s', $activityType, implode(', ', $allowedActivityTypes)));
|
||||
|
||||
return 4;
|
||||
}
|
||||
|
||||
$csv = Reader::createFromPath($csvFile, 'r');
|
||||
$csv->setDelimiter($input->getOption('delimiter'));
|
||||
$csv->setHeaderOffset(0);
|
||||
$header = $csv->getHeader();
|
||||
if (!$this->validateHeader($header)) {
|
||||
$io->error(
|
||||
sprintf(
|
||||
'Found invalid CSV. The header: ' . PHP_EOL .
|
||||
'%s' . PHP_EOL .
|
||||
'did not match the expected structure: ' . PHP_EOL .
|
||||
'%s',
|
||||
implode(', ', $header),
|
||||
implode(', ', self::$supportedHeader)
|
||||
)
|
||||
);
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
$records = $csv->getRecords();
|
||||
|
||||
$doImport = true;
|
||||
$row = 1;
|
||||
$errors = 0;
|
||||
|
||||
// ======================= validate rows =======================
|
||||
foreach ($records as $record) {
|
||||
try {
|
||||
$this->validateRow($record);
|
||||
} catch (InvalidFieldsException $ex) {
|
||||
$io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->getUser($record['User']);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Unknown user %s in row %s', $record['User'], $row));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
|
||||
$row++;
|
||||
}
|
||||
|
||||
if (!$doImport) {
|
||||
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
$durationParser = new Duration();
|
||||
$row = 0;
|
||||
foreach ($records as $record) {
|
||||
$row++;
|
||||
try {
|
||||
$project = $this->getProject($record['Project'], $record['Customer'], $input->getOption('customer'));
|
||||
$activity = $this->getActivity($record['Activity'], $project, $activityType);
|
||||
$user = $this->getUser($record['User']);
|
||||
|
||||
$begin = null;
|
||||
$end = null;
|
||||
$duration = 0;
|
||||
|
||||
if (!empty($record['Duration'])) {
|
||||
if (is_int($record['Duration'])) {
|
||||
$duration = $record['Duration'];
|
||||
} else {
|
||||
$duration = $durationParser->parseDurationString($record['Duration']);
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $timezone) {
|
||||
$timezone = new \DateTimeZone($user->getTimezone());
|
||||
}
|
||||
|
||||
if (empty($record['From']) && empty($record['To'])) {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $this->begin, $timezone);
|
||||
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
|
||||
} elseif (empty($record['From'])) {
|
||||
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
|
||||
$begin = (new \DateTime())->setTimezone($timezone)->setTimestamp($end->getTimestamp() - $duration);
|
||||
} elseif (empty($record['To'])) {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
|
||||
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
|
||||
} else {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
|
||||
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
|
||||
}
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setActivity($activity);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setBegin($begin);
|
||||
$timesheet->setEnd($end);
|
||||
$timesheet->setUser($user);
|
||||
$timesheet->setDescription($record['Description']);
|
||||
$timesheet->setExported((bool) $record['Exported']);
|
||||
|
||||
if (!empty($record['Tags'])) {
|
||||
foreach (explode(',', $record['Tags']) as $tag) {
|
||||
if (empty($tag)) {
|
||||
continue;
|
||||
}
|
||||
$timesheet->addTag((new Tag())->setName($tag));
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($record['Rate'])) {
|
||||
$timesheet->setRate($record['Rate']);
|
||||
}
|
||||
if (!empty($record['Hourly rate'])) {
|
||||
$timesheet->setHourlyRate($record['Hourly rate']);
|
||||
}
|
||||
if (!empty($record['Fixed rate'])) {
|
||||
$timesheet->setFixedRate($record['Fixed rate']);
|
||||
}
|
||||
|
||||
$this->timesheets->save($timesheet);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed importing timesheet row %s with: %s', $row, $ex->getMessage()));
|
||||
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
$io->success(sprintf('Imported %s rows', $row));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getUser($user): User
|
||||
{
|
||||
if (!array_key_exists($user, $this->userCache)) {
|
||||
$tmpUser = $this->users->findOneBy(['username' => $user]);
|
||||
if (null === $tmpUser) {
|
||||
$tmpUser = $this->users->findOneBy(['email' => $user]);
|
||||
if (null === $tmpUser) {
|
||||
throw new UnknownUserException($user);
|
||||
}
|
||||
}
|
||||
$this->userCache[$user] = $tmpUser;
|
||||
}
|
||||
|
||||
return $this->userCache[$user];
|
||||
}
|
||||
|
||||
private function getActivity($activity, Project $project, $activityType): Activity
|
||||
{
|
||||
$tmpActivity = null;
|
||||
|
||||
$tmpActivities = $this->activities->findBy(['project' => $project, 'name' => $activity]);
|
||||
|
||||
if (count($tmpActivities) === 0) {
|
||||
$tmpActivity = $this->activities->findOneBy(['project' => null, 'name' => $activity]);
|
||||
} elseif (count($tmpActivities) === 1) {
|
||||
$tmpActivity = $tmpActivities[0];
|
||||
}
|
||||
|
||||
if (null === $tmpActivity) {
|
||||
$tmpActivity = new Activity();
|
||||
$tmpActivity->setName($activity);
|
||||
$tmpActivity->setComment($this->comment);
|
||||
if ($activityType === 'project') {
|
||||
$tmpActivity->setProject($project);
|
||||
}
|
||||
$this->activities->saveActivity($tmpActivity);
|
||||
}
|
||||
|
||||
return $tmpActivity;
|
||||
}
|
||||
|
||||
private function getProject($project, $customer, $fallbackCustomer): Project
|
||||
{
|
||||
/** @var Customer $tmpCustomer */
|
||||
$tmpCustomer = $this->getCustomer($customer, $fallbackCustomer);
|
||||
/** @var Project $tmpProject */
|
||||
$tmpProject = null;
|
||||
/** @var Project[] $tmpProjects */
|
||||
$tmpProjects = $this->projects->findBy(['name' => $project]);
|
||||
|
||||
if (count($tmpProjects) > 1) {
|
||||
/** @var Project $prj */
|
||||
foreach ($tmpProjects as $prj) {
|
||||
if ($prj->getCustomer()->getName() !== $tmpCustomer->getName()) {
|
||||
continue;
|
||||
}
|
||||
$tmpProject = $prj;
|
||||
break;
|
||||
}
|
||||
} elseif (count($tmpProjects) === 1) {
|
||||
$tmpProject = $tmpProjects[0];
|
||||
}
|
||||
|
||||
if (null !== $tmpProject) {
|
||||
if ($tmpProject->getCustomer()->getName() !== $tmpCustomer->getName()) {
|
||||
$tmpProject = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($tmpProject === null) {
|
||||
$tmpProject = new Project();
|
||||
$tmpProject->setName($project);
|
||||
$tmpProject->setComment($this->comment);
|
||||
$tmpProject->setCustomer($tmpCustomer);
|
||||
$this->projects->saveProject($tmpProject);
|
||||
}
|
||||
|
||||
return $tmpProject;
|
||||
}
|
||||
|
||||
private function getCustomer($customer, $fallback): Customer
|
||||
{
|
||||
if (!empty($customer)) {
|
||||
if (!array_key_exists($customer, $this->customerCache)) {
|
||||
$tmpCustomer = $this->customers->findBy(['name' => $customer]);
|
||||
if (count($tmpCustomer) > 1) {
|
||||
throw new \Exception(sprintf('Found multiple customers with the name: %s', $customer));
|
||||
} elseif (count($tmpCustomer) === 1) {
|
||||
$tmpCustomer = $tmpCustomer[0];
|
||||
}
|
||||
|
||||
if ($tmpCustomer instanceof Customer) {
|
||||
$this->customerCache[$customer] = $tmpCustomer;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists($customer, $this->customerCache)) {
|
||||
return $this->customerCache[$customer];
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $this->customerFallback) {
|
||||
$tmpFallback = null;
|
||||
|
||||
if (!empty($fallback)) {
|
||||
if (is_int($customer)) {
|
||||
$tmpFallback = $this->customers->find($fallback);
|
||||
} else {
|
||||
/** @var Customer|null $tmpFallback */
|
||||
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $tmpFallback) {
|
||||
$newName = self::DEFAULT_CUSTOMER;
|
||||
if (!empty($fallback) && is_string($fallback)) {
|
||||
$newName = $fallback;
|
||||
}
|
||||
$tmpFallback = new Customer();
|
||||
$tmpFallback->setName(sprintf($newName, $this->dateTime));
|
||||
$tmpFallback->setComment($this->comment);
|
||||
$tmpFallback->setCountry($this->configuration->getCustomerDefaultCountry());
|
||||
$timezone = date_default_timezone_get();
|
||||
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
|
||||
$timezone = $this->configuration->getCustomerDefaultTimezone();
|
||||
}
|
||||
$tmpFallback->setTimezone($timezone);
|
||||
$this->customers->saveCustomer($tmpFallback);
|
||||
}
|
||||
|
||||
$this->customerFallback = $tmpFallback;
|
||||
}
|
||||
|
||||
return $this->customerFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $row
|
||||
* @return bool
|
||||
* @throws InvalidFieldsException
|
||||
*/
|
||||
private function validateRow(array $row)
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
if (empty($row['Project'])) {
|
||||
$fields[] = 'Project';
|
||||
}
|
||||
|
||||
if (empty($row['Activity'])) {
|
||||
$fields[] = 'Activity';
|
||||
}
|
||||
|
||||
if (empty($row['Date'])) {
|
||||
$fields[] = 'Date';
|
||||
}
|
||||
|
||||
if ((empty($row['From']) || empty($row['To'])) && empty($row['Duration'])) {
|
||||
$fields[] = 'Duration';
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
throw new InvalidFieldsException($fields);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateHeader(array $header)
|
||||
{
|
||||
$result = array_diff(self::$supportedHeader, $header);
|
||||
|
||||
return empty($result);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use App\Utils\File;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
@@ -23,36 +22,28 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
/**
|
||||
* Command used to do the basic installation steps for Kimai.
|
||||
*/
|
||||
class InstallCommand extends Command
|
||||
final class InstallCommand extends Command
|
||||
{
|
||||
public const ERROR_PERMISSIONS = 1;
|
||||
public const ERROR_CACHE_CLEAN = 2;
|
||||
public const ERROR_CACHE_WARMUP = 4;
|
||||
public const ERROR_DATABASE = 8;
|
||||
public const ERROR_SCHEMA = 16;
|
||||
public const ERROR_MIGRATIONS = 32;
|
||||
public const ERROR_INTERACTIVE = 64;
|
||||
|
||||
protected static $defaultName = 'kimai:install';
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $rootDir;
|
||||
private $rootDir;
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
protected $connection;
|
||||
/**
|
||||
* @var File
|
||||
*/
|
||||
protected $file;
|
||||
private $connection;
|
||||
|
||||
public function __construct(string $projectDirectory, Connection $connection, File $files)
|
||||
public function __construct(string $projectDirectory, Connection $connection)
|
||||
{
|
||||
parent::__construct(self::$defaultName);
|
||||
parent::__construct();
|
||||
$this->rootDir = $projectDirectory;
|
||||
$this->connection = $connection;
|
||||
$this->file = $files;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,7 +52,7 @@ class InstallCommand extends Command
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName(self::$defaultName)
|
||||
->setName('kimai:install')
|
||||
->setDescription('Basic installation for Kimai')
|
||||
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
|
||||
;
|
||||
@@ -76,18 +67,11 @@ class InstallCommand extends Command
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai installer - v' . Constants::VERSION);
|
||||
$io->title('Kimai installation running ...');
|
||||
|
||||
$result = $this->reviewPermissions($io, $input, $output);
|
||||
if (true !== $result) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// we cannot change the environment here, as it needs to be configured in the .env file before this command is started
|
||||
// $environment = $io->choice('Which environment should be used ("dev" is only for testing and imports demo data)?', ['dev', 'production'], 'production');
|
||||
// $io->note(sprintf('You have chosen the "%s" environment', $environment));
|
||||
$environment = getenv('APP_ENV');
|
||||
|
||||
// create the database, in case it is not yet existing
|
||||
try {
|
||||
$this->createDatabase($io, $input, $output);
|
||||
} catch (\Exception $ex) {
|
||||
@@ -96,15 +80,7 @@ class InstallCommand extends Command
|
||||
return self::ERROR_DATABASE;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->createSchema($io, $input, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to create database schema: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_SCHEMA;
|
||||
}
|
||||
|
||||
// initialize database with proper migration status
|
||||
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
|
||||
try {
|
||||
$this->importMigrations($io, $output);
|
||||
} catch (\Exception $ex) {
|
||||
@@ -113,10 +89,11 @@ class InstallCommand extends Command
|
||||
return self::ERROR_MIGRATIONS;
|
||||
}
|
||||
|
||||
// flush the cache, just to make sure ... and ignore result
|
||||
$this->rebuildCaches($environment, $io, $input, $output);
|
||||
|
||||
$io->success(
|
||||
'Congratulations! ' . Constants::SOFTWARE . ' (' . Constants::VERSION . ' ' . Constants::STATUS . ') was successful installed!'
|
||||
sprintf('Congratulations! Successfully installed %s version %s (%s)', Constants::SOFTWARE, Constants::VERSION, Constants::STATUS)
|
||||
);
|
||||
|
||||
return 0;
|
||||
@@ -124,14 +101,7 @@ class InstallCommand extends Command
|
||||
|
||||
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
if ($input->isInteractive()) {
|
||||
$question = 'Do you want me to rebuild the caches (yes) or skip this step (no)?';
|
||||
if (!$this->askConfirmation($input, $output, $question, true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$io->text('Rebuilding your cache now, please be patient ...');
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
$command = $this->getApplication()->find('cache:clear');
|
||||
try {
|
||||
@@ -146,104 +116,16 @@ class InstallCommand extends Command
|
||||
try {
|
||||
$command->run(new ArrayInput(['--env' => $environment]), $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to clear cache: ' . $ex->getMessage());
|
||||
$io->error('Failed to warmup cache: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_WARMUP;
|
||||
}
|
||||
}
|
||||
|
||||
protected function checkPermissions(): array
|
||||
{
|
||||
$directories = [
|
||||
'var/cache/',
|
||||
'var/data/',
|
||||
'var/log/',
|
||||
'var/plugins/',
|
||||
'var/sessions/',
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
$absDir = rtrim($this->rootDir) . DIRECTORY_SEPARATOR . $directory;
|
||||
$perms = $this->file->getPermissions($absDir);
|
||||
$reason = [];
|
||||
if (!($perms & 0x0100)) {
|
||||
$reason[] = 'read owner';
|
||||
}
|
||||
if (!($perms & 0x0080)) {
|
||||
$reason[] = 'write owner';
|
||||
}
|
||||
if (!($perms & 0x0020)) {
|
||||
$reason[] = 'read group';
|
||||
}
|
||||
if (!($perms & 0x0010)) {
|
||||
$reason[] = 'write group';
|
||||
}
|
||||
|
||||
if (!empty($reason)) {
|
||||
$rows[] = [$directory, 'missing: ' . implode(',', $reason)];
|
||||
} elseif (!is_writable($absDir)) {
|
||||
$rows[] = [$directory, 'Directory not writable'];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
protected function reviewPermissions(SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
if (!$input->isInteractive()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$permissions = $this->checkPermissions();
|
||||
|
||||
if (empty($permissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$question = 'Kimai found file permissions which look incorrect.' .
|
||||
' More information is available at https://www.kimai.org/documentation/installation.html.' .
|
||||
' If you are sure that all directories can be written by the webserver, you can continue.' .
|
||||
' Otherwise it is recommended to abort the installation and check them first.';
|
||||
|
||||
$io->caution($question);
|
||||
|
||||
$io->table(['Directory', 'Permission'], $permissions);
|
||||
|
||||
if (!$this->askConfirmation($input, $output, 'Continue with the installation (yes) or review permissions first (no)?', false)) {
|
||||
$io->warning('Aborting installation to review the permissions for above mentioned directories');
|
||||
|
||||
return self::ERROR_PERMISSIONS;
|
||||
}
|
||||
$io->writeln('');
|
||||
|
||||
return true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function importMigrations(SymfonyStyle $io, OutputInterface $output)
|
||||
{
|
||||
if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) {
|
||||
$command = $this->getApplication()->find('doctrine:migrations:version');
|
||||
$cmdInput = new ArrayInput(['--add' => true, '--all' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
$command->run($cmdInput, $output);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// this case should not happen, but you know ... everything is possible
|
||||
$amount = $this->connection->executeQuery('SELECT count(*) as counter FROM migration_versions')->fetchColumn(0);
|
||||
if ($amount === 0) {
|
||||
$command = $this->getApplication()->find('doctrine:migrations:version');
|
||||
$cmdInput = new ArrayInput(['--add' => true, '--all' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
$command->run($cmdInput, $output);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
@@ -264,24 +146,17 @@ class InstallCommand extends Command
|
||||
throw new \Exception('Skipped database creation, aborting installation');
|
||||
}
|
||||
|
||||
$options = [];
|
||||
if ($this->connection->getDatabasePlatform()->getName() !== 'sqlite') {
|
||||
$options = ['--if-not-exists' => true];
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('doctrine:database:create');
|
||||
$command->run(new ArrayInput([]), $output);
|
||||
}
|
||||
$result = $command->run(new ArrayInput($options), $output);
|
||||
|
||||
protected function createSchema(SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
if (!$this->connection->isConnected() && !$this->connection->connect()) {
|
||||
throw new \Exception(sprintf('Cannot create tables in database "%s", connection could not be established', $this->connection->getDatabase()));
|
||||
if (0 !== $result) {
|
||||
throw new \Exception('Failed creating database. Check your credentials in DATABASE_URL');
|
||||
}
|
||||
|
||||
if ($this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
|
||||
$io->note('It seems as if you already have the required tables in your database, skipping schema creation');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('doctrine:schema:create');
|
||||
$command->run(new ArrayInput([]), $output);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,25 +11,34 @@ namespace App\Command;
|
||||
|
||||
use App\Doctrine\TimesheetSubscriber;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectMeta;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Timesheet\Util;
|
||||
use Doctrine\Common\Persistence\ObjectManager;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Configuration;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Doctrine\DBAL\Types\DateTimeType;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Exception;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
@@ -39,7 +48,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
* This command is way to messy and complex to be tested ... so we use something, which I actually don't like:
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class KimaiImporterCommand extends Command
|
||||
final class KimaiImporterCommand extends Command
|
||||
{
|
||||
// minimum required Kimai and database version, lower versions are not supported by this command
|
||||
public const MIN_VERSION = '1.0.1';
|
||||
@@ -57,7 +66,7 @@ class KimaiImporterCommand extends Command
|
||||
protected $validator;
|
||||
/**
|
||||
* Connection to the Kimai v2 database to write imported data to
|
||||
* @var RegistryInterface
|
||||
* @var ManagerRegistry
|
||||
*/
|
||||
protected $doctrine;
|
||||
/**
|
||||
@@ -96,16 +105,8 @@ class KimaiImporterCommand extends Command
|
||||
*/
|
||||
protected $oldActivities = [];
|
||||
|
||||
/**
|
||||
* @param UserPasswordEncoderInterface $encoder
|
||||
* @param RegistryInterface $registry
|
||||
* @param ValidatorInterface $validator
|
||||
*/
|
||||
public function __construct(
|
||||
UserPasswordEncoderInterface $encoder,
|
||||
RegistryInterface $registry,
|
||||
ValidatorInterface $validator
|
||||
) {
|
||||
public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
|
||||
{
|
||||
$this->encoder = $encoder;
|
||||
$this->doctrine = $registry;
|
||||
$this->validator = $validator;
|
||||
@@ -125,12 +126,14 @@ class KimaiImporterCommand extends Command
|
||||
->addArgument(
|
||||
'connection',
|
||||
InputArgument::REQUIRED,
|
||||
'The database connection as URL, e.g.: mysql://user:password@127.0.0.1:3306/kimai?charset=latin1'
|
||||
'The database connection as URL, e.g.: mysql://user:password@127.0.0.1:3306/kimai?charset=utf8'
|
||||
)
|
||||
->addArgument('prefix', InputArgument::REQUIRED, 'The database prefix for the old Kimai v1 tables')
|
||||
->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user')
|
||||
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer (2-character uppercase)', 'DE')
|
||||
->addArgument('currency', InputArgument::OPTIONAL, 'The default currency for customer (code like EUR, CHF, GBP or USD)', 'EUR')
|
||||
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Default timezone for imported users', date_default_timezone_get())
|
||||
->addOption('language', null, InputOption::VALUE_OPTIONAL, 'Default language for imported users', User::DEFAULT_LANGUAGE)
|
||||
;
|
||||
}
|
||||
|
||||
@@ -140,7 +143,7 @@ class KimaiImporterCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
// do not convert the times, Kimai 1 stored them already in UTC
|
||||
Type::overrideType(Type::DATETIME, DateTimeType::class);
|
||||
Type::overrideType(Types::DATETIME_MUTABLE, DateTimeType::class);
|
||||
|
||||
// don't calculate rates ... this was done in Kimai 1
|
||||
$this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection());
|
||||
@@ -157,25 +160,25 @@ class KimaiImporterCommand extends Command
|
||||
if (trim(strlen($password)) < 6) {
|
||||
$io->error('Password length is not sufficient, at least 6 character are required');
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
$country = $input->getArgument('country');
|
||||
if (2 != trim(strlen($country))) {
|
||||
$io->error('Country code needs to be exactly 2 character');
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
$currency = $input->getArgument('currency');
|
||||
if (3 != trim(strlen($currency))) {
|
||||
$io->error('Currency code needs to be exactly 3 character');
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$this->checkDatabaseVersion($io, self::MIN_VERSION, self::MIN_REVISION)) {
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
$bytesStart = memory_get_usage(true);
|
||||
@@ -183,66 +186,66 @@ class KimaiImporterCommand extends Command
|
||||
// pre-load all data to make sure we can fully import everything
|
||||
try {
|
||||
$users = $this->fetchAllFromImport('users');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load users: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$customer = $this->fetchAllFromImport('customers');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load customers: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$projects = $this->fetchAllFromImport('projects');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load projects: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$activities = $this->fetchAllFromImport('activities');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load activities: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$activityToProject = $this->fetchAllFromImport('projects_activities');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load activities-project mapping: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$records = $this->fetchAllFromImport('timeSheet');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load timeSheet: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$fixedRates = $this->fetchAllFromImport('fixedRates');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load fixedRates: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$rates = $this->fetchAllFromImport('rates');
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to load rates: ' . $ex->getMessage());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
$bytesCached = memory_get_usage(true);
|
||||
@@ -252,57 +255,55 @@ class KimaiImporterCommand extends Command
|
||||
$allImports = 0;
|
||||
|
||||
try {
|
||||
$counter = $this->importUsers($io, $password, $users, $rates);
|
||||
$counter = $this->importUsers($io, $password, $users, $rates, $input->getOption('timezone'), $input->getOption('language'));
|
||||
$allImports += $counter;
|
||||
$io->success('Imported users: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import users: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importCustomers($io, $customer, $country, $currency);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported customers: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import customers: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importProjects($io, $projects, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported projects: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import projects: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importActivities($io, $activities, $activityToProject, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported activities: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import activities: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$counter = $this->importTimesheetRecords($io, $records, $fixedRates, $rates);
|
||||
$allImports += $counter;
|
||||
$io->success('Imported timesheet records: ' . $counter);
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import timesheet records: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
|
||||
|
||||
return;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// TODO support expenses - new database required
|
||||
|
||||
$bytesImported = memory_get_usage(true);
|
||||
|
||||
$io->success(
|
||||
@@ -313,6 +314,8 @@ class KimaiImporterCommand extends Command
|
||||
'Total consumption for importing ' . $allImports . ' new database entries: ' .
|
||||
$this->bytesHumanReadable($bytesImported - $bytesStart)
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,7 +326,6 @@ class KimaiImporterCommand extends Command
|
||||
* @param string $requiredVersion
|
||||
* @param string $requiredRevision
|
||||
* @return bool
|
||||
* @throws \Doctrine\DBAL\DBALException
|
||||
*/
|
||||
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
|
||||
{
|
||||
@@ -419,7 +421,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* @return RegistryInterface
|
||||
* @return ManagerRegistry
|
||||
*/
|
||||
protected function getDoctrine()
|
||||
{
|
||||
@@ -436,7 +438,7 @@ class KimaiImporterCommand extends Command
|
||||
$errors = $this->validator->validate($object);
|
||||
|
||||
if ($errors->count() > 0) {
|
||||
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
|
||||
/** @var ConstraintViolation $error */
|
||||
foreach ($errors as $error) {
|
||||
$io->error(
|
||||
(string) $error
|
||||
@@ -476,10 +478,12 @@ class KimaiImporterCommand extends Command
|
||||
* @param string $password
|
||||
* @param array $users
|
||||
* @param array $rates
|
||||
* @param string $timezone
|
||||
* @param string $language
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importUsers(SymfonyStyle $io, $password, $users, $rates)
|
||||
protected function importUsers(SymfonyStyle $io, $password, $users, $rates, $timezone, $language)
|
||||
{
|
||||
$counter = 0;
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
@@ -501,7 +505,7 @@ class KimaiImporterCommand extends Command
|
||||
$user->setPassword($pwd);
|
||||
|
||||
if (!$this->validateImport($io, $user)) {
|
||||
throw new \Exception('Failed to validate user: ' . $user->getUsername());
|
||||
throw new Exception('Failed to validate user: ' . $user->getUsername());
|
||||
}
|
||||
|
||||
// find and migrate user preferences
|
||||
@@ -525,6 +529,14 @@ class KimaiImporterCommand extends Command
|
||||
$user->addPreference($newPref);
|
||||
}
|
||||
|
||||
// set default values if they were not set in the the user preferences
|
||||
$defaults = ['language' => $language, 'timezone' => $timezone];
|
||||
foreach ($defaults as $key => $default) {
|
||||
if (null === $user->getPreferenceValue($key)) {
|
||||
$user->setPreferenceValue($key, $default);
|
||||
}
|
||||
}
|
||||
|
||||
// find hourly rate
|
||||
foreach ($rates as $ratesRow) {
|
||||
if ($ratesRow['userID'] === $oldUser['userID'] && $ratesRow['activityID'] === null && $ratesRow['projectID'] === null) {
|
||||
@@ -543,7 +555,7 @@ class KimaiImporterCommand extends Command
|
||||
$io->success('Created user: ' . $user->getUsername());
|
||||
}
|
||||
++$counter;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create user: ' . $user->getUsername());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
}
|
||||
@@ -584,7 +596,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param string $country
|
||||
* @param string $currency
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importCustomers(SymfonyStyle $io, $customers, $country, $currency)
|
||||
{
|
||||
@@ -617,8 +629,15 @@ class KimaiImporterCommand extends Command
|
||||
->setCurrency(strtoupper($currency))
|
||||
;
|
||||
|
||||
$metaField = new CustomerMeta();
|
||||
$metaField->setName('_imported_id');
|
||||
$metaField->setValue($oldCustomer['customerID']);
|
||||
$metaField->setIsVisible(false);
|
||||
|
||||
$customer->setMetaField($metaField);
|
||||
|
||||
if (!$this->validateImport($io, $customer)) {
|
||||
throw new \Exception('Failed to validate customer: ' . $customer->getName());
|
||||
throw new Exception('Failed to validate customer: ' . $customer->getName());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -628,7 +647,7 @@ class KimaiImporterCommand extends Command
|
||||
$io->success('Created customer: ' . $customer->getName());
|
||||
}
|
||||
++$counter;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
$io->error('Failed to create customer: ' . $customer->getName());
|
||||
}
|
||||
@@ -659,7 +678,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importProjects(SymfonyStyle $io, $projects, array $fixedRates, array $rates)
|
||||
{
|
||||
@@ -684,6 +703,13 @@ class KimaiImporterCommand extends Command
|
||||
->setBudget($oldProject['budget'] ?: 0)
|
||||
;
|
||||
|
||||
$metaField = new ProjectMeta();
|
||||
$metaField->setName('_imported_id');
|
||||
$metaField->setValue($oldProject['projectID']);
|
||||
$metaField->setIsVisible(false);
|
||||
|
||||
$project->setMetaField($metaField);
|
||||
|
||||
foreach ($fixedRates as $fixedRow) {
|
||||
if ($fixedRow['activityID'] !== null || $fixedRow['projectID'] === null) {
|
||||
continue;
|
||||
@@ -703,7 +729,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
if (!$this->validateImport($io, $project)) {
|
||||
throw new \Exception('Failed to validate project: ' . $project->getName());
|
||||
throw new Exception('Failed to validate project: ' . $project->getName());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -713,7 +739,7 @@ class KimaiImporterCommand extends Command
|
||||
$io->success('Created project: ' . $project->getName() . ' for customer: ' . $customer->getName());
|
||||
}
|
||||
++$counter;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create project: ' . $project->getName());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
}
|
||||
@@ -748,7 +774,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject, array $fixedRates, array $rates)
|
||||
{
|
||||
@@ -781,7 +807,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
foreach ($oldActivityMapping[$oldActivity['activityID']] as $projectId) {
|
||||
if (!isset($this->projects[$projectId])) {
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
'Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId
|
||||
);
|
||||
}
|
||||
@@ -802,7 +828,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param array $rates
|
||||
* @param int $projectId
|
||||
* @return Activity
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function createActivity(
|
||||
SymfonyStyle $io,
|
||||
@@ -826,7 +852,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
if (null !== $projectId && !isset($this->projects[$projectId])) {
|
||||
throw new \Exception(
|
||||
throw new Exception(
|
||||
sprintf('Did not find project [%s], skipping activity creation [%s] %s', $projectId, $activityId, $name)
|
||||
);
|
||||
}
|
||||
@@ -844,6 +870,13 @@ class KimaiImporterCommand extends Command
|
||||
$activity->setProject($project);
|
||||
}
|
||||
|
||||
$metaField = new ActivityMeta();
|
||||
$metaField->setName('_imported_id');
|
||||
$metaField->setValue($oldActivity['activityID']);
|
||||
$metaField->setIsVisible(false);
|
||||
|
||||
$activity->setMetaField($metaField);
|
||||
|
||||
foreach ($fixedRates as $fixedRow) {
|
||||
if ($fixedRow['activityID'] === null) {
|
||||
continue;
|
||||
@@ -871,7 +904,7 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
if (!$this->validateImport($io, $activity)) {
|
||||
throw new \Exception('Failed to validate activity: ' . $activity->getName());
|
||||
throw new Exception('Failed to validate activity: ' . $activity->getName());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -880,7 +913,7 @@ class KimaiImporterCommand extends Command
|
||||
if ($this->debug) {
|
||||
$io->success('Created activity: ' . $activity->getName());
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create activity: ' . $activity->getName());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
}
|
||||
@@ -921,7 +954,7 @@ class KimaiImporterCommand extends Command
|
||||
* @param array $fixedRates
|
||||
* @param array $rates
|
||||
* @return int
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function importTimesheetRecords(SymfonyStyle $io, array $records, array $fixedRates, array $rates)
|
||||
{
|
||||
@@ -1010,7 +1043,7 @@ class KimaiImporterCommand extends Command
|
||||
$io->success('Created deactivated user: ' . $user->getUsername());
|
||||
}
|
||||
$userCounter++;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create user: ' . $user->getUsername());
|
||||
$io->error('Reason: ' . $ex->getMessage());
|
||||
$failed++;
|
||||
@@ -1042,12 +1075,12 @@ class KimaiImporterCommand extends Command
|
||||
}
|
||||
|
||||
$user = $this->users[$oldRecord['userID']];
|
||||
$timezone = $user->getPreferenceValue('timezone', date_default_timezone_get());
|
||||
$dateTimezone = new \DateTimeZone('UTC');
|
||||
$timezone = $user->getTimezone();
|
||||
$dateTimezone = new DateTimeZone('UTC');
|
||||
|
||||
$begin = new \DateTime('@' . $oldRecord['start']);
|
||||
$begin = new DateTime('@' . $oldRecord['start']);
|
||||
$begin->setTimezone($dateTimezone);
|
||||
$end = new \DateTime('@' . $oldRecord['end']);
|
||||
$end = new DateTime('@' . $oldRecord['end']);
|
||||
$end->setTimezone($dateTimezone);
|
||||
|
||||
// ---------- workaround for localizeDates ----------
|
||||
@@ -1075,30 +1108,33 @@ class KimaiImporterCommand extends Command
|
||||
;
|
||||
|
||||
if (!$this->validateImport($io, $timesheet)) {
|
||||
$io->error('Failed to validate timesheet record: ' . $oldRecord['timeEntryID'] . ' - skipping!');
|
||||
$io->caution('Failed to validate timesheet record: ' . $oldRecord['timeEntryID'] . ' - skipping!');
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$entityManager->persist($timesheet);
|
||||
$entityManager->flush();
|
||||
if ($this->debug) {
|
||||
$io->success('Created timesheet record: ' . $timesheet->getId());
|
||||
}
|
||||
++$counter;
|
||||
} catch (\Exception $ex) {
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create timesheet record: ' . $ex->getMessage());
|
||||
$failed++;
|
||||
}
|
||||
|
||||
$io->write('.');
|
||||
if (0 == $counter % 80) {
|
||||
$io->writeln(' (' . $counter . '/' . $total . ')');
|
||||
$entityManager->flush();
|
||||
$entityManager->clear(Timesheet::class);
|
||||
$io->writeln(' (' . $counter . '/' . $total . ')');
|
||||
}
|
||||
}
|
||||
|
||||
$entityManager->flush();
|
||||
$entityManager->clear(Timesheet::class);
|
||||
|
||||
for ($i = 0; $i < 80 - ($counter % 80); $i++) {
|
||||
$io->write(' ');
|
||||
}
|
||||
|
||||
149
src/Command/UpdateCommand.php
Normal file
149
src/Command/UpdateCommand.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Command used to update a Kimai installation.
|
||||
*/
|
||||
final class UpdateCommand extends Command
|
||||
{
|
||||
public const ERROR_CACHE_CLEAN = 2;
|
||||
public const ERROR_CACHE_WARMUP = 4;
|
||||
public const ERROR_DATABASE = 8;
|
||||
public const ERROR_MIGRATIONS = 32;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $rootDir;
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
private $connection;
|
||||
|
||||
public function __construct(string $projectDirectory, Connection $connection)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->rootDir = $projectDirectory;
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:update')
|
||||
->setDescription('Update your Kimai installation')
|
||||
->setHelp('This command will execute all required steps to update your Kimai installation.')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai updates running ...');
|
||||
|
||||
// we cannot change the environment here, as it needs to be configured in the .env file before this command is started
|
||||
$environment = getenv('APP_ENV');
|
||||
|
||||
// make sure database is available, Kimai running and installed
|
||||
try {
|
||||
if (!$this->connection->isConnected() && !$this->connection->connect()) {
|
||||
throw new \Exception(
|
||||
sprintf('Database connection could not be established: %s', $this->connection->getDatabase())
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
|
||||
$io->error('Tables missing. Did you run the installer already?');
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
}
|
||||
|
||||
if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) {
|
||||
$io->error('Unknown migration status, aborting database update');
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to validate database: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
}
|
||||
|
||||
// execute latest doctrine migrations
|
||||
try {
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
$command->run($cmdInput, $output);
|
||||
|
||||
$io->writeln('');
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to set migration status: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_MIGRATIONS;
|
||||
}
|
||||
|
||||
// flush the cache, in case values from the database are cached
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
|
||||
$io->success(
|
||||
sprintf('Congratulations! Successfully updated %s to version %s (%s)', Constants::SOFTWARE, Constants::VERSION, Constants::STATUS)
|
||||
);
|
||||
|
||||
if ($cacheResult !== 0) {
|
||||
$io->warning('Problem resetting cache, please execute cache clean manually');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
$command = $this->getApplication()->find('cache:clear');
|
||||
try {
|
||||
$command->run(new ArrayInput(['--env' => $environment]), $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to clear cache: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_CLEAN;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
try {
|
||||
$command->run(new ArrayInput(['--env' => $environment]), $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to warmup cache: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_WARMUP;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ class VersionCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$io->writeln('Kimai 2 - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.');
|
||||
$io->writeln(Constants::SOFTWARE . ' - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -11,20 +11,17 @@ namespace App\Configuration;
|
||||
|
||||
use App\Utils\MomentFormatConverter;
|
||||
|
||||
class LanguageFormattings
|
||||
final class LanguageFormattings
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $settings;
|
||||
private $settings;
|
||||
/**
|
||||
* @var MomentFormatConverter
|
||||
*/
|
||||
protected $momentFormatter;
|
||||
private $momentFormatter;
|
||||
|
||||
/**
|
||||
* @param array $languageSettings
|
||||
*/
|
||||
public function __construct(array $languageSettings)
|
||||
{
|
||||
$this->settings = $languageSettings;
|
||||
@@ -145,7 +142,7 @@ class LanguageFormattings
|
||||
* @param string $locale
|
||||
* @return string
|
||||
*/
|
||||
protected function getConfig(string $key, string $locale): string
|
||||
private function getConfig(string $key, string $locale): string
|
||||
{
|
||||
if (!isset($this->settings[$locale])) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown locale given: %s', $locale));
|
||||
|
||||
@@ -26,9 +26,6 @@ trait StringAccessibleConfigTrait
|
||||
*/
|
||||
protected $initialized = false;
|
||||
|
||||
/**
|
||||
* @param array $settings
|
||||
*/
|
||||
public function __construct(ConfigLoaderInterface $repository, array $settings)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
@@ -41,7 +38,7 @@ trait StringAccessibleConfigTrait
|
||||
*/
|
||||
protected function getConfigurations(ConfigLoaderInterface $repository): array
|
||||
{
|
||||
return $repository->getConfiguration($this->getPrefix() . '.');
|
||||
return $repository->getConfiguration($this->getPrefix());
|
||||
}
|
||||
|
||||
protected function prepare()
|
||||
@@ -120,4 +117,45 @@ trait StringAccessibleConfigTrait
|
||||
|
||||
return $config[$search];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function offsetExists($offset)
|
||||
{
|
||||
try {
|
||||
$this->find($offset);
|
||||
} catch (\Exception $ex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function offsetGet($offset)
|
||||
{
|
||||
return $this->find($offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $offset
|
||||
* @param mixed $value
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetSet($offset, $value)
|
||||
{
|
||||
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetSet()');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $offset
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetUnset($offset)
|
||||
{
|
||||
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
class ThemeConfiguration implements SystemBundleConfiguration
|
||||
class ThemeConfiguration implements SystemBundleConfiguration, \ArrayAccess
|
||||
{
|
||||
use StringAccessibleConfigTrait;
|
||||
|
||||
@@ -23,6 +23,10 @@ class ThemeConfiguration implements SystemBundleConfiguration
|
||||
return (bool) $this->find('auto_reload_datatable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently unused, as JS selects are always activated.
|
||||
* @deprecated since 1.7 will be removed with 2.0
|
||||
*/
|
||||
public function getSelectPicker(): string
|
||||
{
|
||||
return (string) $this->find('select_type');
|
||||
|
||||
@@ -47,4 +47,29 @@ class TimesheetConfiguration implements SystemBundleConfiguration
|
||||
{
|
||||
return (int) $this->find('active_entries.soft_limit');
|
||||
}
|
||||
|
||||
public function getDefaultRoundingDays(): string
|
||||
{
|
||||
return (string) $this->find('rounding.default.days');
|
||||
}
|
||||
|
||||
public function getDefaultRoundingMode(): string
|
||||
{
|
||||
return (string) $this->find('rounding.default.mode');
|
||||
}
|
||||
|
||||
public function getDefaultRoundingBegin(): int
|
||||
{
|
||||
return (int) $this->find('rounding.default.begin');
|
||||
}
|
||||
|
||||
public function getDefaultRoundingEnd(): int
|
||||
{
|
||||
return (int) $this->find('rounding.default.end');
|
||||
}
|
||||
|
||||
public function getDefaultRoundingDuration(): int
|
||||
{
|
||||
return (int) $this->find('rounding.default.duration');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '1.4';
|
||||
public const VERSION = '1.7';
|
||||
/**
|
||||
* The current release status, either "stable" or "dev"
|
||||
*/
|
||||
@@ -38,4 +38,8 @@ class Constants
|
||||
* Homepage, used in multiple views
|
||||
*/
|
||||
public const HOMEPAGE = 'https://www.kimai.org';
|
||||
/**
|
||||
* Application wide default locale.
|
||||
*/
|
||||
public const DEFAULT_LOCALE = 'en';
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
|
||||
use Symfony\Component\Translation\DataCollectorTranslator;
|
||||
use Symfony\Contracts\Service\ServiceSubscriberInterface;
|
||||
@@ -26,7 +27,10 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
public const DOMAIN_FLASH = 'flashmessages';
|
||||
public const DOMAIN_ERROR = 'exceptions';
|
||||
|
||||
public const ROLE_ADMIN = 'ROLE_ADMIN';
|
||||
/**
|
||||
* @deprecated since 1.6, will be removed with 2.0
|
||||
*/
|
||||
public const ROLE_ADMIN = User::ROLE_ADMIN;
|
||||
|
||||
/**
|
||||
* @return DataCollectorTranslator
|
||||
@@ -36,6 +40,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
return $this->container->get('translator');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User|null
|
||||
*/
|
||||
protected function getUser()
|
||||
{
|
||||
return parent::getUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a "successful" flash message to the stack.
|
||||
*
|
||||
|
||||
@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
* Controller used to display calendars.
|
||||
*
|
||||
* @Route(path="/calendar")
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class CalendarController extends AbstractController
|
||||
{
|
||||
|
||||
@@ -11,17 +11,24 @@ namespace App\Controller;
|
||||
|
||||
use App\Configuration\FormConfiguration;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerComment;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Team;
|
||||
use App\Event\CustomerMetaDefinitionEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Form\CustomerCommentForm;
|
||||
use App\Form\CustomerEditForm;
|
||||
use App\Form\CustomerTeamPermissionForm;
|
||||
use App\Form\Toolbar\CustomerToolbarForm;
|
||||
use App\Form\Type\CustomerType;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\CustomerFormTypeQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -34,42 +41,28 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
* Controller used to manage customer in the admin part of the site.
|
||||
*
|
||||
* @Route(path="/admin/customer")
|
||||
* @Security("is_granted('view_customer')")
|
||||
* @Security("is_granted('view_customer') or is_granted('view_teamlead_customer') or is_granted('view_team_customer')")
|
||||
*/
|
||||
class CustomerController extends AbstractController
|
||||
final class CustomerController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var FormConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
protected $dispatcher;
|
||||
private $dispatcher;
|
||||
|
||||
public function __construct(CustomerRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(CustomerRepository $repository, EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->configuration = $configuration;
|
||||
$this->dispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \App\Repository\CustomerRepository
|
||||
*/
|
||||
protected function getRepository()
|
||||
{
|
||||
return $this->repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"})
|
||||
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"})
|
||||
* @Security("is_granted('view_customer')")
|
||||
*/
|
||||
public function indexAction($page, Request $request)
|
||||
{
|
||||
@@ -85,7 +78,7 @@ class CustomerController extends AbstractController
|
||||
$query->resetByFormError($form->getErrors());
|
||||
}
|
||||
|
||||
$entries = $this->getRepository()->getPagerfantaForQuery($query);
|
||||
$entries = $this->repository->getPagerfantaForQuery($query);
|
||||
|
||||
return $this->render('customer/index.html.twig', [
|
||||
'entries' => $entries,
|
||||
@@ -99,7 +92,7 @@ class CustomerController extends AbstractController
|
||||
* @param CustomerQuery $query
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
protected function findMetaColumns(CustomerQuery $query): array
|
||||
private function findMetaColumns(CustomerQuery $query): array
|
||||
{
|
||||
$event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -111,16 +104,16 @@ class CustomerController extends AbstractController
|
||||
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
|
||||
* @Security("is_granted('create_customer')")
|
||||
*/
|
||||
public function createAction(Request $request)
|
||||
public function createAction(Request $request, FormConfiguration $configuration)
|
||||
{
|
||||
$timezone = date_default_timezone_get();
|
||||
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
|
||||
$timezone = $this->configuration->getCustomerDefaultTimezone();
|
||||
if (null !== $configuration->getCustomerDefaultTimezone()) {
|
||||
$timezone = $configuration->getCustomerDefaultTimezone();
|
||||
}
|
||||
|
||||
$customer = new Customer();
|
||||
$customer->setCountry($this->configuration->getCustomerDefaultCountry());
|
||||
$customer->setCurrency($this->configuration->getCustomerDefaultCurrency());
|
||||
$customer->setCountry($configuration->getCustomerDefaultCountry());
|
||||
$customer->setCurrency($configuration->getCustomerDefaultCurrency());
|
||||
$customer->setTimezone($timezone);
|
||||
|
||||
return $this->renderCustomerForm($customer, $request);
|
||||
@@ -130,7 +123,7 @@ class CustomerController extends AbstractController
|
||||
* @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"})
|
||||
* @Security("is_granted('permissions', customer)")
|
||||
*/
|
||||
public function teamPermissions(Customer $customer, Request $request)
|
||||
public function teamPermissionsAction(Customer $customer, Request $request)
|
||||
{
|
||||
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
|
||||
'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]),
|
||||
@@ -141,11 +134,11 @@ class CustomerController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->getRepository()->saveCustomer($customer);
|
||||
$this->repository->saveCustomer($customer);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_customer');
|
||||
} catch (ORMException $ex) {
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
|
||||
}
|
||||
}
|
||||
@@ -157,18 +150,160 @@ class CustomerController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/budget", name="admin_customer_budget", methods={"GET"})
|
||||
* @Security("is_granted('budget', customer)")
|
||||
* @Route(path="/{id}/comment_delete", name="customer_comment_delete", methods={"GET"})
|
||||
* @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
|
||||
*/
|
||||
public function budgetAction(Customer $customer)
|
||||
public function deleteCommentAction(CustomerComment $comment)
|
||||
{
|
||||
$stats = $this->getRepository()->getCustomerStatistics($customer);
|
||||
$customerId = $comment->getCustomer()->getId();
|
||||
|
||||
// TODO sent event with stats
|
||||
try {
|
||||
$this->repository->deleteComment($comment);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->render('customer/budget.html.twig', [
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/comment_add", name="customer_comment_add", methods={"POST"})
|
||||
* @Security("is_granted('edit', customer) and is_granted('comments', customer)")
|
||||
*/
|
||||
public function addCommentAction(Customer $customer, Request $request)
|
||||
{
|
||||
$comment = new CustomerComment();
|
||||
$form = $this->getCommentForm($customer, $comment);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->repository->saveComment($comment);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/comment_pin", name="customer_comment_pin", methods={"GET"})
|
||||
* @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
|
||||
*/
|
||||
public function pinCommentAction(CustomerComment $comment)
|
||||
{
|
||||
$comment->setPinned(!$comment->isPinned());
|
||||
try {
|
||||
$this->repository->saveComment($comment);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $comment->getCustomer()->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/create_team", name="customer_team_create", methods={"GET"})
|
||||
* @Security("is_granted('create_team') and is_granted('permissions', customer)")
|
||||
*/
|
||||
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository)
|
||||
{
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
|
||||
if (null !== $defaultTeam) {
|
||||
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
$defaultTeam = new Team();
|
||||
$defaultTeam->setName($customer->getName());
|
||||
$defaultTeam->setTeamLead($this->getUser());
|
||||
$defaultTeam->addCustomer($customer);
|
||||
|
||||
try {
|
||||
$teamRepository->saveTeam($defaultTeam);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/projects/{page}", defaults={"page": 1}, name="customer_projects", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view', customer)")
|
||||
*/
|
||||
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository)
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
$query->setPage($page);
|
||||
$query->setPageSize(5);
|
||||
$query->setCustomer($customer);
|
||||
|
||||
/* @var $entries Pagerfanta */
|
||||
$entries = $projectRepository->getPagerfantaForQuery($query);
|
||||
|
||||
return $this->render('customer/embed_projects.html.twig', [
|
||||
'customer' => $customer,
|
||||
'projects' => $entries,
|
||||
'page' => $page,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/details", name="customer_details", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view', customer)")
|
||||
*/
|
||||
public function detailsAction(Customer $customer, TeamRepository $teamRepository)
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$stats = null;
|
||||
$timezone = null;
|
||||
$defaultTeam = null;
|
||||
$commentForm = null;
|
||||
$attachments = [];
|
||||
$comments = null;
|
||||
$teams = null;
|
||||
$projects = null;
|
||||
|
||||
if ($this->isGranted('edit', $customer)) {
|
||||
$commentForm = $this->getCommentForm($customer, new CustomerComment())->createView();
|
||||
|
||||
if ($this->isGranted('create_team')) {
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $customer->getTimezone()) {
|
||||
$timezone = new \DateTimeZone($customer->getTimezone());
|
||||
}
|
||||
|
||||
if ($this->isGranted('budget', $customer)) {
|
||||
$stats = $this->repository->getCustomerStatistics($customer);
|
||||
}
|
||||
|
||||
if ($this->isGranted('comments', $customer)) {
|
||||
$comments = $this->repository->getComments($customer);
|
||||
}
|
||||
|
||||
if ($this->isGranted('permissions', $customer) || $this->isGranted('details', $customer) || $this->isGranted('view_team')) {
|
||||
$teams = $customer->getTeams();
|
||||
}
|
||||
|
||||
return $this->render('customer/details.html.twig', [
|
||||
'customer' => $customer,
|
||||
'comments' => $comments,
|
||||
'commentForm' => $commentForm,
|
||||
'attachments' => $attachments,
|
||||
'stats' => $stats,
|
||||
'team' => $defaultTeam,
|
||||
'teams' => $teams,
|
||||
'now' => new \DateTime('now', $timezone),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -187,7 +322,7 @@ class CustomerController extends AbstractController
|
||||
*/
|
||||
public function deleteAction(Customer $customer, Request $request)
|
||||
{
|
||||
$stats = $this->getRepository()->getCustomerStatistics($customer);
|
||||
$stats = $this->repository->getCustomerStatistics($customer);
|
||||
|
||||
$deleteForm = $this->createFormBuilder(null, [
|
||||
'attr' => [
|
||||
@@ -215,7 +350,7 @@ class CustomerController extends AbstractController
|
||||
|
||||
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
|
||||
try {
|
||||
$this->getRepository()->deleteCustomer($customer, $deleteForm->get('customer')->getData());
|
||||
$this->repository->deleteCustomer($customer, $deleteForm->get('customer')->getData());
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (ORMException $ex) {
|
||||
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
|
||||
@@ -236,21 +371,18 @@ class CustomerController extends AbstractController
|
||||
* @param Request $request
|
||||
* @return RedirectResponse|Response
|
||||
*/
|
||||
protected function renderCustomerForm(Customer $customer, Request $request)
|
||||
private function renderCustomerForm(Customer $customer, Request $request)
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$editForm = $this->createEditForm($customer);
|
||||
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->getRepository()->saveCustomer($customer);
|
||||
$this->repository->saveCustomer($customer);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_customer');
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
} catch (ORMException $ex) {
|
||||
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
|
||||
}
|
||||
@@ -262,7 +394,7 @@ class CustomerController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getToolbarForm(CustomerQuery $query): FormInterface
|
||||
private function getToolbarForm(CustomerQuery $query): FormInterface
|
||||
{
|
||||
return $this->createForm(CustomerToolbarForm::class, $query, [
|
||||
'action' => $this->generateUrl('admin_customer', [
|
||||
@@ -272,8 +404,24 @@ class CustomerController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
private function getCommentForm(Customer $customer, CustomerComment $comment): FormInterface
|
||||
{
|
||||
if (null === $comment->getId()) {
|
||||
$comment->setCustomer($customer);
|
||||
$comment->setCreatedBy($this->getUser());
|
||||
}
|
||||
|
||||
return $this->createForm(CustomerCommentForm::class, $comment, [
|
||||
'action' => $this->generateUrl('customer_comment_add', ['id' => $customer->getId()]),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createEditForm(Customer $customer): FormInterface
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
if ($customer->getId() === null) {
|
||||
$url = $this->generateUrl('admin_customer_create');
|
||||
} else {
|
||||
|
||||
@@ -24,7 +24,7 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
* Dashboard controller for the admin area.
|
||||
*
|
||||
* @Route(path="/dashboard")
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class DashboardController extends AbstractController
|
||||
{
|
||||
@@ -121,9 +121,16 @@ class DashboardController extends AbstractController
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
$sections = $event->getSections();
|
||||
$clearedSections = [];
|
||||
/** @var WidgetContainerInterface $section */
|
||||
foreach ($sections as $key => $section) {
|
||||
if (!empty($section->getWidgets())) {
|
||||
$clearedSections[] = $section;
|
||||
}
|
||||
}
|
||||
|
||||
uasort(
|
||||
$sections,
|
||||
$clearedSections,
|
||||
function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
|
||||
if ($a->getOrder() == $b->getOrder()) {
|
||||
return 0;
|
||||
@@ -134,7 +141,7 @@ class DashboardController extends AbstractController
|
||||
);
|
||||
|
||||
return $this->render('dashboard/index.html.twig', [
|
||||
'widgets' => $sections
|
||||
'widgets' => $clearedSections
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,16 +48,36 @@ class DoctorController extends AbstractController
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $projectDirectory;
|
||||
private $projectDirectory;
|
||||
|
||||
/**
|
||||
* @param string $projectDirectory
|
||||
*/
|
||||
public function __construct(string $projectDirectory)
|
||||
{
|
||||
$this->projectDirectory = $projectDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/flush-log", name="doctor_flush_log", methods={"GET"})
|
||||
* @Security("is_granted('system_configuration')")
|
||||
*/
|
||||
public function deleteLogfileAction(): Response
|
||||
{
|
||||
$logfile = $this->getLogFilename();
|
||||
|
||||
if (file_exists($logfile)) {
|
||||
if (!is_writable($logfile)) {
|
||||
$this->flashError('action.delete.error', ['%reason%' => 'Logfile cannot be written']);
|
||||
} else {
|
||||
if (false === file_put_contents($logfile, '')) {
|
||||
$this->flashError('action.delete.error', ['%reason%' => 'Failed writing to logfile']);
|
||||
} else {
|
||||
$this->flashSuccess('action.delete.success');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('doctor');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="", name="doctor", methods={"GET"})
|
||||
*/
|
||||
@@ -65,6 +85,8 @@ class DoctorController extends AbstractController
|
||||
{
|
||||
$logLines = 100;
|
||||
|
||||
$canDeleteLogfile = $this->isGranted('system_configuration') && is_writable($this->getLogFilename());
|
||||
|
||||
return $this->render('doctor/index.html.twig', array_merge(
|
||||
[
|
||||
'modules' => get_loaded_extensions(),
|
||||
@@ -73,6 +95,7 @@ class DoctorController extends AbstractController
|
||||
'settings' => $this->getIniSettings(),
|
||||
'extensions' => $this->getLoadedExtensions(),
|
||||
'directories' => $this->getFilePermissions(),
|
||||
'log_delete' => $canDeleteLogfile,
|
||||
'logs' => $this->getLog(),
|
||||
'logLines' => $logLines,
|
||||
'logSize' => $this->getLogSize(),
|
||||
@@ -92,6 +115,12 @@ class DoctorController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
$results['Freetype Support'] = true;
|
||||
// @see AvatarService::hasDependencies()
|
||||
if (!function_exists('imagettfbbox')) {
|
||||
$results['Freetype Support'] = false;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
@@ -103,20 +132,31 @@ class DoctorController extends AbstractController
|
||||
return filesize($logfile);
|
||||
}
|
||||
|
||||
private function getLog(int $lines = 100)
|
||||
private function getLogFilename(): string
|
||||
{
|
||||
// why is this check here ???
|
||||
if (!in_array(getenv('APP_ENV'), ['test', 'dev', 'prod'])) {
|
||||
return [
|
||||
'Unsupported log environment'
|
||||
];
|
||||
throw new \RuntimeException('Unsupported log environment');
|
||||
}
|
||||
|
||||
$logfileName = 'var/log/' . getenv('APP_ENV') . '.log';
|
||||
$logfile = $this->projectDirectory . '/' . $logfileName;
|
||||
|
||||
return $this->projectDirectory . '/' . $logfileName;
|
||||
}
|
||||
|
||||
private function getLog(int $lines = 100)
|
||||
{
|
||||
try {
|
||||
$logfile = $this->getLogFilename();
|
||||
} catch (\Exception $ex) {
|
||||
return [
|
||||
$ex->getMessage()
|
||||
];
|
||||
}
|
||||
|
||||
if (!file_exists($logfile)) {
|
||||
return [
|
||||
'Could not find logfile: ' . $logfileName
|
||||
'Empty or missing logfile'
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -143,8 +143,12 @@ class ExportController extends AbstractController
|
||||
*/
|
||||
protected function getEntries(ExportQuery $query): array
|
||||
{
|
||||
$query->getBegin()->setTime(0, 0, 0);
|
||||
$query->getEnd()->setTime(23, 59, 59);
|
||||
if (null !== $query->getBegin()) {
|
||||
$query->getBegin()->setTime(0, 0, 0);
|
||||
}
|
||||
if (null !== $query->getEnd()) {
|
||||
$query->getEnd()->setTime(23, 59, 59);
|
||||
}
|
||||
|
||||
return $this->timesheetRepository->getTimesheetsForQuery($query, true);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
* Homepage controller is a redirect controller with user specific logic.
|
||||
*
|
||||
* @Route(path="/homepage")
|
||||
* @Security("is_granted('ROLE_USER')")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class HomepageController extends AbstractController
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user