Compare commits

...

71 Commits
1.4 ... 1.7

Author SHA1 Message Date
Kevin Papst
85a35a3063 prepare release 1.7 (#1388) 2020-01-19 21:50:29 +01:00
Kevin Papst
e0e391efbe prepare release 1.7 (#1388) 2020-01-19 21:49:41 +01:00
Kevin Papst
9eff89963c prepare release 1.7 (#1388) 2020-01-19 21:49:04 +01:00
Lukas
352fac26f3 added missing raw filter (#1393) 2020-01-19 21:47:40 +01:00
Lukas
8c16bf126a added missing raw filter (#1393) 2020-01-19 21:47:17 +01:00
Lukas
42de968f8f added missing raw filter (#1393) 2020-01-19 21:46:47 +01:00
ikusimakusi
ed8f024e42 added Basque translations (#1382) 2020-01-17 15:28:12 +01:00
Kevin Papst
e534369c5b fix widgets imports (#1387) 2020-01-17 00:02:02 +01:00
Kevin Papst
fed797df93 order user roles and permissions (#1372) 2020-01-16 16:54:42 +01:00
Kevin Papst
6a44dbfe83 detail pages for customers and projects (#1371) 2020-01-16 16:25:28 +01:00
Kevin Papst
28c5357f11 allow to hide rate or duration in html export summary (#1370) 2020-01-14 13:46:07 +01:00
Kevin Papst
a8824f92a1 added support on PHP 7.4 (#1355) 2020-01-07 21:43:33 +01:00
Kevin Papst
e57b69973f re-written exporter for simpler extendability (#1349) 2020-01-07 21:20:45 +01:00
Kevin Papst
d6798eed1e added project start and end date (#1303)
* added sortable js library
* activity in invoice is optional
* added javascript widget for paginated boxes
* fix activity dropdown for globals only
* added timesheet service to reduce code duplication
* use repository to query for teams in dropdowns
* added project validator
* validate project start and end against timesheet
* include begin and end in dynamic form requests for projects
* added timezone and language option to import flag, improve timesheet import speed
* deactivate cross-timezone filter
* add virtual fields to field order list
* composer update
* added param to ignore dates
* position loader icon fixed - fixes #1330
* permission problem when creating a new project - fixes #1340
* remove dev dependencies webserver and thanks bundle
* stop information leak (begin and end date) in duration mode - fixes #1307
* unify timesheet edit dialog for user and admins
* fix security issue, own rates exposed to unauthorized users in multi-update dialog
2020-01-05 02:49:01 +01:00
hmr-it-jr
50383673ca added api endpoint to return instance specific timesheet config (#1335) 2020-01-04 01:44:11 +01:00
Kevin Papst
022dbe32e5 allow empty date in export (#1338) 2019-12-31 20:24:47 +01:00
Kevin Papst
baac4eb698 set rates via batch update (#1326) 2019-12-29 16:52:43 +01:00
Kevin Papst
8aa8f452d5 added exported filter in timesheet view (#1327) 2019-12-29 13:55:15 +01:00
Kevin Papst
54255cf923 validate tag name (#1325) 2019-12-16 16:55:34 +01:00
Kevin Papst
bd3d82d224 fix daterange filter for multi-timezone setups (#1321) 2019-12-16 16:23:03 +01:00
Kevin Papst
b14ad9f3ab renamed translation files from .xliff to .xlf (#1314) 2019-12-09 13:25:31 +01:00
Kevin Papst
261b4646e4 added chinese translations (#1310) 2019-12-09 12:56:38 +01:00
Kevin Papst
d62e1c4468 simplify to add widgets via plugin (#1308) 2019-12-06 23:36:03 +01:00
benzel
5b0266c462 clarified translation (#1309) 2019-12-06 22:05:33 +01:00
Kevin Papst
06118dcabb loose email validation on customer (#1224) 2019-12-02 15:06:06 +01:00
Kevin Papst
984c852ab6 Release 1.6.2 (#1289)
* include user teams in user entity
* prevent unauthorized access via API
* improve teamlead permission handling in team timesheets
* add team data to user entity
* add security tests
* highlight menu for invoice template copy
* unified handling of invoice data across all templates
* access to the current users data in invoice templates
* permission improvement in invoice form
* allow to skip record rows
* allow to add new invoice locations without overwriting the global ones
* allow to order user preferences
* change permission for normal users with access to view_other_timesheets
* properly validate invoice template field length
* allow to replace multiple variables in cell values text
* upgraded office invoice template
* doctrine deprecation fix
* upgrade phpoffice/phpword
* fix future begin check for default rounding rules
* dashboard widget counter: respect visibility and teams - fixes #1161
* fix future begin check for default rounding rules
* added new events for pre and post invoice rendering
* fix permission issue for users without team seeing all records
* prevent error in spreadsheet renderer for empty invoices
2019-12-02 10:57:03 +01:00
Badgie
47414cfd0e Danish translations (#1296) 2019-12-02 00:25:28 +01:00
Kevin Papst
fdf4fdfb6a fix sqlite installer (#1283) 2019-11-26 19:32:16 +01:00
Kevin Papst
5ee0396a02 Release 1.6.1 (#1282) 2019-11-26 17:22:31 +01:00
Kevin Papst
724eb9c658 release 1.6 (#1277) 2019-11-24 15:29:01 +01:00
Kevin Papst
936ab05882 prepare release 1.6 (#1270) 2019-11-24 00:02:43 +01:00
Kevin Papst
817a43b178 added csv importer for timesheet and project data (#1216) 2019-11-23 22:02:55 +01:00
Kevin Papst
0f16f42887 Select2 search enhancement (#1276) 2019-11-23 14:26:35 +01:00
Kevin Papst
641c199c8c API: allow to set user for timesheet (#1259) 2019-11-23 14:21:46 +01:00
Aptevo
04dd6b900f added de_CH language (#1272) 2019-11-23 11:29:29 +01:00
Kevin Papst
536a05210e enable interactive features of Swagger UI (#1273) 2019-11-22 21:59:25 +01:00
Kevin Papst
60d16016e8 allow to configure calendar slot duration (#1269) 2019-11-22 01:30:25 +01:00
Kevin Papst
9222075c7a Added update command (#1268) 2019-11-21 23:15:41 +01:00
1willem
641dcdff85 Dutch translation files (#1226) 2019-11-18 21:48:16 +01:00
Kevin Papst
668649be7a use homepage link to redirect from error page (#1260) 2019-11-18 12:29:21 +01:00
Kevin Papst
0de936d474 fix the password reset page layout (#1261) 2019-11-18 12:28:59 +01:00
Kevin Papst
47bd8b0ce7 added new invoice fields, improved invoice templates (#1258) 2019-11-18 12:01:26 +01:00
Kevin Papst
2287c9951c invoice: money fields without currency, title cleanup (#1254) 2019-11-15 18:54:08 +01:00
Kevin Papst
94c28ebbc5 Update and delete multi timesheets and tags (#1240) 2019-11-15 01:25:31 +01:00
Kevin Papst
b4739f8f03 allow to include bundle configuration in system configuration screen (#1235) 2019-11-14 19:25:38 +01:00
Kevin Papst
c32cd36861 fix reset input for forms (#1251) 2019-11-14 19:09:55 +01:00
Kevin Papst
945562b34d fixes wrong foreign keys on timesheet_tags join table (#1242) 2019-11-14 01:57:58 +01:00
Kevin Papst
af0f89774e User roles and permission management via Admin UI (#1231) 2019-11-10 18:53:56 +01:00
Kevin Papst
c6c4098759 rounding rules via admin screen, round begin when starting record (#1229) 2019-11-10 13:57:05 +01:00
Kevin Papst
fa1c79e15c support different formats in user timesheet exports (#1222) 2019-11-08 16:10:38 +01:00
Kevin Papst
0705c26513 highlight menus on sub-pages (#1220) 2019-11-07 11:19:10 +01:00
Kevin Papst
140fa4b7c0 prepare release 1.5 (#1214) 2019-11-03 17:31:33 +01:00
Kevin Papst
cd88901868 re-style error pages (#1210) 2019-10-31 14:20:53 +01:00
Kevin Papst
8eec97deb5 respect users timezone in statistic widgets (#1207) 2019-10-31 00:17:31 +01:00
Kevin Papst
4331a7c9f4 fix error pages (#1209) 2019-10-31 00:16:25 +01:00
Kevin Papst
f4fcfeba9d dynamic branding options (#1205) 2019-10-30 14:22:34 +01:00
Kevin Papst
48aefb1862 fix avatar path for subdirectory installations (#1201) 2019-10-30 14:00:48 +01:00
Kevin Papst
bda9584346 reformat edit forms (#1192) 2019-10-30 13:59:53 +01:00
Kevin Papst
ed1154af67 fix absolute url (#1200) 2019-10-30 13:45:59 +01:00
Kevin Papst
c368eacd87 fixed renamed filter name, added that disabled users cannot be filtered (#1193) 2019-10-27 13:56:45 +01:00
Kevin Papst
949c59230f improve list views (#1191) 2019-10-25 16:21:30 +02:00
Kevin Papst
1114538f41 duration field in timesheet edit dialog (#1180) 2019-10-24 23:46:22 +02:00
Kevin Papst
b0f83291ee upgraded to phpunit 8 (#1155) 2019-10-24 17:35:05 +02:00
Kevin Papst
e91e4ff430 support search term in API collection requests (#1185) 2019-10-24 17:31:15 +02:00
Ahmed Şeref
4ad290d4bd added turkish translations (#1160) 2019-10-24 17:30:34 +02:00
Kevin Papst
37c6e2c0af added order-date field to project (#1186) 2019-10-24 17:04:21 +02:00
Kevin Papst
b4a45afce9 fix API login in subdirectory installations (#1181) 2019-10-24 11:06:12 +02:00
yayitazale
330ff6eb47 updated spanish translations (#1183) 2019-10-24 10:53:23 +02:00
Adrian Rudnik
c681eb1bdb docker fix to hide details for 500 errors (#1173) 2019-10-21 16:22:29 +02:00
Kevin Papst
de533e7031 Kimai 1.4.2 (#1167) 2019-10-14 12:30:11 +02:00
Kevin Papst
ae6ed3f23f Bugfixes for Kimai 1.4.1 (#1158)
* fix missing translation for preset datatable headers
* better dependency check for avatar service
* show only own teams to teamleads
2019-10-07 16:04:19 +02:00
818 changed files with 28898 additions and 8651 deletions

View File

@@ -3,7 +3,8 @@ root = true
; Unix-style newlines
[*]
end_of_line = LF
end_of_line = lf
charset = utf-8
[*.php]
indent_style = space

View File

@@ -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
View File

@@ -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

View File

@@ -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;

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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');

View File

@@ -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');

View File

@@ -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

View File

@@ -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();

View File

@@ -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);
}
});
});

View File

@@ -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')

View 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;
}
});
}
}

View File

@@ -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");
});
});

View File

@@ -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');

View 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();
}
}
}

View File

@@ -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] = [];

View 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);
}
}

View 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;
}
});
}
}

View File

@@ -48,4 +48,9 @@ td {
.label-gray {
background-color: $gray-lte;
}
/* Delete link in action dropdowns */
.dropdown-menu > li.delete > a {
color: #dd4b39;
}

View File

@@ -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 {
/*

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
*/

View File

@@ -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');
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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],

View File

@@ -1,3 +1,6 @@
framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
packages:
avatars:
base_path: 'avatars'

View File

@@ -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:

View 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

View 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

View File

@@ -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'

View File

@@ -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'
# --------------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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]

View File

@@ -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:

View File

@@ -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]

View File

@@ -10,7 +10,7 @@ FOS\UserBundle\Model\User:
roles:
type: array<string>
include: true
groups: [Entity]
groups: [User_Entity]
groups:
exclude: true
email:

View File

@@ -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%']

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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

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

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

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

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

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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

View 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;
}
}

View File

@@ -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);

View File

@@ -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));
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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
);

View File

@@ -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;
}
/**

View 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);
}
}

View 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);
}
}

View File

@@ -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);
}
/**

View File

@@ -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(' ');
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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));

View File

@@ -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()');
}
}

View File

@@ -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');

View File

@@ -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');
}
}

View File

@@ -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';
}

View File

@@ -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.
*

View File

@@ -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
{

View File

@@ -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 {

View File

@@ -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
]);
}
}

View File

@@ -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'
];
}

View File

@@ -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);
}

View File

@@ -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