Compare commits

...

33 Commits
0.6 ... 0.7

Author SHA1 Message Date
Kevin Papst
67dab35b57 prepare release 0.7 (#514) 2019-01-28 01:40:42 +01:00
Kevin Papst
55d8bc9ba5 pagination without reload while keeping filters applied (#521) 2019-01-23 18:48:17 +01:00
Kevin Papst
572f01ed74 go back to calendar after editing and creation of time-records (#519) 2019-01-23 17:30:45 +01:00
Kevin Papst
ee32d6f926 fix wrong include filename in user registration (#520) 2019-01-23 14:16:37 +01:00
Kevin Papst
89abaa9dda fetch toolbar results without page reload (#518) 2019-01-23 02:45:33 +01:00
Kevin Papst
a9ece209ae Form and theme improvements (#513) 2019-01-21 13:10:53 +01:00
Lukas
47ac50b4d0 Only one running timesheet: automatically stop others (#386) 2019-01-21 12:49:22 +01:00
Kevin Papst
5539f68a54 validation for future and negative times (#512) 2019-01-21 01:51:57 +01:00
Kevin Papst
5b1ec1eb8f alphabetical order for selectboxes (#510) 2019-01-19 19:24:29 +01:00
Kevin Papst
a609cbf84a export team timesheets (#508) 2019-01-19 00:25:00 +01:00
Kevin Papst
1ad440f488 support template variable entry.row for docx if description is not used (#504) 2019-01-18 16:19:02 +01:00
Kevin Papst
64330e6cc4 display invoice entries in ascending order (#505) 2019-01-18 14:32:08 +01:00
Kevin Papst
03421c099a fix broken sql in activity admin (#506)
* fix broken sql in activity admin
* pre-select customer for existing activities
2019-01-18 14:30:20 +01:00
Lukas
b5c4084452 support anchor links for inline documentation (#482) 2019-01-17 23:30:02 +01:00
Kevin Papst
5d5b971f57 do not display admin menu if it has no children (#500) 2019-01-17 01:25:00 +01:00
Lukas
825d31af20 improve login screen and simplify customizations (#493) 2019-01-17 01:01:30 +01:00
Toby Batch
7880f980a8 Better composer install in docker (#481) 2018-12-21 14:32:04 +01:00
Steve Colson
137fd69f47 Update dockerfile for https in composer installer (#478) 2018-12-20 13:01:09 +01:00
Kevin Papst
ed8efcf139 support line breaks in docx (#466) 2018-12-07 11:24:39 +01:00
Kevin Papst
7fa3b67cc4 updated README and installation docu (#465) 2018-12-05 23:47:59 +01:00
Kevin Papst
93c500fbf2 fix segmentation fault - rollback composer dependencies (#463) 2018-12-05 00:01:48 +01:00
Kevin Papst
c58c12ea3d fixed null project for advanced invoice calculator (#462) 2018-12-03 22:36:38 +01:00
Kevin Papst
35305ad107 updated all composer packages (#459) 2018-12-03 20:24:22 +01:00
Kevin Papst
589d750e59 allow decimals in users hourly rate (#460) 2018-12-03 20:23:52 +01:00
Kevin Papst
43b488f21f moved some packages to dev requirements (#456) 2018-12-01 19:53:44 +01:00
Matheus
54d06462fc added portuguese translations (#446) 2018-11-28 18:22:05 +01:00
Kevin Papst
2d6109d587 simplify timesheet edit form if only one customer is existing (#443) 2018-11-28 15:05:12 +01:00
Kevin Papst
8fddf627bf added configurable permission system (#424) 2018-11-26 13:20:32 +01:00
Kevin Papst
0334f6ce86 added project variables for invoice templates (#439) 2018-11-23 15:56:06 +01:00
Kevin Papst
025558e58e fix restart timesheet button (#436) 2018-11-22 21:48:05 +01:00
Kevin Papst
f74547008d prepare release 0.6.1 (#433) 2018-11-19 16:17:28 +01:00
Kevin Papst
67dc1c7640 fix migration for MySQL (#430) 2018-11-19 15:24:00 +01:00
Kevin Papst
40322c6de4 fix importer for MySQL #428 (#429) 2018-11-19 00:32:12 +01:00
180 changed files with 5592 additions and 2378 deletions

2
.github/lock.yml vendored
View File

@@ -14,6 +14,8 @@ lockComment: >
This thread has been automatically locked since there has not been
any recent activity after it was closed. Please open a new issue for
related bugs.
If you use Kimai on a daily basis, please [consider donating](https://www.kimai.org/donate/) to
support further development of Kimai.
# Assign `resolved` as the reason for locking. Set to `false` to disable
setLockReason: false

View File

@@ -1,3 +1,4 @@
unreleased=true
future-release=0.6
exclude-labels=duplicate,question,invalid,wontfix,release
future-release=0.7
exclude-labels=duplicate,question,invalid,wontfix,release
enhancement_labels=>enhancement,Enhancement,feature request

13
.gitignore vendored
View File

@@ -1,4 +1,3 @@
# ========== KIMAI ==========
# IDE
.idea/
@@ -10,6 +9,7 @@
templates/invoice/renderer/.~lock*
/var/data/*.sqlite
/var/data/*.sqlite-journal
!/var/data/kimai_test.sqlite
/var/coverage/
/var/cache/*
@@ -43,12 +43,13 @@ templates/invoice/renderer/.~lock*
.php_cs
.php_cs.cache
###< friendsofphp/php-cs-fixer ###
###> symfony/webpack-encore-pack ###
/node_modules/
###< symfony/webpack-encore-pack ###
###> symfony/phpunit-bridge ###
.phpunit
/phpunit.xml
###< symfony/phpunit-bridge ###
###> symfony/webpack-encore-pack ###
/node_modules/
npm-debug.log
yarn-error.log
###< symfony/webpack-encore-pack ###

View File

@@ -1,8 +1,118 @@
# Change Log
## [0.7](https://github.com/kevinpapst/kimai2/tree/0.7) (2019-01-28)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.6.1...0.7)
**Implemented enhancements:**
- Editing from calendar view will return to my times table instead of calendar [\#515](https://github.com/kevinpapst/kimai2/issues/515)
- Timesheet Export for Admins [\#503](https://github.com/kevinpapst/kimai2/issues/503)
- Customer List Not Alphabetic [\#499](https://github.com/kevinpapst/kimai2/issues/499)
- sorting of ${entry.X} values [\#487](https://github.com/kevinpapst/kimai2/issues/487)
- ${entry.description} needed in Word-Docx [\#485](https://github.com/kevinpapst/kimai2/issues/485)
- Automatic sort for activities, customers, etc. [\#477](https://github.com/kevinpapst/kimai2/issues/477)
- User title in the timesheet invoice [\#461](https://github.com/kevinpapst/kimai2/issues/461)
- global variables for reports/invoices [\#438](https://github.com/kevinpapst/kimai2/issues/438)
- Configuration option: Only one active record for each user [\#427](https://github.com/kevinpapst/kimai2/issues/427)
- User creating activity and projects [\#393](https://github.com/kevinpapst/kimai2/issues/393)
- Configuration option to disable fixed rate and hourly rate from "edit timesheet" [\#330](https://github.com/kevinpapst/kimai2/issues/330)
- Set other users hourly rate [\#303](https://github.com/kevinpapst/kimai2/issues/303)
- Feature request - Make "Rate" hideable [\#217](https://github.com/kevinpapst/kimai2/issues/217)
- fixed null project for advanced invoice calculator [\#462](https://github.com/kevinpapst/kimai2/pull/462) ([kevinpapst](https://github.com/kevinpapst))
**Fixed bugs:**
- This value should be greater than or equal to zero [\#511](https://github.com/kevinpapst/kimai2/issues/511)
- Timesheet Export for Admins [\#503](https://github.com/kevinpapst/kimai2/issues/503)
- admin activity: visibility "none" \(no filter\) causes sql-error [\#491](https://github.com/kevinpapst/kimai2/issues/491)
- login-screen optimizations [\#483](https://github.com/kevinpapst/kimai2/issues/483)
- Configuration of roles to add/edit customers, projects, activities... [\#479](https://github.com/kevinpapst/kimai2/issues/479)
- Line breaks for address and payment information fields [\#464](https://github.com/kevinpapst/kimai2/issues/464)
- Possible to use a decimal in hourly rate field? [\#458](https://github.com/kevinpapst/kimai2/issues/458)
- Invoice Number Generator possibly not compatible [\#454](https://github.com/kevinpapst/kimai2/issues/454)
- users can change their role to system-admin [\#440](https://github.com/kevinpapst/kimai2/issues/440)
- fix wrong include filename in user registration [\#520](https://github.com/kevinpapst/kimai2/pull/520) ([kevinpapst](https://github.com/kevinpapst))
- fix segmentation fault - rollback composer dependencies [\#463](https://github.com/kevinpapst/kimai2/pull/463) ([kevinpapst](https://github.com/kevinpapst))
- fixed null project for advanced invoice calculator [\#462](https://github.com/kevinpapst/kimai2/pull/462) ([kevinpapst](https://github.com/kevinpapst))
- fix the restart timesheet button [\#436](https://github.com/kevinpapst/kimai2/pull/436) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
- Redirecting when using Kimai on a subdirectory + reverse proxy [\#492](https://github.com/kevinpapst/kimai2/issues/492)
- Installation \(Cannot declare.... in use\) [\#455](https://github.com/kevinpapst/kimai2/issues/455)
- New Number Generator not recognized [\#453](https://github.com/kevinpapst/kimai2/issues/453)
- de/help/invoices returns 404 error [\#452](https://github.com/kevinpapst/kimai2/issues/452)
- Can you Add Brazilian Portuguese Translation? I can help it.. [\#444](https://github.com/kevinpapst/kimai2/issues/444)
- replace all selects with smart-selects and live-search [\#441](https://github.com/kevinpapst/kimai2/issues/441)
- use parsedown-extra for rendering markdown [\#388](https://github.com/kevinpapst/kimai2/issues/388)
- Think about a cooler name [\#133](https://github.com/kevinpapst/kimai2/issues/133)
**Merged pull requests:**
- pagination without reload while keeping filters applied [\#521](https://github.com/kevinpapst/kimai2/pull/521) ([kevinpapst](https://github.com/kevinpapst))
- go back to calendar after editing and creation of time-records [\#519](https://github.com/kevinpapst/kimai2/pull/519) ([kevinpapst](https://github.com/kevinpapst))
- fetch toolbar results without page reload [\#518](https://github.com/kevinpapst/kimai2/pull/518) ([kevinpapst](https://github.com/kevinpapst))
- Form and theme improvements [\#513](https://github.com/kevinpapst/kimai2/pull/513) ([kevinpapst](https://github.com/kevinpapst))
- validation for future and negative times [\#512](https://github.com/kevinpapst/kimai2/pull/512) ([kevinpapst](https://github.com/kevinpapst))
- alphabetical order for selectboxes [\#510](https://github.com/kevinpapst/kimai2/pull/510) ([kevinpapst](https://github.com/kevinpapst))
- Export team timesheets [\#508](https://github.com/kevinpapst/kimai2/pull/508) ([kevinpapst](https://github.com/kevinpapst))
- fix broken sql in activity admin [\#506](https://github.com/kevinpapst/kimai2/pull/506) ([kevinpapst](https://github.com/kevinpapst))
- display invoice entries in ascending order [\#505](https://github.com/kevinpapst/kimai2/pull/505) ([kevinpapst](https://github.com/kevinpapst))
- Improve DOCX template row [\#504](https://github.com/kevinpapst/kimai2/pull/504) ([kevinpapst](https://github.com/kevinpapst))
- do not display admin menu if it has no children [\#500](https://github.com/kevinpapst/kimai2/pull/500) ([kevinpapst](https://github.com/kevinpapst))
- login-screen optimizations [\#493](https://github.com/kevinpapst/kimai2/pull/493) ([lduer](https://github.com/lduer))
- preg\_replace for md-file-extensions when rendering correct link… [\#482](https://github.com/kevinpapst/kimai2/pull/482) ([lduer](https://github.com/lduer))
- Better composer install in the docker [\#481](https://github.com/kevinpapst/kimai2/pull/481) ([tobybatch](https://github.com/tobybatch))
- Update dockerfile for https in composer installer [\#478](https://github.com/kevinpapst/kimai2/pull/478) ([scolson](https://github.com/scolson))
- support line breaks in docx [\#466](https://github.com/kevinpapst/kimai2/pull/466) ([kevinpapst](https://github.com/kevinpapst))
- updated README and docu [\#465](https://github.com/kevinpapst/kimai2/pull/465) ([kevinpapst](https://github.com/kevinpapst))
- allow decimals in users hourly rate [\#460](https://github.com/kevinpapst/kimai2/pull/460) ([kevinpapst](https://github.com/kevinpapst))
- updated all composer packages [\#459](https://github.com/kevinpapst/kimai2/pull/459) ([kevinpapst](https://github.com/kevinpapst))
- moved some packages to dev requirements [\#456](https://github.com/kevinpapst/kimai2/pull/456) ([kevinpapst](https://github.com/kevinpapst))
- Added portuguese translations [\#446](https://github.com/kevinpapst/kimai2/pull/446) ([marquesmatheus](https://github.com/marquesmatheus))
- Simplify timesheet edit form if only one customer is existing [\#443](https://github.com/kevinpapst/kimai2/pull/443) ([kevinpapst](https://github.com/kevinpapst))
- added project variables for invoice templates [\#439](https://github.com/kevinpapst/kimai2/pull/439) ([kevinpapst](https://github.com/kevinpapst))
- added configurable permission system [\#424](https://github.com/kevinpapst/kimai2/pull/424) ([kevinpapst](https://github.com/kevinpapst))
- Only one running timesheet: automatically stop others [\#386](https://github.com/kevinpapst/kimai2/pull/386) ([lduer](https://github.com/lduer))
## [0.6.1](https://github.com/kevinpapst/kimai2/tree/0.6.1) (2018-11-19)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.6...0.6.1)
**Fixed bugs:**
- Error importing v1 Database [\#428](https://github.com/kevinpapst/kimai2/issues/428)
- Redo button missing? [\#426](https://github.com/kevinpapst/kimai2/issues/426)
**Closed issues:**
- Error importing v1 Database \(could not convert to string\) [\#431](https://github.com/kevinpapst/kimai2/issues/431)
**Merged pull requests:**
- fix migration for MySQL [\#430](https://github.com/kevinpapst/kimai2/pull/430) ([kevinpapst](https://github.com/kevinpapst))
- fix importer for MySQL [\#429](https://github.com/kevinpapst/kimai2/pull/429) ([kevinpapst](https://github.com/kevinpapst))
## [0.6](https://github.com/kevinpapst/kimai2/tree/0.6) (2018-11-18)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.5...0.6)
**Implemented enhancements:**
- Language setting does not work immediately [\#418](https://github.com/kevinpapst/kimai2/issues/418)
- Merge projects and/or activities [\#406](https://github.com/kevinpapst/kimai2/issues/406)
- Global activities support for Kimai 1 importer [\#400](https://github.com/kevinpapst/kimai2/issues/400)
- Invoice Grouped by Activity [\#379](https://github.com/kevinpapst/kimai2/issues/379)
- Set the order of columns \(in my times page\) [\#371](https://github.com/kevinpapst/kimai2/issues/371)
- Columns on small/narrow displays \(mobile\) on „my times“ page [\#370](https://github.com/kevinpapst/kimai2/issues/370)
- Make settings more accessible [\#365](https://github.com/kevinpapst/kimai2/issues/365)
- Data validation for register page username field. [\#360](https://github.com/kevinpapst/kimai2/issues/360)
- Option for setting default calendar view [\#359](https://github.com/kevinpapst/kimai2/issues/359)
- Activities just Duration [\#334](https://github.com/kevinpapst/kimai2/issues/334)
- API: Add filtering/sorting/limits [\#333](https://github.com/kevinpapst/kimai2/issues/333)
- API: Timesheets [\#315](https://github.com/kevinpapst/kimai2/issues/315)
- Feature request: Quick search box for activities when creating new timesheet [\#307](https://github.com/kevinpapst/kimai2/issues/307)
- Separate Customer / Project / Activity input when logging time [\#250](https://github.com/kevinpapst/kimai2/issues/250)
- Interactive help after fresh installation [\#94](https://github.com/kevinpapst/kimai2/issues/94)
**Fixed bugs:**
- Invoicing export - 500: Internal Server Error [\#390](https://github.com/kevinpapst/kimai2/issues/390)
@@ -13,31 +123,16 @@
**Closed issues:**
- Language setting does not work immediately [\#418](https://github.com/kevinpapst/kimai2/issues/418)
- After installation no graphic/background/icons shown [\#417](https://github.com/kevinpapst/kimai2/issues/417)
- Form fields should be always visible [\#408](https://github.com/kevinpapst/kimai2/issues/408)
- Merge projects and/or activities [\#406](https://github.com/kevinpapst/kimai2/issues/406)
- Active filter should stay open [\#405](https://github.com/kevinpapst/kimai2/issues/405)
- Make project field optional [\#401](https://github.com/kevinpapst/kimai2/issues/401)
- Global activities support for Kimai 1 importer [\#400](https://github.com/kevinpapst/kimai2/issues/400)
- Unrecognized Options "renderer, number\_generator" under "kimai.invoice" [\#381](https://github.com/kevinpapst/kimai2/issues/381)
- Invoice Grouped by Activity [\#379](https://github.com/kevinpapst/kimai2/issues/379)
- Set the order of columns \(in my times page\) [\#371](https://github.com/kevinpapst/kimai2/issues/371)
- Columns on small/narrow displays \(mobile\) on „my times“ page [\#370](https://github.com/kevinpapst/kimai2/issues/370)
- Make settings more accessible [\#365](https://github.com/kevinpapst/kimai2/issues/365)
- Data validation for register page username field. [\#360](https://github.com/kevinpapst/kimai2/issues/360)
- Option for setting default calendar view [\#359](https://github.com/kevinpapst/kimai2/issues/359)
- 500: Internal Server Error after login \(new install\) [\#342](https://github.com/kevinpapst/kimai2/issues/342)
- Invoices export error [\#341](https://github.com/kevinpapst/kimai2/issues/341)
- Activities just Duration [\#334](https://github.com/kevinpapst/kimai2/issues/334)
- API: Add filtering/sorting/limits [\#333](https://github.com/kevinpapst/kimai2/issues/333)
- add the same activity for multiple projects [\#325](https://github.com/kevinpapst/kimai2/issues/325)
- API: Endpoint for API docs needs session cookie [\#319](https://github.com/kevinpapst/kimai2/issues/319)
- API: Timesheets [\#315](https://github.com/kevinpapst/kimai2/issues/315)
- Feature request: Quick search box for activities when creating new timesheet [\#307](https://github.com/kevinpapst/kimai2/issues/307)
- docker image for kimai2 [\#284](https://github.com/kevinpapst/kimai2/issues/284)
- Separate Customer / Project / Activity input when logging time [\#250](https://github.com/kevinpapst/kimai2/issues/250)
- Interactive help after fresh installation [\#94](https://github.com/kevinpapst/kimai2/issues/94)
**Merged pull requests:**
@@ -77,16 +172,10 @@
## [0.5](https://github.com/kevinpapst/kimai2/tree/0.5) (2018-09-27)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.4...0.5)
**Fixed bugs:**
**Implemented enhancements:**
- Excel Output not containing data [\#327](https://github.com/kevinpapst/kimai2/issues/327)
**Closed issues:**
- Creating database schema throws exceptions [\#322](https://github.com/kevinpapst/kimai2/issues/322)
- API: Kimai metadata endpoint [\#320](https://github.com/kevinpapst/kimai2/issues/320)
- Support Markdown in timesheet description [\#295](https://github.com/kevinpapst/kimai2/issues/295)
- Add permission voter for invoices and templates [\#293](https://github.com/kevinpapst/kimai2/issues/293)
- Add support for MS Office templates [\#283](https://github.com/kevinpapst/kimai2/issues/283)
- Adding rates to customers, projects and tasks [\#271](https://github.com/kevinpapst/kimai2/issues/271)
- Delete invoice templates [\#267](https://github.com/kevinpapst/kimai2/issues/267)
@@ -97,6 +186,15 @@
- Feature Request - Excel Output of times [\#221](https://github.com/kevinpapst/kimai2/issues/221)
- Add hourly rate options [\#111](https://github.com/kevinpapst/kimai2/issues/111)
**Fixed bugs:**
- Excel Output not containing data [\#327](https://github.com/kevinpapst/kimai2/issues/327)
**Closed issues:**
- Creating database schema throws exceptions [\#322](https://github.com/kevinpapst/kimai2/issues/322)
- Add permission voter for invoices and templates [\#293](https://github.com/kevinpapst/kimai2/issues/293)
**Merged pull requests:**
- added copy invoice template action [\#331](https://github.com/kevinpapst/kimai2/pull/331) ([kevinpapst](https://github.com/kevinpapst))
@@ -118,6 +216,10 @@
## [0.4](https://github.com/kevinpapst/kimai2/tree/0.4) (2018-09-01)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.3...0.4)
**Implemented enhancements:**
- Delete user [\#225](https://github.com/kevinpapst/kimai2/issues/225)
**Fixed bugs:**
- Don't allow to stop an already stopped entry [\#282](https://github.com/kevinpapst/kimai2/issues/282)
@@ -131,7 +233,6 @@
- Administration of projects throws error if locale is "de" [\#247](https://github.com/kevinpapst/kimai2/issues/247)
- Web Installer Package for near-automated installs from browser [\#235](https://github.com/kevinpapst/kimai2/issues/235)
- Improve dashboard display [\#280](https://github.com/kevinpapst/kimai2/issues/280)
- Delete user [\#225](https://github.com/kevinpapst/kimai2/issues/225)
- Feature request - Monthly overview / report [\#207](https://github.com/kevinpapst/kimai2/issues/207)
**Merged pull requests:**
@@ -162,6 +263,16 @@
## [0.3](https://github.com/kevinpapst/kimai2/tree/0.3) (2018-07-22)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.2...0.3)
**Implemented enhancements:**
- Install Kimai as WebApp [\#203](https://github.com/kevinpapst/kimai2/issues/203)
- Replace AvanzuAdminTheme with AdminLTE bundle [\#201](https://github.com/kevinpapst/kimai2/issues/201)
- Add dynamic column filter [\#174](https://github.com/kevinpapst/kimai2/issues/174)
- Add register user function [\#164](https://github.com/kevinpapst/kimai2/issues/164)
- Add forgot password function - login screen [\#163](https://github.com/kevinpapst/kimai2/issues/163)
- Integrate FOSUserBundle \(for registration, password-reset, etc.\) [\#144](https://github.com/kevinpapst/kimai2/issues/144)
- added dynamic column filter \#174 [\#184](https://github.com/kevinpapst/kimai2/pull/184) ([kevinpapst](https://github.com/kevinpapst))
**Fixed bugs:**
- Missing assets in fresh installation [\#213](https://github.com/kevinpapst/kimai2/issues/213)
@@ -175,16 +286,10 @@
- Error on bin/console doctrine:schema:create [\#191](https://github.com/kevinpapst/kimai2/issues/191)
- Verifying email configuration [\#226](https://github.com/kevinpapst/kimai2/issues/226)
- Add favicon [\#205](https://github.com/kevinpapst/kimai2/issues/205)
- Install Kimai as WebApp [\#203](https://github.com/kevinpapst/kimai2/issues/203)
- Replace AvanzuAdminTheme with AdminLTE bundle [\#201](https://github.com/kevinpapst/kimai2/issues/201)
- Document DB requirements in install docu [\#196](https://github.com/kevinpapst/kimai2/issues/196)
- easier creation of tasks [\#195](https://github.com/kevinpapst/kimai2/issues/195)
- Integrate lock bot [\#183](https://github.com/kevinpapst/kimai2/issues/183)
- Upgrade to FontAwesome 5 [\#179](https://github.com/kevinpapst/kimai2/issues/179)
- Add dynamic column filter [\#174](https://github.com/kevinpapst/kimai2/issues/174)
- Add register user function [\#164](https://github.com/kevinpapst/kimai2/issues/164)
- Add forgot password function - login screen [\#163](https://github.com/kevinpapst/kimai2/issues/163)
- Integrate FOSUserBundle \(for registration, password-reset, etc.\) [\#144](https://github.com/kevinpapst/kimai2/issues/144)
**Merged pull requests:**
@@ -203,42 +308,24 @@
- improved install docs and composer [\#187](https://github.com/kevinpapst/kimai2/pull/187) ([kevinpapst](https://github.com/kevinpapst))
- added lock-bot config \#183 [\#186](https://github.com/kevinpapst/kimai2/pull/186) ([kevinpapst](https://github.com/kevinpapst))
- Composer package name [\#185](https://github.com/kevinpapst/kimai2/pull/185) ([kevinpapst](https://github.com/kevinpapst))
- added dynamic column filter \#174 [\#184](https://github.com/kevinpapst/kimai2/pull/184) ([kevinpapst](https://github.com/kevinpapst))
- updated to font-awesome 5 \#179 [\#181](https://github.com/kevinpapst/kimai2/pull/181) ([kevinpapst](https://github.com/kevinpapst))
- language specific money and date display [\#180](https://github.com/kevinpapst/kimai2/pull/180) ([kevinpapst](https://github.com/kevinpapst))
## [0.2](https://github.com/kevinpapst/kimai2/tree/0.2) (2018-06-23)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.1...0.2)
**Fixed bugs:**
- Change to users language after login [\#7](https://github.com/kevinpapst/kimai2/issues/7)
- Prevent end time before start time [\#152](https://github.com/kevinpapst/kimai2/issues/152)
- Importing old data fails [\#145](https://github.com/kevinpapst/kimai2/issues/145)
- Translate LanguageType choices [\#83](https://github.com/kevinpapst/kimai2/issues/83)
- Mobile alignments and toolbar \#151 [\#157](https://github.com/kevinpapst/kimai2/pull/157) ([kevinpapst](https://github.com/kevinpapst))
- Improved mobile view \#151 [\#150](https://github.com/kevinpapst/kimai2/pull/150) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
**Implemented enhancements:**
- Add english translation [\#8](https://github.com/kevinpapst/kimai2/issues/8)
- Add configurable system configuration [\#6](https://github.com/kevinpapst/kimai2/issues/6)
- Add CHANGELOG.md [\#177](https://github.com/kevinpapst/kimai2/issues/177)
- Hide notifications after some seconds [\#169](https://github.com/kevinpapst/kimai2/issues/169)
- Add preview blog message and info text on old website [\#162](https://github.com/kevinpapst/kimai2/issues/162)
- Setup Scrutinizer to run code sniffer [\#158](https://github.com/kevinpapst/kimai2/issues/158)
- Local config to overwrite settings [\#153](https://github.com/kevinpapst/kimai2/issues/153)
- Improve mobile view [\#151](https://github.com/kevinpapst/kimai2/issues/151)
- Upgrade to Symfony 4.1 [\#149](https://github.com/kevinpapst/kimai2/issues/149)
- Replace has\_role\(\) with is\_granted\(\) [\#148](https://github.com/kevinpapst/kimai2/issues/148)
- Add help text to form elements [\#138](https://github.com/kevinpapst/kimai2/issues/138)
- Duration mode: creating new timesheet records prefills duration with some seconds [\#137](https://github.com/kevinpapst/kimai2/issues/137)
- Add duration-only mode [\#131](https://github.com/kevinpapst/kimai2/issues/131)
- Add help controller [\#129](https://github.com/kevinpapst/kimai2/issues/129)
- \[RFC\] Encore: configureFilenames with hash after filename instead of in the filename [\#125](https://github.com/kevinpapst/kimai2/issues/125)
- \[RFC\] Kimai license header [\#124](https://github.com/kevinpapst/kimai2/issues/124)
- Secure create-user command [\#123](https://github.com/kevinpapst/kimai2/issues/123)
- Setup travis to run phpunit tests [\#120](https://github.com/kevinpapst/kimai2/issues/120)
- Add command for building a production release [\#115](https://github.com/kevinpapst/kimai2/issues/115)
- Add webpack and webpack-encore for frontend assets [\#113](https://github.com/kevinpapst/kimai2/issues/113)
- Add time rounding option [\#112](https://github.com/kevinpapst/kimai2/issues/112)
@@ -248,7 +335,6 @@
- Add importer from Kimai v1 [\#102](https://github.com/kevinpapst/kimai2/issues/102)
- Setup error pages [\#100](https://github.com/kevinpapst/kimai2/issues/100)
- Add basic HTML invoice rendering [\#97](https://github.com/kevinpapst/kimai2/issues/97)
- Add russian translation [\#95](https://github.com/kevinpapst/kimai2/issues/95)
- Filter user in timesheet admin [\#93](https://github.com/kevinpapst/kimai2/issues/93)
- Support to switch theme settings [\#89](https://github.com/kevinpapst/kimai2/issues/89)
- Roles should become a real table [\#86](https://github.com/kevinpapst/kimai2/issues/86)
@@ -257,6 +343,31 @@
- Add remember me login [\#53](https://github.com/kevinpapst/kimai2/issues/53)
- Add preferences sidebar [\#10](https://github.com/kevinpapst/kimai2/issues/10)
- Add user settings [\#9](https://github.com/kevinpapst/kimai2/issues/9)
- Mobile alignments and toolbar \#151 [\#157](https://github.com/kevinpapst/kimai2/pull/157) ([kevinpapst](https://github.com/kevinpapst))
- Local config \#153 [\#156](https://github.com/kevinpapst/kimai2/pull/156) ([kevinpapst](https://github.com/kevinpapst))
- Filter in hidden toolbar - improved mobile \#151 [\#155](https://github.com/kevinpapst/kimai2/pull/155) ([kevinpapst](https://github.com/kevinpapst))
- Improved mobile view \#151 [\#150](https://github.com/kevinpapst/kimai2/pull/150) ([kevinpapst](https://github.com/kevinpapst))
**Fixed bugs:**
- Change to users language after login [\#7](https://github.com/kevinpapst/kimai2/issues/7)
- Prevent end time before start time [\#152](https://github.com/kevinpapst/kimai2/issues/152)
- Importing old data fails [\#145](https://github.com/kevinpapst/kimai2/issues/145)
- Translate LanguageType choices [\#83](https://github.com/kevinpapst/kimai2/issues/83)
- Mobile alignments and toolbar \\#151 [\#157](https://github.com/kevinpapst/kimai2/pull/157) ([kevinpapst](https://github.com/kevinpapst))
- Improved mobile view \\#151 [\#150](https://github.com/kevinpapst/kimai2/pull/150) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
- Add CHANGELOG.md [\#177](https://github.com/kevinpapst/kimai2/issues/177)
- Add preview blog message and info text on old website [\#162](https://github.com/kevinpapst/kimai2/issues/162)
- Upgrade to Symfony 4.1 [\#149](https://github.com/kevinpapst/kimai2/issues/149)
- Replace has\_role\(\) with is\_granted\(\) [\#148](https://github.com/kevinpapst/kimai2/issues/148)
- Duration mode: creating new timesheet records prefills duration with some seconds [\#137](https://github.com/kevinpapst/kimai2/issues/137)
- \[RFC\] Encore: configureFilenames with hash after filename instead of in the filename [\#125](https://github.com/kevinpapst/kimai2/issues/125)
- \[RFC\] Kimai license header [\#124](https://github.com/kevinpapst/kimai2/issues/124)
- Setup travis to run phpunit tests [\#120](https://github.com/kevinpapst/kimai2/issues/120)
- Add russian translation [\#95](https://github.com/kevinpapst/kimai2/issues/95)
**Merged pull requests:**
@@ -272,8 +383,6 @@
- added scrutinizer config \#158 [\#161](https://github.com/kevinpapst/kimai2/pull/161) ([kevinpapst](https://github.com/kevinpapst))
- Update to Symfony 4.1 \#149 [\#160](https://github.com/kevinpapst/kimai2/pull/160) ([kevinpapst](https://github.com/kevinpapst))
- Updated README [\#159](https://github.com/kevinpapst/kimai2/pull/159) ([kevinpapst](https://github.com/kevinpapst))
- Local config \#153 [\#156](https://github.com/kevinpapst/kimai2/pull/156) ([kevinpapst](https://github.com/kevinpapst))
- Filter in hidden toolbar - improved mobile \#151 [\#155](https://github.com/kevinpapst/kimai2/pull/155) ([kevinpapst](https://github.com/kevinpapst))
- added russian translation \#95 [\#147](https://github.com/kevinpapst/kimai2/pull/147) ([kevinpapst](https://github.com/kevinpapst))
- Bugfix importer \(\#145\) [\#146](https://github.com/kevinpapst/kimai2/pull/146) ([kevinpapst](https://github.com/kevinpapst))
- help text and link for form labels \#138 [\#143](https://github.com/kevinpapst/kimai2/pull/143) ([kevinpapst](https://github.com/kevinpapst))
@@ -305,7 +414,7 @@
- upgraded to symfony 4 \#74 [\#81](https://github.com/kevinpapst/kimai2/pull/81) ([kevinpapst](https://github.com/kevinpapst))
## [0.1](https://github.com/kevinpapst/kimai2/tree/0.1) (2018-01-10)
**Closed issues:**
**Implemented enhancements:**
- Hide inactive delete user button [\#78](https://github.com/kevinpapst/kimai2/issues/78)
- Delete activity [\#71](https://github.com/kevinpapst/kimai2/issues/71)

View File

@@ -5,10 +5,7 @@
FROM php:7.2.9-apache-stretch
WORKDIR /opt/kimai
RUN php -r "readfile('http://getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer && \
apt update && \
RUN apt update && \
apt install -y \
git \
libicu-dev \
@@ -17,24 +14,35 @@ RUN php -r "readfile('http://getcomposer.org/installer');" | php -- --install-di
libldb-dev \
libpng-dev \
unzip \
wget \
zip \
&& \
EXPECTED_SIGNATURE="$(wget -q -O - https://composer.github.io/installer.sig)" && \
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && \
ACTUAL_SIGNATURE="$(php -r "echo hash_file('sha384', 'composer-setup.php');")" && \
if [ "$EXPECTED_SIGNATURE" != "$ACTUAL_SIGNATURE" ]; then >&2 echo 'ERROR: Invalid installer signature'; rm composer-setup.php; exit 1; fi && \
php composer-setup.php --quiet && \
rm composer-setup.php && \
mv /var/www/html/composer.phar /usr/bin/composer && \
docker-php-ext-install \
gd \
intl \
ldap \
pdo_mysql \
zip && \
apt-get -y autoremove && \
apt-get clean && \
apt remove -y wget && \
apt -y autoremove && \
apt clean && \
git clone https://github.com/kevinpapst/kimai2.git /opt/kimai && \
sed "s/prod/dev/g" .env.dist > .env && \
composer install --dev --optimize-autoloader && \
bin/console doctrine:database:create && \
bin/console doctrine:schema:create && \
bin/console doctrine:migrations:version --add --all && \
bin/console cache:warmup && \
chown -R www-data:www-data var
sed "s/prod/dev/g" /opt/kimai/.env.dist > /opt/kimai/.env && \
composer install --working-dir=/opt/kimai --dev --optimize-autoloader && \
/opt/kimai/bin/console doctrine:database:create && \
/opt/kimai/bin/console doctrine:schema:create && \
/opt/kimai/bin/console doctrine:migrations:version --add --all && \
/opt/kimai/bin/console cache:warmup && \
chown -R www-data:www-data /opt/kimai/var
WORKDIR /opt/kimai
EXPOSE 8001
USER www-data

171
README.md
View File

@@ -1,6 +1,6 @@
# Kimai v2 - Time Tracking
# Kimai 2 - Time-tracking made easy
Kimai v2 - the open source time-tracking application with a mobile-first approach, read more at the [official website](http://v2.kimai.org).
Kimai - the open source time-tracker application with a mobile-first approach (read more at the [official website](http://v2.kimai.org)).
[![Latest Stable Version](https://poser.pugx.org/kevinpapst/kimai2/v/stable)](https://packagist.org/packages/kevinpapst/kimai2)
[![License](https://poser.pugx.org/kevinpapst/kimai2/license)](https://packagist.org/packages/kevinpapst/kimai2)
@@ -11,156 +11,58 @@ Kimai v2 - the open source time-tracking application with a mobile-first approac
## Introduction
This is the reloaded version of the open source timetracker Kimai.
The new version has not much in common with its predecessor [Kimai v1](http://www.kimai.org) besides the basic ideas of time-tracking and the current development team.
This is the _reloaded_ version of the open source timetracker Kimai.
It is built from scratch and doesn't share any source code with its [predecessor](http://www.kimai.org).
But it adapts the same ideas and a clean & simple UI for your time-tracking experience.
Right now its in an early development phase, its usable but some advanced features from Kimai v1 are missing by now (like export and ODT invoices).
But we already support to [import your timesheets](var/docs/migration_v1.md) from Kimai v1.
By now it is in an pre-stable development phase, usable and with most advanced features from Kimai 1.
You can even [import your data](var/docs/migration_v1.md) and start testing and using it today.
It is developed with modern frameworks like [Symfony v4](https://github.com/symfony/symfony), [Doctrine](https://github.com/doctrine/),
[AdminLTE](https://github.com/kevinpapst/AdminLTEBundle/) and [many](composer.json) [more](package.json).
Kimai is a multi-language application and already translated to: English, German, Italian, French, Spanish, Russian, Arabic and Hungarian.
If you want to support us in translating Kimai, please [read this documentation](var/docs/translations.md).
Kimai is a [multi-language application](var/docs/translations.md) and already translated to english, german, italian, french, spanish, russian, arabic, hungarian and portuguese.
### Requirements
- PHP 7.1.3 or higher
- The PHP extensions:
- [PDO](https://php.net/manual/en/book.pdo.php) (with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled)
- [intl](https://php.net/manual/en/book.intl.php)
- [zip](https://php.net/manual/en/book.zip.php)
- The [usual Symfony application requirements](http://symfony.com/doc/current/reference/requirements.html)
- PHP 7.1.3 or higher (test your system compatibility with the [requirements-checker](http://symfony.com/doc/current/reference/requirements.html))
- The PHP extensions [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled
- If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](var/docs/faq.md))
- Kimai needs to be installed in the root directory of a domain or you need to [recompile the frontend assets](var/docs/developers.md)
- A modern browser, Kimai v2 might be broken on old browsers like IE 10
## Documentation & Roadmap
## Documentation
Looking for more information about using Kimai? Check out our more detailed [documentation](var/docs/).
Looking for more information about Kimai 2? Check out our detailed [documentation](var/docs/).
You can see our development roadmap for the future in the [Milestones](https://github.com/kevinpapst/kimai2/milestones) sections.
### Installation
Our roadmap is open for changes and input from the community, please [send us](https://github.com/kevinpapst/kimai2/issues) your ideas and questions.
There are multiple ways to install Kimai, all of them described in the [installation docu](var/docs/installation.md):
## Installation
- [Recommended installation with GIT and Composer](var/docs/installation.md#recommended-setup)
- [Development setup](var/docs/installation.md#development-installation)
- [Docker](var/docs/docker.md)
- [1-click installations](var/docs/installation.md#hosting-and-1-click-installations)
- [FTP](var/docs/installation.md#ftp-installation)
> **NOTE**
>
> There are [further infos about installation](var/docs/installation.md) if you have to use FTP, want to develop with Kimai
or are setting up Kimai on your personal computer. There is also a [docker image](var/docs/docker.md) available.
### Updating Kimai
If you want to install Kimai 2 in your production environment, then SSH into your server and change to your webserver root.
You need to install Git and [Composer](https://getcomposer.org/doc/00-intro.md) if you haven't already.
Read the following documentations before you start your upgrade:
First clone this repo:
- The [update documentation](var/docs/updates.md)
- The version specific [UPGRADING guide](UPGRADING.md)
- The [release information](https://github.com/kevinpapst/kimai2/releases)
```bash
git clone https://github.com/kevinpapst/kimai2.git
cd kimai2/
```
## Roadmap and releases
Make sure the [file permissions are correct](https://symfony.com/doc/current/setup/file_permissions.html) and create your `.env` file:
```bash
chown -R :www-data .
chmod -R g+r .
chmod -R g+rw var/
cp .env.dist .env
```
You can see our development roadmap in the [Milestones](https://github.com/kevinpapst/kimai2/milestones) sections.
It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome!
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite, but SQLite is [not recommended](var/docs/faq.md) for production usage.
Configure the database connection string in your the `.env` file:
```
# adjust all settings in .env to your needs
APP_ENV=prod
DATABASE_URL=mysql://user:password@127.0.0.1:3306/database
```
> 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.
Now install all dependencies for Kimai 2:
```bash
sudo -u www-data composer install --no-dev --optimize-autoloader
```
Optionally create the database:
```bash
bin/console doctrine:database:create
```
Create all schema tables:
```bash
bin/console doctrine:schema:create
```
Make sure that upcoming updates can be correctly applied by setting the initial database version:
```bash
bin/console doctrine:migrations:version --add --all
```
Warm up the cache (as webserver user):
```bash
sudo -u www-data bin/console cache:warmup --env=prod
```
Create your first user with the following command. You will be asked to enter a password afterwards:
```bash
bin/console kimai:create-user username admin@example.com ROLE_SUPER_ADMIN
```
_Tip: You can skip the "create user" step, if you are going to [import data from Kimai v1](var/docs/migration_v1.md)._
For available roles, please refer to the [user documentation](var/docs/users.md).
> **NOTE**
>
> If you want to use a fully-featured web server (like Nginx or Apache) to run
> Kimai, configure it to point at the `public/` directory of the project.
> For more details, see:
> http://symfony.com/doc/current/cookbook/configuration/web_server_configuration.html
Installation complete: enjoy time-tracking :-)
## Updating Kimai
> **NOTE**
>
> Don't forget tweaks that may be necessary to these instructions if you are using FTP, developing or updating on your
personal computer instead of a server. See [further infos about installation](var/docs/installation.md).
**STOP**
1. It's important that you don't execute the Installation steps before or after your upgrade
2. Make sure that you have a working database backup before you start the update
3. Read the [UPGRADING](UPGRADING.md) guide and [release information](https://github.com/kevinpapst/kimai2/releases) to check if there a further steps required
Get the latest code and install dependencies:
```bash
cd kimai2/
git pull origin master
sudo -u www-data composer install --no-dev --optimize-autoloader
```
Refresh your cache:
```bash
sudo -u www-data bin/console cache:clear --env=prod
sudo -u www-data bin/console cache:warmup --env=prod
```
And upgrade your database:
```bash
bin/console doctrine:migrations:migrate
```
Done! You can use the latest version of Kimai 2.
## Rolling releases & Git
Please note: Kimai 2 uses a rolling release concept for delivering updates.
Release versions will be created on a regular base and you can use these tags if you are familiar with Git, but we
will not provide support for any specific version (whether its bugs or installation/update docu).
Every code change, whether it's a new features or bug fixes, will be targeted against the master branch and
intensively tested before merging. We have to go this way, as we develop Kimai in our free time and want to put our
effort into the software instead of installation scripts and complicated upgrade processes.
Release versions will be created on a regular base and you can use these tags if you are familiar with Git,
but we will not provide support for any specific version.
Every code change, whether it's a new feature or a bug fix, will be done on the master branch and
intensively tested before merging. We have to do it this way, as we develop Kimai in our free time and want to put our
effort into the software instead of backporting changes for old versions.
## Extensions for Kimai 2
@@ -171,7 +73,10 @@ All available Kimai 2 bundles can be found at the [Kimai recipes](https://github
## Developer
If you want to develop with and for Kimai 2 please read the following documentation:
Kimai 2 is developed with modern frameworks like [Symfony v4](https://github.com/symfony/symfony), [Doctrine](https://github.com/doctrine/),
[AdminLTE](https://github.com/kevinpapst/AdminLTEBundle/) and [many](composer.json) [more](package.json).
If you want to start developing for Kimai 2, please read the following documentation:
- an example on how to extend Kimai 2 can be found in this [GitHub repository](https://github.com/kevinpapst/kimai2-invoice)
- the [developer documentation](var/docs/developers.md) is available both on GitHub and your local installation

View File

@@ -18,6 +18,15 @@ bin/console doctrine:migrations:migrate
There might be version specific tasks that need to be executed before or after these steps, please see below
if your updated version is mentioned below.
## [0.7](https://github.com/kevinpapst/kimai2/releases/tag/0.7) (2019-01-28)
The configuration `kimai.theme.active_warning` was deprecated and should be replaced in your local.yaml,
[read config docs for more information](https://github.com/kevinpapst/kimai2/blob/master/var/docs/configurations.md#limit-active-entries).
## [0.6.1](https://github.com/kevinpapst/kimai2/releases/tag/0.6.1) (2018-11-19)
A bugfix release to address database compatibility issues with older MySQL/MariaDB versions.
## [0.6](https://github.com/kevinpapst/kimai2/releases/tag/0.6) (2018-11-18)
The API has some minor BC breaks: some fields were renamed and entities have a larger attribute set than collections.

View File

@@ -1,165 +0,0 @@
/*
* 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 "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
.error-page {
margin-bottom: 50px;
}
/* ================================ CONTENT ================================ */
.content {
padding: 15px 0;
}
.content-header {
h1 {
small {
display: none;
}
}
}
@media (min-width: $screen-sm-min) {
.content-header {
h1 {
small {
display: inline-block;
}
}
}
.content {
padding: 15px;
}
}
/* ================================ NAVBAR ================================ */
.navbar-nav > li > a.ddt-large {
padding-top: 12px;
padding-bottom: 9px;
}
li.messages-menu ul.menu li .pull-left i {
color: #dd4b39;
}
.navbar-nav>.messages-menu>.dropdown-menu>li .menu>li>a>p {
overflow: hidden;
text-overflow: ellipsis;
}
.navbar-nav .start_record {
text-align: center;
}
/*
.ticktac:hover i.running{
color: #4ff131;
}
li.open .ticktac i.running{
color: #4ff131;
}
.ticktac i.stopped{
color: #fff;
}
.ticktac:hover i.stopped{
color: #4ff131;
}
.user-panel>.info {
left: 40px;
padding-top: 0px;
}
*/
/* ================================ TOOLBAR ================================ */
/* Page based action buttons in the upper right corner of the content area */
.content-header > .breadcrumb {
position: absolute;
float: right;
background: transparent;
}
/* Right sidebar with additional tabs for personal settings and "About Kimai" section */
.control-sidebar {
select {
color: $input-color;
background-color: $input-bg;
}
div.image {
img.img-circle {
max-width: 34px;
}
}
}
/* The filter form is available on most pages above the datatable */
@media (min-width: 768px) {
.toolbar {
form.navbar-form {
font-size: $font-size-base;
.form-control {
display: inline-block;
width: 100%;
vertical-align: middle;
}
}
}
}
/* ================================ SIDEBAR ================================ */
/* Used for the Sidebar UserPanel (which is deactivated right now) */
.user-panel>.info {
left: 80px;
}
/* ================================ FOOTER ================================ */
footer.main-footer {
padding: 5px;
font-size: 80%;
}
/* ================================ PRINT ================================ */
@media print{
.sf-toolbar, .control-sidebar {display: none !important;}
}
/* ================================ INVOICES ================================ */
.invoice {
margin: 0;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
}
}
.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;
}

View File

@@ -33,7 +33,7 @@ $(function() {
moment.locale($.kimai.settings['locale']);
// ask before a delete call is executed
$('a.btn-trash').click(function (event) {
$('body').on('click', 'a.btn-trash', function (event) {
return confirm($.kimai.settings['confirmDelete']);
});
@@ -97,11 +97,21 @@ $(function() {
method: 'GET',
dataType: 'json',
success: function(data){
$('#' + targetSelect).find('option').remove().end().find('optgroup').remove().end();
var selectName = '#' + targetSelect;
var $select = $(selectName);
var $emptyOption = $(selectName + ' option[value=""]');
$select.find('option').remove().end().find('optgroup').remove().end();
if ($emptyOption.length != 0) {
$select.append('<option value="">' + $emptyOption.text() + '</option>');
}
$.each(data, function(i, obj) {
$('#' + targetSelect).append('<option value="' + obj.id + '">' + obj.name + '</option>');
$select.append('<option value="' + obj.id + '">' + obj.name + '</option>');
});
$('#' + targetSelect).trigger('change')
$select.trigger('change');
}
});
});
@@ -115,16 +125,6 @@ $(function() {
}, function(){
$(this).find('a.anchor').hide();
});
/*
$('input').iCheck({
checkboxClass: 'icheckbox_square-blue',
radioClass: 'iradio_square-blue',
increaseArea: '20%' // optional
});
*/
// $.AdminLTE.pushMenu.expandOnHover();
},
pauseRecord: function(selector) {
$(selector + ' .pull-left i').hover(function () {

View File

@@ -6,28 +6,51 @@
*/
$(document).ready(function () {
//$('.navbar-form select').selectpicker({});
/* Submit the pagination including the toolbar filters */
$('body').on('click', 'div.navigation ul.pagination li a', function(event) {
var $pager = $(".toolbar form input[name='page']");
if ($pager.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
var $urlParts = $(this).attr('href').split('/');
var page = $urlParts[$urlParts.length-1];
$pager.val(page);
$pager.trigger('change');
return false;
});
// DateRange - TODO improve me, so users can type without reloading in between
$('.toolbar form input').change(function (event) {
$('.toolbar form').submit();
toolbarLoadDataAfterChange();
});
$('.toolbar form select').change(function (event) {
switch (event.target.id) {
case 'customer':
$('.toolbar form select#project').val('');
if ($('.toolbar form select#activity').find(':selected').attr('data-project')) {
$('.toolbar form select#activity').val('');
}
break;
case 'project':
if ($('.toolbar form select#activity').find(':selected').attr('data-project')) {
$('.toolbar form select#activity').val('');
}
case 'page':
break;
default:
$('.toolbar form input#page').val(1);
}
$('.toolbar form').submit();
toolbarLoadDataAfterChange();
});
function toolbarLoadDataAfterChange()
{
var $form = $('.toolbar form');
$.ajax({
url: $form.attr('action'),
type: $form.attr('method'),
data: $form.serialize(),
success: function(html) {
$('section.content').replaceWith(
$(html).find('section.content')
);
},
error: function(xhr, err) {
$form.submit();
}
});
}
});

View File

@@ -222,3 +222,15 @@ div.dataTables_scrollHead table.table-bordered {
.table-striped.dataTable tbody tr.active:nth-child(odd) th {
background-color: #017ebc;
}
/*
Make sure that the action buttons do not line-break if another column takes all available space
See https://github.com/twbs/bootstrap/issues/9939#issuecomment-37682952
*/
table.dataTable {
td {
.btn-group {
display: flex;
}
}
}

View File

@@ -35,6 +35,12 @@
width: 100%;
vertical-align: middle;
}
.control-label {
width: 100%;
}
.input-group {
width: 100%;
}
}
}
}

View File

@@ -9,9 +9,12 @@
"ext-pdo": "*",
"ext-zip": "*",
"beberlei/DoctrineExtensions": "^1.0",
"dama/doctrine-test-bundle": "^5.0",
"doctrine/doctrine-fixtures-bundle": "^3.0",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^2.3",
"friendsofsymfony/user-bundle": "~2.0",
"fzaninotto/faker": "^1.8",
"jms/serializer-bundle": "^2.4",
"kevinpapst/adminlte-bundle": "~2.1",
"nelmio/api-doc-bundle": "^3.2",
@@ -21,11 +24,13 @@
"sensio/framework-extra-bundle": "^5.2",
"symfony/asset": "^4.0",
"symfony/console": "^4.0",
"symfony/debug-pack": "^1.0",
"symfony/dotenv": "^4.0",
"symfony/expression-language": "^4.0",
"symfony/flex": "^1.0",
"symfony/form": "^4.0",
"symfony/framework-bundle": "^4.0",
"symfony/maker-bundle": "^1.0",
"symfony/monolog-bundle": "^3.1",
"symfony/orm-pack": "^1.0",
"symfony/profiler-pack": "^1.0",
@@ -36,25 +41,20 @@
"symfony/translation": "^4.0",
"symfony/twig-bundle": "^4.0",
"symfony/validator": "^4.0",
"symfony/webpack-encore-pack": "^1.0",
"symfony/web-server-bundle": "^4.0",
"symfony/yaml": "^4.0",
"twig/extensions": "^1.5",
"white-october/pagerfanta-bundle": "^1.1"
},
"require-dev": {
"dama/doctrine-test-bundle": "^5.0",
"doctrine/doctrine-fixtures-bundle": "^3.0",
"friendsofphp/php-cs-fixer": "^2.10",
"fzaninotto/faker": "^1.8",
"phpunit/phpunit": "^7.0",
"squizlabs/php_codesniffer": "^3.2",
"symfony/browser-kit": "^4.0",
"symfony/css-selector": "^4.0",
"symfony/debug-pack": "^1.0",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^4.0",
"symfony/thanks": "^1.0",
"symfony/web-server-bundle": "^4.0"
"symfony/webpack-encore-pack": "^1.0"
},
"config": {
"preferred-install": {

1202
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
kimai:
# --------------------------------------------------------------------------------
# Settings for the user management and login forms
user:
registration: true
password_reset: true
# --------------------------------------------------------------------------------
# All configs related to timesheet and record management
timesheet:
@@ -33,6 +35,19 @@ kimai:
# 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 (formerly "kimai.theme.active_warning"):
# display a warning color if the user has at least 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. The first one is stopped when the new one starts.
# When hard_limit is > 1: as soon as the limit is reached and warning is shown and the user has to stop one active entry.
active_entries:
soft_limit: 1
hard_limit: 3
# --------------------------------------------------------------------------------
# Invoice management
#invoice:
# # all files in these directories will be used as invoice documents (if supported by a renderer)
@@ -40,6 +55,45 @@ kimai:
# - 'var/invoices/'
# - 'templates/invoice/renderer/'
# --------------------------------------------------------------------------------
# Default settings used to populate forms
#defaults:
# customer:
# timezone: Europe/Berlin
# country: DE
# currency: EUR
# --------------------------------------------------------------------------------
# Free configurable permission system, see var/docs/permissions.md
permissions:
# mapping complex rulesets of single permissions to named "sets" ("set name" = [array of "permissions"])
sets:
ACTIVITIES: [view_activity,create_activity,edit_activity,delete_activity]
PROJECTS: [view_project,create_project,edit_project,delete_project]
CUSTOMERS: [view_customer,create_customer,edit_customer,delete_customer]
INVOICE: [view_invoice,create_invoice]
INVOICE_TEMPLATE: [view_invoice_template,create_invoice_template,edit_invoice_template,delete_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]
USER: [view_user,create_user,delete_user]
RATE: [view_rate_own_timesheet,edit_rate_own_timesheet]
RATE_OTHER: [view_rate_other_timesheet,edit_rate_other_timesheet]
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps:
ROLE_USER: [TIMESHEET,PROFILE]
ROLE_TEAMLEAD: [INVOICE,TIMESHEET,TIMESHEET_OTHER,PROFILE]
ROLE_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,RATE,RATE_OTHER]
ROLE_SUPER_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,PROFILE_OTHER,USER,RATE,RATE_OTHER]
# adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"])
roles:
ROLE_USER: []
ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile]
ROLE_ADMIN: [hourly-rate_own_profile]
ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile]
# --------------------------------------------------------------------------------
# Language specific settings, like the date formats
languages:
de:
@@ -50,6 +104,10 @@ kimai:
date_short: 'Y-m-d'
duration: '%%h:%%m hours'
duration_short: '%%h:%%m h'
pt_BR:
date_short: 'd-m-Y'
duration: '%%h:%%m horas'
duration_short: '%%h:%%m h'
it:
date_short: 'd.m.Y'
duration: '%%h:%%m h'
@@ -75,6 +133,7 @@ kimai:
duration: '%%h:%%m óra'
duration_short: '%%h:%%m h'
# --------------------------------------------------------------------------------
# timesheet calendar configuration
calendar:
week_numbers: true
@@ -93,18 +152,18 @@ kimai:
# id: 'de.german#holiday@group.v.calendar.google.com'
# color: '#ccc'
# --------------------------------------------------------------------------------
# theme related settings, will be available as twig globals at "kimai_context.*"
# please see documentation at var/docs/theme.md
theme:
# BETA test: If you set this to 'selectpicker' the customer/project/activity select boxes will be transformed
# into a searchable and javascript enhanced input type
select_type: ~
# display a warning color if the user has at least X active recordings
active_warning: 3
# fallback color for all widgets that don't have a dedicated color
# possible options: blue, black, purple, yellow, red, green
box_color: 'green'
# --------------------------------------------------------------------------------
# Dashboard widget sections, please see documentation at var/docs/dashboard.md
dashboard:
user_duration:
@@ -133,6 +192,7 @@ kimai:
permission: ROLE_ADMIN
widgets: [amountToday, amountWeek, amountMonth, amountYear]
# --------------------------------------------------------------------------------
# All available widgets, please see documentation at var/docs/dashboard.md
widgets:
userDurationToday: { title: stats.durationToday, query: duration, user: true, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }

View File

@@ -47,5 +47,5 @@ security:
- { path: '^/(%app_locales%)/login', role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/(%app_locales%)/register', role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/(%app_locales%)/resetting', role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/(%app_locales%)/admin', roles: ROLE_ADMIN }
- { path: '^/(%app_locales%)/', roles: ROLE_USER }
- { path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED }

View File

@@ -2,3 +2,5 @@ twig:
#paths: ['%kernel.project_dir%/templates']
debug: '%kernel.debug%'
strict_variables: '%kernel.debug%'
form_themes:
- 'form/kimai-theme.html.twig'

View File

@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app_locales: en|de|it|fr|es|ru|ar|hu
app_locales: en|de|it|fr|es|ru|ar|hu|pt_BR
services:
# default configuration for services in *this* file
@@ -14,7 +14,7 @@ services:
# The best practice is to be explicit about your dependencies anyway.
bind:
$projectDirectory: "%kernel.project_dir%"
$durationOnly: "%kimai.timesheet.duration_only%"
$hardLimit: "%kimai.timesheet.active_entries.hard_limit%"
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
@@ -93,6 +93,14 @@ services:
tags:
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
App\Form\TimesheetEditForm:
arguments:
$durationOnly: "%kimai.timesheet.duration_only%"
App\Form\Extension\SelectWithApiDataExtension:
tags:
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
# ================================================================================
# THEME
# ================================================================================
@@ -118,6 +126,13 @@ services:
App\Timesheet\Calculator\RateCalculator:
arguments: ["%kimai.timesheet.rates%"]
# ================================================================================
# SECURITY & VOTER
# ================================================================================
App\Security\RolePermissionManager:
arguments: ["%kimai.permissions%"]
# ================================================================================
# REPOSITORIES
# ================================================================================

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"build/app.js": "/build/app.js?d823754f6c3293656052",
"build/app.css": "/build/app.css?979d88ed847886068fcf3eb0bc3de545",
"build/app.js": "/build/app.js?5b6a5a9160ea779a403e",
"build/app.css": "/build/app.css?fc142c29c80f5dcdbc4c745af16509f6",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29",

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\Activity;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
@@ -59,6 +58,9 @@ class ActivityController extends BaseApiController
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter activities. If none is provided, only global activities will be returned.")
* @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="globals", requirements="true", strict=true, nullable=true, description="Pass 'true' as string to fetch only global activities")
* @Rest\QueryParam(name="globalsFirst", requirements="false", strict=true, nullable=true, description="Pass 'false' as string if you don't want the global activities to be listed first")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')")
* @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')")
*
* @return Response
*/
@@ -66,12 +68,26 @@ class ActivityController extends BaseApiController
{
$query = new ActivityQuery();
$query->setOrderGlobalsFirst(true)
->setResultType(ActivityQuery::RESULT_TYPE_OBJECTS);
->setResultType(ActivityQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('name')
;
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
if (null !== ($globals = $paramFetcher->get('globals'))) {
$query->setGlobalsOnly(true);
}
if ('false' === ($globalsFirst = $paramFetcher->get('globalsFirst'))) {
$query->setOrderGlobalsFirst(false);
}
if (null !== ($project = $paramFetcher->get('project'))) {
$query->setProject($project);
}

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\Customer;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
@@ -57,13 +56,26 @@ class CustomerController extends BaseApiController
* @SWG\Schema(ref="#/definitions/CustomerCollection"),
* )
* @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')")
* @Rest\QueryParam(name="orderBy", requirements="id|name", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'name')")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new CustomerQuery();
$query->setResultType(CustomerQuery::RESULT_TYPE_OBJECTS);
$query
->setResultType(CustomerQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('name')
;
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\Project;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
@@ -58,6 +57,8 @@ 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="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')")
* @Rest\QueryParam(name="orderBy", requirements="id|name", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'name')")
*
* @param ParamFetcherInterface $paramFetcher
* @return Response
@@ -65,7 +66,18 @@ class ProjectController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new ProjectQuery();
$query->setResultType(ProjectQuery::RESULT_TYPE_OBJECTS);
$query
->setResultType(ProjectQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('name')
;
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
if (null !== ($customer = $paramFetcher->get('customer'))) {
$query->setCustomer($customer);

View File

@@ -28,8 +28,6 @@ use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Timesheet")
*
* @Security("is_granted('ROLE_USER')")
*/
class TimesheetController extends BaseApiController
{
@@ -43,14 +41,21 @@ class TimesheetController extends BaseApiController
*/
protected $viewHandler;
/**
* @var int
*/
protected $hardLimit;
/**
* @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository
* @param int $hardLimit
*/
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository)
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, int $hardLimit)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->hardLimit = $hardLimit;
}
/**
@@ -67,6 +72,8 @@ class TimesheetController extends BaseApiController
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')")
* @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')")
*
* @Security("is_granted('view_own_timesheet')")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
@@ -120,6 +127,8 @@ class TimesheetController extends BaseApiController
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
* )
*
* @Security("is_granted('view_own_timesheet')")
*
* @param int $id
* @return Response
*/
@@ -146,6 +155,8 @@ class TimesheetController extends BaseApiController
* )
* )
*
* @Security("is_granted('create_own_timesheet')")
*
* @param Request $request
* @return Response
*/
@@ -157,8 +168,7 @@ class TimesheetController extends BaseApiController
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,
'method' => 'POST',
'duration_only' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet),
]);
$form->setData($timesheet);
@@ -173,6 +183,21 @@ class TimesheetController extends BaseApiController
return new Response('You are not allowed to start this timesheet record', Response::HTTP_BAD_REQUEST);
}
if ($form->has('duration')) {
$duration = $form->get('duration')->getData();
if ($duration > 0) {
/** @var Timesheet $record */
$record = $form->getData();
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
$record->setEnd($end);
}
}
$this->repository->stopActiveEntries(
$timesheet->getUser(),
$this->hardLimit
);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();

View File

@@ -22,9 +22,6 @@ use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("User")
*
* @Security("is_granted('ROLE_SUPER_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
*/
class UserController extends BaseApiController
{
@@ -55,6 +52,8 @@ class UserController extends BaseApiController
* @SWG\Schema(ref="#/definitions/UserCollection"),
* )
*
* @Security("is_granted('view_user')")
*
* @return Response
*/
public function cgetAction()
@@ -73,6 +72,8 @@ class UserController extends BaseApiController
* @SWG\Schema(ref="#/definitions/UserEntity"),
* )
*
* @Security("is_granted('view_user')")
*
* @param int $id
* @return Response
*/

View File

@@ -69,8 +69,8 @@ class TimesheetEntity
if (null === $entry->getEnd()) {
// TODO move these colors to the controller
$this->borderColor = '#f39c12';
$this->backgroundColor = '#f39c12';
$this->borderColor = '367fa9'; //'#f39c12';
$this->backgroundColor = '#3c8dbc'; //'#f39c12';
} else {
$this->end = $entry->getEnd();
}

View File

@@ -247,7 +247,7 @@ class KimaiImporterCommand extends Command
$allImports += $counter;
$io->success('Imported users: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import users: ' . $ex->getMessage());
$io->error('Failed to import users: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
@@ -257,7 +257,7 @@ class KimaiImporterCommand extends Command
$allImports += $counter;
$io->success('Imported customers: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import customers: ' . $ex->getMessage());
$io->error('Failed to import customers: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
@@ -267,7 +267,7 @@ class KimaiImporterCommand extends Command
$allImports += $counter;
$io->success('Imported projects: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import projects: ' . $ex->getMessage());
$io->error('Failed to import projects: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
@@ -277,7 +277,7 @@ class KimaiImporterCommand extends Command
$allImports += $counter;
$io->success('Imported activities: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import activities: ' . $ex->getMessage());
$io->error('Failed to import activities: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
@@ -287,7 +287,7 @@ class KimaiImporterCommand extends Command
$allImports += $counter;
$io->success('Imported timesheet records: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import timesheet records: ' . $ex->getMessage());
$io->error('Failed to import timesheet records: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
@@ -318,19 +318,22 @@ class KimaiImporterCommand extends Command
*/
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
{
$optionColumn = $this->connection->quoteIdentifier('option');
$qb = $this->connection->createQueryBuilder();
$version = $this->connection->createQueryBuilder()
->select('value')
->from($this->connection->quoteIdentifier($this->dbPrefix . 'configuration'))
->where('option = :option')
->setParameter(':option', 'version')
->where($qb->expr()->eq($optionColumn, ':option'))
->setParameter('option', 'version')
->execute()
->fetchColumn();
$revision = $this->connection->createQueryBuilder()
->select('value')
->from($this->connection->quoteIdentifier($this->dbPrefix . 'configuration'))
->where('option = :option')
->setParameter(':option', 'revision')
->where($qb->expr()->eq($optionColumn, ':option'))
->setParameter('option', 'revision')
->execute()
->fetchColumn();
@@ -382,8 +385,10 @@ class KimaiImporterCommand extends Command
protected function bytesHumanReadable($size)
{
$unit = ['b', 'kB', 'MB', 'GB'];
$i = floor(log($size, 1024));
$a = (int) $i;
return @round($size / pow(1024, ($i = floor(log($size, 1024)))), 2) . ' ' . $unit[$i];
return @round($size / pow(1024, $i), 2) . ' ' . $unit[$a];
}
/**
@@ -419,12 +424,8 @@ class KimaiImporterCommand extends Command
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach ($errors as $error) {
$value = $error->getInvalidValue();
$io->error(
$error->getPropertyPath()
. ' (' . (is_array($value) ? implode(',', $value) : $value) . ')'
. "\n "
. $error->getMessage()
(string) $error
);
}
@@ -938,7 +939,7 @@ class KimaiImporterCommand extends Command
;
if (!$this->validateImport($io, $timesheet)) {
throw new \Exception('Failed to validate timesheet record: ' . $timesheet->getId());
throw new \Exception('Failed to validate timesheet record: ' . $oldRecord['timeEntryID']);
}
try {
@@ -949,8 +950,7 @@ class KimaiImporterCommand extends Command
}
++$counter;
} catch (\Exception $ex) {
$io->error('Failed to create timesheet record: ' . $timesheet->getId());
$io->error('Reason: ' . $ex->getMessage());
$io->error('Failed to create timesheet record: ' . $ex->getMessage());
}
$io->write('.');

View File

@@ -17,7 +17,7 @@ class Constants
/**
* The current release version
*/
public const VERSION = 0.6;
public const VERSION = '0.7';
/**
* The release name, will only change for new major version
*/

View File

@@ -42,15 +42,7 @@ abstract class AbstractController extends Controller
*/
protected function flashSuccess($translationKey, $parameter = [])
{
if (!empty($parameter)) {
$translationKey = $this->getTranslator()->trans(
$translationKey,
$parameter,
self::DOMAIN_FLASH
);
}
$this->addFlash(self::FLASH_SUCCESS, $translationKey);
$this->addFlashTranslated(self::FLASH_SUCCESS, $translationKey, $parameter);
}
/**
@@ -61,15 +53,7 @@ abstract class AbstractController extends Controller
*/
protected function flashWarning($translationKey, $parameter = [])
{
if (!empty($parameter)) {
$translationKey = $this->getTranslator()->trans(
$translationKey,
$parameter,
self::DOMAIN_FLASH
);
}
$this->addFlash(self::FLASH_WARNING, $translationKey);
$this->addFlashTranslated(self::FLASH_WARNING, $translationKey, $parameter);
}
/**
@@ -79,15 +63,30 @@ abstract class AbstractController extends Controller
* @param array $parameter
*/
protected function flashError($translationKey, $parameter = [])
{
$this->addFlashTranslated(self::FLASH_ERROR, $translationKey, $parameter);
}
/**
* Adds a fully translated (both $message and all keys in $parameter) flash message to the stack.
*
* @param string $type
* @param string $message
* @param array $parameter
*/
protected function addFlashTranslated(string $type, string $message, array $parameter = [])
{
if (!empty($parameter)) {
$translationKey = $this->getTranslator()->trans(
$translationKey,
foreach ($parameter as $key => $value) {
$parameter[$key] = $this->getTranslator()->trans($value, [], self::DOMAIN_FLASH);
}
$message = $this->getTranslator()->trans(
$message,
$parameter,
self::DOMAIN_FLASH
);
}
$this->addFlash(self::FLASH_ERROR, $translationKey);
$this->addFlash($type, $message);
}
}

View File

@@ -62,6 +62,7 @@ class AboutController extends AbstractController
'open_basedir',
'post_max_size',
'sys_temp_dir',
'date.timezone',
];
$settings = [];

View File

@@ -27,8 +27,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to manage activities in the admin part of the site.
*
* @Route(path="/admin/activity")
* @Security("is_granted('ROLE_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Security("is_granted('view_activity')")
*/
class ActivityController extends AbstractController
{
@@ -44,6 +43,11 @@ class ActivityController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"})
* @Cache(smaxage="10")
* @Security("is_granted('view_activity')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -71,6 +75,7 @@ class ActivityController extends AbstractController
/**
* @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"})
* @Security("is_granted('create_activity')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
@@ -94,8 +99,6 @@ class ActivityController extends AbstractController
}
/**
* The route to delete an existing entry.
*
* @Route(path="/{id}/delete", name="admin_activity_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', activity)")
*

View File

@@ -26,8 +26,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to manage activities in the admin part of the site.
*
* @Route(path="/admin/customer")
* @Security("is_granted('ROLE_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Security("is_granted('view_customer')")
*/
class CustomerController extends AbstractController
{
@@ -55,6 +54,11 @@ class CustomerController extends AbstractController
/**
* @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')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -81,6 +85,10 @@ class CustomerController extends AbstractController
/**
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function createAction(Request $request)
{
@@ -95,6 +103,10 @@ class CustomerController extends AbstractController
/**
* @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', customer)")
*
* @param Customer $customer
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function editAction(Customer $customer, Request $request)
{
@@ -102,35 +114,6 @@ class CustomerController extends AbstractController
}
/**
* @param Customer $customer
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
protected function renderCustomerForm(Customer $customer, Request $request)
{
$editForm = $this->createEditForm($customer);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($customer);
$entityManager->flush();
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer');
}
return $this->render('admin/customer_edit.html.twig', [
'customer' => $customer,
'form' => $editForm->createView()
]);
}
/**
* The route to delete an existing entry.
*
* @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', customer)")
*
@@ -179,6 +162,33 @@ class CustomerController extends AbstractController
]);
}
/**
* @param Customer $customer
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
protected function renderCustomerForm(Customer $customer, Request $request)
{
$editForm = $this->createEditForm($customer);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($customer);
$entityManager->flush();
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer');
}
return $this->render('admin/customer_edit.html.twig', [
'customer' => $customer,
'form' => $editForm->createView()
]);
}
/**
* @param CustomerQuery $query
* @return \Symfony\Component\Form\FormInterface

View File

@@ -28,8 +28,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to manage projects in the admin part of the site.
*
* @Route(path="/admin/project")
* @Security("is_granted('ROLE_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Security("is_granted('view_project')")
*/
class ProjectController extends AbstractController
{
@@ -45,6 +44,11 @@ class ProjectController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"})
* @Cache(smaxage="10")
* @Security("is_granted('view_project')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -72,6 +76,10 @@ class ProjectController extends AbstractController
/**
* @Route(path="/create", name="admin_project_create", methods={"GET", "POST"})
* @Security("is_granted('create_project')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function createAction(Request $request)
{
@@ -81,6 +89,10 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*
* @param Project $project
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function editAction(Project $project, Request $request)
{
@@ -88,8 +100,6 @@ class ProjectController extends AbstractController
}
/**
* The route to delete an existing entry.
*
* @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', project)")
*

View File

@@ -24,27 +24,16 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used for manage timesheet entries in the admin part of the site.
*
* @Route(path="/team/timesheet")
* @Security("is_granted('ROLE_TEAMLEAD')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Security("is_granted('view_other_timesheet')")
*/
class TimesheetController extends AbstractController
{
use TimesheetControllerTrait;
/**
* TimesheetController constructor.
* @param bool $durationOnly
*/
public function __construct(bool $durationOnly)
{
$this->setDurationMode($durationOnly);
}
/**
* This route shows all users timesheet entries.
*
* @Route(path="/", defaults={"page": 1}, name="admin_timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated", methods={"GET"})
* @Security("is_granted('view_other_timesheet')")
*
* @param $page
* @param Request $request
@@ -73,7 +62,7 @@ class TimesheetController extends AbstractController
return $this->render('admin/timesheet.html.twig', [
'entries' => $entries,
'page' => $page,
'page' => $query->getPage(),
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
@@ -81,8 +70,38 @@ class TimesheetController extends AbstractController
}
/**
* The route to stop a running entry.
* @Route(path="/export", name="admin_timesheet_export", methods={"GET"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function exportAction(Request $request)
{
$query = new TimesheetQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var TimesheetQuery $query */
$query = $form->getData();
if (null !== $query->getBegin()) {
$query->getBegin()->setTime(0, 0, 0);
}
if (null !== $query->getEnd()) {
$query->getEnd()->setTime(23, 59, 59);
}
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
return $this->render('admin/timesheet_export.html.twig', [
'entries' => $entries,
'query' => $query,
]);
}
/**
* @Route(path="/{id}/stop", name="admin_timesheet_stop", methods={"GET"})
* @Security("is_granted('stop', entry)")
*
@@ -95,8 +114,6 @@ class TimesheetController extends AbstractController
}
/**
* The route to edit an existing entry.
*
* @Route(path="/{id}/edit", name="admin_timesheet_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', entry)")
*
@@ -110,9 +127,8 @@ class TimesheetController extends AbstractController
}
/**
* The route to create a new entry by form.
*
* @Route(path="/create", name="admin_timesheet_create", methods={"GET", "POST"})
* @Security("is_granted('create_other_timesheet')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
@@ -123,8 +139,6 @@ class TimesheetController extends AbstractController
}
/**
* The route to delete an existing entry.
*
* @Route(path="/{id}/delete", defaults={"page": 1}, name="admin_timesheet_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', entry)")
*
@@ -149,33 +163,33 @@ class TimesheetController extends AbstractController
/**
* @param Timesheet $entry
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
protected function getCreateForm(Timesheet $entry)
protected function getCreateForm(Timesheet $entry, string $redirectRoute)
{
return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_create'),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'include_user' => true
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_user' => true,
]);
}
/**
* @param Timesheet $entry
* @param int $page
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
protected function getEditForm(Timesheet $entry, $page)
protected function getEditForm(Timesheet $entry, $page, string $redirectRoute)
{
return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_edit', [
'id' => $entry->getId(),
'page' => $page
'page' => $page,
]),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'include_user' => true
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_user' => true,
]);
}

View File

@@ -25,8 +25,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
* Controller used to manage users in the admin part of the site.
*
* @Route(path="/admin/user")
* @Security("is_granted('ROLE_SUPER_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Security("is_granted('view_user')")
*/
class UserController extends AbstractController
{
@@ -54,7 +53,8 @@ class UserController extends AbstractController
/**
* @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"})
* @Security("is_granted('view_user')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
@@ -84,11 +84,15 @@ class UserController extends AbstractController
/**
* @Route(path="/create", name="admin_user_create", methods={"GET", "POST"})
* @Security("is_granted('create', user)")
* @Security("is_granted('create_user')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function createAction(Request $request)
{
$user = new User();
$user->setEnabled(true);
$editForm = $this->createEditForm($user);
$editForm->handleRequest($request);
@@ -124,8 +128,6 @@ class UserController extends AbstractController
}
/**
* The route to delete an existing user.
*
* @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', userToDelete)")
*
@@ -136,6 +138,7 @@ class UserController extends AbstractController
*/
public function deleteAction(User $userToDelete, Request $request)
{
// $userToDelete MUST not be called $user, as $user is always the current user!
$stats = $this->getDoctrine()->getRepository(Timesheet::class)->getUserStatistics($userToDelete);
$deleteForm = $this->createFormBuilder()

View File

@@ -79,14 +79,9 @@ class CalendarController extends AbstractController
->setUser($this->getUser())
->setState(TimesheetQuery::STATE_ALL)
->setResultType(TimesheetQuery::RESULT_TYPE_QUERYBUILDER)
->setEnd($end)
;
// running entries should only occur for the current month, but they won't
// be found if we add the end to the query
if ((new \DateTime())->getTimestamp() > $end->getTimestamp()) {
$query->setEnd($end);
}
$repository = $this->getDoctrine()->getRepository(Timesheet::class);
/* @var $entries Timesheet[] */

View File

@@ -60,6 +60,8 @@ class HelpController extends AbstractController
$content = file_get_contents($chapterFile);
$content = preg_replace('/\[(.+)\]\(([\w\-\/]+)(\.md)(\#[\w\-]+){0,1}\)/i', '[$1]($2$4)', $content);
return $this->render('help/index.html.twig', [
'breadcrumb' => $breadcrumb,
'chapter' => $chapter,

View File

@@ -28,7 +28,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to manage invoices.
*
* @Route(path="/invoice")
* @Security("is_granted('ROLE_TEAMLEAD')")
* @Security("is_granted('view_invoice') or is_granted('view_invoice_template')")
*/
class InvoiceController extends AbstractController
{
@@ -67,6 +67,7 @@ class InvoiceController extends AbstractController
$end = new \DateTime('last day of this month');
$query = new InvoiceQuery();
$query->setOrder(InvoiceQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setUser($this->getUser());
@@ -77,7 +78,7 @@ class InvoiceController extends AbstractController
/**
* @Route(path="/", name="invoice", methods={"GET", "POST"})
* @Security("is_granted('view', 'invoice')")
* @Security("is_granted('view_invoice')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
@@ -111,7 +112,7 @@ class InvoiceController extends AbstractController
/**
* @Route(path="/print", name="invoice_print", methods={"GET", "POST"})
* @Security("is_granted('create', 'invoice')")
* @Security("is_granted('create_invoice')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
@@ -212,7 +213,7 @@ class InvoiceController extends AbstractController
/**
* @Route(path="/template", defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"})
* @Route(path="/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated", methods={"GET", "POST"})
* @Security("is_granted('view', 'invoice_template')")
* @Security("is_granted('view_invoice_template')")
*
* @param $page
* @return \Symfony\Component\HttpFoundation\Response
@@ -244,7 +245,7 @@ class InvoiceController extends AbstractController
/**
* @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"})
* @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"})
* @Security("is_granted('create', 'invoice_template')")
* @Security("is_granted('create_invoice_template')")
*
* @param Request $request
* @param InvoiceTemplate|null $template

View File

@@ -11,6 +11,7 @@ namespace App\Controller;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\PrepareUserEvent;
use App\Form\UserApiTokenType;
use App\Form\UserEditType;
use App\Form\UserPasswordType;
@@ -19,6 +20,7 @@ use App\Form\UserRolesType;
use App\Repository\TimesheetRepository;
use App\Voter\UserVoter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
@@ -28,10 +30,15 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
* User profile controller
*
* @Route(path="/profile")
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('view_own_profile') or is_granted('view_other_profile')")
*/
class ProfileController extends AbstractController
{
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var UserPasswordEncoderInterface
*/
@@ -40,9 +47,10 @@ class ProfileController extends AbstractController
/**
* @param UserPasswordEncoderInterface $encoder
*/
public function __construct(UserPasswordEncoderInterface $encoder)
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher)
{
$this->encoder = $encoder;
$this->dispatcher = $dispatcher;
}
/**
@@ -162,6 +170,10 @@ class ProfileController extends AbstractController
*/
public function savePreferencesAction(User $profile, Request $request)
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($profile);
$this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event);
$original = [];
foreach ($profile->getPreferences() as $preference) {
$original[$preference->getName()] = $preference;
@@ -194,8 +206,11 @@ class ProfileController extends AbstractController
$this->flashSuccess('action.update.success');
// switch locale if neccessary
$locale = $profile->getPreferenceValue('language', $request->getLocale());
// switch locale ONLY if updated profile is the current user
$locale = $request->getLocale();
if ($this->getUser()->getId() === $profile->getId()) {
$locale = $profile->getPreferenceValue('language', $locale);
}
return $this->redirectToRoute('user_profile_preferences', [
'_locale' => $locale,
@@ -269,6 +284,10 @@ class ProfileController extends AbstractController
*/
private function createPreferencesForm(User $user)
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($user);
$this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event);
return $this->createForm(
UserPreferencesForm::class,
$user,

View File

@@ -14,7 +14,6 @@ use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\Query\TimesheetQuery;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
@@ -24,24 +23,24 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
* Controller used to manage timesheets.
*
* @Route(path="/timesheet")
* @Security("is_granted('ROLE_USER')")
* @Security("is_granted('view_own_timesheet')")
*/
class TimesheetController extends AbstractController
{
use TimesheetControllerTrait;
/**
* @param bool $durationOnly
* @param int $hardLimit
*/
public function __construct(bool $durationOnly)
public function __construct(int $hardLimit)
{
$this->setDurationMode($durationOnly);
$this->setHardLimit($hardLimit);
}
/**
* @Route(path="/", defaults={"page": 1}, name="timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated", methods={"GET"})
* @Cache(smaxage="10")
* @Security("is_granted('view_own_timesheet')")
*
* @param int $page
* @param Request $request
@@ -72,7 +71,7 @@ class TimesheetController extends AbstractController
return $this->render('timesheet/index.html.twig', [
'entries' => $entries,
'page' => $page,
'page' => $query->getPage(),
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
@@ -81,6 +80,7 @@ class TimesheetController extends AbstractController
/**
* @Route(path="/export", name="timesheet_export", methods={"GET"})
* @Security("is_granted('export_own_timesheet')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
@@ -114,8 +114,6 @@ class TimesheetController extends AbstractController
}
/**
* The "main button and fly-out" for displaying (and stopping) active entries.
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function activeEntriesAction()
@@ -144,8 +142,6 @@ class TimesheetController extends AbstractController
}
/**
* The route to re-start a timesheet entry.
*
* @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"})
* @Security("is_granted('start', timesheet)")
*
@@ -171,6 +167,8 @@ class TimesheetController extends AbstractController
} else {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$this->stopActiveEntries($user);
$entityManager->flush();
$this->flashSuccess('timesheet.start.success');
}
@@ -182,8 +180,6 @@ class TimesheetController extends AbstractController
}
/**
* The route to edit an existing entry.
*
* @Route(path="/{id}/edit", name="timesheet_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', entry)")
*
@@ -193,29 +189,35 @@ class TimesheetController extends AbstractController
*/
public function editAction(Timesheet $entry, Request $request)
{
$route = 'timesheet';
if (null !== $request->get('page')) {
return $this->edit($entry, $request, 'timesheet_paginated', 'timesheet/edit.html.twig');
$route = 'timesheet_paginated';
} elseif ('calendar' === $request->get('origin')) {
$route = 'calendar';
}
return $this->edit($entry, $request, 'timesheet', 'timesheet/edit.html.twig');
return $this->edit($entry, $request, $route, 'timesheet/edit.html.twig');
}
/**
* The route to create a new entry by form.
*
* @Route(path="/create", name="timesheet_create", methods={"GET", "POST"})
* @Security("is_granted('create_own_timesheet')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function createAction(Request $request)
{
return $this->create($request, 'timesheet', 'timesheet/edit.html.twig');
$route = 'timesheet';
if ('calendar' === $request->get('origin')) {
$route = 'calendar';
}
return $this->create($request, $route, 'timesheet/edit.html.twig');
}
/**
* The route to delete an existing entry.
*
* @Route(path="/{id}/delete", defaults={"page": 1}, name="timesheet_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', entry)")
*
@@ -240,31 +242,32 @@ class TimesheetController extends AbstractController
/**
* @param Timesheet $entry
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
protected function getCreateForm(Timesheet $entry)
protected function getCreateForm(Timesheet $entry, string $redirectRoute)
{
return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('timesheet_create'),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'action' => $this->generateUrl('timesheet_create', ['origin' => $redirectRoute]),
'include_rate' => $this->isGranted('edit_rate', $entry),
]);
}
/**
* @param Timesheet $entry
* @param int $page
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
protected function getEditForm(Timesheet $entry, $page)
protected function getEditForm(Timesheet $entry, $page, string $redirectRoute)
{
return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('timesheet_edit', [
'id' => $entry->getId(),
'page' => $page
'page' => $page,
'origin' => $redirectRoute,
]),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'include_rate' => $this->isGranted('edit_rate', $entry),
]);
}

View File

@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use Doctrine\Common\Persistence\ManagerRegistry;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -22,24 +23,24 @@ use Symfony\Component\HttpFoundation\Response;
trait TimesheetControllerTrait
{
/**
* @var bool
* @var int
*/
private $durationOnly = false;
private $hardLimit = 1;
/**
* @param bool $durationOnly
* @param int $hardLimit
*/
protected function setDurationMode(bool $durationOnly)
protected function setHardLimit(int $hardLimit)
{
$this->durationOnly = $durationOnly;
$this->hardLimit = $hardLimit;
}
/**
* @return bool
* @return int
*/
protected function isDurationOnlyMode()
protected function getHardLimit()
{
return $this->durationOnly;
return $this->hardLimit;
}
/**
@@ -76,7 +77,7 @@ trait TimesheetControllerTrait
*/
protected function edit(Timesheet $entry, Request $request, $redirectRoute, $renderTemplate)
{
$editForm = $this->getEditForm($entry, $request->get('page'));
$editForm = $this->getEditForm($entry, $request->get('page'), $request->get('origin', 'timesheet'));
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
@@ -153,7 +154,7 @@ trait TimesheetControllerTrait
}
}
$createForm = $this->getCreateForm($entry);
$createForm = $this->getCreateForm($entry, $redirectRoute);
$createForm->handleRequest($request);
if ($createForm->isSubmitted() && $createForm->isValid()) {
@@ -169,11 +170,16 @@ trait TimesheetControllerTrait
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
try {
$this->stopActiveEntries($entry->getUser());
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('action.update.success');
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute($redirectRoute);
}
@@ -184,18 +190,31 @@ trait TimesheetControllerTrait
]);
}
/**
* @param User $user
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function stopActiveEntries(User $user)
{
$this->getRepository()->stopActiveEntries($user, $this->getHardLimit());
}
/**
* @param Timesheet $entry
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
abstract protected function getCreateForm(Timesheet $entry);
abstract protected function getCreateForm(Timesheet $entry, string $redirectRoute);
/**
* @param Timesheet $entry
* @param int $page
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
abstract protected function getEditForm(Timesheet $entry, $page);
abstract protected function getEditForm(Timesheet $entry, $page, string $redirectRoute);
/**
* Adds a "successful" flash message to the stack.

View File

@@ -47,7 +47,6 @@ class UserFixtures extends Fixture
private $encoder;
/**
* AppFixtures constructor.
* @param UserPasswordEncoderInterface $encoder
*/
public function __construct(UserPasswordEncoderInterface $encoder)

View File

@@ -40,11 +40,43 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.invoice.documents', $config['invoice']['documents']);
$container->setParameter('kimai.defaults', $config['defaults']);
$this->createPermissionParameter($config['permissions'], $container);
$this->createThemeParameter($config['theme'], $container);
$this->createUserParameter($config['user'], $container);
$this->createTimesheetParameter($config['timesheet'], $container);
}
/**
* Performs some pre-compilation on the configured permissions from kimai.yaml
* to save us from constant array lookups from during runtime.
*
* @param array $config
* @param ContainerBuilder $container
*/
protected function createPermissionParameter(array $config, ContainerBuilder $container)
{
foreach ($config['maps'] as $role => $sets) {
if (!isset($config['roles'][$role])) {
$exception = new InvalidConfigurationException(
'Configured permission set includes unknown role "' . $role . '"'
);
$exception->setPath('kimai.permissions.maps.' . $role);
throw $exception;
}
foreach ($sets as $set) {
if (!isset($config['sets'][$set])) {
$exception = new InvalidConfigurationException(
'Configured permission set "' . $set . '" for role "' . $role . '" is unknown'
);
$exception->setPath('kimai.permissions.maps.' . $role);
throw $exception;
}
$config['roles'][$role] = array_unique(array_merge($config['roles'][$role], $config['sets'][$set]));
}
}
$container->setParameter('kimai.permissions', $config['roles']);
}
/**
* @param array $config
* @param ContainerBuilder $container
@@ -86,6 +118,8 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.timesheet.rounding', $config['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['duration_only']);
$container->setParameter('kimai.timesheet.markdown', $config['markdown_content']);
$container->setParameter('kimai.timesheet.active_entries.soft_limit', $config['active_entries']['soft_limit']);
$container->setParameter('kimai.timesheet.active_entries.hard_limit', $config['active_entries']['hard_limit']);
}
/**

View File

@@ -23,8 +23,6 @@ class DoctrineCompilerPass implements CompilerPassInterface
*/
protected $allowedEngines = [
'mysql',
'oracle',
'postgres',
'sqlite'
];
@@ -37,15 +35,6 @@ class DoctrineCompilerPass implements CompilerPassInterface
{
$engine = null;
// TODO - this does return the wrong connection. it used to be mysql, even if
// TODO - getenv('DATABASE_URL') returned an sqlite:// connection string
/*
$dbConfig = $container->getExtensionConfig('doctrine');
if (isset($dbConfig[0]['dbal']['driver'])) {
$engine = str_replace('pdo_', '', $dbConfig[0]['dbal']['driver']);
}
*/
if (null === $engine) {
$dbConfig = explode('://', getenv('DATABASE_URL'));
$engine = $dbConfig['0'] ?: null;

View File

@@ -27,7 +27,9 @@ class TwigContextCompilerPass implements CompilerPassInterface
$theme = $container->getParameter('kimai.theme');
$durationOnly = $container->getParameter('kimai.timesheet.duration_only');
$twig->addMethodCall('addGlobal', ['kimai_context', $theme]);
$twig->addMethodCall('addGlobal', ['kimai_context', array_merge($theme, [
'active_warning' => $container->getParameter('kimai.timesheet.active_entries.soft_limit')
])]);
$twig->addMethodCall('addGlobal', ['duration_only', $durationOnly]);
if ($container->hasDefinition('twig.loader.native_filesystem')) {

View File

@@ -40,6 +40,7 @@ class Configuration implements ConfigurationInterface
->append($this->getDashboardNode())
->append($this->getWidgetsNode())
->append($this->getDefaultsNode())
->append($this->getPermissionsNode())
->end()
->end();
@@ -84,7 +85,6 @@ class Configuration implements ConfigurationInterface
->end()
->defaultValue([])
->end()
->arrayNode('rates')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
@@ -111,6 +111,30 @@ class Configuration implements ConfigurationInterface
->end()
->defaultValue([])
->end()
->arrayNode('active_entries')
->addDefaultsIfNotSet()
->children()
->integerNode('soft_limit')
->defaultValue(1)
->validate()
->ifTrue(function ($value) {
return $value <= 0;
})
->thenInvalid('The soft_limit must be at least 1')
->end()
->end()
->integerNode('hard_limit')
->defaultValue(1)
->validate()
->ifTrue(function ($value) {
return $value <= 0;
})
->thenInvalid('The hard_limit must be at least 1')
->end()
->end()
->end()
->end()
->end()
->end()
;
@@ -211,6 +235,7 @@ class Configuration implements ConfigurationInterface
->children()
->integerNode('active_warning')
->defaultValue(3)
->setDeprecated('The node "%node%" at path "%path%" is deprecated, please use "kimai.timesheet.active_entries.soft_limit" instead.')
->end()
->scalarNode('box_color')
->defaultValue('green')
@@ -329,4 +354,51 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getPermissionsNode()
{
$builder = new TreeBuilder();
$node = $builder->root('permissions');
$node
->addDefaultsIfNotSet()
->children()
->arrayNode('sets')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->performNoDeepMerging()
->arrayPrototype()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->end()
->arrayNode('maps')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->performNoDeepMerging()
->arrayPrototype()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->end()
->arrayNode('roles')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->performNoDeepMerging()
->arrayPrototype()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([])
->end()
->end()
->end()
;
return $node;
}
}

View File

@@ -25,6 +25,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* )
* @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository")
* @ORM\HasLifecycleCallbacks()
* @Assert\Callback("validate")
*/
class Timesheet
{
@@ -163,9 +164,9 @@ class Timesheet
{
$this->end = $end;
// FIXME test and then remove it, this should not be neccessary
if (null === $end) {
$this->duration = 0;
$this->rate = 0;
}
return $this;
@@ -345,12 +346,8 @@ class Timesheet
}
/**
* These validations are used in places, where we don't use a form yet (like the API).
*
* @param ExecutionContextInterface $context
* @param mixed $payload
*
* @Assert\Callback
* @param $payload
*/
public function validate(ExecutionContextInterface $context, $payload)
{
@@ -376,21 +373,21 @@ class Timesheet
->addViolation();
}
if ($activity->getVisible() === false) {
if (null === $this->getEnd() && $activity->getVisible() === false) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
if ($project->getVisible() === false) {
if (null === $this->getEnd() && $project->getVisible() === false) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
if ($project->getCustomer()->getVisible() === false) {
if (null === $this->getEnd() && $project->getCustomer()->getVisible() === false) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
@@ -398,18 +395,25 @@ class Timesheet
}
}
if (null === $this->getBegin() && null !== $this->getEnd()) {
$context->buildViolation('You must submit a begin date before an end date is added.')
if (null === $this->getBegin()) {
$context->buildViolation('You must submit a begin date.')
->atPath('begin')
->setTranslationDomain('validators')
->addViolation();
}
} else {
if (null !== $this->getBegin() && null !== $this->getEnd() && $this->getEnd()->getTimestamp() < $this->getBegin()->getTimestamp()) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')
->addViolation();
}
if (null !== $this->getBegin() && null !== $this->getEnd() && $this->getEnd()->getTimestamp() < $this->getBegin()->getTimestamp()) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')
->addViolation();
if (time() < $this->getBegin()->getTimestamp()) {
$context->buildViolation('The begin date cannot be in the future.')
->atPath('begin')
->setTranslationDomain('validators')
->addViolation();
}
}
}
}

View File

@@ -69,6 +69,11 @@ class UserPreference
*/
protected $type;
/**
* @var bool
*/
protected $enabled = true;
/**
* @var Constraint[]
*/
@@ -181,6 +186,25 @@ class UserPreference
return $this->type;
}
/**
* @return bool
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* @param bool $enabled
* @return UserPreference
*/
public function setEnabled(bool $enabled)
{
$this->enabled = $enabled;
return $this;
}
/**
* Set the constraints which are used for validation of the value.
*

View File

@@ -10,23 +10,48 @@
namespace App\Event;
use KevinPapst\AdminLTEBundle\Model\MenuItemModel;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\HttpFoundation\Request;
/**
* The ConfigureAdminMenuEvent is used for populating the administration navigation.
*/
class ConfigureAdminMenuEvent extends ConfigureMenuEvent
class ConfigureAdminMenuEvent extends Event
{
public const CONFIGURE = 'app.admin_menu_configure';
/**
* This function will either return a MenuItem or null.
*
* In case this returns null, the user has not the ROLE_ADMIN.
*
* @return MenuItemModel|null
* @var Request
*/
private $request;
/**
* @var MenuItemModel
*/
private $menu;
/**
* @param Request $request
* @param MenuItemModel $menu
*/
public function __construct(Request $request, MenuItemModel $menu)
{
$this->request = $request;
$this->menu = $menu;
}
/**
* @return Request
*/
public function getRequest()
{
return $this->request;
}
/**
* @return MenuItemModel
*/
public function getAdminMenu()
{
return $this->getMenu()->getRootItem('admin');
return $this->menu;
}
}

View File

@@ -9,10 +9,51 @@
namespace App\Event;
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\HttpFoundation\Request;
/**
* The ConfigureMainMenuEvent is used for populating the main navigation.
*/
class ConfigureMainMenuEvent extends ConfigureMenuEvent
class ConfigureMainMenuEvent extends Event
{
public const CONFIGURE = 'app.main_menu_configure';
/**
* @var Request
*/
private $request;
/**
* @var SidebarMenuEvent
*/
private $event;
/**
* @param Request $request
* @param SidebarMenuEvent $event
*/
public function __construct(
Request $request,
SidebarMenuEvent $event
) {
$this->request = $request;
$this->event = $event;
}
/**
* @return Request
*/
public function getRequest()
{
return $this->request;
}
/**
* @return SidebarMenuEvent
*/
public function getMenu()
{
return $this->event;
}
}

View File

@@ -1,58 +0,0 @@
<?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\Event;
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\HttpFoundation\Request;
/**
* The ConfigureMenuEvent is used for populating navigations.
*/
abstract class ConfigureMenuEvent extends Event
{
/**
* @var Request
*/
private $request;
/**
* @var SidebarMenuEvent
*/
private $event;
/**
* ConfigureMenuEvent constructor.
* @param Request $request
* @param SidebarMenuEvent $event
*/
public function __construct(
Request $request,
SidebarMenuEvent $event
) {
$this->request = $request;
$this->event = $event;
}
/**
* @return Request
*/
public function getRequest()
{
return $this->request;
}
/**
* @return SidebarMenuEvent
*/
public function getMenu()
{
return $this->event;
}
}

View File

@@ -0,0 +1,42 @@
<?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\Event;
use App\Entity\User;
use Symfony\Component\EventDispatcher\Event;
/**
* This event should be used, if a user profile is loaded and we full data including dynamic user preferences
*/
class PrepareUserEvent extends Event
{
public const PREPARE = 'app.prepare_user';
/**
* @var User
*/
protected $user;
/**
* @param User $user
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* @return User
*/
public function getUser()
{
return $this->user;
}
}

View File

@@ -14,7 +14,7 @@ use App\Entity\UserPreference;
use Symfony\Component\EventDispatcher\Event;
/**
* Class UserPreferenceEvent
* This event should be used, if further user preferences should added dynamically
*/
class UserPreferenceEvent extends Event
{
@@ -30,7 +30,6 @@ class UserPreferenceEvent extends Event
protected $preferences;
/**
* UserPreferenceEvent constructor.
* @param User $user
* @param UserPreference[] $preferences
*/
@@ -41,6 +40,7 @@ class UserPreferenceEvent extends Event
}
/**
* Do not set the preferences directly to the user object, but ONLY via addUserPreference()
* @return User
*/
public function getUser()

View File

@@ -61,8 +61,6 @@ class MenuBuilderSubscriber implements EventSubscriberInterface
public function onSetupNavbar(SidebarMenuEvent $event)
{
$request = $event->getRequest();
$isLoggedIn = $this->security->isGranted('IS_AUTHENTICATED_REMEMBERED');
$isTeamlead = $isLoggedIn && $this->security->isGranted('ROLE_TEAMLEAD');
$event->addItem(
new MenuItemModel('dashboard', 'menu.homepage', 'dashboard', [], 'fas fa-tachometer-alt')
@@ -76,17 +74,18 @@ class MenuBuilderSubscriber implements EventSubscriberInterface
)
);
if ($isTeamlead) {
$admin = new MenuItemModel('admin', 'menu.admin', '', [], 'fas fa-wrench');
$event->addItem($admin);
$admin = new MenuItemModel('admin', 'menu.admin', '', [], 'fas fa-wrench');
$this->eventDispatcher->dispatch(
ConfigureAdminMenuEvent::CONFIGURE,
new ConfigureAdminMenuEvent(
$request,
$event
)
);
$this->eventDispatcher->dispatch(
ConfigureAdminMenuEvent::CONFIGURE,
new ConfigureAdminMenuEvent(
$request,
$admin
)
);
if ($admin->hasChildren()) {
$event->addItem($admin);
}
$this->activateByRoute(

View File

@@ -53,26 +53,23 @@ class MenuSubscriber implements EventSubscriberInterface
{
$auth = $this->security;
$isLoggedIn = $auth->isGranted('IS_AUTHENTICATED_REMEMBERED');
$isUser = $isLoggedIn && $auth->isGranted('ROLE_USER');
$isTeamlead = $isLoggedIn && $auth->isGranted('ROLE_TEAMLEAD');
if (!$isLoggedIn || !$isUser) {
if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
return;
}
$menu = $event->getMenu();
$menu->addItem(
new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], 'far fa-clock')
);
if (!$isTeamlead) {
return;
if ($auth->isGranted('view_own_timesheet')) {
$menu->addItem(
new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], 'far fa-clock')
);
}
$menu->addItem(
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice')
);
if ($auth->isGranted('view_invoice')) {
$menu->addItem(
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice')
);
}
}
/**
@@ -80,33 +77,42 @@ class MenuSubscriber implements EventSubscriberInterface
*/
public function onAdminMenuConfigure(ConfigureAdminMenuEvent $event)
{
$menu = $event->getAdminMenu();
$auth = $this->security;
if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED') || !$auth->isGranted('ROLE_TEAMLEAD')) {
if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
return;
}
$menu->addChild(
new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], 'far fa-clock')
);
$menu = $event->getAdminMenu();
if (!$auth->isGranted('ROLE_ADMIN')) {
return;
if ($auth->isGranted('view_other_timesheet')) {
$menu->addChild(
new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], 'far fa-clock')
);
}
if ($auth->isGranted('ROLE_SUPER_ADMIN')) {
if ($auth->isGranted('view_user')) {
$menu->addChild(
new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], 'fas fa-user')
);
}
$menu->addChild(
new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], 'fas fa-users')
)->addChild(
new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], 'fas fa-project-diagram')
)->addChild(
new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks')
);
if ($auth->isGranted('view_customer')) {
$menu->addChild(
new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], 'fas fa-users')
);
}
if ($auth->isGranted('view_project')) {
$menu->addChild(
new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], 'fas fa-project-diagram')
);
}
if ($auth->isGranted('view_activity')) {
$menu->addChild(
new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks')
);
}
}
}

View File

@@ -11,6 +11,7 @@ namespace App\EventSubscriber;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
use App\Event\UserPreferenceEvent;
use App\Form\Type\CalendarViewType;
use App\Form\Type\LanguageType;
@@ -18,16 +19,11 @@ use App\Form\Type\SkinType;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\HttpKernel\Event\KernelEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\Range;
/**
* Class UserPreferenceSubscriber
*/
class UserPreferenceSubscriber implements EventSubscriberInterface
{
/**
@@ -35,20 +31,26 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
*/
protected $eventDispatcher;
/**
* @var AuthorizationCheckerInterface
*/
protected $voter;
/**
* @var TokenStorageInterface
*/
protected $storage;
/**
* UserPreferenceSubscriber constructor.
* @param EventDispatcherInterface $dispatcher
* @param TokenStorageInterface $storage
* @param AuthorizationCheckerInterface $voter
*/
public function __construct(EventDispatcherInterface $dispatcher, TokenStorageInterface $storage)
public function __construct(EventDispatcherInterface $dispatcher, TokenStorageInterface $storage, AuthorizationCheckerInterface $voter)
{
$this->eventDispatcher = $dispatcher;
$this->storage = $storage;
$this->voter = $voter;
}
/**
@@ -57,30 +59,40 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER => ['loadUserPreferences', 200]
PrepareUserEvent::PREPARE => ['loadUserPreferences', 200]
];
}
/**
* @param User $user
* @return UserPreference[]
*/
public function getDefaultPreferences()
public function getDefaultPreferences(User $user)
{
return [
(new UserPreference())
->setName(UserPreference::HOURLY_RATE)
->setValue(0)
->setType(IntegerType::class)
->addConstraint(new Range(['min' => 0])),
/*
$enableHourlyRate = false;
if ($this->voter->isGranted('hourly-rate', $user)) {
$enableHourlyRate = true;
}
/*
(new UserPreference())
->setName('timezone')
->setValue(date_default_timezone_get())
->setType(TimezoneType::class),
*/
*/
return [
(new UserPreference())
->setName(UserPreference::HOURLY_RATE)
->setValue(0)
->setType(MoneyType::class)
->setEnabled($enableHourlyRate)
->addConstraint(new Range(['min' => 0])),
(new UserPreference())
->setName('language')
->setValue('en') // TODO fetch from services.yaml
->setValue('en')
->setType(LanguageType::class),
(new UserPreference())
@@ -116,31 +128,32 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
}
/**
* @param KernelEvent $event
* @param PrepareUserEvent $event
*/
public function loadUserPreferences(KernelEvent $event)
public function loadUserPreferences(PrepareUserEvent $event)
{
if (!$this->canHandleEvent($event)) {
return;
}
/** @var User $user */
$user = $this->storage->getToken()->getUser();
$user = $event->getUser();
$prefs = [];
foreach ($user->getPreferences() as $preference) {
$prefs[$preference->getName()] = $preference;
}
$event = new UserPreferenceEvent($user, $this->getDefaultPreferences());
$event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user));
$this->eventDispatcher->dispatch(UserPreferenceEvent::CONFIGURE, $event);
foreach ($event->getPreferences() as $preference) {
/* @var UserPreference[] $prefs */
if (isset($prefs[$preference->getName()])) {
/* @var UserPreference $pref */
$prefs[$preference->getName()]
->setType($preference->getType())
->setConstraints($preference->getConstraints())
->setEnabled($preference->isEnabled())
;
} else {
$prefs[$preference->getName()] = $preference;
@@ -151,24 +164,15 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
}
/**
* @param KernelEvent $event
* @param PrepareUserEvent $event
* @return bool
*/
protected function canHandleEvent(KernelEvent $event): bool
protected function canHandleEvent(PrepareUserEvent $event): bool
{
// Ignore sub-requests
if (!$event->isMasterRequest()) {
if (null === ($user = $event->getUser())) {
return false;
}
// ignore events like the toolbar where we do not have a token
if (null === $this->storage->getToken()) {
return false;
}
/** @var User $user */
$user = $this->storage->getToken()->getUser();
return ($user instanceof User);
}
}

View File

@@ -0,0 +1,89 @@
<?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\EventSubscriber;
use App\Entity\User;
use App\Event\PrepareUserEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\KernelEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class UserProfileSubscriber implements EventSubscriberInterface
{
/**
* @var EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* @var TokenStorageInterface
*/
protected $storage;
/**
* @param EventDispatcherInterface $dispatcher
* @param TokenStorageInterface $storage
*/
public function __construct(EventDispatcherInterface $dispatcher, TokenStorageInterface $storage)
{
$this->eventDispatcher = $dispatcher;
$this->storage = $storage;
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER => ['prepareUserProfile', 200]
];
}
/**
* @param KernelEvent $event
*/
public function prepareUserProfile(KernelEvent $event)
{
if (!$this->canHandleEvent($event)) {
return;
}
/** @var User $user */
$user = $this->storage->getToken()->getUser();
$event = new PrepareUserEvent($user);
$this->eventDispatcher->dispatch(PrepareUserEvent::PREPARE, $event);
}
/**
* @param KernelEvent $event
* @return bool
*/
protected function canHandleEvent(KernelEvent $event): bool
{
// Ignore sub-requests
if (!$event->isMasterRequest()) {
return false;
}
// ignore events like the toolbar where we do not have a token
if (null === $this->storage->getToken()) {
return false;
}
/** @var User $user */
$user = $this->storage->getToken()->getUser();
return ($user instanceof User);
}
}

View File

@@ -17,10 +17,12 @@ use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -38,10 +40,12 @@ class ActivityEditForm extends AbstractType
$project = null;
$customer = null;
$currency = false;
if (null !== $entry->getProject()) {
$project = $entry->getProject();
$customer = $project->getCustomer();
$currency = $customer->getCurrency();
}
$builder
@@ -59,27 +63,47 @@ class ActivityEditForm extends AbstractType
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer);
},
'data' => $customer ? $customer : null,
'required' => false,
'mapped' => false,
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_project',
'data-api-url' => ['get_projects', ['customer' => '-s-']],
],
'project_enabled' => true,
])
->add('project', ProjectType::class, [
'label' => 'label.project',
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($project) {
return $repo->builderForEntityType($project);
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->builderForEntityType($project, $customer);
},
])
->add('fixedRate', NumberType::class, [
]);
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($project) {
$data = $event->getData();
if (!isset($data['customer']) || empty($data['customer'])) {
return;
}
$event->getForm()->add('project', ProjectType::class, [
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($data, $project) {
return $repo->builderForEntityType($project, $data['customer']);
},
]);
}
);
$builder
->add('fixedRate', MoneyType::class, [
'label' => 'label.fixed_rate',
'required' => false,
'currency' => $currency,
])
->add('hourlyRate', NumberType::class, [
->add('hourlyRate', MoneyType::class, [
'label' => 'label.hourly_rate',
'required' => false,
'currency' => $currency,
])
// boolean
->add('visible', YesNoType::class, [
@@ -87,7 +111,7 @@ class ActivityEditForm extends AbstractType
])
;
if ($entry->getId() === null) {
if (null === $entry->getId()) {
$builder->add('create_more', CheckboxType::class, [
'label' => 'label.create_more',
'required' => false,

View File

@@ -15,7 +15,7 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -34,6 +34,9 @@ class CustomerEditForm extends AbstractType
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var Customer $customer */
$customer = $options['data'];
$builder
->add('name', TextType::class, [
'label' => 'label.name',
@@ -71,10 +74,12 @@ class CustomerEditForm extends AbstractType
->add('fax', TelType::class, [
'label' => 'label.fax',
'required' => false,
'attr' => ['icon' => 'fax'],
])
->add('mobile', TelType::class, [
'label' => 'label.mobile',
'required' => false,
'attr' => ['icon' => 'mobile'],
])
->add('mail', EmailType::class, [
'label' => 'label.email',
@@ -87,13 +92,15 @@ class CustomerEditForm extends AbstractType
->add('timezone', TimezoneType::class, [
'label' => 'label.timezone',
])
->add('fixedRate', NumberType::class, [
->add('fixedRate', MoneyType::class, [
'label' => 'label.fixed_rate',
'required' => false,
'currency' => $customer->getCurrency() ?? false,
])
->add('hourlyRate', NumberType::class, [
->add('hourlyRate', MoneyType::class, [
'label' => 'label.hourly_rate',
'required' => false,
'currency' => $customer->getCurrency() ?? false,
])
->add('visible', YesNoType::class, [
'label' => 'label.visible',

View File

@@ -0,0 +1,94 @@
<?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\Form\Extension;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Support Remote-API calls for Entity select-boxes.
*/
class SelectWithApiDataExtension extends AbstractTypeExtension
{
/**
* @var UrlGeneratorInterface
*/
private $router;
/**
* @param UrlGeneratorInterface $router
*/
public function __construct(UrlGeneratorInterface $router)
{
$this->router = $router;
}
/**
* @return string
*/
public function getExtendedType()
{
return EntityType::class;
}
/**
* @param FormView $view
* @param FormInterface $form
* @param array $options
*/
public function buildView(FormView $view, FormInterface $form, array $options)
{
if (!isset($options['api_data'])) {
return;
}
$apiData = $options['api_data'];
if (!is_array($apiData)) {
throw new \InvalidArgumentException('Option "api_data" must be an array for form "' . $form->getName() . '"');
}
if (!isset($apiData['select'])) {
return;
}
if (!isset($apiData['route'])) {
throw new \InvalidArgumentException('Missing "route" option for "api_data" option for form "' . $form->getName() . '"');
}
if (!isset($apiData['route_params'])) {
$apiData['route_params'] = [];
}
$formPrefix = $form->getParent()->getName();
if (!empty($formPrefix)) {
$formPrefix .= '_';
}
$view->vars['attr'] = array_merge($view->vars['attr'], [
'data-related-select' => $formPrefix . $apiData['select'],
'data-api-url' => $this->router->generate($apiData['route'], $apiData['route_params']),
]);
}
/**
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefined(['api_data']);
$resolver->setAllowedTypes('api_data', 'array');
//$resolver->setDefault('api_data', []);
}
}

View File

@@ -17,7 +17,6 @@ use App\Repository\CustomerRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -37,8 +36,11 @@ class ProjectEditForm extends AbstractType
$entry = $options['data'];
$customer = null;
$currency = false;
if ($entry->getId() !== null) {
$customer = $entry->getCustomer();
$currency = $customer->getCurrency();
}
$builder
@@ -59,18 +61,20 @@ class ProjectEditForm extends AbstractType
return $repo->builderForEntityType($customer);
},
])
->add('fixedRate', NumberType::class, [
->add('fixedRate', MoneyType::class, [
'label' => 'label.fixed_rate',
'required' => false,
'currency' => $currency,
])
->add('hourlyRate', NumberType::class, [
->add('hourlyRate', MoneyType::class, [
'label' => 'label.hourly_rate',
'required' => false,
'currency' => $currency,
])
->add('budget', MoneyType::class, [
'label' => 'label.budget',
'currency' => $customer ? $customer->getCurrency() : $builder->getOption('currency'),
'required' => false,
'currency' => $currency,
])
->add('visible', YesNoType::class, [
'label' => 'label.visible',

View File

@@ -20,7 +20,7 @@ use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
@@ -32,6 +32,33 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class TimesheetEditForm extends AbstractType
{
/**
* @var CustomerRepository
*/
private $customers;
/**
* @var ProjectRepository
*/
private $projects;
/**
* @var bool
*/
private $durationOnly = false;
/**
* @param CustomerRepository $customer
* @param ProjectRepository $project
* @param bool $durationOnly
*/
public function __construct(CustomerRepository $customer, ProjectRepository $project, bool $durationOnly)
{
$this->customers = $customer;
$this->projects = $project;
$this->durationOnly = $durationOnly;
}
/**
* {@inheritdoc}
*/
@@ -40,6 +67,7 @@ class TimesheetEditForm extends AbstractType
$activity = null;
$project = null;
$customer = null;
$currency = false;
$end = null;
if (isset($options['data'])) {
@@ -48,12 +76,16 @@ class TimesheetEditForm extends AbstractType
$activity = $entry->getActivity();
$project = $entry->getProject();
$customer = null === $entry->getProject() ? null : $entry->getProject()->getCustomer();
$customer = null === $project ? null : $project->getCustomer();
if (null === $project && null !== $activity) {
$project = $activity->getProject();
}
if (null !== $customer) {
$currency = $customer->getCurrency();
}
$end = $entry->getEnd();
}
@@ -64,12 +96,14 @@ class TimesheetEditForm extends AbstractType
'html5' => false,
'format' => 'yyyy-MM-dd HH:mm',
'with_seconds' => false,
'attr' => ['autocomplete' => 'off', 'data-datetimepicker' => 'on'],
'attr' => ['autocomplete' => 'off', 'data-datetimepicker' => 'on', 'placeholder' => 'yyyy-MM-dd HH:mm'],
]);
}
if ($options['duration_only']) {
$builder->add('duration', DurationType::class);
$builder->add('duration', DurationType::class, [
'required' => false,
]);
} else {
$builder->add('end', DateTimeType::class, [
'label' => 'label.end',
@@ -78,102 +112,127 @@ class TimesheetEditForm extends AbstractType
'html5' => false,
'format' => 'yyyy-MM-dd HH:mm',
'with_seconds' => false,
'attr' => ['autocomplete' => 'off', 'data-datetimepicker' => 'on'],
'attr' => ['autocomplete' => 'off', 'data-datetimepicker' => 'on', 'placeholder' => 'yyyy-MM-dd HH:mm'],
]);
}
$projectOptions = [];
if ($this->customers->countCustomer(true) > 1) {
$builder
->add('customer', CustomerType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Customer ID',
],
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer);
},
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => null === $customer ? '' : null,
'mapped' => false,
'project_enabled' => true,
]);
} else {
$projectOptions['group_by'] = null;
}
if ($this->projects->countProject(true) > 1) {
$projectOptions['placeholder'] = null;
} else {
$projectOptions['group_by'] = null;
}
$builder
->add('customer', CustomerType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Customer ID',
],
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer);
},
'data' => $customer ? $customer : '',
'required' => false,
'mapped' => false,
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_project',
'data-api-url' => ['get_projects', ['customer' => '-s-']],
],
])
->add('project', ProjectType::class, [
->add(
'project',
ProjectType::class,
array_merge($projectOptions, [
'activity_enabled' => true,
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Project ID',
],
'required' => true,
'placeholder' => '',
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($project) {
return $repo->builderForEntityType($project);
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->builderForEntityType($project, $customer);
},
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_activity',
'data-api-url' => ['get_activities', ['project' => '-s-']],
],
])
);
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($project) {
$data = $event->getData();
if (!isset($data['customer']) || empty($data['customer'])) {
return;
}
$event->getForm()->add('project', ProjectType::class, [
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($data, $project) {
return $repo->builderForEntityType($project, $data['customer']);
},
]);
}
);
$builder
->add('activity', ActivityType::class, [
// documentation is for NelmioApiDocBundle
'placeholder' => null,
'documentation' => [
'type' => 'integer',
'description' => 'Activity ID',
],
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($activity) {
return $repo->builderForEntityType($activity);
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
return $repo->builderForEntityType($activity, $project);
},
])
;
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($activity) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'placeholder' => null,
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
return $repo->builderForEntityType($activity, $data['project']);
},
]);
}
);
$builder
->add('description', TextareaType::class, [
'label' => 'label.description',
'required' => false,
])
->add('fixedRate', NumberType::class, [
'label' => 'label.fixed_rate',
'required' => false,
])
->add('hourlyRate', NumberType::class, [
'label' => 'label.hourly_rate',
'required' => false,
])
;
/*
$builder->get('customer')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) {
$customer = $event->getForm()->getData();
$event->getForm()->getParent()->add('project', ProjectType::class, [
'required' => true,
'placeholder' => '',
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($customer) {
return $repo->builderForEntityType(null, $customer);
},
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_activity',
'data-api-url' => ['get_activities', ['project' => '-s-']],
],
if ($options['include_rate']) {
$builder
->add('fixedRate', MoneyType::class, [
'label' => 'label.fixed_rate',
'required' => false,
'currency' => $currency,
])
->add('hourlyRate', MoneyType::class, [
'label' => 'label.hourly_rate',
'required' => false,
'currency' => $currency,
]);
}
);
*/
$builder->get('project')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) {
$project = $event->getForm()->getData();
$event->getForm()->getParent()->add('activity', ActivityType::class, [
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($project) {
return $repo->builderForEntityType(null, $project);
},
]);
}
);
}
if ($options['include_user']) {
$builder->add('user', UserType::class);
@@ -190,9 +249,11 @@ class TimesheetEditForm extends AbstractType
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_edit',
'duration_only' => false,
'duration_only' => $this->durationOnly,
'include_user' => false,
'include_rate' => true,
'docu_chapter' => 'timesheet',
'method' => 'POST',
]);
}
}

View File

@@ -10,7 +10,9 @@
namespace App\Form\Toolbar;
use App\Form\Type\ActivityType;
use App\Form\Type\BeginDateType;
use App\Form\Type\CustomerType;
use App\Form\Type\EndDateType;
use App\Form\Type\PageSizeType;
use App\Form\Type\ProjectType;
use App\Form\Type\UserRoleType;
@@ -23,8 +25,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
@@ -65,10 +66,13 @@ abstract class AbstractToolbarForm extends AbstractType
{
$builder->add('customer', CustomerType::class, [
'required' => false,
'project_enabled' => true,
'project_visibility' => ProjectQuery::SHOW_BOTH,
'query_builder' => function (CustomerRepository $repo) {
$query = new CustomerQuery();
$query->setVisibility(CustomerQuery::SHOW_BOTH); // this field is the reason for the query here
$query->setResultType(CustomerQuery::RESULT_TYPE_QUERYBUILDER);
$query->setOrderBy('name');
return $repo->findByQuery($query);
},
@@ -77,10 +81,15 @@ abstract class AbstractToolbarForm extends AbstractType
/**
* @param FormBuilderInterface $builder
* @param null|string $label
*/
protected function addVisibilityChoice(FormBuilderInterface $builder)
protected function addVisibilityChoice(FormBuilderInterface $builder, ?string $label = null)
{
$builder->add('visibility', VisibilityType::class, []);
$builder->add('visibility', VisibilityType::class, [
'required' => false,
'placeholder' => null,
'label' => $label
]);
}
/**
@@ -108,14 +117,8 @@ abstract class AbstractToolbarForm extends AbstractType
*/
protected function addStartDateChoice(FormBuilderInterface $builder)
{
$builder->add('begin', DateType::class, [
'label' => 'label.begin',
'widget' => 'single_text',
'html5' => false,
$builder->add('begin', BeginDateType::class, [
'required' => false,
'format' => DateType::HTML5_FORMAT,
'attr' => ['autocomplete' => 'off', 'data-datepicker' => 'on'],
'empty_data' => (new \DateTime('first day of this month'))->format('Y-M-d')
]);
}
@@ -124,14 +127,8 @@ abstract class AbstractToolbarForm extends AbstractType
*/
protected function addEndDateChoice(FormBuilderInterface $builder)
{
$builder->add('end', DateType::class, [
'label' => 'label.end',
'widget' => 'single_text',
'html5' => false,
$builder->add('end', EndDateType::class, [
'required' => false,
'format' => DateType::HTML5_FORMAT,
'attr' => ['autocomplete' => 'off', 'data-datepicker' => 'on'],
'empty_data' => (new \DateTime('last day of this month'))->format('Y-M-d')
]);
}
@@ -140,10 +137,11 @@ abstract class AbstractToolbarForm extends AbstractType
*/
protected function addProjectChoice(FormBuilderInterface $builder)
{
$builder->add('project', ChoiceType::class, [
'group_by' => null,
$builder->add('project', ProjectType::class, [
'required' => false,
'label' => 'label.project',
'activity_enabled' => true,
'activity_visibility' => ActivityQuery::SHOW_BOTH,
'choices' => [],
]);
$builder->addEventListener(
@@ -157,11 +155,14 @@ abstract class AbstractToolbarForm extends AbstractType
$event->getForm()->add('project', ProjectType::class, [
'group_by' => null,
'required' => false,
'activity_enabled' => true,
'activity_visibility' => ActivityQuery::SHOW_BOTH,
'query_builder' => function (ProjectRepository $repo) use ($data) {
$query = new ProjectQuery();
$query->setCustomer($data['customer']);
$query->setResultType(ProjectQuery::RESULT_TYPE_QUERYBUILDER);
$query->setVisibility(ProjectQuery::SHOW_BOTH);
$query->setOrderBy('name');
return $repo->findByQuery($query);
},
@@ -176,7 +177,6 @@ abstract class AbstractToolbarForm extends AbstractType
protected function addActivityChoice(FormBuilderInterface $builder)
{
$builder->add('activity', ActivityType::class, [
'group_by' => null,
'required' => false,
'query_builder' => function (ActivityRepository $repo) {
$query = new ActivityQuery();
@@ -184,6 +184,7 @@ abstract class AbstractToolbarForm extends AbstractType
$query->setGlobalsOnly(true);
$query->setOrderGlobalsFirst(true);
$query->setVisibility(ActivityQuery::SHOW_BOTH);
$query->setOrderBy('name');
return $repo->findByQuery($query);
},
@@ -198,7 +199,6 @@ abstract class AbstractToolbarForm extends AbstractType
}
$event->getForm()->add('activity', ActivityType::class, [
'group_by' => null,
'required' => false,
'query_builder' => function (ActivityRepository $repo) use ($data) {
$query = new ActivityQuery();
@@ -206,6 +206,7 @@ abstract class AbstractToolbarForm extends AbstractType
$query->setProject($data['project']);
$query->setOrderGlobalsFirst(true);
$query->setVisibility(ActivityQuery::SHOW_BOTH);
$query->setOrderBy('name');
return $repo->findByQuery($query);
},
@@ -213,4 +214,14 @@ abstract class AbstractToolbarForm extends AbstractType
}
);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addHiddenPagination(FormBuilderInterface $builder)
{
$builder->add('page', HiddenType::class, [
'empty_data' => 1
]);
}
}

View File

@@ -27,6 +27,7 @@ class ActivityToolbarForm extends AbstractToolbarForm
$this->addVisibilityChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addHiddenPagination($builder);
}
/**

View File

@@ -25,6 +25,7 @@ class CustomerToolbarForm extends AbstractToolbarForm
{
$this->addPageSizeChoice($builder);
$this->addVisibilityChoice($builder);
$this->addHiddenPagination($builder);
}
/**

View File

@@ -36,7 +36,8 @@ class InvoiceToolbarForm extends AbstractToolbarForm
protected function addTemplateChoice(FormBuilderInterface $builder)
{
$builder->add('template', InvoiceTemplateType::class, [
'required' => true
'required' => false,
'placeholder' => null,
]);
}

View File

@@ -26,6 +26,7 @@ class ProjectToolbarForm extends AbstractToolbarForm
$this->addPageSizeChoice($builder);
$this->addVisibilityChoice($builder);
$this->addCustomerChoice($builder);
$this->addHiddenPagination($builder);
}
/**

View File

@@ -29,5 +29,6 @@ class TimesheetAdminToolbarForm extends TimesheetToolbarForm
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addHiddenPagination($builder);
}
}

View File

@@ -31,6 +31,7 @@ class TimesheetToolbarForm extends AbstractToolbarForm
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addHiddenPagination($builder);
}
/**
@@ -40,6 +41,8 @@ class TimesheetToolbarForm extends AbstractToolbarForm
{
$builder->add('state', ChoiceType::class, [
'label' => 'label.entryState',
'required' => false,
'placeholder' => null,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.running' => TimesheetQuery::STATE_RUNNING,

View File

@@ -24,8 +24,9 @@ class UserToolbarForm extends AbstractToolbarForm
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addPageSizeChoice($builder);
$this->addVisibilityChoice($builder);
$this->addVisibilityChoice($builder, 'label.active');
$this->addUserRoleChoice($builder);
$this->addHiddenPagination($builder);
}
/**

View File

@@ -0,0 +1,50 @@
<?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\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to display the begin-date input field.
*/
class BeginDateType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.begin',
'widget' => 'single_text',
'html5' => false,
'format' => DateType::HTML5_FORMAT,
]);
$resolver->setDefault('attr', function (Options $options) {
return [
'autocomplete' => 'off',
'data-datepicker' => 'on',
'placeholder' => $options['format'],
];
});
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return DateType::class;
}
}

View File

@@ -11,8 +11,10 @@ namespace App\Form\Type;
use App\Entity\Customer;
use App\Repository\CustomerRepository;
use App\Repository\Query\ProjectQuery;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -32,8 +34,22 @@ class CustomerType extends AbstractType
'query_builder' => function (CustomerRepository $repo) {
return $repo->builderForEntityType(null);
},
'project_enabled' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
$resolver->setDefault('api_data', function (Options $options) {
if (true === $options['project_enabled']) {
return [
'select' => 'project',
'route' => 'get_projects',
'route_params' => ['customer' => '-s-', 'orderBy' => 'name', 'visible' => $options['project_visibility']],
];
}
return [];
});
}
/**

View File

@@ -0,0 +1,50 @@
<?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\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to display the end-date input field.
*/
class EndDateType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.end',
'widget' => 'single_text',
'html5' => false,
'format' => DateType::HTML5_FORMAT,
]);
$resolver->setDefault('attr', function (Options $options) {
return [
'autocomplete' => 'off',
'data-datepicker' => 'on',
'placeholder' => $options['format'],
];
});
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return DateType::class;
}
}

View File

@@ -26,7 +26,7 @@ class PageSizeType extends AbstractType
$resolver->setDefaults([
'label' => 'label.pageSize',
'choices' => [10 => 10, 25 => 25, 50 => 50, 75 => 75, 100 => 100],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
'placeholder' => null,
]);
}

View File

@@ -11,8 +11,10 @@ namespace App\Form\Type;
use App\Entity\Project;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -57,8 +59,22 @@ class ProjectType extends AbstractType
'query_builder' => function (ProjectRepository $repo) {
return $repo->builderForEntityType(null);
},
'activity_enabled' => false,
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
$resolver->setDefault('api_data', function (Options $options) {
if (true === $options['activity_enabled']) {
return [
'select' => 'activity',
'route' => 'get_activities',
'route_params' => ['project' => '-s-', 'orderBy' => 'name', 'visible' => $options['activity_visibility']],
];
}
return [];
});
}
/**

View File

@@ -35,23 +35,31 @@ class UserPreferenceType extends AbstractType
/** @var UserPreference $preference */
$preference = $event->getData();
if ($preference instanceof UserPreference) {
// prevents unconfigured values from showing up in the form
if ($preference->getType() === null) {
return;
}
$required = true;
if (CheckboxType::class == $preference->getType()) {
$required = false;
}
$event->getForm()->add('value', $preference->getType(), [
'label' => 'label.' . $preference->getName(),
'constraints' => $preference->getConstraints(),
'required' => $required,
]);
if (!($preference instanceof UserPreference)) {
return;
}
// prevents unconfigured values from showing up in the form
if ($preference->getType() === null) {
return;
}
$required = true;
if (CheckboxType::class == $preference->getType()) {
$required = false;
}
$type = $preference->getType();
if (!$preference->isEnabled()) {
$type = HiddenType::class;
}
$event->getForm()->add('value', $type, [
'label' => 'label.' . $preference->getName(),
'constraints' => $preference->getConstraints(),
'required' => $required,
'disabled' => !$preference->isEnabled(),
]);
}
);
$builder->add('name', HiddenType::class);

View File

@@ -10,7 +10,7 @@
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -24,9 +24,9 @@ class YesNoType extends AbstractType
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'choices' => ['yes' => true, 'no' => false],
'multiple' => false,
'expanded' => true,
'value' => true,
'false_values' => [null, 0, false],
'required' => false,
]);
}
@@ -35,6 +35,6 @@ class YesNoType extends AbstractType
*/
public function getParent()
{
return ChoiceType::class;
return CheckboxType::class;
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Form;
use App\Entity\User;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -27,26 +28,21 @@ class UserEditType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
// string - length 160
->add('alias', TextType::class, [
'label' => 'label.alias',
'required' => false,
])
// string - length 50
->add('title', TextType::class, [
'label' => 'label.title',
'required' => false,
])
// string - length 255
->add('avatar', TextType::class, [
'label' => 'label.avatar',
'required' => false,
])
// string - length 160
->add('email', TextType::class, [
->add('email', EmailType::class, [
'label' => 'label.email',
])
// boolean
->add('enabled', YesNoType::class, [
'label' => 'label.active',
])

View File

@@ -44,6 +44,10 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
$timesheet->setActivity($entry->getActivity());
}
if (null === $timesheet->getProject()) {
$timesheet->setProject($entry->getProject());
}
if (empty($timesheet->getDescription())) {
$timesheet->setDescription($entry->getActivity()->getName());
}

View File

@@ -12,33 +12,14 @@ namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\RendererInterface;
use App\Model\InvoiceModel;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\Escaper\Xml;
use PhpOffice\PhpWord\Exception\Exception as OfficeException;
use PhpOffice\PhpWord\TemplateProcessor;
use Symfony\Component\HttpFoundation\File\Stream;
use Symfony\Component\HttpFoundation\Response;
class DocxRenderer extends AbstractRenderer implements RendererInterface
{
/*
protected function setPhpWordOptions(PhpWord $phpWord)
{
if (!extension_loaded('zip')) {
\PhpOffice\PhpWord\Settings::setZipClass(\PhpOffice\PhpWord\Settings::PCLZIP);
}
// \PhpOffice\PhpWord\Settings::setPdfRendererPath(__DIR__ . '/../../vendor/tecnickcom/tcpdf/');
// \PhpOffice\PhpWord\Settings::setPdfRendererName(\PhpOffice\PhpWord\Settings::PDF_RENDERER_TCPDF);
// \PhpOffice\PhpWord\Settings::setOutputEscapingEnabled(true);
// $phpWord->getSettings()->setThemeFontLang(new Language(Language::EN_US));
$properties = $phpWord->getDocInfo();
$properties->setCreator('Kimai 2');
$properties->setDescription('Created with Kimai 2, the open-source time-tracking software! Get more information at www.kimai.org.');
$properties->setCreated(time());
$properties->setModified(time());
}
*/
/**
* @param InvoiceDocument $document
* @param InvoiceModel $model
@@ -46,18 +27,33 @@ class DocxRenderer extends AbstractRenderer implements RendererInterface
*/
public function render(InvoiceDocument $document, InvoiceModel $model): Response
{
\PhpOffice\PhpWord\Settings::setOutputEscapingEnabled(false);
$filename = basename($document->getFilename());
$xmlEscaper = new Xml();
$template = new TemplateProcessor($document->getFilename());
foreach ($this->modelToReplacer($model) as $key => $value) {
$template->setValue($key, $value);
foreach ($this->modelToReplacer($model) as $search => $replace) {
$replace = $xmlEscaper->escape($replace);
$replace = str_replace(PHP_EOL, '</w:t><w:br /><w:t xml:space="preserve">', $replace);
$template->setValue($search, $replace);
}
try {
$template->cloneRow('entry.description', count($model->getCalculator()->getEntries()));
} catch (OfficeException $ex) {
$template->cloneRow('entry.row', count($model->getCalculator()->getEntries()));
}
$template->cloneRow('entry.description', count($model->getCalculator()->getEntries()));
$i = 1;
foreach ($model->getCalculator()->getEntries() as $entry) {
$values = $this->timesheetToArray($entry);
foreach ($values as $search => $replace) {
$replace = $xmlEscaper->escape($replace);
$replace = str_replace(PHP_EOL, '</w:t><w:br /><w:t xml:space="preserve">', $replace);
$template->setValue($search . '#' . $i, $replace);
}
$i++;

View File

@@ -73,7 +73,10 @@ trait RendererTrait
*/
protected function modelToReplacer(InvoiceModel $model)
{
return [
$customer = $model->getCustomer();
$project = $model->getQuery()->getProject();
$values = [
'invoice.due_date' => $this->getFormattedDateTime($model->getDueDate()),
'invoice.date' => $this->getFormattedDateTime($model->getInvoiceDate()),
'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
@@ -95,16 +98,32 @@ trait RendererTrait
'query.end' => $this->getFormattedDateTime($model->getQuery()->getEnd()),
'query.month' => $this->getFormattedMonthName($model->getQuery()->getBegin()),
'query.year' => $model->getQuery()->getBegin()->format('Y'),
'customer.address' => $model->getCustomer()->getAddress(),
'customer.name' => $model->getCustomer()->getName(),
'customer.contact' => $model->getCustomer()->getContact(),
'customer.company' => $model->getCustomer()->getCompany(),
'customer.number' => $model->getCustomer()->getNumber(),
'customer.country' => $model->getCustomer()->getCountry(),
'customer.homepage' => $model->getCustomer()->getHomepage(),
'customer.comment' => $model->getCustomer()->getComment(),
];
if (null !== $project) {
$values = array_merge($values, [
'project.id' => $project->getId(),
'project.name' => $project->getName(),
'project.comment' => $project->getComment(),
'project.order_number' => $project->getOrderNumber(),
]);
}
if (null !== $customer) {
$values = array_merge($values, [
'customer.id' => $customer->getId(),
'customer.address' => $customer->getAddress(),
'customer.name' => $customer->getName(),
'customer.contact' => $customer->getContact(),
'customer.company' => $customer->getCompany(),
'customer.number' => $customer->getNumber(),
'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(),
'customer.comment' => $customer->getComment(),
]);
}
return $values;
}
/**
@@ -142,6 +161,7 @@ trait RendererTrait
$end = $timesheet->getEnd();
return [
'entry.row' => '',
'entry.description' => $description,
'entry.amount' => $amount,
'entry.rate' => $this->getFormattedMoney($hourlyRate),
@@ -157,6 +177,7 @@ trait RendererTrait
'entry.date' => $this->getFormattedDateTime($begin),
'entry.user_id' => $user->getId(),
'entry.user_name' => $user->getUsername(),
'entry.user_title' => $user->getTitle(),
'entry.user_alias' => $user->getAlias(),
'entry.activity' => $activity->getName(),
'entry.activity_id' => $activity->getId(),

View File

@@ -56,10 +56,11 @@ final class Version20181031220003 extends AbstractMigration
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
} else {
// project table
$this->addSql('ALTER TABLE ' . $projects . ' DROP FOREIGN KEY FK_407F12069395C3F3');
$this->addSql('ALTER TABLE ' . $projects . ' CHANGE customer_id customer_id INT NOT NULL');
$this->addSql('ALTER TABLE ' . $projects . ' ADD CONSTRAINT FK_407F12069395C3F3 FOREIGN KEY (customer_id) REFERENCES ' . $customers . ' (id) ON DELETE CASCADE');
// timesheet table
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD project_id INT DEFAULT NULL AFTER activity_id');
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE');
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
}
@@ -80,7 +81,12 @@ final class Version20181031220003 extends AbstractMigration
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP FOREIGN KEY FK_4F60C6B18D93D649');
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP FOREIGN KEY FK_4F60C6B181C06096');
$this->addSql('ALTER TABLE ' . $timesheet . ' CHANGE project_id project_id INT NOT NULL, CHANGE user user INT NOT NULL, CHANGE activity_id activity_id INT NOT NULL');
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $users . ' (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD CONSTRAINT FK_4F60C6B181C06096 FOREIGN KEY (activity_id) REFERENCES ' . $activities . ' (id) ON DELETE CASCADE');
}
}
@@ -94,6 +100,7 @@ final class Version20181031220003 extends AbstractMigration
$timesheet = $this->getTableName('timesheet');
$projects = $this->getTableName('projects');
$customers = $this->getTableName('customers');
if ($platform === 'sqlite') {
// project table
@@ -117,7 +124,9 @@ final class Version20181031220003 extends AbstractMigration
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
} else {
// project table
$this->addSql('ALTER TABLE ' . $projects . ' DROP FOREIGN KEY FK_407F12069395C3F3');
$this->addSql('ALTER TABLE ' . $projects . ' CHANGE customer_id customer_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE ' . $projects . ' ADD CONSTRAINT FK_407F12069395C3F3 FOREIGN KEY (customer_id) REFERENCES ' . $customers . ' (id) ON DELETE CASCADE');
// timesheet table
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP FOREIGN KEY FK_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet);

View File

@@ -123,6 +123,7 @@ class ActivityRepository extends AbstractRepository
$query->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER);
$query->setProject($project);
$query->setOrderGlobalsFirst(true);
$query->setOrderBy('name');
if (null === $activity && $project === null) {
$query->setGlobalsOnly(true);
@@ -142,8 +143,13 @@ class ActivityRepository extends AbstractRepository
$qb->select('a', 'p', 'c')
->from(Activity::class, 'a')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
->orderBy('a.' . $query->getOrderBy(), $query->getOrder());
->leftJoin('p.customer', 'c');
if ($query->isOrderGlobalsFirst()) {
$qb->orderBy('a.project', 'ASC');
}
$qb->addOrderBy('a.' . $query->getOrderBy(), $query->getOrder());
$where = $qb->expr()->andX();
@@ -202,12 +208,10 @@ class ActivityRepository extends AbstractRepository
$qb->setParameter('activity', $entity);
}
if ($query->isOrderGlobalsFirst()) {
$qb->orderBy('a.project', 'ASC');
if ($or->count() > 0) {
$qb->andWhere($or);
}
$qb->andWhere($or);
return $this->getBaseQueryResult($qb, $query);
}

View File

@@ -35,10 +35,15 @@ class CustomerRepository extends AbstractRepository
}
/**
* @param null|bool $visible
* @return int
*/
public function countCustomer()
public function countCustomer($visible = null)
{
if (null !== $visible) {
return $this->count(['visible' => (int) $visible]);
}
return $this->count([]);
}
@@ -94,6 +99,7 @@ class CustomerRepository extends AbstractRepository
$query = new CustomerQuery();
$query->setHiddenEntity($entity);
$query->setResultType(CustomerQuery::RESULT_TYPE_QUERYBUILDER);
$query->setOrderBy('name');
return $this->findByQuery($query);
}

View File

@@ -35,10 +35,15 @@ class ProjectRepository extends AbstractRepository
}
/**
* @param null|bool $visible
* @return int
*/
public function countProject()
public function countProject($visible = null)
{
if (null !== $visible) {
return $this->count(['visible' => (int) $visible]);
}
return $this->count([]);
}
@@ -80,16 +85,17 @@ class ProjectRepository extends AbstractRepository
/**
* Returns a query builder that is used for ProjectType and your own 'query_builder' option.
*
* @param Project|null $entity
* @param Customer|null $customer
* @param Project|int|null $entity
* @param Customer|int|null $customer
* @return array|QueryBuilder|Pagerfanta
*/
public function builderForEntityType(Project $entity = null, Customer $customer = null)
public function builderForEntityType($entity = null, $customer = null)
{
$query = new ProjectQuery();
$query->setHiddenEntity($entity);
$query->setCustomer($customer);
$query->setResultType(ProjectQuery::RESULT_TYPE_QUERYBUILDER);
$query->setOrderBy('name');
return $this->findByQuery($query);
}
@@ -115,7 +121,6 @@ class ProjectRepository extends AbstractRepository
}
$qb->andWhere('p.visible = 1');
/** @var Project $entity */
$entity = $query->getHiddenEntity();
if (null !== $entity) {
$qb->orWhere('p.id = :project')->setParameter('project', $entity);

View File

@@ -260,6 +260,42 @@ class TimesheetRepository extends AbstractRepository
return $qb->getQuery()->execute($params);
}
/**
* @param User $user
* @param int $limit
* @return int
* @throws RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function stopActiveEntries(User $user, int $hardLimit)
{
$counter = 0;
$activeEntries = $this->getActiveEntries($user);
// reduce limit by one:
// this method is only called when a new entry is started
// -> all entries, including the new one must not exceed the $limit
$limit = $hardLimit - 1;
if (count($activeEntries) > $limit) {
$i = 1;
foreach ($activeEntries as $activeEntry) {
if ($i > $limit) {
if ($hardLimit > 1) {
throw new \Exception('timesheet.start.exceeded_limit');
}
$this->stopRecording($activeEntry);
$counter++;
}
$i++;
}
}
return $counter;
}
/**
* @param TimesheetQuery $query
* @return QueryBuilder|Pagerfanta|array
@@ -281,19 +317,24 @@ class TimesheetRepository extends AbstractRepository
->setParameter('user', $query->getUser());
}
if (TimesheetQuery::STATE_RUNNING == $query->getState()) {
$qb->andWhere($qb->expr()->isNull('t.end'));
} elseif (TimesheetQuery::STATE_STOPPED == $query->getState()) {
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
if (null !== $query->getBegin()) {
$qb->andWhere('t.begin >= :begin')
->setParameter('begin', $query->getBegin());
}
if (null !== $query->getEnd()) {
$qb->andWhere('t.end <= :end')
if (TimesheetQuery::STATE_RUNNING == $query->getState()) {
$qb->andWhere($qb->expr()->isNull('t.end'));
}
if (TimesheetQuery::STATE_STOPPED == $query->getState()) {
$qb->andWhere($qb->expr()->isNotNull('t.end'));
if (null !== $query->getEnd()) {
$qb->andWhere('t.end <= :end')
->setParameter('end', $query->getEnd());
}
} elseif (null !== $query->getEnd()) {
$qb->andWhere('t.begin <= :end')
->setParameter('end', $query->getEnd());
}

View File

@@ -27,6 +27,19 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface
return $this->find($id);
}
/**
* Overwritten to fetch preferences when using the Profile controller actions.
* Depends on the query, some magic mechanisms like the ParamConverter will use this method to fetch the user.
*/
public function findOneBy(array $criteria, array $orderBy = null)
{
if (count($criteria) == 1 && isset($criteria['username'])) {
return $this->loadUserByUsername($criteria['username']);
}
return parent::findOneBy($criteria, $orderBy);
}
/**
* @return int
*/
@@ -43,7 +56,6 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface
{
$qb = $this->getEntityManager()->createQueryBuilder();
// if we join activities, the maxperpage limit will limit the list to the amount or projects + activties
$qb->select('u')
->from(User::class, 'u')
->orderBy('u.' . $query->getOrderBy(), $query->getOrder());
@@ -77,6 +89,8 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface
public function loadUserByUsername($username)
{
return $this->createQueryBuilder('u')
->select('u', 'p')
->leftJoin('u.preferences', 'p')
->where('u.username = :username')
->orWhere('u.email = :username')
->setParameter('username', $username)

View File

@@ -0,0 +1,67 @@
<?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\Security;
class RolePermissionManager
{
/**
* @var array
*/
protected $permissions = [];
/**
* @var array
*/
protected $knownPermissions = [];
/**
* @param array $permissions
*/
public function __construct(array $permissions)
{
$this->permissions = $permissions;
foreach ($permissions as $role => $perms) {
$this->knownPermissions = array_merge($this->knownPermissions, $perms);
}
$this->knownPermissions = array_unique($this->knownPermissions);
}
/**
* @param string $permission
* @return bool
*/
public function isRegisteredPermission($permission)
{
return in_array($permission, $this->knownPermissions);
}
/**
* @param string $role
* @return bool
*/
public function roleHasPermission($role)
{
return isset($this->permissions[$role]);
}
/**
* @param string $role
* @param string $permission
* @return bool
*/
public function hasPermission($role, $permission)
{
if (!isset($this->permissions[$role])) {
return false;
}
return in_array($permission, $this->permissions[$role]);
}
}

View File

@@ -38,6 +38,8 @@ class RateCalculator implements CalculatorInterface
public function calculate(Timesheet $record)
{
if (null === $record->getEnd()) {
$record->setRate(0);
return;
}

View File

@@ -196,6 +196,10 @@ class Extensions extends \Twig_Extension
}
}
if ($seconds < 0) {
return '?';
}
$locale = $this->getLocale();
switch ($format) {
case 'full':

View File

@@ -30,6 +30,10 @@ class Duration
*/
public function format($seconds, $format = self::FORMAT_NO_SECONDS)
{
if (null === $seconds) {
return null;
}
$hour = floor($seconds / 3600);
$minute = floor(($seconds / 60) % 60);

View File

@@ -9,12 +9,14 @@
namespace App\Voter;
use App\Entity\User;
use App\Security\AclDecisionManager;
use App\Security\RolePermissionManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Abstract voter to help with checking user roles.
* Abstract voter to help with checking user permissions.
*/
abstract class AbstractVoter extends Voter
{
@@ -22,14 +24,19 @@ abstract class AbstractVoter extends Voter
* @var AclDecisionManager
*/
protected $decisionManager;
/**
* @var RolePermissionManager
*/
protected $roleManager;
/**
* AbstractVoter constructor.
* @param AclDecisionManager $decisionManager
* @param RolePermissionManager $roleManager
*/
public function __construct(AclDecisionManager $decisionManager)
public function __construct(AclDecisionManager $decisionManager, RolePermissionManager $roleManager)
{
$this->decisionManager = $decisionManager;
$this->roleManager = $roleManager;
}
/**
@@ -50,4 +57,39 @@ abstract class AbstractVoter extends Voter
{
return $this->decisionManager->hasRole($token, [$role]);
}
/**
* @param string $role
* @param string $permission
* @return bool
*/
protected function hasPermission($role, $permission)
{
return $this->roleManager->hasPermission($role, $permission);
}
/**
* @param User $user
* @param string $permission
* @return bool
*/
protected function hasRolePermission(User $user, $permission)
{
foreach ($user->getRoles() as $role) {
if ($this->hasPermission($role, $permission)) {
return true;
}
}
return false;
}
/**
* @param string $permission
* @return bool
*/
public function isRegisteredPermission($permission)
{
return $this->roleManager->isRegisteredPermission($permission);
}
}

View File

@@ -22,6 +22,9 @@ class ActivityVoter extends AbstractVoter
public const EDIT = 'edit';
public const DELETE = 'delete';
/**
* support rules based on the given $subject (here: Activity)
*/
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
@@ -30,16 +33,16 @@ class ActivityVoter extends AbstractVoter
/**
* @param string $attribute
* @param mixed $subject
* @param Activity $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
if (!$subject instanceof Activity) {
return false;
}
if (!$subject instanceof Activity) {
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
@@ -60,54 +63,10 @@ class ActivityVoter extends AbstractVoter
return false;
}
switch ($attribute) {
case self::VIEW:
return $this->canView($subject, $user, $token);
case self::EDIT:
return $this->canEdit($subject, $user, $token);
case self::DELETE:
return $this->canDelete($token);
if ($subject instanceof Activity) {
return $this->hasRolePermission($user, $attribute . '_activity');
}
return false;
}
/**
* @param Activity $activity
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canView(Activity $activity, User $user, TokenInterface $token)
{
if ($this->canEdit($activity, $user, $token)) {
return true;
}
return false;
}
/**
* @param Activity $activity
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canEdit(Activity $activity, User $user, TokenInterface $token)
{
if ($this->canDelete($token)) {
return true;
}
return false;
}
/**
* @param TokenInterface $token
* @return bool
*/
protected function canDelete(TokenInterface $token)
{
return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token);
}
}

View File

@@ -22,6 +22,9 @@ class CustomerVoter extends AbstractVoter
public const EDIT = 'edit';
public const DELETE = 'delete';
/**
* support rules based on the given $subject (here: Customer)
*/
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
@@ -30,16 +33,16 @@ class CustomerVoter extends AbstractVoter
/**
* @param string $attribute
* @param mixed $subject
* @param Customer $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
if (!$subject instanceof Customer) {
return false;
}
if (!$subject instanceof Customer) {
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
@@ -60,54 +63,10 @@ class CustomerVoter extends AbstractVoter
return false;
}
switch ($attribute) {
case self::VIEW:
return $this->canView($subject, $user, $token);
case self::EDIT:
return $this->canEdit($subject, $user, $token);
case self::DELETE:
return $this->canDelete($token);
if ($subject instanceof Customer) {
return $this->hasRolePermission($user, $attribute . '_customer');
}
return false;
}
/**
* @param Customer $customer
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canView(Customer $customer, User $user, TokenInterface $token)
{
if ($this->canEdit($customer, $user, $token)) {
return true;
}
return false;
}
/**
* @param Customer $customer
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canEdit(Customer $customer, User $user, TokenInterface $token)
{
if ($this->canDelete($token)) {
return true;
}
return false;
}
/**
* @param TokenInterface $token
* @return bool
*/
protected function canDelete(TokenInterface $token)
{
return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token);
}
}

View File

@@ -0,0 +1,72 @@
<?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\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* A voter to check permissions on InvoiceTemplateVote.
*/
class InvoiceTemplateVoter extends AbstractVoter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
/**
* support rules based on the given $subject (here: InvoiceTemplate)
*/
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
self::DELETE
];
/**
* @param string $attribute
* @param InvoiceTemplate $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!$subject instanceof InvoiceTemplate) {
return false;
}
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
}
/**
* @param string $attribute
* @param InvoiceTemplate $subject
* @param TokenInterface $token
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($subject instanceof InvoiceTemplate) {
return $this->hasRolePermission($user, $attribute . '_invoice_template');
}
return false;
}
}

View File

@@ -1,136 +0,0 @@
<?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\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* A voter to check permissions on Invoices.
*/
class InvoiceVoter extends AbstractVoter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const CREATE = 'create';
public const DELETE = 'delete';
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::CREATE,
self::EDIT,
self::DELETE
];
public const ALLOWED_SUBJECTS = [
'invoice',
'invoice_template'
];
/**
* @param string $attribute
* @param mixed $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
if (!$subject instanceof InvoiceTemplate) {
if (!is_string($subject) || !in_array($subject, self::ALLOWED_SUBJECTS)) {
return false;
}
}
return true;
}
/**
* @param string $attribute
* @param string|InvoiceTemplate $subject
* @param TokenInterface $token
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
switch ($attribute) {
case self::VIEW:
return $this->canView($user, $token);
case self::CREATE:
return $this->canCreate($user, $token);
case self::EDIT:
return $this->canEdit($user, $token);
case self::DELETE:
return $this->canDelete($token);
}
return false;
}
/**
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canView(User $user, TokenInterface $token)
{
if ($this->canEdit($user, $token)) {
return true;
}
return false;
}
/**
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canCreate(User $user, TokenInterface $token)
{
if ($this->canDelete($token)) {
return true;
}
return false;
}
/**
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canEdit(User $user, TokenInterface $token)
{
if ($this->canDelete($token)) {
return true;
}
return false;
}
/**
* @param TokenInterface $token
* @return bool
*/
protected function canDelete(TokenInterface $token)
{
return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_TEAMLEAD', $token);
}
}

View File

@@ -22,6 +22,9 @@ class ProjectVoter extends AbstractVoter
public const EDIT = 'edit';
public const DELETE = 'delete';
/**
* support rules based on the given $subject (here: Project)
*/
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
@@ -30,16 +33,16 @@ class ProjectVoter extends AbstractVoter
/**
* @param string $attribute
* @param mixed $subject
* @param Project $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
if (!$subject instanceof Project) {
return false;
}
if (!$subject instanceof Project) {
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
@@ -60,54 +63,10 @@ class ProjectVoter extends AbstractVoter
return false;
}
switch ($attribute) {
case self::VIEW:
return $this->canView($subject, $user, $token);
case self::EDIT:
return $this->canEdit($subject, $user, $token);
case self::DELETE:
return $this->canDelete($token);
if ($subject instanceof Project) {
return $this->hasRolePermission($user, $attribute . '_project');
}
return false;
}
/**
* @param Project $project
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canView(Project $project, User $user, TokenInterface $token)
{
if ($this->canEdit($project, $user, $token)) {
return true;
}
return false;
}
/**
* @param Project $project
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canEdit(Project $project, User $user, TokenInterface $token)
{
if ($this->canDelete($token)) {
return true;
}
return false;
}
/**
* @param TokenInterface $token
* @return bool
*/
protected function canDelete(TokenInterface $token)
{
return $this->isFullyAuthenticated($token) && $this->hasRole('ROLE_ADMIN', $token);
}
}

View File

@@ -0,0 +1,64 @@
<?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\Voter;
use App\Entity\Activity;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* A voter to check the free-configurable permission from "kimai.permissions".
*/
class RolePermissionVoter extends AbstractVoter
{
/**
* @param string $attribute
* @param mixed $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
// we only work on single strings that have no subject
if (null !== $subject) {
return false;
}
// and which is not neither a user role like USER_ADMIN
// nor an implicit role like IS_REMEMBERED / IS_FULLY_AUTHENTICATED
if (strpos($attribute, 'ROLE_') === false && strpos($attribute, 'IS_') === false) {
return $this->isRegisteredPermission($attribute);
}
return false;
}
/**
* @param string $attribute
* @param Activity $subject
* @param TokenInterface $token
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!($user instanceof User)) {
return false;
}
foreach ($user->getRoles() as $role) {
if ($this->hasPermission($role, $attribute)) {
return true;
}
}
return false;
}
}

View File

@@ -21,16 +21,23 @@ class TimesheetVoter extends AbstractVoter
{
public const START = 'start';
public const STOP = 'stop';
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
public const EXPORT = 'export';
public const VIEW_RATE = 'view_rate';
public const EDIT_RATE = 'edit_rate';
/**
* support rules based on the given $subject (here: Timesheet)
*/
public const ALLOWED_ATTRIBUTES = [
self::START,
self::STOP,
self::VIEW,
self::EDIT,
self::DELETE
self::DELETE,
self::EXPORT,
self::VIEW_RATE,
self::EDIT_RATE,
];
/**
@@ -40,11 +47,11 @@ class TimesheetVoter extends AbstractVoter
*/
protected function supports($attribute, $subject)
{
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
if (!$subject instanceof Timesheet) {
return false;
}
if (!$subject instanceof Timesheet) {
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
@@ -61,40 +68,49 @@ class TimesheetVoter extends AbstractVoter
{
$user = $token->getUser();
if (!$user instanceof User) {
if (!($user instanceof User)) {
return false;
}
switch ($attribute) {
case self::STOP:
return $this->canStop($subject, $user, $token);
case self::START:
return $this->canStart($subject, $user, $token);
case self::VIEW:
return $this->canView($subject, $user, $token);
case self::EDIT:
return $this->canEdit($subject, $user, $token);
case self::DELETE:
return $this->canDelete($subject, $user, $token);
if (!($subject instanceof Timesheet)) {
return false;
}
return false;
}
$permission = '';
/**
* @param Timesheet $timesheet
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canStop(Timesheet $timesheet, User $user, TokenInterface $token)
{
// if a teamlead stops an entry for another user, check that this user is part of his team
return $this->isOwnOrTeamlead($timesheet, $user, $token);
switch ($attribute) {
case self::START:
if (!$this->canStart($subject, $user, $token)) {
return false;
}
$permission .= $attribute;
break;
case self::VIEW_RATE:
case self::EDIT_RATE:
case self::STOP:
case self::EDIT:
case self::DELETE:
case self::EXPORT:
$permission .= $attribute;
break;
default:
return false;
}
$permission .= '_';
// extend me for "team" support later on
if ($subject->getUser()->getId() == $user->getId()) {
$permission .= 'own';
} else {
$permission .= 'other';
}
$permission .= '_timesheet';
return $this->hasRolePermission($user, $permission);
}
/**
@@ -118,65 +134,4 @@ class TimesheetVoter extends AbstractVoter
return true;
}
/**
* @param Timesheet $timesheet
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canView(Timesheet $timesheet, User $user, TokenInterface $token)
{
return $this->isOwnOrTeamlead($timesheet, $user, $token);
}
/**
* @param Timesheet $timesheet
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canEdit(Timesheet $timesheet, User $user, TokenInterface $token)
{
return $this->isOwnOrTeamlead($timesheet, $user, $token);
}
/**
* @param TokenInterface $token
* @return bool
*/
protected function canDelete(Timesheet $timesheet, User $user, TokenInterface $token)
{
if (!$this->isFullyAuthenticated($token)) {
return false;
}
return $this->isOwnOrAdmin($timesheet, $user, $token);
}
/**
* @param TokenInterface $token
* @return bool
*/
protected function isOwnOrTeamlead(Timesheet $timesheet, User $user, TokenInterface $token)
{
if ($timesheet->getUser()->getId() == $user->getId()) {
return true;
}
return $this->hasRole('ROLE_TEAMLEAD', $token);
}
/**
* @param TokenInterface $token
* @return bool
*/
protected function isOwnOrAdmin(Timesheet $timesheet, User $user, TokenInterface $token)
{
if ($timesheet->getUser()->getId() == $user->getId()) {
return true;
}
return $this->hasRole('ROLE_ADMIN', $token);
}
}

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