From 561ec3b1e904a2d4314bc7ed4ec61bbfbd795380 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Fri, 16 Aug 2019 01:22:33 +0200 Subject: [PATCH] added team permissions (#996) --- README.md | 1 + composer.lock | 120 +++++----- config/packages/kimai.yaml | 51 ++--- config/packages/nelmio_api_doc.yaml | 2 + config/serializer/App/Entity.Team.yml | 14 ++ config/services.yaml | 5 + src/API/CustomerController.php | 1 + src/API/ProjectController.php | 1 + src/API/TagController.php | 4 +- src/API/TeamController.php | 133 +++++++++++ src/Configuration/FormConfiguration.php | 5 + src/Controller/ActivityController.php | 20 +- src/Controller/CustomerController.php | 63 +++--- src/Controller/ExportController.php | 1 + src/Controller/InvoiceController.php | 1 + src/Controller/ProfileController.php | 93 ++++---- src/Controller/ProjectController.php | 80 ++++--- .../SystemConfigurationController.php | 4 + src/Controller/TeamController.php | 185 +++++++++++++++ .../TimesheetAbstractController.php | 13 +- src/Controller/TimesheetTeamController.php | 6 + src/DependencyInjection/Configuration.php | 5 +- src/Entity/Customer.php | 44 ++++ src/Entity/Project.php | 44 ++++ src/Entity/Team.php | 210 ++++++++++++++++++ src/Entity/Timesheet.php | 6 +- src/Entity/User.php | 66 ++++++ src/Entity/UserPreference.php | 40 +++- src/EventSubscriber/MenuSubscriber.php | 6 + .../UserPreferenceSubscriber.php | 9 + src/Form/ActivityEditForm.php | 23 +- src/Form/CustomerTeamPermissionForm.php | 51 +++++ src/Form/Extension/UserExtension.php | 45 ++++ src/Form/ProjectEditForm.php | 7 +- src/Form/ProjectTeamPermissionForm.php | 51 +++++ src/Form/TeamCustomerForm.php | 51 +++++ src/Form/TeamEditForm.php | 61 +++++ src/Form/TeamProjectForm.php | 52 +++++ src/Form/TimesheetEditForm.php | 22 +- src/Form/Toolbar/AbstractToolbarForm.php | 10 +- src/Form/Toolbar/TeamToolbarForm.php | 37 +++ src/Form/Type/CustomerType.php | 15 +- src/Form/Type/ProjectType.php | 15 +- src/Form/Type/TeamType.php | 44 ++++ src/Form/Type/UserPreferenceType.php | 33 ++- src/Form/Type/UserType.php | 12 + src/Form/UserTeamsType.php | 49 ++++ src/Migrations/Version20190510205245.php | 7 - src/Migrations/Version20190729162655.php | 44 ++++ src/Migrations/Version20190730123324.php | 68 ++++++ src/Repository/ActivityRepository.php | 53 ++++- src/Repository/CustomerRepository.php | 42 +++- src/Repository/Loader/ActivityIdLoader.php | 21 ++ src/Repository/Loader/CustomerIdLoader.php | 9 + src/Repository/Loader/ProjectIdLoader.php | 19 ++ src/Repository/Loader/TeamIdLoader.php | 46 ++++ src/Repository/Loader/TeamLoader.php | 38 ++++ src/Repository/ProjectRepository.php | 52 ++++- src/Repository/Query/BaseQuery.php | 42 ++++ .../Query/CustomerFormTypeQuery.php | 37 +++ src/Repository/Query/ProjectFormTypeQuery.php | 37 +++ src/Repository/Query/TagQuery.php | 4 + src/Repository/Query/TeamQuery.php | 18 ++ src/Repository/Query/TimesheetQuery.php | 8 +- src/Repository/Query/UserFormTypeQuery.php | 55 +++++ src/Repository/TagRepository.php | 27 ++- src/Repository/TeamRepository.php | 99 +++++++++ src/Repository/TimesheetRepository.php | 69 +++++- src/Repository/UserRepository.php | 59 ++++- src/Security/CurrentUser.php | 19 +- src/Twig/IconExtension.php | 4 +- src/Voter/ActivityVoter.php | 41 +++- src/Voter/CustomerVoter.php | 27 ++- src/Voter/ProjectVoter.php | 38 +++- src/Voter/TeamVoter.php | 65 ++++++ src/Voter/UserVoter.php | 3 + templates/activity/index.html.twig | 4 +- templates/customer/index.html.twig | 8 + templates/customer/permissions.html.twig | 15 ++ templates/default/_form.html.twig | 5 +- templates/macros/actions.html.twig | 43 ++++ templates/macros/widgets.html.twig | 2 + templates/project/index.html.twig | 10 +- templates/project/permissions.html.twig | 15 ++ templates/team/edit.html.twig | 34 +++ templates/team/index.html.twig | 49 ++++ tests/API/TeamControllerTest.php | 102 +++++++++ tests/Controller/ActivityControllerTest.php | 4 +- tests/Controller/CustomerControllerTest.php | 40 +++- tests/Controller/ProfileControllerTest.php | 50 ++++- tests/Controller/ProjectControllerTest.php | 39 +++- tests/Controller/TeamControllerTest.php | 155 +++++++++++++ .../TimesheetTeamControllerTest.php | 7 +- tests/Controller/UserControllerTest.php | 4 +- tests/DataFixtures/TeamFixtures.php | 144 ++++++++++++ .../DependencyInjection/AppExtensionTest.php | 1 + tests/Entity/CustomerTest.php | 22 ++ tests/Entity/ProjectTest.php | 21 ++ tests/Entity/TeamTest.php | 108 +++++++++ tests/Entity/UserTest.php | 41 ++++ tests/Mocks/AbstractMockFactory.php | 7 +- tests/Mocks/Security/CurrentUserFactory.php | 46 ++++ .../Security/UserDateTimeFactoryFactory.php | 28 +-- .../Query/ActivityFormTypeQueryTest.php | 44 ++++ tests/Repository/Query/BaseQueryTest.php | 10 + .../Query/CustomerFormTypeQueryTest.php | 45 ++++ .../Query/ProjectFormTypeQueryTest.php | 51 +++++ .../Query/UserFormTypeQueryTest.php | 34 +++ tests/Repository/WidgetRepositoryTest.php | 13 +- tests/Voter/AbstractVoterTest.php | 13 +- tests/Voter/ActivityVoterTest.php | 83 ++++++- tests/Voter/CustomerVoterTest.php | 56 ++++- tests/Voter/ProjectVoterTest.php | 74 +++++- tests/Voter/RolePermissionVoterTest.php | 6 +- tests/Voter/TeamVoterTest.php | 82 +++++++ .../Widget/Type/DailyWorkingTimeChartTest.php | 12 +- tests/phpstan.neon | 1 + translations/actions.de.xliff | 4 + translations/actions.en.xliff | 4 + translations/messages.de.xliff | 20 ++ translations/messages.en.xliff | 20 ++ translations/teams.de.xliff | 23 ++ translations/teams.en.xliff | 23 ++ var/data/kimai_test.sqlite | Bin 770048 -> 770048 bytes 124 files changed, 4122 insertions(+), 362 deletions(-) create mode 100644 config/serializer/App/Entity.Team.yml create mode 100644 src/API/TeamController.php create mode 100644 src/Controller/TeamController.php create mode 100644 src/Entity/Team.php create mode 100644 src/Form/CustomerTeamPermissionForm.php create mode 100644 src/Form/Extension/UserExtension.php create mode 100644 src/Form/ProjectTeamPermissionForm.php create mode 100644 src/Form/TeamCustomerForm.php create mode 100644 src/Form/TeamEditForm.php create mode 100644 src/Form/TeamProjectForm.php create mode 100644 src/Form/Toolbar/TeamToolbarForm.php create mode 100644 src/Form/Type/TeamType.php create mode 100644 src/Form/UserTeamsType.php create mode 100644 src/Migrations/Version20190729162655.php create mode 100644 src/Migrations/Version20190730123324.php create mode 100644 src/Repository/Loader/TeamIdLoader.php create mode 100644 src/Repository/Loader/TeamLoader.php create mode 100644 src/Repository/Query/TeamQuery.php create mode 100644 src/Repository/Query/UserFormTypeQuery.php create mode 100644 src/Repository/TeamRepository.php create mode 100644 src/Voter/TeamVoter.php create mode 100644 templates/customer/permissions.html.twig create mode 100644 templates/project/permissions.html.twig create mode 100644 templates/team/edit.html.twig create mode 100644 templates/team/index.html.twig create mode 100644 tests/API/TeamControllerTest.php create mode 100644 tests/Controller/TeamControllerTest.php create mode 100644 tests/DataFixtures/TeamFixtures.php create mode 100644 tests/Entity/TeamTest.php create mode 100644 tests/Mocks/Security/CurrentUserFactory.php create mode 100644 tests/Repository/Query/ActivityFormTypeQueryTest.php create mode 100644 tests/Repository/Query/CustomerFormTypeQueryTest.php create mode 100644 tests/Repository/Query/ProjectFormTypeQueryTest.php create mode 100644 tests/Repository/Query/UserFormTypeQueryTest.php create mode 100644 tests/Voter/TeamVoterTest.php create mode 100644 translations/teams.de.xliff create mode 100644 translations/teams.en.xliff diff --git a/README.md b/README.md index 11e00e7f..6245af34 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Travis Status](https://travis-ci.org/kevinpapst/kimai2.svg?branch=master)](https://travis-ci.org/kevinpapst/kimai2) [![Code Coverage](https://codecov.io/gh/kevinpapst/kimai2/branch/master/graph/badge.svg)](https://codecov.io/gh/kevinpapst/kimai2) [![Gitter](https://badges.gitter.im/kimai2/support.svg)](https://gitter.im/kimai2/support) +[![Bountysource](https://img.shields.io/bountysource/team/kimai2/activity)](https://www.bountysource.com/teams/kimai2) Kimai is a free, open source and online time-tracking software designed for small businesses and freelancers. It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Doctrine, AdminLTE, Webpack, ES6 etc. diff --git a/composer.lock b/composer.lock index f136f495..3425f72e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1b34c28fac76bd8fd3aeb4cc048e2f47", + "content-hash": "c4acae186589f23fac807bdacdf372c5", "packages": [ { "name": "beberlei/doctrineextensions", @@ -2808,16 +2808,16 @@ }, { "name": "kevinpapst/adminlte-bundle", - "version": "2.8.3", + "version": "2.8.5", "source": { "type": "git", "url": "https://github.com/kevinpapst/AdminLTEBundle.git", - "reference": "51ebd9e9a6d36885f0e4830527fbbfa46d87955e" + "reference": "12c5deaad9276458a2536da281732d70e5255441" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/51ebd9e9a6d36885f0e4830527fbbfa46d87955e", - "reference": "51ebd9e9a6d36885f0e4830527fbbfa46d87955e", + "url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/12c5deaad9276458a2536da281732d70e5255441", + "reference": "12c5deaad9276458a2536da281732d70e5255441", "shasum": "" }, "require": { @@ -2855,6 +2855,10 @@ "MIT" ], "authors": [ + { + "name": "Kevin Papst", + "homepage": "https://www.kevinpapst.de" + }, { "name": "Marc Bach", "homepage": "http://www.avanzu.de" @@ -2862,14 +2866,10 @@ { "name": "Ángel Guzmán Maeso", "homepage": "https://shakaran.net/blog" - }, - { - "name": "Kevin Papst", - "homepage": "https://www.kevinpapst.de" } ], "description": "Admin theme bundle for Symfony 4 based on AdminLTE 2.4.8 with FOSUserBundle support", - "time": "2019-06-30T16:09:16+00:00" + "time": "2019-08-08T08:09:47+00:00" }, { "name": "kimai/kimai2-composer", @@ -6227,16 +6227,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "82ebae02209c21113908c229e9883c419720738a" + "reference": "550ebaac289296ce228a706d0867afc34687e3f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", - "reference": "82ebae02209c21113908c229e9883c419720738a", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/550ebaac289296ce228a706d0867afc34687e3f4", + "reference": "550ebaac289296ce228a706d0867afc34687e3f4", "shasum": "" }, "require": { @@ -6248,7 +6248,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -6264,13 +6264,13 @@ "MIT" ], "authors": [ - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - }, { "name": "Gert de Pagter", "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], "description": "Symfony polyfill for ctype functions", @@ -6281,25 +6281,25 @@ "polyfill", "portable" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", - "reference": "999878a3a09d73cae157b0cf89bb6fb2cc073057" + "reference": "66810b9d6eb4af54d543867909d65ab9af654d7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/999878a3a09d73cae157b0cf89bb6fb2cc073057", - "reference": "999878a3a09d73cae157b0cf89bb6fb2cc073057", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/66810b9d6eb4af54d543867909d65ab9af654d7e", + "reference": "66810b9d6eb4af54d543867909d65ab9af654d7e", "shasum": "" }, "require": { "php": ">=5.3.3", - "symfony/intl": "~2.3|~3.0|~4.0" + "symfony/intl": "~2.3|~3.0|~4.0|~5.0" }, "suggest": { "ext-intl": "For best performance" @@ -6307,7 +6307,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -6339,20 +6339,20 @@ "portable", "shim" ], - "time": "2019-01-07T19:39:47+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "c766e95bec706cdd89903b1eda8afab7d7a6b7af" + "reference": "6af626ae6fa37d396dc90a399c0ff08e5cfc45b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c766e95bec706cdd89903b1eda8afab7d7a6b7af", - "reference": "c766e95bec706cdd89903b1eda8afab7d7a6b7af", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/6af626ae6fa37d396dc90a399c0ff08e5cfc45b2", + "reference": "6af626ae6fa37d396dc90a399c0ff08e5cfc45b2", "shasum": "" }, "require": { @@ -6366,7 +6366,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.9-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -6382,13 +6382,13 @@ "MIT" ], "authors": [ - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - }, { "name": "Laurent Bassin", "email": "laurent@bassin.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", @@ -6401,20 +6401,20 @@ "portable", "shim" ], - "time": "2019-03-04T13:44:35+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fe5e94c604826c35a32fa832f35bd036b6799609" + "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", - "reference": "fe5e94c604826c35a32fa832f35bd036b6799609", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/b42a2f66e8f1b15ccf25652c3424265923eb4f17", + "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17", "shasum": "" }, "require": { @@ -6426,7 +6426,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -6460,20 +6460,20 @@ "portable", "shim" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c" + "reference": "04ce3335667451138df4307d6a9b61565560199e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/ab50dcf166d5f577978419edd37aa2bb8eabce0c", - "reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/04ce3335667451138df4307d6a9b61565560199e", + "reference": "04ce3335667451138df4307d6a9b61565560199e", "shasum": "" }, "require": { @@ -6482,7 +6482,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -6515,20 +6515,20 @@ "portable", "shim" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.11.0", + "version": "v1.12.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" + "reference": "2ceb49eaccb9352bff54d22570276bb75ba4a188" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", - "reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/2ceb49eaccb9352bff54d22570276bb75ba4a188", + "reference": "2ceb49eaccb9352bff54d22570276bb75ba4a188", "shasum": "" }, "require": { @@ -6537,7 +6537,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.11-dev" + "dev-master": "1.12-dev" } }, "autoload": { @@ -6573,7 +6573,7 @@ "portable", "shim" ], - "time": "2019-02-06T07:57:58+00:00" + "time": "2019-08-06T08:03:45+00:00" }, { "name": "symfony/profiler-pack", @@ -9160,16 +9160,16 @@ }, { "name": "nette/di", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/nette/di.git", - "reference": "19d83539245aaacb59470828919182411061841f" + "reference": "4aff517a1c6bb5c36fa09733d4cea089f529de6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/di/zipball/19d83539245aaacb59470828919182411061841f", - "reference": "19d83539245aaacb59470828919182411061841f", + "url": "https://api.github.com/repos/nette/di/zipball/4aff517a1c6bb5c36fa09733d4cea089f529de6d", + "reference": "4aff517a1c6bb5c36fa09733d4cea089f529de6d", "shasum": "" }, "require": { @@ -9229,7 +9229,7 @@ "nette", "static" ], - "time": "2019-04-03T19:35:46+00:00" + "time": "2019-08-07T12:11:33+00:00" }, { "name": "nette/finder", diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index 273f2f20..9169a900 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -84,23 +84,33 @@ kimai: # mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and sets"]) sets: ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity'] - PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project'] - CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer'] + ACTIVITIES_TEAM: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity'] + PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project','permissions_project'] + PROJECTS_TEAM: ['view_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project'] + CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer'] + CUSTOMERS_TEAM: ['view_customer','edit_teamlead_customer','budget_teamlead_customer'] 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'] + PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile'] TAGS: ['view_tag','delete_tag'] USER: ['view_user','create_user','delete_user','role_permissions'] RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet'] RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet'] EXPORT: ['view_export','create_export','edit_export_own_timesheet','edit_export_other_timesheet'] + TEAMS: ['view_team','create_team','edit_team','delete_team'] + # some single default definitions for roles + SINGLE_USER: [] + SINGLE_TEAMLEAD: ['!delete_invoice_template','view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile'] + SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile'] + SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile'] + # link above sets to one set for each user role ROLE_USER: ['@TIMESHEET','@PROFILE'] - ROLE_TEAMLEAD: ['@INVOICE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@EXPORT','@TAGS','@INVOICE_TEMPLATE','!delete_invoice_template','view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile'] - ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@RATE','@RATE_OTHER','@EXPORT','@TAGS','hourly-rate_own_profile','edit_exported_timesheet'] - ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@RATE','@RATE_OTHER','@EXPORT','@TAGS','hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet'] + ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@INVOICE_TEMPLATE','@SINGLE_TEAMLEAD'] + ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN'] + ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN'] # mapping "sets" or permissions to user roles ("role name" = [array of "set names"]) maps: ROLE_USER: ['ROLE_USER'] @@ -154,18 +164,6 @@ kimai: # -------------------------------------------------------------------------------- -# -------------------------------------------------------------------------------- -# THEME Settings -# will be available as twig globals at "kimai_context.*". -# See documentation at https://www.kimai.org/documentation/theme.html -# -------------------------------------------------------------------------------- -# 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: ~ -# -------------------------------------------------------------------------------- - - # -------------------------------------------------------------------------------- # DASHBOARD # Using the configurable widget sections. @@ -278,23 +276,6 @@ kimai: date: 'd. m. Y' date_time: 'd. m. H:i' duration: '%%h:%%m h' - -# -------------------------------------------------------------------------------- - - -# -------------------------------------------------------------------------------- -# FORMS -# Default settings used to populate forms -# -------------------------------------------------------------------------------- -# defaults: -# customer: -# timezone: Europe/Berlin -# country: DE -# currency: EUR -# user: -# timezone: Europe/Berlin -# language: de -# theme: blue-light # -------------------------------------------------------------------------------- diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index 1e4ec35a..aa0f7584 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -21,6 +21,8 @@ nelmio_api_doc: - { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] } - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] } - { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] } + - { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team] } + - { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] } - { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] } areas: path_patterns: diff --git a/config/serializer/App/Entity.Team.yml b/config/serializer/App/Entity.Team.yml new file mode 100644 index 00000000..9b47f46c --- /dev/null +++ b/config/serializer/App/Entity.Team.yml @@ -0,0 +1,14 @@ +App\Entity\Team: + exclusion_policy: All + custom_accessor_order: [id, name, users] + properties: + id: + include: true + name: + include: true + users: + exclude: true + customers: + exclude: true + projects: + exclude: true diff --git a/config/services.yaml b/config/services.yaml index 6b40add3..30ced67c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -154,6 +154,11 @@ services: factory: ['@doctrine.orm.entity_manager', getRepository] arguments: ['App\Entity\User'] + App\Repository\TeamRepository: + class: Doctrine\ORM\EntityRepository + factory: ['@doctrine.orm.entity_manager', getRepository] + arguments: ['App\Entity\Team'] + App\Repository\ActivityRepository: class: Doctrine\ORM\EntityRepository factory: ['@doctrine.orm.entity_manager', getRepository] diff --git a/src/API/CustomerController.php b/src/API/CustomerController.php index b1549960..efc1d827 100644 --- a/src/API/CustomerController.php +++ b/src/API/CustomerController.php @@ -68,6 +68,7 @@ class CustomerController extends BaseApiController public function cgetAction(ParamFetcherInterface $paramFetcher) { $query = new CustomerQuery(); + $query->setCurrentUser($this->getUser()); if (null !== ($order = $paramFetcher->get('order'))) { $query->setOrder($order); diff --git a/src/API/ProjectController.php b/src/API/ProjectController.php index 03e10384..9d5b2dbe 100644 --- a/src/API/ProjectController.php +++ b/src/API/ProjectController.php @@ -70,6 +70,7 @@ class ProjectController extends BaseApiController public function cgetAction(ParamFetcherInterface $paramFetcher) { $query = new ProjectQuery(); + $query->setCurrentUser($this->getUser()); if (null !== ($order = $paramFetcher->get('order'))) { $query->setOrder($order); diff --git a/src/API/TagController.php b/src/API/TagController.php index cc69e182..f0b964ad 100644 --- a/src/API/TagController.php +++ b/src/API/TagController.php @@ -104,9 +104,7 @@ class TagController extends BaseApiController throw new NotFoundException(); } - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->remove($tag); - $entityManager->flush(); + $this->repository->deleteTag($tag); $view = new View(null, Response::HTTP_NO_CONTENT); diff --git a/src/API/TeamController.php b/src/API/TeamController.php new file mode 100644 index 00000000..cb0d23a9 --- /dev/null +++ b/src/API/TeamController.php @@ -0,0 +1,133 @@ +viewHandler = $viewHandler; + $this->repository = $repository; + } + + /** + * Fetch all existing teams + * + * @SWG\Response( + * response=200, + * description="Returns the collection of all existing teams", + * @SWG\Schema( + * type="array", + * @SWG\Items(ref="#/definitions/TeamCollection") + * ) + * ) + * + * @Security("is_granted('view_team')") + * + * @return Response + */ + public function cgetAction(ParamFetcherInterface $paramFetcher) + { + $data = $this->repository->findAll(); + + $view = new View($data, 200); + $view->getContext()->setGroups(['Default', 'Collection', 'Team']); + + return $this->viewHandler->handle($view); + } + + /** + * Returns one team + * + * @SWG\Response( + * response=200, + * description="Returns one team entity", + * @SWG\Schema(ref="#/definitions/TeamEntity"), + * ) + * + * @param int $id + * @return Response + */ + public function getAction($id) + { + /** @var Team $data */ + $data = $this->repository->find($id); + + if (null === $data) { + throw new NotFoundException(); + } + + $view = new View($data, 200); + $view->getContext()->setGroups(['Default', 'Entity', 'Team']); + + return $this->viewHandler->handle($view); + } + + /** + * Delete a team + * + * @SWG\Delete( + * @SWG\Response( + * response=204, + * description="Delete one team" + * ), + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="Team ID to delete", + * required=true, + * ) + * + * @Security("is_granted('delete_team')") + * + * @param int $id + * @return Response + */ + public function deleteAction($id) + { + $team = $this->repository->find($id); + + if (null === $team) { + throw new NotFoundException(); + } + + $this->repository->deleteTeam($team); + + $view = new View(null, Response::HTTP_NO_CONTENT); + + return $this->viewHandler->handle($view); + } +} diff --git a/src/Configuration/FormConfiguration.php b/src/Configuration/FormConfiguration.php index 95d54550..5372ab44 100644 --- a/src/Configuration/FormConfiguration.php +++ b/src/Configuration/FormConfiguration.php @@ -47,4 +47,9 @@ class FormConfiguration implements SystemBundleConfiguration { return $this->find('user.language'); } + + public function getUserDefaultCurrency(): string + { + return $this->find('user.currency'); + } } diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 7145ab9c..b1f61347 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -9,6 +9,7 @@ namespace App\Controller; +use App\Configuration\FormConfiguration; use App\Entity\Activity; use App\Entity\Project; use App\Event\ActivityMetaDefinitionEvent; @@ -40,14 +41,19 @@ class ActivityController extends AbstractController * @var ActivityRepository */ private $repository; + /** + * @var FormConfiguration + */ + private $configuration; /** * @var EventDispatcherInterface */ protected $dispatcher; - public function __construct(ActivityRepository $repository, EventDispatcherInterface $dispatcher) + public function __construct(ActivityRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher) { $this->repository = $repository; + $this->configuration = $configuration; $this->dispatcher = $dispatcher; } @@ -68,6 +74,7 @@ class ActivityController extends AbstractController public function indexAction($page, Request $request) { $query = new ActivityQuery(); + $query->setCurrentUser($this->getUser()); $query->setPage($page); $form = $this->getToolbarForm($query); @@ -250,15 +257,20 @@ class ActivityController extends AbstractController */ private function createEditForm(Activity $activity) { - if ($activity->getId() === null) { - $url = $this->generateUrl('admin_activity_create'); - } else { + $currency = $this->configuration->getCustomerDefaultCurrency(); + $url = $this->generateUrl('admin_activity_create'); + + if ($activity->getId() !== null) { $url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]); + if (null !== $activity->getProject()) { + $currency = $activity->getProject()->getCustomer()->getCurrency(); + } } return $this->createForm(ActivityEditForm::class, $activity, [ 'action' => $url, 'method' => 'POST', + 'currency' => $currency, 'create_more' => true, 'customer' => true, 'include_budget' => $this->isGranted('budget', $activity) diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php index 31413cc8..e5006ea2 100644 --- a/src/Controller/CustomerController.php +++ b/src/Controller/CustomerController.php @@ -13,6 +13,7 @@ use App\Configuration\FormConfiguration; use App\Entity\Customer; use App\Event\CustomerMetaDefinitionEvent; use App\Form\CustomerEditForm; +use App\Form\CustomerTeamPermissionForm; use App\Form\Toolbar\CustomerToolbarForm; use App\Form\Type\CustomerType; use App\Repository\CustomerRepository; @@ -71,14 +72,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 Response */ public function indexAction($page, Request $request) { $query = new CustomerQuery(); + $query->setCurrentUser($this->getUser()); $query->setPage($page); $form = $this->getToolbarForm($query); @@ -98,9 +96,6 @@ class CustomerController extends AbstractController /** * @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"}) * @Security("is_granted('create_customer')") - * - * @param Request $request - * @return RedirectResponse|Response */ public function createAction(Request $request) { @@ -117,12 +112,39 @@ class CustomerController extends AbstractController return $this->renderCustomerForm($customer, $request); } + /** + * @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"}) + * @Security("is_granted('permissions', customer)") + */ + public function teamPermissions(Customer $customer, Request $request) + { + $form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [ + 'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]), + 'method' => 'POST', + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->getRepository()->saveCustomer($customer); + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_customer'); + } catch (ORMException $ex) { + $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); + } + } + + return $this->render('customer/permissions.html.twig', [ + 'customer' => $customer, + 'form' => $form->createView() + ]); + } + /** * @Route(path="/{id}/budget", name="admin_customer_budget", methods={"GET"}) * @Security("is_granted('budget', customer)") - * - * @param Customer $customer - * @return Response */ public function budgetAction(Customer $customer) { @@ -135,10 +157,6 @@ 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 RedirectResponse|Response */ public function editAction(Customer $customer, Request $request) { @@ -148,10 +166,6 @@ class CustomerController extends AbstractController /** * @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', customer)") - * - * @param Customer $customer - * @param Request $request - * @return RedirectResponse|Response */ public function deleteAction(Customer $customer, Request $request) { @@ -169,6 +183,7 @@ class CustomerController extends AbstractController 'query_builder' => function (CustomerRepository $repo) use ($customer) { $query = new CustomerFormTypeQuery(); $query->setCustomerToIgnore($customer); + $query->setUser($this->getUser()); return $repo->getQueryBuilderForFormType($query); }, @@ -229,11 +244,7 @@ class CustomerController extends AbstractController ]); } - /** - * @param CustomerQuery $query - * @return FormInterface - */ - protected function getToolbarForm(CustomerQuery $query) + protected function getToolbarForm(CustomerQuery $query): FormInterface { return $this->createForm(CustomerToolbarForm::class, $query, [ 'action' => $this->generateUrl('admin_customer', [ @@ -243,11 +254,7 @@ class CustomerController extends AbstractController ]); } - /** - * @param Customer $customer - * @return FormInterface - */ - private function createEditForm(Customer $customer) + private function createEditForm(Customer $customer): FormInterface { if ($customer->getId() === null) { $url = $this->generateUrl('admin_customer_create'); diff --git a/src/Controller/ExportController.php b/src/Controller/ExportController.php index f1e4302d..df826590 100644 --- a/src/Controller/ExportController.php +++ b/src/Controller/ExportController.php @@ -69,6 +69,7 @@ class ExportController extends AbstractController $query->setEnd($end); $query->setState(ExportQuery::STATE_STOPPED); $query->setExported(ExportQuery::STATE_NOT_EXPORTED); + $query->setCurrentUser($this->getUser()); return $query; } diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 5d10078f..202ad39e 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -71,6 +71,7 @@ class InvoiceController extends AbstractController $query->setBegin($begin); $query->setEnd($end); $query->setState(InvoiceQuery::STATE_STOPPED); + $query->setCurrentUser($this->getUser()); return $query; } diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 04a2beb5..a8c72821 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -16,12 +16,14 @@ use App\Form\UserEditType; use App\Form\UserPasswordType; use App\Form\UserPreferencesForm; use App\Form\UserRolesType; +use App\Form\UserTeamsType; 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\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; @@ -175,6 +177,28 @@ class ProfileController extends AbstractController return $this->getProfileView($profile, 'roles', null, null, $form); } + /** + * @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"}) + * @Security("is_granted('teams', profile)") + */ + public function teamsAction(User $profile, Request $request) + { + $form = $this->createTeamsForm($profile); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($profile); + $entityManager->flush(); + + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]); + } + + return $this->getProfileView($profile, 'teams', null, null, null, null, $form); + } + /** * @Route(path="/{username}/prefs", name="user_profile_preferences", methods={"GET", "POST"}) * @Security("is_granted('preferences', profile)") @@ -236,24 +260,15 @@ class ProfileController extends AbstractController ]); } - /** - * @param User $user - * @param string $tab - * @param Form|null $editForm - * @param Form|null $pwdForm - * @param Form|null $rolesForm - * @param Form|null $apiTokenForm - * @return \Symfony\Component\HttpFoundation\Response - * @throws \Doctrine\ORM\NonUniqueResultException - */ protected function getProfileView( User $user, string $tab, - Form $editForm = null, - Form $pwdForm = null, - Form $rolesForm = null, - Form $apiTokenForm = null - ) { + FormInterface $editForm = null, + FormInterface $pwdForm = null, + FormInterface $rolesForm = null, + FormInterface $apiTokenForm = null, + FormInterface $teamsForm = null + ): Response { $forms = []; if ($this->isGranted(UserVoter::EDIT, $user)) { @@ -268,6 +283,10 @@ class ProfileController extends AbstractController $apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user); $forms['api-token'] = $apiTokenForm->createView(); } + if ($this->isGranted(UserVoter::TEAMS, $user)) { + $teamsForm = $teamsForm ?: $this->createTeamsForm($user); + $forms['teams'] = $teamsForm->createView(); + } if ($this->isGranted(UserVoter::ROLES, $user)) { $rolesForm = $rolesForm ?: $this->createRolesForm($user); $forms['roles'] = $rolesForm->createView(); @@ -280,11 +299,7 @@ class ProfileController extends AbstractController ]); } - /** - * @param User $user - * @return \Symfony\Component\Form\FormInterface - */ - private function createPreferencesForm(User $user) + private function createPreferencesForm(User $user): FormInterface { // we need to prepare the user preferences, which is done via an EventSubscriber $event = new PrepareUserEvent($user); @@ -300,11 +315,7 @@ class ProfileController extends AbstractController ); } - /** - * @param User $user - * @return \Symfony\Component\Form\FormInterface - */ - private function createEditForm(User $user) + private function createEditForm(User $user): FormInterface { return $this->createForm( UserEditType::class, @@ -317,11 +328,7 @@ class ProfileController extends AbstractController ); } - /** - * @param User $user - * @return \Symfony\Component\Form\FormInterface - */ - private function createRolesForm(User $user) + private function createRolesForm(User $user): FormInterface { return $this->createForm( UserRolesType::class, @@ -333,11 +340,19 @@ class ProfileController extends AbstractController ); } - /** - * @param User $user - * @return \Symfony\Component\Form\FormInterface - */ - private function createPasswordForm(User $user) + private function createTeamsForm(User $user): FormInterface + { + return $this->createForm( + UserTeamsType::class, + $user, + [ + 'action' => $this->generateUrl('user_profile_teams', ['username' => $user->getUsername()]), + 'method' => 'POST', + ] + ); + } + + private function createPasswordForm(User $user): FormInterface { return $this->createForm( UserPasswordType::class, @@ -350,11 +365,7 @@ class ProfileController extends AbstractController ); } - /** - * @param User $user - * @return \Symfony\Component\Form\FormInterface - */ - private function createApiTokenForm(User $user) + private function createApiTokenForm(User $user): FormInterface { return $this->createForm( UserApiTokenType::class, diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 97891170..12cda5d3 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -9,10 +9,12 @@ namespace App\Controller; +use App\Configuration\FormConfiguration; use App\Entity\Customer; use App\Entity\Project; use App\Event\ProjectMetaDefinitionEvent; use App\Form\ProjectEditForm; +use App\Form\ProjectTeamPermissionForm; use App\Form\Toolbar\ProjectToolbarForm; use App\Form\Type\ProjectType; use App\Repository\ProjectRepository; @@ -40,14 +42,19 @@ class ProjectController extends AbstractController * @var ProjectRepository */ private $repository; + /** + * @var FormConfiguration + */ + private $configuration; /** * @var EventDispatcherInterface */ protected $dispatcher; - public function __construct(ProjectRepository $repository, EventDispatcherInterface $dispatcher) + public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher) { $this->repository = $repository; + $this->configuration = $configuration; $this->dispatcher = $dispatcher; } @@ -60,14 +67,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"}) * @Security("is_granted('view_project')") - * - * @param int $page - * @param Request $request - * @return Response */ public function indexAction($page, Request $request) { $query = new ProjectQuery(); + $query->setCurrentUser($this->getUser()); $query->setPage($page); $form = $this->getToolbarForm($query); @@ -85,14 +89,40 @@ class ProjectController extends AbstractController ]); } + /** + * @Route(path="/{id}/permissions", name="admin_project_permissions", methods={"GET", "POST"}) + * @Security("is_granted('permissions', project)") + */ + public function teamPermissions(Project $project, Request $request) + { + $form = $this->createForm(ProjectTeamPermissionForm::class, $project, [ + 'action' => $this->generateUrl('admin_project_permissions', ['id' => $project->getId()]), + 'method' => 'POST', + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->getRepository()->saveProject($project); + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_project'); + } catch (ORMException $ex) { + $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); + } + } + + return $this->render('project/permissions.html.twig', [ + 'project' => $project, + 'form' => $form->createView() + ]); + } + /** * @Route(path="/create", name="admin_project_create", methods={"GET", "POST"}) * @Route(path="/create/{customer}", name="admin_project_create_with_customer", methods={"GET", "POST"}) * @Security("is_granted('create_project')") - * - * @param Request $request - * @param Customer|null $customer - * @return RedirectResponse|Response */ public function createAction(Request $request, ?Customer $customer = null) { @@ -108,9 +138,6 @@ class ProjectController extends AbstractController /** * @Route(path="/{id}/budget", name="admin_project_budget", methods={"GET"}) * @Security("is_granted('budget', project)") - * - * @param Project $project - * @return Response */ public function budgetAction(Project $project) { @@ -123,10 +150,6 @@ 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 RedirectResponse|Response */ public function editAction(Project $project, Request $request) { @@ -136,10 +159,6 @@ class ProjectController extends AbstractController /** * @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"}) * @Security("is_granted('delete', project)") - * - * @param Project $project - * @param Request $request - * @return RedirectResponse|Response */ public function deleteAction(Project $project, Request $request) { @@ -158,6 +177,7 @@ class ProjectController extends AbstractController $query = new ProjectFormTypeQuery(); $query->setCustomer($project->getCustomer()); $query->setProjectToIgnore($project); + $query->setUser($this->getUser()); return $repo->getQueryBuilderForFormType($query); }, @@ -225,11 +245,7 @@ class ProjectController extends AbstractController ]); } - /** - * @param ProjectQuery $query - * @return FormInterface - */ - protected function getToolbarForm(ProjectQuery $query) + protected function getToolbarForm(ProjectQuery $query): FormInterface { return $this->createForm(ProjectToolbarForm::class, $query, [ 'action' => $this->generateUrl('admin_project', [ @@ -239,16 +255,12 @@ class ProjectController extends AbstractController ]); } - /** - * @param Project $project - * @return FormInterface - */ - private function createEditForm(Project $project) + private function createEditForm(Project $project): FormInterface { - if ($project->getId() === null) { - $url = $this->generateUrl('admin_project_create'); - $currency = Customer::DEFAULT_CURRENCY; - } else { + $currency = $this->configuration->getCustomerDefaultCurrency(); + $url = $this->generateUrl('admin_project_create'); + + if ($project->getId() !== null) { $url = $this->generateUrl('admin_project_edit', ['id' => $project->getId()]); $currency = $project->getCustomer()->getCurrency(); } diff --git a/src/Controller/SystemConfigurationController.php b/src/Controller/SystemConfigurationController.php index 1587d357..475844f5 100644 --- a/src/Controller/SystemConfigurationController.php +++ b/src/Controller/SystemConfigurationController.php @@ -248,6 +248,10 @@ class SystemConfigurationController extends AbstractController ->setName('defaults.user.theme') ->setLabel('skin') ->setType(SkinType::class), + (new Configuration()) + ->setName('defaults.user.currency') + ->setLabel('currency') + ->setType(CurrencyType::class), ]), (new SystemConfigurationModel()) ->setSection(SystemConfigurationModel::SECTION_THEME) diff --git a/src/Controller/TeamController.php b/src/Controller/TeamController.php new file mode 100644 index 00000000..7bb11643 --- /dev/null +++ b/src/Controller/TeamController.php @@ -0,0 +1,185 @@ +repository = $repository; + } + + /** + * @Route(path="/", defaults={"page": 1}, name="admin_team", methods={"GET"}) + * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_team_paginated", methods={"GET"}) + * + * @param TeamRepository $repository + * @param Request $request + * @param int $page + * @return Response + */ + public function listTeams(TeamRepository $repository, Request $request, $page) + { + $query = new TeamQuery(); + $query->setPage($page); + $query->setOrderBy('name'); + + $form = $this->getToolbarForm($query); + $form->setData($query); + $form->submit($request->query->all(), false); + + $teams = $repository->getPagerfantaForQuery($query); + + return $this->render('team/index.html.twig', [ + 'teams' => $teams, + 'query' => $query, + 'showFilter' => $query->isDirty(), + 'toolbarForm' => $form->createView(), + ]); + } + + /** + * @Route(path="/create", name="admin_team_create", methods={"GET", "POST"}) + * @Security("is_granted('create_team')") + * + * @param Request $request + * @return RedirectResponse|Response + */ + public function createTeam(Request $request) + { + return $this->renderEditScreen(new Team(), $request); + } + + /** + * @Route(path="/{id}/edit", name="admin_team_edit", methods={"GET", "POST"}) + * @Security("is_granted('edit', team)") + */ + public function editAction(Team $team, Request $request) + { + return $this->renderEditScreen($team, $request); + } + + private function renderEditScreen(Team $team, Request $request): Response + { + $customerForm = null; + $projectForm = null; + + if ($team->getId() === null) { + $url = $this->generateUrl('admin_team_create'); + } else { + $url = $this->generateUrl('admin_team_edit', ['id' => $team->getId()]); + } + + $editForm = $this->createForm(TeamEditForm::class, $team, [ + 'action' => $url, + 'method' => 'POST', + ]); + + if ($request->isMethod('POST') && (null !== ($editFormValues = $request->get($editForm->getName())))) { + $editForm->submit($editFormValues, true); + + if ($editForm->isValid()) { + try { + // make sure that the teamlead is always part of the team, otherwise permission checks + // and filtering might not work as expected! + $team->addUser($team->getTeamLead()); + + $this->repository->saveTeam($team); + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]); + } catch (ORMException $ex) { + $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); + } + } + } + + if (null !== $team->getId()) { + $customerForm = $this->createForm(TeamCustomerForm::class, $team, [ + 'method' => 'POST', + ]); + + if ($request->isMethod('POST') && (null !== ($customerFormValues = $request->get($customerForm->getName())))) { + $customerForm->submit($customerFormValues, true); + + if ($customerForm->isValid()) { + try { + $this->repository->saveTeam($team); + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]); + } catch (ORMException $ex) { + $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); + } + } + } + + $projectForm = $this->createForm(TeamProjectForm::class, $team, [ + 'method' => 'POST', + ]); + + if ($request->isMethod('POST') && (null !== ($projectFormValues = $request->get($projectForm->getName())))) { + $projectForm->submit($projectFormValues, true); + + if ($projectForm->isValid()) { + try { + $this->repository->saveTeam($team); + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]); + } catch (ORMException $ex) { + $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); + } + } + } + } + + return $this->render('team/edit.html.twig', [ + 'team' => $team, + 'form' => $editForm->createView(), + 'customerForm' => $customerForm ? $customerForm->createView() : null, + 'projectForm' => $projectForm ? $projectForm->createView() : null, + ]); + } + + private function getToolbarForm(TeamQuery $query): FormInterface + { + return $this->createForm(TeamToolbarForm::class, $query, [ + 'action' => $this->generateUrl('admin_team', [ + 'page' => $query->getPage(), + ]), + 'method' => 'GET', + ]); + } +} diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index 4245e874..ecda708e 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -112,9 +112,7 @@ abstract class TimesheetAbstractController extends AbstractController $dirtyQuery = $query->isDirty(); - if (!$this->includeUserInForms()) { - $query->setUser($this->getUser()); - } + $this->prepareQuery($query); $pager = $this->getRepository()->getPagerfantaForQuery($query); @@ -253,9 +251,7 @@ abstract class TimesheetAbstractController extends AbstractController } $query->getEnd()->setTime(23, 59, 59); - if (!$this->includeUserInForms()) { - $query->setUser($this->getUser()); - } + $this->prepareQuery($query); $entries = $this->getRepository()->getTimesheetsForQuery($query); @@ -265,6 +261,11 @@ abstract class TimesheetAbstractController extends AbstractController ]); } + protected function prepareQuery(TimesheetQuery $query) + { + $query->setUser($this->getUser()); + } + protected function getCreateForm(Timesheet $entry, TrackingModeInterface $mode): FormInterface { return $this->createForm($this->getCreateFormClassName(), $entry, [ diff --git a/src/Controller/TimesheetTeamController.php b/src/Controller/TimesheetTeamController.php index d385f88f..2910c29b 100644 --- a/src/Controller/TimesheetTeamController.php +++ b/src/Controller/TimesheetTeamController.php @@ -13,6 +13,7 @@ use App\Entity\Timesheet; use App\Form\TimesheetAdminEditForm; use App\Repository\ActivityRepository; use App\Repository\ProjectRepository; +use App\Repository\Query\TimesheetQuery; use App\Repository\TagRepository; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpFoundation\Request; @@ -76,6 +77,11 @@ class TimesheetTeamController extends TimesheetAbstractController return $this->create($request, 'timesheet-team/edit.html.twig', $projectRepository, $activityRepository, $tagRepository); } + protected function prepareQuery(TimesheetQuery $query) + { + $query->setCurrentUser($this->getUser()); + } + protected function getCreateFormClassName(): string { return TimesheetAdminEditForm::class; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2f68045d..bcc44061 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -9,6 +9,7 @@ namespace App\DependencyInjection; +use App\Entity\Customer; use App\Entity\User; use App\Timesheet\Rounding\RoundingInterface; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; @@ -439,7 +440,7 @@ class Configuration implements ConfigurationInterface ->children() ->scalarNode('timezone')->defaultNull()->end() ->scalarNode('country')->defaultValue('DE')->end() - ->scalarNode('currency')->defaultValue('EUR')->end() + ->scalarNode('currency')->defaultValue(Customer::DEFAULT_CURRENCY)->end() ->end() ->end() ->arrayNode('user') @@ -448,9 +449,9 @@ class Configuration implements ConfigurationInterface ->scalarNode('timezone')->defaultNull()->end() ->scalarNode('language')->defaultValue(User::DEFAULT_LANGUAGE)->end() ->scalarNode('theme')->defaultNull()->end() + ->scalarNode('currency')->defaultValue(Customer::DEFAULT_CURRENCY)->end() ->end() ->end() - ->end() ; diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 0de21ec4..e33ddb55 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -160,9 +160,26 @@ class Customer implements EntityWithMetaFields */ private $meta; + /** + * @var Team[]|ArrayCollection + * + * @ORM\ManyToMany(targetEntity="Team", cascade={"remove", "persist"}, inversedBy="customers") + * @ORM\JoinTable( + * name="kimai2_customers_teams", + * joinColumns={ + * @ORM\JoinColumn(name="customer_id", referencedColumnName="id", onDelete="CASCADE") + * }, + * inverseJoinColumns={ + * @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE") + * } + * ) + */ + private $teams; + public function __construct() { $this->meta = new ArrayCollection(); + $this->teams = new ArrayCollection(); } public function getId(): ?int @@ -399,6 +416,33 @@ class Customer implements EntityWithMetaFields return $this; } + public function addTeam(Team $team) + { + if ($this->teams->contains($team)) { + return $this; + } + + $this->teams->add($team); + $team->addCustomer($this); + } + + public function removeTeam(Team $team) + { + if (!$this->teams->contains($team)) { + return; + } + $this->teams->removeElement($team); + $team->removeCustomer($this); + } + + /** + * @return Collection + */ + public function getTeams(): Collection + { + return $this->teams; + } + /** * @return string */ diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 7ba41e1c..b2ddc8ae 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -90,9 +90,26 @@ class Project implements EntityWithMetaFields */ private $meta; + /** + * @var Team[]|ArrayCollection + * + * @ORM\ManyToMany(targetEntity="Team", cascade={"remove", "persist"}, inversedBy="projects") + * @ORM\JoinTable( + * name="kimai2_projects_teams", + * joinColumns={ + * @ORM\JoinColumn(name="project_id", referencedColumnName="id", onDelete="CASCADE") + * }, + * inverseJoinColumns={ + * @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE") + * } + * ) + */ + private $teams; + public function __construct() { $this->meta = new ArrayCollection(); + $this->teams = new ArrayCollection(); } public function getId(): ?int @@ -223,6 +240,33 @@ class Project implements EntityWithMetaFields return $this; } + public function addTeam(Team $team) + { + if ($this->teams->contains($team)) { + return; + } + + $this->teams->add($team); + $team->addProject($this); + } + + public function removeTeam(Team $team) + { + if (!$this->teams->contains($team)) { + return; + } + $this->teams->removeElement($team); + $team->removeProject($this); + } + + /** + * @return Collection + */ + public function getTeams(): Collection + { + return $this->teams; + } + /** * @return string */ diff --git a/src/Entity/Team.php b/src/Entity/Team.php new file mode 100644 index 00000000..fb659904 --- /dev/null +++ b/src/Entity/Team.php @@ -0,0 +1,210 @@ +users = new ArrayCollection(); + $this->customers = new ArrayCollection(); + $this->projects = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setName(string $name): Team + { + $this->name = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getTeamLead(): ?User + { + return $this->teamlead; + } + + public function isTeamlead(User $user): bool + { + return $this->teamlead === $user; + } + + public function setTeamLead(User $teamlead): Team + { + $this->teamlead = $teamlead; + $this->addUser($teamlead); + + return $this; + } + + public function hasUser(User $user): bool + { + return $this->users->contains($user); + } + + public function addUser(User $user) + { + if ($this->users->contains($user)) { + return; + } + + $this->users->add($user); + $user->addTeam($this); + } + + public function removeUser(User $user) + { + if (!$this->users->contains($user)) { + return; + } + + $this->users->removeElement($user); + $user->removeTeam($this); + } + + /** + * @return Collection + */ + public function getUsers(): iterable + { + return $this->users; + } + + public function addCustomer(Customer $customer) + { + if ($this->customers->contains($customer)) { + return; + } + + $this->customers->add($customer); + $customer->addTeam($this); + } + + public function removeCustomer(Customer $customer) + { + if (!$this->customers->contains($customer)) { + return; + } + + $this->customers->removeElement($customer); + $customer->removeTeam($this); + } + + /** + * @return Collection + */ + public function getCustomers(): iterable + { + return $this->customers; + } + + public function addProject(Project $project) + { + if ($this->projects->contains($project)) { + return; + } + + $this->projects->add($project); + $project->addTeam($this); + } + + public function removeProject(Project $project) + { + if (!$this->projects->contains($project)) { + return; + } + + $this->projects->removeElement($project); + $project->removeTeam($this); + } + + /** + * @return Collection + */ + public function getProjects(): iterable + { + return $this->projects; + } + + /** + * @return string + */ + public function __toString() + { + return $this->getName(); + } +} diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index d69fecca..c2461c9c 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -140,14 +140,14 @@ class Timesheet implements EntityWithMetaFields /** * @var Tag[]|ArrayCollection * - * @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"persist"}) + * @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"remove", "persist"}) * @ORM\JoinTable( * name="kimai2_timesheet_tags", * joinColumns={ - * @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id") + * @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id", onDelete="CASCADE") * }, * inverseJoinColumns={ - * @ORM\JoinColumn(name="tag_id", referencedColumnName="id") + * @ORM\JoinColumn(name="tag_id", referencedColumnName="id", onDelete="CASCADE") * } * ) */ diff --git a/src/Entity/User.php b/src/Entity/User.php index 7cbffc54..a6a093ab 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -95,6 +95,22 @@ class User extends BaseUser implements UserInterface */ private $preferences; + /** + * @var Team[]|ArrayCollection + * + * @ORM\ManyToMany(targetEntity="Team", inversedBy="users", cascade={"remove", "persist"}) + * @ORM\JoinTable( + * name="kimai2_users_teams", + * joinColumns={ + * @ORM\JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE") + * }, + * inverseJoinColumns={ + * @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE") + * } + * ) + */ + private $teams; + /** * User constructor. */ @@ -103,6 +119,7 @@ class User extends BaseUser implements UserInterface parent::__construct(); $this->registeredAt = new \DateTime(); $this->preferences = new ArrayCollection(); + $this->teams = new ArrayCollection(); } public function getId(): ?int @@ -274,6 +291,55 @@ class User extends BaseUser implements UserInterface return $this; } + public function addTeam(Team $team): User + { + if ($this->teams->contains($team)) { + return $this; + } + + $this->teams->add($team); + $team->addUser($this); + + return $this; + } + + public function removeTeam(Team $team) + { + if (!$this->teams->contains($team)) { + return; + } + $this->teams->removeElement($team); + $team->removeUser($this); + } + + /** + * @return Collection + */ + public function getTeams(): Collection + { + return $this->teams; + } + + public function isInTeam(Team $team): bool + { + return $this->teams->contains($team); + } + + public function isTeamleadOf(Team $team): bool + { + return $team->getTeamLead() === $this; + } + + public function isTeamlead(): bool + { + return $this->hasRole(static::ROLE_TEAMLEAD); + } + + public function isAdmin(): bool + { + return $this->hasRole(static::ROLE_ADMIN); + } + /** * @return string */ diff --git a/src/Entity/UserPreference.php b/src/Entity/UserPreference.php index 1893f2a6..92b41d2d 100644 --- a/src/Entity/UserPreference.php +++ b/src/Entity/UserPreference.php @@ -38,7 +38,6 @@ class UserPreference * @ORM\Column(name="id", type="integer") */ private $id; - /** * @var User * @@ -47,7 +46,6 @@ class UserPreference * @Assert\NotNull() */ private $user; - /** * @var string * @@ -55,28 +53,29 @@ class UserPreference * @Assert\Length(min=2, max=50) */ private $name; - /** * @var string * * @ORM\Column(name="value", type="string", length=255, nullable=true) */ private $value; - /** * @var string */ - protected $type; - + private $type; /** * @var bool */ - protected $enabled = true; - + private $enabled = true; /** * @var Constraint[] */ - protected $constraints = []; + private $constraints = []; + /** + * An array of options for the form element + * @var array + */ + private $options = []; /** * @return int @@ -237,4 +236,27 @@ class UserPreference { return $this->constraints; } + + /** + * Set an array of options for the FormType. + * + * @param array $options + * @return UserPreference + */ + public function setOptions(array $options): UserPreference + { + $this->options = $options; + + return $this; + } + + /** + * Returns an array with options for the FormType. + * + * @return array + */ + public function getOptions(): array + { + return $this->options; + } } diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php index 3381fa6e..a4d578b5 100644 --- a/src/EventSubscriber/MenuSubscriber.php +++ b/src/EventSubscriber/MenuSubscriber.php @@ -142,6 +142,12 @@ final class MenuSubscriber implements EventSubscriberInterface ); } + if ($auth->isGranted('view_team')) { + $menu->addChild( + new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $this->getIcon('team')) + ); + } + if ($auth->isGranted('plugins')) { $menu->addChild( new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $this->getIcon('plugin')) diff --git a/src/EventSubscriber/UserPreferenceSubscriber.php b/src/EventSubscriber/UserPreferenceSubscriber.php index c09b5a6e..75a901b8 100644 --- a/src/EventSubscriber/UserPreferenceSubscriber.php +++ b/src/EventSubscriber/UserPreferenceSubscriber.php @@ -66,6 +66,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface return $this->formConfig->getUserDefaultTheme(); } + private function getDefaultCurrency(): ?string + { + return $this->formConfig->getUserDefaultCurrency(); + } + private function getDefaultLanguage(): string { return $this->formConfig->getUserDefaultLanguage(); @@ -88,9 +93,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface public function getDefaultPreferences(User $user) { $enableHourlyRate = false; + $hourlyRateOptions = []; if ($this->voter->isGranted('hourly-rate', $user)) { $enableHourlyRate = true; + $hourlyRateOptions = ['currency' => $this->getDefaultCurrency()]; } return [ @@ -99,6 +106,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface ->setValue(0) ->setType(MoneyType::class) ->setEnabled($enableHourlyRate) + ->setOptions($hourlyRateOptions) ->addConstraint(new Range(['min' => 0])), (new UserPreference()) @@ -165,6 +173,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface ->setType($preference->getType()) ->setConstraints($preference->getConstraints()) ->setEnabled($preference->isEnabled()) + ->setOptions($preference->getOptions()) ; } else { $prefs[$preference->getName()] = $preference; diff --git a/src/Form/ActivityEditForm.php b/src/Form/ActivityEditForm.php index 9ae012d2..8a5d6388 100644 --- a/src/Form/ActivityEditForm.php +++ b/src/Form/ActivityEditForm.php @@ -67,8 +67,11 @@ class ActivityEditForm extends AbstractType if ($options['customer']) { $builder ->add('customer', CustomerType::class, [ - 'query_builder' => function (CustomerRepository $repo) use ($customer) { - return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer)); + 'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) { + $query = new CustomerFormTypeQuery($customer); + $query->setUser($builder->getOption('user')); + + return $repo->getQueryBuilderForFormType($query); }, 'data' => $customer ? $customer : null, 'required' => false, @@ -80,15 +83,18 @@ class ActivityEditForm extends AbstractType $builder ->add('project', ProjectType::class, [ 'required' => false, - 'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { - return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer)); + 'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) { + $query = new ProjectFormTypeQuery($project, $customer); + $query->setUser($builder->getOption('user')); + + return $repo->getQueryBuilderForFormType($query); }, ]); // 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) { + function (FormEvent $event) use ($builder, $project) { $data = $event->getData(); if (!isset($data['customer']) || empty($data['customer'])) { return; @@ -96,8 +102,11 @@ class ActivityEditForm extends AbstractType $event->getForm()->add('project', ProjectType::class, [ 'group_by' => null, - 'query_builder' => function (ProjectRepository $repo) use ($data, $project) { - return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $data['customer'])); + 'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $project) { + $query = new ProjectFormTypeQuery($project, $data['customer']); + $query->setUser($builder->getOption('user')); + + return $repo->getQueryBuilderForFormType($query); }, ]); } diff --git a/src/Form/CustomerTeamPermissionForm.php b/src/Form/CustomerTeamPermissionForm.php new file mode 100644 index 00000000..1c314b38 --- /dev/null +++ b/src/Form/CustomerTeamPermissionForm.php @@ -0,0 +1,51 @@ +add('teams', TeamType::class, [ + 'multiple' => true, + 'expanded' => true, + 'by_reference' => false, + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Customer::class, + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'admin_customer_teams_edit', + 'attr' => [ + 'data-form-event' => 'kimai.customerTeamUpdate' + ], + ]); + } +} diff --git a/src/Form/Extension/UserExtension.php b/src/Form/Extension/UserExtension.php new file mode 100644 index 00000000..570ca8ec --- /dev/null +++ b/src/Form/Extension/UserExtension.php @@ -0,0 +1,45 @@ +user = $user; + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } + + /** + * @param OptionsResolver $resolver + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefined(['user']); + // null needs to be allowed, as there is no user for anonymoud forms (like "forgot password" and "registration") + $resolver->setAllowedTypes('user', [User::class, 'null']); + $resolver->setDefault('user', $this->user->getUser()); + } +} diff --git a/src/Form/ProjectEditForm.php b/src/Form/ProjectEditForm.php index b214f228..ff8ce7df 100644 --- a/src/Form/ProjectEditForm.php +++ b/src/Form/ProjectEditForm.php @@ -59,8 +59,11 @@ class ProjectEditForm extends AbstractType 'required' => false, ]) ->add('customer', CustomerType::class, [ - 'query_builder' => function (CustomerRepository $repo) use ($customer) { - return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer)); + 'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) { + $query = new CustomerFormTypeQuery($customer); + $query->setUser($builder->getOption('user')); + + return $repo->getQueryBuilderForFormType($query); }, ]); diff --git a/src/Form/ProjectTeamPermissionForm.php b/src/Form/ProjectTeamPermissionForm.php new file mode 100644 index 00000000..c0a9a989 --- /dev/null +++ b/src/Form/ProjectTeamPermissionForm.php @@ -0,0 +1,51 @@ +add('teams', TeamType::class, [ + 'multiple' => true, + 'expanded' => true, + 'by_reference' => false, + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Project::class, + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'admin_project_teams_edit', + 'attr' => [ + 'data-form-event' => 'kimai.projectTeamUpdate' + ], + ]); + } +} diff --git a/src/Form/TeamCustomerForm.php b/src/Form/TeamCustomerForm.php new file mode 100644 index 00000000..2deb5cce --- /dev/null +++ b/src/Form/TeamCustomerForm.php @@ -0,0 +1,51 @@ +add('customers', CustomerType::class, [ + 'multiple' => true, + 'expanded' => true, + 'by_reference' => false, + 'query_builder_for_user' => false, + 'required' => false, + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Team::class, + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'admin_team_customer', + 'attr' => [ + 'data-form-event' => 'kimai.teamUpdate' + ], + ]); + } +} diff --git a/src/Form/TeamEditForm.php b/src/Form/TeamEditForm.php new file mode 100644 index 00000000..735de8cd --- /dev/null +++ b/src/Form/TeamEditForm.php @@ -0,0 +1,61 @@ +add('name', TextType::class, [ + 'label' => 'label.name', + 'attr' => [ + 'autofocus' => 'autofocus' + ], + ]) + ->add('teamlead', UserType::class, [ + 'label' => 'label.teamlead', + 'multiple' => false, + 'expanded' => false, + ]) + ->add('users', UserType::class, [ + 'multiple' => true, + 'expanded' => true, + 'by_reference' => false, + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Team::class, + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'admin_team_edit', + 'attr' => [ + 'data-form-event' => 'kimai.teamUpdate' + ], + ]); + } +} diff --git a/src/Form/TeamProjectForm.php b/src/Form/TeamProjectForm.php new file mode 100644 index 00000000..2e53847c --- /dev/null +++ b/src/Form/TeamProjectForm.php @@ -0,0 +1,52 @@ +add('projects', ProjectType::class, [ + 'multiple' => true, + 'expanded' => false, + 'by_reference' => false, + 'attr' => ['size' => '20'], + 'query_builder_for_user' => false, + 'required' => false, + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Team::class, + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'admin_team_project', + 'attr' => [ + 'data-form-event' => 'kimai.teamUpdate' + ], + ]); + } +} diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index b57c3a0d..a9aadb49 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -163,8 +163,11 @@ class TimesheetEditForm extends AbstractType { $builder ->add('customer', CustomerType::class, [ - 'query_builder' => function (CustomerRepository $repo) use ($customer) { - return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer)); + 'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) { + $query = new CustomerFormTypeQuery($customer); + $query->setUser($builder->getOption('user')); + + return $repo->getQueryBuilderForFormType($query); }, 'data' => $customer ? $customer : '', 'required' => false, @@ -189,8 +192,11 @@ class TimesheetEditForm extends AbstractType array_merge($projectOptions, [ 'placeholder' => '', 'activity_enabled' => true, - 'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { - return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer)); + 'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) { + $query = new ProjectFormTypeQuery($project, $customer); + $query->setUser($builder->getOption('user')); + + return $repo->getQueryBuilderForFormType($query); }, ]) ); @@ -198,7 +204,7 @@ class TimesheetEditForm extends AbstractType // 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, $customer, $isNew) { + function (FormEvent $event) use ($builder, $project, $customer, $isNew) { $data = $event->getData(); $customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null; $project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project; @@ -207,7 +213,7 @@ class TimesheetEditForm extends AbstractType 'placeholder' => '', 'activity_enabled' => true, 'group_by' => null, - 'query_builder' => function (ProjectRepository $repo) use ($project, $customer, $isNew) { + 'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) { // is there a better wa to prevent starting a record with a hidden project ? if ($isNew && !is_object($project)) { /** @var Project $project */ @@ -221,8 +227,10 @@ class TimesheetEditForm extends AbstractType } } } + $query = new ProjectFormTypeQuery($project, $customer); + $query->setUser($builder->getOption('user')); - return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer)); + return $repo->getQueryBuilderForFormType($query); }, ]); } diff --git a/src/Form/Toolbar/AbstractToolbarForm.php b/src/Form/Toolbar/AbstractToolbarForm.php index 813afc19..43224602 100644 --- a/src/Form/Toolbar/AbstractToolbarForm.php +++ b/src/Form/Toolbar/AbstractToolbarForm.php @@ -66,13 +66,14 @@ abstract class AbstractToolbarForm extends AbstractType $builder->addEventListener( FormEvents::PRE_SUBMIT, - function (FormEvent $event) { + function (FormEvent $event) use ($builder) { $data = $event->getData(); $event->getForm()->add('customer', CustomerType::class, [ 'required' => false, 'project_enabled' => true, - 'query_builder' => function (CustomerRepository $repo) use ($data) { + 'query_builder' => function (CustomerRepository $repo) use ($builder, $data) { $query = new CustomerFormTypeQuery(); + $query->setUser($builder->getOption('user')); if (isset($data['customer']) && !empty($data['customer'])) { $query->setCustomer($data['customer']); } @@ -138,13 +139,14 @@ abstract class AbstractToolbarForm extends AbstractType $builder->addEventListener( FormEvents::PRE_SUBMIT, - function (FormEvent $event) { + function (FormEvent $event) use ($builder) { $data = $event->getData(); $event->getForm()->add('project', ProjectType::class, [ 'required' => false, 'activity_enabled' => true, - 'query_builder' => function (ProjectRepository $repo) use ($data) { + 'query_builder' => function (ProjectRepository $repo) use ($builder, $data) { $query = new ProjectFormTypeQuery(); + $query->setUser($builder->getOption('user')); if (isset($data['customer']) && !empty($data['customer'])) { $query->setCustomer($data['customer']); diff --git a/src/Form/Toolbar/TeamToolbarForm.php b/src/Form/Toolbar/TeamToolbarForm.php new file mode 100644 index 00000000..582952b0 --- /dev/null +++ b/src/Form/Toolbar/TeamToolbarForm.php @@ -0,0 +1,37 @@ +addPageSizeChoice($builder); + $this->addHiddenPagination($builder); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => TeamQuery::class, + 'csrf_protection' => false, + ]); + } +} diff --git a/src/Form/Type/CustomerType.php b/src/Form/Type/CustomerType.php index bab7ab9a..4217438c 100644 --- a/src/Form/Type/CustomerType.php +++ b/src/Form/Type/CustomerType.php @@ -37,13 +37,22 @@ class CustomerType extends AbstractType 'label' => 'label.customer', 'class' => Customer::class, 'choice_label' => 'name', - 'query_builder' => function (CustomerRepository $repo) { - return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery()); - }, + 'query_builder_for_user' => true, 'project_enabled' => false, 'project_visibility' => ProjectQuery::SHOW_VISIBLE, ]); + $resolver->setDefault('query_builder', function (Options $options) { + return function (CustomerRepository $repo) use ($options) { + $query = new CustomerFormTypeQuery(); + if (true === $options['query_builder_for_user']) { + $query->setUser($options['user']); + } + + return $repo->getQueryBuilderForFormType($query); + }; + }); + $resolver->setDefault('api_data', function (Options $options) { if (true === $options['project_enabled']) { return [ diff --git a/src/Form/Type/ProjectType.php b/src/Form/Type/ProjectType.php index bee6148d..fc05f0e9 100644 --- a/src/Form/Type/ProjectType.php +++ b/src/Form/Type/ProjectType.php @@ -62,13 +62,22 @@ class ProjectType extends AbstractType 'group_by' => function (Project $project, $key, $index) { return $project->getCustomer()->getName(); }, - 'query_builder' => function (ProjectRepository $repo) { - return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery()); - }, + 'query_builder_for_user' => true, 'activity_enabled' => false, 'activity_visibility' => ActivityQuery::SHOW_VISIBLE, ]); + $resolver->setDefault('query_builder', function (Options $options) { + return function (ProjectRepository $repo) use ($options) { + $query = new ProjectFormTypeQuery(); + if (true === $options['query_builder_for_user']) { + $query->setUser($options['user']); + } + + return $repo->getQueryBuilderForFormType($query); + }; + }); + $resolver->setDefault('api_data', function (Options $options) { if (true === $options['activity_enabled']) { return [ diff --git a/src/Form/Type/TeamType.php b/src/Form/Type/TeamType.php new file mode 100644 index 00000000..61521896 --- /dev/null +++ b/src/Form/Type/TeamType.php @@ -0,0 +1,44 @@ +setDefaults([ + 'class' => Team::class, + 'label' => 'label.team', + 'query_builder' => function (TeamRepository $repo) { + return $repo->createQueryBuilder('t')->orderBy('t.name', 'ASC'); + }, + 'choice_label' => function (Team $team) { + return $team->getName(); + }, + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return EntityType::class; + } +} diff --git a/src/Form/Type/UserPreferenceType.php b/src/Form/Type/UserPreferenceType.php index 3390a80a..e23a730e 100644 --- a/src/Form/Type/UserPreferenceType.php +++ b/src/Form/Type/UserPreferenceType.php @@ -17,12 +17,23 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Custom form field type to edit a user preference. */ class UserPreferenceType extends AbstractType { + /** + * @var TranslatorInterface + */ + private $translate; + + public function __construct(TranslatorInterface $translator) + { + $this->translate = $translator; + } + /** * @param FormBuilderInterface $builder * @param array $options @@ -54,12 +65,22 @@ class UserPreferenceType extends AbstractType $type = HiddenType::class; } - $event->getForm()->add('value', $type, [ - 'label' => 'label.' . $preference->getName(), - 'constraints' => $preference->getConstraints(), - 'required' => $required, - 'disabled' => !$preference->isEnabled(), - ]); + $transId = 'label.' . $preference->getName(); + if ($this->translate->trans($transId) === $transId) { + $transId = $preference->getName(); + } + + $options = array_merge( + [ + 'label' => $transId, + 'constraints' => $preference->getConstraints(), + 'required' => $required, + 'disabled' => !$preference->isEnabled(), + ], + $preference->getOptions() + ); + + $event->getForm()->add('value', $type, $options); } ); $builder->add('name', HiddenType::class); diff --git a/src/Form/Type/UserType.php b/src/Form/Type/UserType.php index 0c78a8f3..39569513 100644 --- a/src/Form/Type/UserType.php +++ b/src/Form/Type/UserType.php @@ -10,8 +10,11 @@ namespace App\Form\Type; use App\Entity\User; +use App\Repository\Query\UserFormTypeQuery; +use App\Repository\UserRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -35,6 +38,15 @@ class UserType extends AbstractType return $user->getUsername(); }, ]); + + $resolver->setDefault('query_builder', function (Options $options) { + return function (UserRepository $repo) use ($options) { + $query = new UserFormTypeQuery(); + $query->setUser($options['user']); + + return $repo->getQueryBuilderForFormType($query); + }; + }); } /** diff --git a/src/Form/UserTeamsType.php b/src/Form/UserTeamsType.php new file mode 100644 index 00000000..0b645677 --- /dev/null +++ b/src/Form/UserTeamsType.php @@ -0,0 +1,49 @@ +add('teams', TeamType::class, [ + 'label' => 'label.team', + 'multiple' => true, + 'expanded' => true, + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => User::class, + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'edit_user_teams', + ]); + } +} diff --git a/src/Migrations/Version20190510205245.php b/src/Migrations/Version20190510205245.php index 99dbd129..c06a78d6 100644 --- a/src/Migrations/Version20190510205245.php +++ b/src/Migrations/Version20190510205245.php @@ -39,13 +39,6 @@ class Version20190510205245 extends AbstractMigration public function down(Schema $schema): void { - $tags = $schema->getTable('kimai2_tags'); - $tags->dropIndex('UNIQ_27CAF54C5E237E06'); - - $timesheetTags = $schema->getTable('kimai2_timesheet_tags'); - $timesheetTags->dropIndex('IDX_E3284EFEABDD46BE'); - $timesheetTags->dropIndex('IDX_E3284EFEBAD26311'); - $schema->dropTable('kimai2_timesheet_tags'); $schema->dropTable('kimai2_tags'); } diff --git a/src/Migrations/Version20190729162655.php b/src/Migrations/Version20190729162655.php new file mode 100644 index 00000000..45ac2484 --- /dev/null +++ b/src/Migrations/Version20190729162655.php @@ -0,0 +1,44 @@ +getTable('kimai2_timesheet_tags'); + + if (!$timesheetTags->hasForeignKey('FK_732EECA9ABDD46BE')) { + $timesheetTags->addForeignKeyConstraint('kimai2_timesheet', ['timesheet_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_732EECA9ABDD46BE'); + } + if (!$timesheetTags->hasForeignKey('FK_732EECA9BAD26311')) { + $timesheetTags->addForeignKeyConstraint('kimai2_tags', ['tag_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_732EECA9BAD26311'); + } + } + + public function down(Schema $schema): void + { + } +} diff --git a/src/Migrations/Version20190730123324.php b/src/Migrations/Version20190730123324.php new file mode 100644 index 00000000..5c84dc33 --- /dev/null +++ b/src/Migrations/Version20190730123324.php @@ -0,0 +1,68 @@ +createTable('kimai2_teams'); + $teams->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]); + $teams->addColumn('name', 'string', ['notnull' => true, 'length' => 100]); + $teams->addColumn('teamlead_id', 'integer', ['length' => 11, 'notnull' => true]); + $teams->setPrimaryKey(['id']); + $teams->addUniqueIndex(['name'], 'UNIQ_3BEDDC7F5E237E06'); + $teams->addForeignKeyConstraint('kimai2_users', ['teamlead_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_3BEDDC7F8F7DE5D7'); + + $userTeams = $schema->createTable('kimai2_users_teams'); + $userTeams->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => true]); + $userTeams->addColumn('team_id', 'integer', ['length' => 11, 'notnull' => true]); + $userTeams->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_B5E92CF8A76ED395'); + $userTeams->addForeignKeyConstraint('kimai2_teams', ['team_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_B5E92CF8296CD8AE'); + $userTeams->setPrimaryKey(['user_id', 'team_id']); + + $customerTeams = $schema->createTable('kimai2_customers_teams'); + $customerTeams->addColumn('customer_id', 'integer', ['length' => 11, 'notnull' => true]); + $customerTeams->addColumn('team_id', 'integer', ['length' => 11, 'notnull' => true]); + $customerTeams->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_50BD83889395C3F3'); + $customerTeams->addForeignKeyConstraint('kimai2_teams', ['team_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_50BD8388296CD8AE'); + $customerTeams->setPrimaryKey(['customer_id', 'team_id']); + + $projectTeams = $schema->createTable('kimai2_projects_teams'); + $projectTeams->addColumn('project_id', 'integer', ['length' => 11, 'notnull' => true]); + $projectTeams->addColumn('team_id', 'integer', ['length' => 11, 'notnull' => true]); + $projectTeams->addForeignKeyConstraint('kimai2_projects', ['project_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_9345D431166D1F9C'); + $projectTeams->addForeignKeyConstraint('kimai2_teams', ['team_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_9345D431296CD8AE'); + $projectTeams->setPrimaryKey(['project_id', 'team_id']); + } + + public function down(Schema $schema): void + { + $schema->dropTable('kimai2_projects_teams'); + $schema->dropTable('kimai2_customers_teams'); + $schema->dropTable('kimai2_users_teams'); + $schema->dropTable('kimai2_teams'); + } +} diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php index b8bfd362..8ecf66c2 100644 --- a/src/Repository/ActivityRepository.php +++ b/src/Repository/ActivityRepository.php @@ -11,6 +11,7 @@ namespace App\Repository; use App\Entity\Activity; use App\Entity\Timesheet; +use App\Entity\User; use App\Model\ActivityStatistic; use App\Repository\Loader\ActivityLoader; use App\Repository\Paginator\LoaderPaginator; @@ -81,6 +82,47 @@ class ActivityRepository extends EntityRepository return $stats; } + private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []) + { + // make sure that all queries without a user see all projects + if (null === $user && empty($teams)) { + return; + } + + // make sure that admins see all activities + if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) { + return; + } + + if (null !== $user) { + $teams = array_merge($teams, $user->getTeams()->toArray()); + } + + $qb->leftJoin('p.teams', 'teams') + ->leftJoin('c.teams', 'c_teams'); + + if (empty($teams)) { + $qb->andWhere($qb->expr()->isNull('c_teams')); + $qb->andWhere($qb->expr()->isNull('teams')); + + return; + } + + $orProject = $qb->expr()->orX( + $qb->expr()->isNull('teams'), + $qb->expr()->isMemberOf(':teams', 'p.teams') + ); + $qb->andWhere($orProject); + + $orCustomer = $qb->expr()->orX( + $qb->expr()->isNull('c_teams'), + $qb->expr()->isMemberOf(':teams', 'c.teams') + ); + $qb->andWhere($orCustomer); + + $qb->setParameter('teams', $teams); + } + /** * @deprecated since 1.1 */ @@ -181,16 +223,11 @@ class ActivityRepository extends EntityRepository $qb ->select('a') ->from(Activity::class, 'a') + ->leftJoin('a.project', 'p') + ->leftJoin('p.customer', 'c') ->addOrderBy('a.' . $query->getOrderBy(), $query->getOrder()) ; - if (!$query->isGlobalsOnly()) { - $qb - ->leftJoin('a.project', 'p') - ->leftJoin('p.customer', 'c') - ; - } - $where = $qb->expr()->andX(); if (in_array($query->getVisibility(), [ActivityQuery::SHOW_VISIBLE, ActivityQuery::SHOW_HIDDEN])) { @@ -239,6 +276,8 @@ class ActivityRepository extends EntityRepository $qb->andWhere($where); } + $this->addPermissionCriteria($qb, $query->getCurrentUser()); + return $qb; } diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php index 1c38d957..105eec90 100644 --- a/src/Repository/CustomerRepository.php +++ b/src/Repository/CustomerRepository.php @@ -13,6 +13,7 @@ use App\Entity\Activity; use App\Entity\Customer; use App\Entity\Project; use App\Entity\Timesheet; +use App\Entity\User; use App\Model\CustomerStatistic; use App\Repository\Loader\CustomerLoader; use App\Repository\Paginator\LoaderPaginator; @@ -107,8 +108,41 @@ class CustomerRepository extends EntityRepository return $stats; } + private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []) + { + // make sure that all queries without a user see all customers + if (null === $user && empty($teams)) { + return; + } + + // make sure that admins see all customers + if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) { + return; + } + + if (null !== $user) { + $teams = array_merge($teams, $user->getTeams()->toArray()); + } + + $qb->leftJoin('c.teams', 'teams'); + + if (empty($teams)) { + $qb->andWhere($qb->expr()->isNull('teams')); + + return; + } + + $or = $qb->expr()->orX( + $qb->expr()->isNull('teams'), + $qb->expr()->isMemberOf(':teams', 'c.teams') + ); + $qb->andWhere($or); + + $qb->setParameter('teams', $teams); + } + /** - * @deprecated since 1.1 + * @deprecated since 1.1 - don't use this method, it ignores team permission checks */ public function builderForEntityType($customer) { @@ -145,6 +179,8 @@ class CustomerRepository extends EntityRepository $qb->setParameter('ignored', $query->getCustomerToIgnore()); } + $this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams()); + return $qb; } @@ -152,7 +188,7 @@ class CustomerRepository extends EntityRepository { $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->select('c', 'meta') + $qb->select('c') ->from(Customer::class, 'c') ->leftJoin('c.meta', 'meta') ->orderBy('c.' . $query->getOrderBy(), $query->getOrder()); @@ -165,6 +201,8 @@ class CustomerRepository extends EntityRepository $qb->setParameter('visible', false, \PDO::PARAM_BOOL); } + $this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams()); + return $qb; } diff --git a/src/Repository/Loader/ActivityIdLoader.php b/src/Repository/Loader/ActivityIdLoader.php index d4040a04..4242b5c4 100644 --- a/src/Repository/Loader/ActivityIdLoader.php +++ b/src/Repository/Loader/ActivityIdLoader.php @@ -69,6 +69,27 @@ final class ActivityIdLoader implements LoaderInterface ->andWhere($qb->expr()->in('p.id', $projectIds)) ->getQuery() ->execute(); + + $qb = $em->createQueryBuilder(); + $qb->select('PARTIAL a.{id}', 'PARTIAL project.{id}', 'teams', 'teamlead') + ->from(Activity::class, 'a') + ->leftJoin('a.project', 'project') + ->leftJoin('project.teams', 'teams') + ->leftJoin('teams.teamlead', 'teamlead') + ->andWhere($qb->expr()->in('a.id', $ids)) + ->getQuery() + ->execute(); + + $qb = $em->createQueryBuilder(); + $qb->select('PARTIAL a.{id}', 'PARTIAL project.{id}', 'PARTIAL customer.{id}', 'teams', 'teamlead') + ->from(Activity::class, 'a') + ->leftJoin('a.project', 'project') + ->leftJoin('project.customer', 'customer') + ->leftJoin('customer.teams', 'teams') + ->leftJoin('teams.teamlead', 'teamlead') + ->andWhere($qb->expr()->in('a.id', $ids)) + ->getQuery() + ->execute(); } } } diff --git a/src/Repository/Loader/CustomerIdLoader.php b/src/Repository/Loader/CustomerIdLoader.php index fa8c7a35..64fa2c81 100644 --- a/src/Repository/Loader/CustomerIdLoader.php +++ b/src/Repository/Loader/CustomerIdLoader.php @@ -42,5 +42,14 @@ final class CustomerIdLoader implements LoaderInterface ->andWhere($qb->expr()->in('c.id', $ids)) ->getQuery() ->execute(); + + $qb = $em->createQueryBuilder(); + $qb->select('PARTIAL c.{id}', 'teams', 'teamlead') + ->from(Customer::class, 'c') + ->leftJoin('c.teams', 'teams') + ->leftJoin('teams.teamlead', 'teamlead') + ->andWhere($qb->expr()->in('c.id', $ids)) + ->getQuery() + ->execute(); } } diff --git a/src/Repository/Loader/ProjectIdLoader.php b/src/Repository/Loader/ProjectIdLoader.php index b68b4bd5..68c4c9a6 100644 --- a/src/Repository/Loader/ProjectIdLoader.php +++ b/src/Repository/Loader/ProjectIdLoader.php @@ -50,5 +50,24 @@ final class ProjectIdLoader implements LoaderInterface ->andWhere($qb->expr()->in('p.id', $ids)) ->getQuery() ->execute(); + + $qb = $em->createQueryBuilder(); + $qb->select('PARTIAL p.{id}', 'teams', 'teamlead') + ->from(Project::class, 'p') + ->leftJoin('p.teams', 'teams') + ->leftJoin('teams.teamlead', 'teamlead') + ->andWhere($qb->expr()->in('p.id', $ids)) + ->getQuery() + ->execute(); + + $qb = $em->createQueryBuilder(); + $qb->select('PARTIAL p.{id}', 'PARTIAL customer.{id}', 'teams', 'teamlead') + ->from(Project::class, 'p') + ->leftJoin('p.customer', 'customer') + ->leftJoin('customer.teams', 'teams') + ->leftJoin('teams.teamlead', 'teamlead') + ->andWhere($qb->expr()->in('p.id', $ids)) + ->getQuery() + ->execute(); } } diff --git a/src/Repository/Loader/TeamIdLoader.php b/src/Repository/Loader/TeamIdLoader.php new file mode 100644 index 00000000..f3ede9ff --- /dev/null +++ b/src/Repository/Loader/TeamIdLoader.php @@ -0,0 +1,46 @@ +entityManager = $entityManager; + } + + /** + * @param int[] $ids + */ + public function loadResults(array $ids): void + { + if (empty($ids)) { + return; + } + + $em = $this->entityManager; + + $qb = $em->createQueryBuilder(); + $qb->select('PARTIAL t.{id}', 'users') + ->from(Team::class, 't') + ->leftJoin('t.users', 'users') + ->andWhere($qb->expr()->in('t.id', $ids)) + ->getQuery() + ->execute(); + } +} diff --git a/src/Repository/Loader/TeamLoader.php b/src/Repository/Loader/TeamLoader.php new file mode 100644 index 00000000..3825a809 --- /dev/null +++ b/src/Repository/Loader/TeamLoader.php @@ -0,0 +1,38 @@ +loader = new TeamIdLoader($entityManager); + } + + /** + * @param Team[] $teams + */ + public function loadResults(array $teams): void + { + $ids = array_map(function (Team $team) { + return $team->getId(); + }, $teams); + + $this->loader->loadResults($ids); + } +} diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index a321bb38..27f43976 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -12,6 +12,7 @@ namespace App\Repository; use App\Entity\Activity; use App\Entity\Project; use App\Entity\Timesheet; +use App\Entity\User; use App\Model\ProjectStatistic; use App\Repository\Loader\ProjectLoader; use App\Repository\Paginator\LoaderPaginator; @@ -91,8 +92,49 @@ class ProjectRepository extends EntityRepository return $stats; } + private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []) + { + // make sure that all queries without a user see all projects + if (null === $user && empty($teams)) { + return; + } + + // make sure that admins see all projects + if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) { + return; + } + + if (null !== $user) { + $teams = array_merge($teams, $user->getTeams()->toArray()); + } + + $qb->leftJoin('p.teams', 'teams') + ->leftJoin('c.teams', 'c_teams'); + + if (empty($teams)) { + $qb->andWhere($qb->expr()->isNull('c_teams')); + $qb->andWhere($qb->expr()->isNull('teams')); + + return; + } + + $orProject = $qb->expr()->orX( + $qb->expr()->isNull('teams'), + $qb->expr()->isMemberOf(':teams', 'p.teams') + ); + $qb->andWhere($orProject); + + $orCustomer = $qb->expr()->orX( + $qb->expr()->isNull('c_teams'), + $qb->expr()->isMemberOf(':teams', 'c.teams') + ); + $qb->andWhere($orCustomer); + + $qb->setParameter('teams', $teams); + } + /** - * @deprecated since 1.1 + * @deprecated since 1.1 - don't use this method, it ignores team permission checks */ public function builderForEntityType($project, $customer) { @@ -114,7 +156,7 @@ class ProjectRepository extends EntityRepository $qb = $this->getEntityManager()->createQueryBuilder(); $qb - ->select('p', 'c') + ->select('p') ->from(Project::class, 'p') ->leftJoin('p.customer', 'c') ->addOrderBy('c.name', 'ASC') @@ -140,6 +182,8 @@ class ProjectRepository extends EntityRepository $qb->setParameter('ignored', $query->getProjectToIgnore()); } + $this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams()); + return $qb; } @@ -150,11 +194,11 @@ class ProjectRepository extends EntityRepository $qb ->select('p') ->from(Project::class, 'p') + ->leftJoin('p.customer', 'c') ; if (in_array($query->getVisibility(), [ProjectQuery::SHOW_VISIBLE, ProjectQuery::SHOW_HIDDEN])) { $qb - ->leftJoin('p.customer', 'c') ->andWhere($qb->expr()->eq('p.visible', ':visible')) ->andWhere($qb->expr()->eq('c.visible', ':customer_visible')) ; @@ -173,6 +217,8 @@ class ProjectRepository extends EntityRepository ->setParameter('customer', $query->getCustomer()); } + $this->addPermissionCriteria($qb, $query->getCurrentUser()); + $qb->orderBy('p.' . $query->getOrderBy(), $query->getOrder()); return $qb; diff --git a/src/Repository/Query/BaseQuery.php b/src/Repository/Query/BaseQuery.php index 4385650c..9afcf6a5 100644 --- a/src/Repository/Query/BaseQuery.php +++ b/src/Repository/Query/BaseQuery.php @@ -9,6 +9,9 @@ namespace App\Repository\Query; +use App\Entity\Team; +use App\Entity\User; + /** * Base class for advanced Repository queries. */ @@ -44,6 +47,45 @@ class BaseQuery * @var string */ private $resultType = self::RESULT_TYPE_PAGER; + /** + * @var User + */ + private $user; + /** + * @var Team[] + */ + private $teams = []; + + public function addTeam(Team $team): self + { + $this->teams[$team->getId()] = $team; + + return $this; + } + + /** + * @return Team[] + */ + public function getTeams(): array + { + return array_values($this->teams); + } + + public function getCurrentUser(): ?User + { + return $this->user; + } + + /** + * @param User $user + * @return $this + */ + public function setCurrentUser(User $user) + { + $this->user = $user; + + return $this; + } /** * @return int diff --git a/src/Repository/Query/CustomerFormTypeQuery.php b/src/Repository/Query/CustomerFormTypeQuery.php index 145a8d11..35aea188 100644 --- a/src/Repository/Query/CustomerFormTypeQuery.php +++ b/src/Repository/Query/CustomerFormTypeQuery.php @@ -10,6 +10,8 @@ namespace App\Repository\Query; use App\Entity\Customer; +use App\Entity\Team; +use App\Entity\User; /** * Can be used for advanced queries with the: CustomerRepository @@ -24,6 +26,14 @@ final class CustomerFormTypeQuery * @var Customer|null */ private $customerToIgnore; + /** + * @var User + */ + private $user; + /** + * @var array + */ + private $teams = []; /** * @param Customer|int|null $customer @@ -33,6 +43,33 @@ final class CustomerFormTypeQuery $this->customer = $customer; } + public function addTeam(Team $team): CustomerFormTypeQuery + { + $this->teams[$team->getId()] = $team; + + return $this; + } + + /** + * @return Team[] + */ + public function getTeams(): array + { + return array_values($this->teams); + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): CustomerFormTypeQuery + { + $this->user = $user; + + return $this; + } + /** * @return Customer|int|null */ diff --git a/src/Repository/Query/ProjectFormTypeQuery.php b/src/Repository/Query/ProjectFormTypeQuery.php index f9b60906..ebc9c6d8 100644 --- a/src/Repository/Query/ProjectFormTypeQuery.php +++ b/src/Repository/Query/ProjectFormTypeQuery.php @@ -11,6 +11,8 @@ namespace App\Repository\Query; use App\Entity\Customer; use App\Entity\Project; +use App\Entity\Team; +use App\Entity\User; final class ProjectFormTypeQuery { @@ -26,6 +28,14 @@ final class ProjectFormTypeQuery * @var Project|null */ private $projectToIgnore; + /** + * @var User + */ + private $user; + /** + * @var array + */ + private $teams = []; /** * @param Project|int|null $project @@ -37,6 +47,33 @@ final class ProjectFormTypeQuery $this->customer = $customer; } + public function addTeam(Team $team): ProjectFormTypeQuery + { + $this->teams[$team->getId()] = $team; + + return $this; + } + + /** + * @return Team[] + */ + public function getTeams(): array + { + return array_values($this->teams); + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): ProjectFormTypeQuery + { + $this->user = $user; + + return $this; + } + /** * @return Customer|int|null */ diff --git a/src/Repository/Query/TagQuery.php b/src/Repository/Query/TagQuery.php index 9917cc41..78653126 100644 --- a/src/Repository/Query/TagQuery.php +++ b/src/Repository/Query/TagQuery.php @@ -11,4 +11,8 @@ namespace App\Repository\Query; class TagQuery extends BaseQuery { + public function __construct() + { + $this->setOrderBy('name'); + } } diff --git a/src/Repository/Query/TeamQuery.php b/src/Repository/Query/TeamQuery.php new file mode 100644 index 00000000..373d840f --- /dev/null +++ b/src/Repository/Query/TeamQuery.php @@ -0,0 +1,18 @@ +setOrderBy('name'); + } +} diff --git a/src/Repository/Query/TimesheetQuery.php b/src/Repository/Query/TimesheetQuery.php index 8011db8b..f7e5b6d5 100644 --- a/src/Repository/Query/TimesheetQuery.php +++ b/src/Repository/Query/TimesheetQuery.php @@ -28,7 +28,7 @@ class TimesheetQuery extends ActivityQuery /** * @var User|null */ - protected $user; + protected $timesheetUser; /** * @var Activity|null */ @@ -63,7 +63,7 @@ class TimesheetQuery extends ActivityQuery */ public function getUser() { - return $this->user; + return $this->timesheetUser; } /** @@ -72,7 +72,7 @@ class TimesheetQuery extends ActivityQuery */ public function setUser($user = null) { - $this->user = $user; + $this->timesheetUser = $user; return $this; } @@ -256,7 +256,7 @@ class TimesheetQuery extends ActivityQuery return true; } - if ($this->user !== null) { + if ($this->timesheetUser !== null) { return true; } diff --git a/src/Repository/Query/UserFormTypeQuery.php b/src/Repository/Query/UserFormTypeQuery.php new file mode 100644 index 00000000..4114bde2 --- /dev/null +++ b/src/Repository/Query/UserFormTypeQuery.php @@ -0,0 +1,55 @@ + + */ + private $teams = []; + + public function addTeam(Team $team): UserFormTypeQuery + { + $this->teams[$team->getId()] = $team; + + return $this; + } + + /** + * @return Team[] + */ + public function getTeams(): array + { + return array_values($this->teams); + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): UserFormTypeQuery + { + $this->user = $user; + + return $this; + } +} diff --git a/src/Repository/TagRepository.php b/src/Repository/TagRepository.php index 73461c2e..3826e11f 100644 --- a/src/Repository/TagRepository.php +++ b/src/Repository/TagRepository.php @@ -9,12 +9,26 @@ namespace App\Repository; +use App\Entity\Tag; use App\Repository\Query\TagQuery; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\ORMException; +use Pagerfanta\Adapter\DoctrineORMAdapter; +use Pagerfanta\Pagerfanta; class TagRepository extends EntityRepository { - use RepositoryTrait; + /** + * @param Tag $tag + * @throws ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function deleteTag(Tag $tag) + { + $entityManager = $this->getEntityManager(); + $entityManager->remove($tag); + $entityManager->flush(); + } /** * Find ids of the given tagNames separated by comma @@ -68,7 +82,7 @@ class TagRepository extends EntityRepository * - amount * * @param TagQuery $query - * @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta + * @return Pagerfanta */ public function getTagCount(TagQuery $query) { @@ -79,9 +93,12 @@ class TagRepository extends EntityRepository ->leftJoin('tag.timesheets', 'timesheets') ->addGroupBy('tag.id') ->addGroupBy('tag.name') - ->orderBy('tag.name') - ; + ->orderBy('tag.' . $query->getOrderBy(), $query->getOrder()); - return $this->getBaseQueryResult($qb, $query); + $paginator = new Pagerfanta(new DoctrineORMAdapter($qb->getQuery(), false)); + $paginator->setMaxPerPage($query->getPageSize()); + $paginator->setCurrentPage($query->getPage()); + + return $paginator; } } diff --git a/src/Repository/TeamRepository.php b/src/Repository/TeamRepository.php new file mode 100644 index 00000000..099a1427 --- /dev/null +++ b/src/Repository/TeamRepository.php @@ -0,0 +1,99 @@ +getEntityManager(); + $entityManager->persist($team); + $entityManager->flush(); + } + + /** + * @param Team $team + * @throws ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function deleteTeam(Team $team) + { + $entityManager = $this->getEntityManager(); + $entityManager->remove($team); + $entityManager->flush(); + } + + public function getPagerfantaForQuery(TeamQuery $query): Pagerfanta + { + $paginator = new Pagerfanta($this->getPaginatorForQuery($query)); + $paginator->setMaxPerPage($query->getPageSize()); + $paginator->setCurrentPage($query->getPage()); + + return $paginator; + } + + protected function getPaginatorForQuery(TeamQuery $query): PaginatorInterface + { + $qb = $this->getQueryBuilderForQuery($query); + $qb + ->resetDQLPart('select') + ->resetDQLPart('orderBy') + ->select($qb->expr()->countDistinct('t.id')) + ; + $counter = (int) $qb->getQuery()->getSingleScalarResult(); + + $qb = $this->getQueryBuilderForQuery($query); + + return new LoaderPaginator(new TeamLoader($qb->getEntityManager()), $qb, $counter); + } + + /** + * @param TeamQuery $query + * @return Timesheet[] + */ + public function getTeamsForQuery(TeamQuery $query): iterable + { + // this is using the paginator internally, as it will load all joined entities into the working unit + // do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries + $paginator = $this->getPaginatorForQuery($query); + + return $paginator->getAll(); + } + + private function getQueryBuilderForQuery(TeamQuery $query): QueryBuilder + { + $qb = $this->getEntityManager()->createQueryBuilder(); + + $qb + ->select('t') + ->from(Team::class, 't') + ; + + $qb->orderBy('t.' . $query->getOrderBy(), $query->getOrder()); + + return $qb; + } +} diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index fce4d95e..e6794809 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -456,6 +456,36 @@ class TimesheetRepository extends EntityRepository return $counter; } + private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []) + { + // make sure that all queries without a user see all projects + if (null === $user && empty($teams)) { + return; + } + + if (empty($teams)) { + return; + } + + // make sure that admins see all timesheet records + if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) { + return; + } + + $qb + ->leftJoin('p.customer', 'c') + ->leftJoin('p.teams', 'teams') + ->leftJoin('c.teams', 'c_teams'); + + $orTeam = $qb->expr()->orX( + $qb->expr()->isMemberOf(':teams', 'p.teams'), + $qb->expr()->isMemberOf(':teams', 'c.teams') + ); + $qb->andWhere($orTeam); + + $qb->setParameter('teams', $teams); + } + public function getPagerfantaForQuery(TimesheetQuery $query): Pagerfanta { $paginator = new Pagerfanta($this->getPaginatorForQuery($query)); @@ -500,11 +530,43 @@ class TimesheetRepository extends EntityRepository $qb ->select('t') ->from(Timesheet::class, 't') + ->leftJoin('t.project', 'p') ; + $user = []; if (null !== $query->getUser()) { - $qb->andWhere('t.user = :user') - ->setParameter('user', $query->getUser()); + $user[] = $query->getUser(); + } + + if (null === $query->getUser() && null !== $query->getCurrentUser()) { + $currentUser = $query->getCurrentUser(); + + if (!$currentUser->isSuperAdmin() && !$currentUser->isAdmin()) { + foreach ($currentUser->getTeams() as $team) { + if ($currentUser->isTeamleadOf($team)) { + $query->addTeam($team); + } + } + } + } + + if (!empty($query->getTeams())) { + foreach ($query->getTeams() as $team) { + $user = array_merge($user, $team->getUsers()->toArray()); + } + } + + $user = array_map(function ($user) { + if ($user instanceof User) { + return $user->getId(); + } + + return $user; + }, $user); + $user = array_unique($user); + + if (!empty($user)) { + $qb->andWhere($qb->expr()->in('t.user', $user)); } if (null !== $query->getBegin()) { @@ -539,7 +601,6 @@ class TimesheetRepository extends EntityRepository $qb->andWhere('t.project = :project') ->setParameter('project', $query->getProject()); } elseif (null !== $query->getCustomer()) { - $qb->join('t.project', 'p'); $qb->andWhere('p.customer = :customer') ->setParameter('customer', $query->getCustomer()); } @@ -551,6 +612,8 @@ class TimesheetRepository extends EntityRepository ->setParameter('tags', $query->getTags()); } + $this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams()); + $qb->orderBy('t.' . $query->getOrderBy(), $query->getOrder()); return $qb; diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 31260e0f..1187bfa7 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -10,8 +10,10 @@ namespace App\Repository; use App\Entity\User; +use App\Repository\Query\UserFormTypeQuery; use App\Repository\Query\UserQuery; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface; class UserRepository extends EntityRepository implements UserLoaderInterface @@ -26,12 +28,28 @@ class UserRepository extends EntityRepository implements UserLoaderInterface } /** + * Used to fetch the currently logged-in user. + * * @param int $id * @return null|User */ public function getUserById($id): ?User { - return $this->find($id); + try { + return $this->createQueryBuilder('u') + ->select('u', 'p', 't', 'tu', 'tl') + ->leftJoin('u.preferences', 'p') + ->leftJoin('u.teams', 't') + ->leftJoin('t.users', 'tu') + ->leftJoin('t.teamlead', 'tl') + ->where('u.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->getSingleResult(); + } catch (\Exception $ex) { + } + + return null; } /** @@ -103,12 +121,49 @@ class UserRepository extends EntityRepository implements UserLoaderInterface public function loadUserByUsername($username) { return $this->createQueryBuilder('u') - ->select('u', 'p') + ->select('u', 'p', 't', 'tu', 'tl') ->leftJoin('u.preferences', 'p') + ->leftJoin('u.teams', 't') + ->leftJoin('t.users', 'tu') + ->leftJoin('t.teamlead', 'tl') ->where('u.username = :username') ->orWhere('u.email = :username') ->setParameter('username', $username) ->getQuery() ->getSingleResult(); } + + public function getQueryBuilderForFormType(UserFormTypeQuery $query): QueryBuilder + { + $qb = $this->createQueryBuilder('u'); + + $qb->andWhere($qb->expr()->eq('u.enabled', ':enabled')); + $qb->setParameter('enabled', true, \PDO::PARAM_BOOL); + + $qb->orderBy('u.username', 'ASC'); + + $this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams()); + + return $qb; + } + + private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []) + { + // make sure that all queries without a user see all user + if (null === $user && empty($teams)) { + return; + } + + // make sure that admins see all user + if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) { + return; + } + + if (null !== $user) { + $qb->leftJoin('u.teams', 'teams') + ->leftJoin('teams.users', 'users') + ->andWhere('teams.teamlead = :id') + ->setParameter('id', $user); + } + } } diff --git a/src/Security/CurrentUser.php b/src/Security/CurrentUser.php index 67187923..40d1b07e 100644 --- a/src/Security/CurrentUser.php +++ b/src/Security/CurrentUser.php @@ -13,16 +13,20 @@ use App\Entity\User; use App\Repository\UserRepository; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -class CurrentUser +final class CurrentUser { /** * @var TokenStorageInterface */ - protected $storage; + private $storage; /** * @var UserRepository */ - protected $repository; + private $repository; + /** + * @var User|null + */ + private $user; /** * @param TokenStorageInterface $storage @@ -43,6 +47,11 @@ class CurrentUser return null; } + // some inline caching to prevent multiple DB lookups + if (null !== $this->user) { + return $this->user; + } + /** @var User $user */ $user = $this->storage->getToken()->getUser(); @@ -50,6 +59,8 @@ class CurrentUser return null; } - return $this->repository->getUserById($user->getId()); + $this->user = $this->repository->getUserById($user->getId()); + + return $this->user; } } diff --git a/src/Twig/IconExtension.php b/src/Twig/IconExtension.php index edbdce86..7df8b268 100644 --- a/src/Twig/IconExtension.php +++ b/src/Twig/IconExtension.php @@ -45,7 +45,8 @@ final class IconExtension extends AbstractExtension 'stop-small' => 'far fa-stop-circle', 'timesheet' => 'fas fa-clock', 'trash' => 'far fa-trash-alt', - 'user' => 'fas fa-users', + 'team' => 'fas fa-users', + 'user' => 'fas fa-user-friends', 'visibility' => 'far fa-eye', 'settings' => 'fas fa-cog', 'export' => 'fas fa-file-export', @@ -64,6 +65,7 @@ final class IconExtension extends AbstractExtension 'profile' => 'fas fa-user-edit', 'warning' => 'fas fa-exclamation-triangle', 'permissions' => 'fas fa-user-lock', + 'unlocked' => 'fas fa-unlock-alt', 'back' => 'fas fa-long-arrow-alt-left', 'tag' => 'fas fa-tags', 'avatar' => 'fas fa-user', diff --git a/src/Voter/ActivityVoter.php b/src/Voter/ActivityVoter.php index 9386f79a..a96d471a 100644 --- a/src/Voter/ActivityVoter.php +++ b/src/Voter/ActivityVoter.php @@ -10,6 +10,7 @@ namespace App\Voter; use App\Entity\Activity; +use App\Entity\Team; use App\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -65,6 +66,44 @@ class ActivityVoter extends AbstractVoter return false; } - return $this->hasRolePermission($user, $attribute . '_activity'); + if ($this->hasRolePermission($user, $attribute . '_activity')) { + return true; + } + + $project = $subject->getProject(); + if (null === $project) { + return false; + } + + $hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_activity'); + $hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_activity'); + + if (!$hasTeamleadPermission && !$hasTeamPermission) { + return false; + } + + /** @var Team $team */ + foreach ($project->getTeams() as $team) { + if ($hasTeamleadPermission && $user->isTeamleadOf($team)) { + return true; + } + + if ($hasTeamPermission && $user->isInTeam($team)) { + return true; + } + } + + /** @var Team $team */ + foreach ($project->getCustomer()->getTeams() as $team) { + if ($hasTeamleadPermission && $user->isTeamleadOf($team)) { + return true; + } + + if ($hasTeamPermission && $user->isInTeam($team)) { + return true; + } + } + + return false; } } diff --git a/src/Voter/CustomerVoter.php b/src/Voter/CustomerVoter.php index d9316e56..8e608c11 100644 --- a/src/Voter/CustomerVoter.php +++ b/src/Voter/CustomerVoter.php @@ -10,6 +10,7 @@ namespace App\Voter; use App\Entity\Customer; +use App\Entity\Team; use App\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -22,6 +23,7 @@ class CustomerVoter extends AbstractVoter public const EDIT = 'edit'; public const BUDGET = 'budget'; public const DELETE = 'delete'; + public const PERMISSIONS = 'permissions'; /** * support rules based on the given $subject (here: Customer) @@ -31,6 +33,7 @@ class CustomerVoter extends AbstractVoter self::EDIT, self::BUDGET, self::DELETE, + self::PERMISSIONS, ]; /** @@ -65,6 +68,28 @@ class CustomerVoter extends AbstractVoter return false; } - return $this->hasRolePermission($user, $attribute . '_customer'); + if ($this->hasRolePermission($user, $attribute . '_customer')) { + return true; + } + + $hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_customer'); + $hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_customer'); + + if (!$hasTeamleadPermission && !$hasTeamPermission) { + return false; + } + + /** @var Team $team */ + foreach ($subject->getTeams() as $team) { + if ($hasTeamleadPermission && $user->isTeamleadOf($team)) { + return true; + } + + if ($hasTeamPermission && $user->isInTeam($team)) { + return true; + } + } + + return false; } } diff --git a/src/Voter/ProjectVoter.php b/src/Voter/ProjectVoter.php index b46402ea..c28bc6ca 100644 --- a/src/Voter/ProjectVoter.php +++ b/src/Voter/ProjectVoter.php @@ -10,6 +10,7 @@ namespace App\Voter; use App\Entity\Project; +use App\Entity\Team; use App\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -22,6 +23,7 @@ class ProjectVoter extends AbstractVoter public const EDIT = 'edit'; public const BUDGET = 'budget'; public const DELETE = 'delete'; + public const PERMISSIONS = 'permissions'; /** * support rules based on the given $subject (here: Project) @@ -31,6 +33,7 @@ class ProjectVoter extends AbstractVoter self::EDIT, self::BUDGET, self::DELETE, + self::PERMISSIONS, ]; /** @@ -65,6 +68,39 @@ class ProjectVoter extends AbstractVoter return false; } - return $this->hasRolePermission($user, $attribute . '_project'); + if ($this->hasRolePermission($user, $attribute . '_project')) { + return true; + } + + $hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_project'); + $hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_project'); + + if (!$hasTeamleadPermission && !$hasTeamPermission) { + return false; + } + + /** @var Team $team */ + foreach ($subject->getTeams() as $team) { + if ($hasTeamleadPermission && $user->isTeamleadOf($team)) { + return true; + } + + if ($hasTeamPermission && $user->isInTeam($team)) { + return true; + } + } + + /** @var Team $team */ + foreach ($subject->getCustomer()->getTeams() as $team) { + if ($hasTeamleadPermission && $user->isTeamleadOf($team)) { + return true; + } + + if ($hasTeamPermission && $user->isInTeam($team)) { + return true; + } + } + + return false; } } diff --git a/src/Voter/TeamVoter.php b/src/Voter/TeamVoter.php new file mode 100644 index 00000000..f8b83690 --- /dev/null +++ b/src/Voter/TeamVoter.php @@ -0,0 +1,65 @@ +getUser(); + + if (!$user instanceof User) { + return false; + } + + return $this->hasRolePermission($user, $attribute . '_team'); + } +} diff --git a/src/Voter/UserVoter.php b/src/Voter/UserVoter.php index 4b0c693e..c64fe5bf 100644 --- a/src/Voter/UserVoter.php +++ b/src/Voter/UserVoter.php @@ -22,6 +22,7 @@ class UserVoter extends AbstractVoter public const DELETE = 'delete'; public const PASSWORD = 'password'; public const ROLES = 'roles'; + public const TEAMS = 'teams'; public const PREFERENCES = 'preferences'; public const API_TOKEN = 'api-token'; public const HOURLY_RATE = 'hourly-rate'; @@ -30,6 +31,7 @@ class UserVoter extends AbstractVoter self::VIEW, self::EDIT, self::ROLES, + self::TEAMS, self::PASSWORD, self::DELETE, self::PREFERENCES, @@ -93,6 +95,7 @@ class UserVoter extends AbstractVoter case self::PASSWORD: case self::API_TOKEN: case self::ROLES: + case self::TEAMS: case self::HOURLY_RATE: $permission .= $attribute; break; diff --git a/templates/activity/index.html.twig b/templates/activity/index.html.twig index 4f7ef761..7fefe365 100644 --- a/templates/activity/index.html.twig +++ b/templates/activity/index.html.twig @@ -38,13 +38,13 @@ {% if entry.project and entry.project.customer %} {# only none-global activities have a project and customer assigned #} - {{ widgets.label_customer(entry.project.customer, path('admin_customer_edit', {'id' : entry.project.customer.id})) }} + {{ widgets.label_customer(entry.project.customer) }} {% endif %} {% if entry.project %} {# only none-global activities have a project and customer assigned #} - {{ widgets.label_project(entry.project, path('admin_project_edit', {'id' : entry.project.id})) }} + {{ widgets.label_project(entry.project) }} {% endif %} {{ entry.comment|comment2html }} diff --git a/templates/customer/index.html.twig b/templates/customer/index.html.twig index aac9ba38..9919b73d 100644 --- a/templates/customer/index.html.twig +++ b/templates/customer/index.html.twig @@ -9,6 +9,7 @@ 'comment': 'hidden-xs', 'country': 'hidden-xs', 'number': 'hidden-xs', + 'team': '', 'visible': 'hidden-xs', 'actions': 'actions alwaysVisible', } %} @@ -37,6 +38,13 @@ {{ entry.comment|comment2html }} {{ entry.country|country }} {{ entry.number }} + + {% if entry.teams|length > 0 %} + {{ widgets.badge_counter(entry.teams|length) }} + {% else %} + {{ widgets.icon('unlocked') }} + {% endif %} + {{ widgets.label_visible(entry.visible) }} {{ actions.customer(entry, 'index') }} diff --git a/templates/customer/permissions.html.twig b/templates/customer/permissions.html.twig new file mode 100644 index 00000000..dacae4f9 --- /dev/null +++ b/templates/customer/permissions.html.twig @@ -0,0 +1,15 @@ +{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} +{% import "macros/widgets.html.twig" as widgets %} +{% import "macros/actions.html.twig" as actions %} + +{% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %} +{% block page_subtitle %}{{ 'admin_customer.subtitle'|trans }}{% endblock %} +{% block page_actions %}{{ actions.customer(customer, 'permissions') }}{% endblock %} + +{% block main %} + {{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', { + 'title': customer.name, + 'form': form, + 'back': path('admin_customer') + }) }} +{% endblock %} diff --git a/templates/default/_form.html.twig b/templates/default/_form.html.twig index 3dbc9c11..f154cb79 100644 --- a/templates/default/_form.html.twig +++ b/templates/default/_form.html.twig @@ -1,4 +1,5 @@
+ {{ form_start(form) }}

{{ title }} @@ -7,7 +8,6 @@ {% endif %}

- {{ form_start(form) }}
{{ form_widget(form) }}
@@ -16,6 +16,9 @@ {% if back|default(false) %} {{ 'action.back'|trans }} {% endif %} + {% if reset|default(true) %} + + {% endif %}
{{ form_end(form) }} \ No newline at end of file diff --git a/templates/macros/actions.html.twig b/templates/macros/actions.html.twig index 278bf7cf..164bc709 100644 --- a/templates/macros/actions.html.twig +++ b/templates/macros/actions.html.twig @@ -145,6 +145,9 @@ {% if is_granted('budget', project) %} {% set actions = actions|merge({'report': {'url': path('admin_project_budget', {'id': project.id})}}) %} {% endif %} + {% if is_granted('permissions', project) %} + {% set actions = actions|merge({'permissions': {'url': path('admin_project_permissions', {'id': project.id})}}) %} + {% endif %} {% if is_granted('view_activity') %} {% set actions = actions|merge({'activity': path('admin_activity', {'customer': project.customer.id, 'project': project.id})}) %} {% endif %} @@ -198,6 +201,9 @@ {% if is_granted('budget', customer) %} {% set actions = actions|merge({'report': {'url': path('admin_customer_budget', {'id': customer.id})}}) %} {% endif %} + {% if is_granted('permissions', customer) %} + {% set actions = actions|merge({'permissions': {'url': path('admin_customer_permissions', {'id': customer.id})}}) %} + {% endif %} {% if is_granted('view_project') %} {% set actions = actions|merge({'project': path('admin_project', {'customer': customer.id})}) %} {% endif %} @@ -443,3 +449,40 @@ {{ widgets.page_actions(actions) }} {% endmacro %} +{% macro teams(view) %} + {% import "macros/widgets.html.twig" as widgets %} + + {% set actions = {} %} + {% if is_granted('create_team') %} + {% set actions = actions|merge({'create': {'url': path('admin_team_create')}}) %} + {% endif %} + + {% set event = trigger('actions.teams', {'actions': actions, 'view': view}) %} + {{ widgets.page_actions(event.payload.actions) }} +{% endmacro %} + +{% macro team(team, view) %} + {% import "macros/widgets.html.twig" as widgets %} + + {% set actions = {} %} + {% if team.id is not empty %} + {% if is_granted('edit', team) %} + {% set class = '' %} + {% if view != 'edit' %} + {% set class = 'modal-ajax-form' %} + {% endif %} + {% set actions = actions|merge({'edit': {'url': path('admin_team_edit', {'id': team.id}), 'class': class}}) %} + {% endif %} + {% endif %} + + {% if view == 'index' and is_granted('delete', team) %} + {% set actions = actions|merge({'trash': {'url': path('delete_team', {'id' : team.id}), 'class': 'api-link', 'attr': {'data-event': 'kimai.teamDelete kimai.teamUpdate', 'data-method': 'DELETE', 'data-question': 'confirm.delete', 'data-msg-error': 'action.delete.error', 'data-msg-success': 'action.delete.success'}}}) %} + {% endif %} + + {% set event = trigger('actions.team', {'actions': actions, 'view': view, 'team': team}) %} + {% if view == 'index' %} + {{ widgets.table_actions(event.payload.actions) }} + {% else %} + {{ widgets.entity_actions(event.payload.actions) }} + {% endif %} +{% endmacro %} diff --git a/templates/macros/widgets.html.twig b/templates/macros/widgets.html.twig index 633a2c70..902f6290 100644 --- a/templates/macros/widgets.html.twig +++ b/templates/macros/widgets.html.twig @@ -42,6 +42,8 @@ {% import _self as macro %} {% if role == 'ROLE_SUPER_ADMIN' %} {{ macro.label(role, 'danger') }} + {% elseif role == 'ROLE_ADMIN' %} + {{ macro.label(role, 'warning') }} {% else %} {{ macro.label(role, 'primary') }} {% endif %} diff --git a/templates/project/index.html.twig b/templates/project/index.html.twig index 62967f98..72737812 100644 --- a/templates/project/index.html.twig +++ b/templates/project/index.html.twig @@ -8,6 +8,7 @@ 'name': 'alwaysVisible', 'customer': 'hidden-xs', 'comment': 'hidden-xs hidden-sm', + 'team': '', 'visible': '', 'actions': 'actions alwaysVisible', } %} @@ -34,9 +35,16 @@ {{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }} - {{ widgets.label_customer(entry.customer, path('admin_customer_edit', {'id' : entry.customer.id})) }} + {{ widgets.label_customer(entry.customer) }} {{ entry.comment|comment2html }} + + {% if entry.teams|length > 0 %} + {{ widgets.badge_counter(entry.teams|length) }} + {% else %} + {{ widgets.icon('unlocked') }} + {% endif %} + {{ widgets.label_visible(entry.visible) }} {{ actions.project(entry, 'index') }} diff --git a/templates/project/permissions.html.twig b/templates/project/permissions.html.twig new file mode 100644 index 00000000..615c695c --- /dev/null +++ b/templates/project/permissions.html.twig @@ -0,0 +1,15 @@ +{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} +{% import "macros/widgets.html.twig" as widgets %} +{% import "macros/actions.html.twig" as actions %} + +{% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %} +{% block page_subtitle %}{{ 'admin_project.subtitle'|trans }}{% endblock %} +{% block page_actions %}{{ actions.project(project, 'permissions') }}{% endblock %} + +{% block main %} + {{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', { + 'title': project.name, + 'form': form, + 'back': path('admin_project') + }) }} +{% endblock %} diff --git a/templates/team/edit.html.twig b/templates/team/edit.html.twig new file mode 100644 index 00000000..1da492c6 --- /dev/null +++ b/templates/team/edit.html.twig @@ -0,0 +1,34 @@ +{% extends 'base.html.twig' %} +{% import "macros/widgets.html.twig" as widgets %} +{% import "macros/actions.html.twig" as actions %} + +{% block page_title %}{{ 'teams.title'|trans({}, 'teams') }}{% endblock %} +{% block page_subtitle %}{{ 'teams.subtitle'|trans({}, 'teams') }}{% endblock %} +{% block page_actions %}{{ actions.team(team, 'edit') }}{% endblock %} + +{% block main %} +
+
+ {{ include('default/_form.html.twig', { + 'title': team.name|default('create'|trans), + 'form': form, + 'back': path('admin_team') + }) }} +
+
+ {% if not customerForm is null %} + {{ include('default/_form.html.twig', { + 'title': 'teams.customer_access'|trans({}, 'teams'), + 'form': customerForm, + }) }} + {% endif %} + + {% if not projectForm is null %} + {{ include('default/_form.html.twig', { + 'title': 'teams.project_access'|trans({}, 'teams'), + 'form': projectForm, + }) }} + {% endif %} +
+
+{% endblock %} diff --git a/templates/team/index.html.twig b/templates/team/index.html.twig new file mode 100644 index 00000000..d24266a1 --- /dev/null +++ b/templates/team/index.html.twig @@ -0,0 +1,49 @@ +{% extends 'base.html.twig' %} +{% import "macros/widgets.html.twig" as widgets %} +{% import "macros/actions.html.twig" as actions %} +{% import "macros/datatables.html.twig" as tables %} +{% import "macros/toolbar.html.twig" as toolbar %} + +{% block page_title %}{{ 'teams.title'|trans({}, 'teams') }}{% endblock %} +{% block page_subtitle %}{{ 'teams.subtitle'|trans({}, 'teams') }}{% endblock %} +{% block page_actions %}{{ actions.teams('index') }}{% endblock %} + +{% block main_before %} + {{ toolbar.toolbar(toolbarForm, 'collapseTeams', showFilter) }} +{% endblock %} + +{% block main %} + + {% if teams|length == 0 %} + {{ widgets.callout('warning', 'error.no_entries_found') }} + {% else %} + + {% set columns = { + 'name': '', + 'teamlead': '', + 'user': '', + 'actions': 'actions alwaysVisible', + } %} + + {% set tableName = 'admin_teams' %} + + {{ tables.data_table_header(tableName, columns, false, 'kimai.teamUpdate') }} + {% for team in teams %} + + {{ team.name }} + {{ widgets.label_user(team.teamlead) }} + + {% for user in team.users %} + {{ widgets.label_user(user) }}  + {% endfor %} + + + {{ actions.team(team, 'index') }} + + + {% endfor %} + {{ tables.data_table_footer(teams, 'admin_team_paginated') }} + + {% endif %} + +{% endblock %} diff --git a/tests/API/TeamControllerTest.php b/tests/API/TeamControllerTest.php new file mode 100644 index 00000000..fd6ec6ff --- /dev/null +++ b/tests/API/TeamControllerTest.php @@ -0,0 +1,102 @@ +getClientForAuthenticatedUser(User::ROLE_ADMIN); + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + $fixture = new TeamFixtures(); + $fixture->setAmount(1); + $this->importFixture($em, $fixture); + } + + public function testIsSecure() + { + $this->assertUrlIsSecured('/api/teams'); + } + + public function testGetCollection() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->assertAccessIsGranted($client, '/api/teams'); + $result = json_decode($client->getResponse()->getContent(), true); + + $this->assertIsArray($result); + $this->assertNotEmpty($result); + $this->assertEquals(1, count($result)); + $this->assertStructure($result[0], false); + } + + public function testGetEntity() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->assertAccessIsGranted($client, '/api/teams/1'); + $result = json_decode($client->getResponse()->getContent(), true); + + $this->assertIsArray($result); + $this->assertStructure($result, true); + } + + public function testNotFound() + { + $this->assertEntityNotFound(User::ROLE_USER, '/api/teams/2'); + } + + public function testDeleteActionWithUnknownTeam() + { + $this->assertEntityNotFoundForDelete(User::ROLE_ADMIN, '/api/teams/255', []); + } + + public function testDeleteAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->assertAccessIsGranted($client, '/api/teams/1'); + $result = json_decode($client->getResponse()->getContent(), true); + + $this->assertIsArray($result); + $this->assertStructure($result); + $this->assertNotEmpty($result['id']); + $id = $result['id']; + + $this->request($client, '/api/teams/' . $id, 'DELETE'); + $this->assertTrue($client->getResponse()->isSuccessful()); + $this->assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode()); + $this->assertEmpty($client->getResponse()->getContent()); + + $this->assertEntityNotFound(User::ROLE_USER, '/api/teams/' . $id); + } + + protected function assertStructure(array $result, $full = true) + { + $expectedKeys = [ + 'id', 'name' + ]; + + if ($full) { + $expectedKeys = array_merge($expectedKeys, []); + } + + $actual = array_keys($result); + sort($actual); + sort($expectedKeys); + + $this->assertEquals($expectedKeys, $actual, 'Team structure does not match'); + } +} diff --git a/tests/Controller/ActivityControllerTest.php b/tests/Controller/ActivityControllerTest.php index da819574..3fa272f3 100644 --- a/tests/Controller/ActivityControllerTest.php +++ b/tests/Controller/ActivityControllerTest.php @@ -26,12 +26,12 @@ class ActivityControllerTest extends ControllerBaseTest public function testIsSecure() { $this->assertUrlIsSecured('/admin/activity/'); - $this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/activity/'); + $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/activity/'); } public function testIndexAction() { - $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $this->assertAccessIsGranted($client, '/admin/activity/'); $this->assertHasDataTable($client); } diff --git a/tests/Controller/CustomerControllerTest.php b/tests/Controller/CustomerControllerTest.php index d761f876..b0ba6334 100644 --- a/tests/Controller/CustomerControllerTest.php +++ b/tests/Controller/CustomerControllerTest.php @@ -9,13 +9,16 @@ namespace App\Tests\Controller; +use App\Entity\Customer; use App\Entity\Project; use App\Entity\Timesheet; use App\Entity\User; use App\Tests\DataFixtures\CustomerFixtures; +use App\Tests\DataFixtures\TeamFixtures; use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock; use Doctrine\ORM\EntityManager; +use Symfony\Component\DomCrawler\Field\ChoiceFormField; /** * @group integration @@ -25,12 +28,12 @@ class CustomerControllerTest extends ControllerBaseTest public function testIsSecure() { $this->assertUrlIsSecured('/admin/customer/'); - $this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/customer/'); + $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/customer/'); } public function testIndexAction() { - $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $this->assertAccessIsGranted($client, '/admin/customer/'); $this->assertHasDataTable($client); } @@ -111,6 +114,39 @@ class CustomerControllerTest extends ControllerBaseTest $this->assertEquals('Test Customer 2', $editForm->get('customer_edit_form[name]')->getValue()); } + public function testTeamPermissionAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + + /** @var Customer $customer */ + $customer = $em->getRepository(Customer::class)->find(1); + self::assertEquals(0, $customer->getTeams()->count()); + + $fixture = new TeamFixtures(); + $fixture->setAmount(2); + $fixture->setAddCustomer(false); + $this->importFixture($em, $fixture); + + $this->assertAccessIsGranted($client, '/admin/customer/1/permissions'); + $form = $client->getCrawler()->filter('form[name=customer_team_permission_form]')->form(); + /** @var ChoiceFormField $team1 */ + $team1 = $form->get('customer_team_permission_form[teams][0]'); + $team1->tick(); + /** @var ChoiceFormField $team2 */ + $team2 = $form->get('customer_team_permission_form[teams][1]'); + $team2->tick(); + + $client->submit($form); + $this->assertIsRedirect($client, $this->createUrl('/admin/customer/')); + $client->followRedirect(); + $this->assertHasDataTable($client); + + /** @var Customer $customer */ + $customer = $em->getRepository(Customer::class)->find(1); + self::assertEquals(2, $customer->getTeams()->count()); + } + public function testDeleteAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); diff --git a/tests/Controller/ProfileControllerTest.php b/tests/Controller/ProfileControllerTest.php index 46c690c8..07130c97 100644 --- a/tests/Controller/ProfileControllerTest.php +++ b/tests/Controller/ProfileControllerTest.php @@ -12,8 +12,10 @@ namespace App\Tests\Controller; use App\DataFixtures\UserFixtures; use App\Entity\User; use App\Entity\UserPreference; +use App\Tests\DataFixtures\TeamFixtures; use App\Tests\DataFixtures\TimesheetFixtures; use Symfony\Bundle\FrameworkBundle\Client; +use Symfony\Component\DomCrawler\Field\ChoiceFormField; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; /** @@ -95,7 +97,7 @@ class ProfileControllerTest extends ControllerBaseTest return [ [User::ROLE_USER, UserFixtures::USERNAME_USER, ['#settings', '#password', '#api-token']], - [User::ROLE_SUPER_ADMIN, UserFixtures::USERNAME_SUPER_ADMIN, array_merge($userTabs, ['#roles'])], + [User::ROLE_SUPER_ADMIN, UserFixtures::USERNAME_SUPER_ADMIN, array_merge($userTabs, ['#teams', '#roles'])], ]; } @@ -315,6 +317,52 @@ class ProfileControllerTest extends ControllerBaseTest $this->assertEquals(['ROLE_TEAMLEAD', 'ROLE_SUPER_ADMIN', 'ROLE_USER'], $user->getRoles()); } + public function testTeamsActionIsSecured() + { + $this->assertUrlIsSecured('/profile/' . UserFixtures::USERNAME_USER . '/teams'); + $this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/profile/' . UserFixtures::USERNAME_USER . '/teams'); + } + + public function testTeamsAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + + /** @var User $user */ + $user = $this->getUserByRole($em, User::ROLE_USER); + + $fixture = new TeamFixtures(); + $fixture->setAmount(3); + $fixture->setAddCustomer(true); + $fixture->setAddUser(false); + $fixture->addUserToIgnore($user); + $this->importFixture($em, $fixture); + + $this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/teams'); + + /** @var User $user */ + $user = $this->getUserByRole($em, User::ROLE_USER); + $this->assertEquals([], $user->getTeams()->toArray()); + + $form = $client->getCrawler()->filter('form[name=user_teams]')->form(); + /** @var ChoiceFormField $team */ + $team = $form->get('user_teams[teams][0]'); + $team->tick(); + + $client->submit($form); + + $this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER) . '/teams')); + $client->followRedirect(); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $this->assertHasFlashSuccess($client); + + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + $user = $this->getUserByRole($em, User::ROLE_USER); + + $this->assertEquals(1, $user->getTeams()->count()); + } + public function getPreferencesTestData() { return [ diff --git a/tests/Controller/ProjectControllerTest.php b/tests/Controller/ProjectControllerTest.php index 92e024cc..b7fe5e5d 100644 --- a/tests/Controller/ProjectControllerTest.php +++ b/tests/Controller/ProjectControllerTest.php @@ -14,9 +14,11 @@ use App\Entity\Timesheet; use App\Entity\User; use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\ProjectFixtures; +use App\Tests\DataFixtures\TeamFixtures; use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock; use Doctrine\ORM\EntityManager; +use Symfony\Component\DomCrawler\Field\ChoiceFormField; /** * @group integration @@ -26,12 +28,12 @@ class ProjectControllerTest extends ControllerBaseTest public function testIsSecure() { $this->assertUrlIsSecured('/admin/project/'); - $this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/project/'); + $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/project/'); } public function testIndexAction() { - $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $this->assertAccessIsGranted($client, '/admin/project/'); $this->assertHasDataTable($client); } @@ -135,6 +137,39 @@ class ProjectControllerTest extends ControllerBaseTest $this->assertEquals('Test 2', $editForm->get('project_edit_form[name]')->getValue()); } + public function testTeamPermissionAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + + /** @var Project $project */ + $project = $em->getRepository(Project::class)->find(1); + self::assertEquals(0, $project->getTeams()->count()); + + $fixture = new TeamFixtures(); + $fixture->setAmount(2); + $fixture->setAddCustomer(false); + $this->importFixture($em, $fixture); + + $this->assertAccessIsGranted($client, '/admin/project/1/permissions'); + $form = $client->getCrawler()->filter('form[name=project_team_permission_form]')->form(); + /** @var ChoiceFormField $team1 */ + $team1 = $form->get('project_team_permission_form[teams][0]'); + $team1->tick(); + /** @var ChoiceFormField $team2 */ + $team2 = $form->get('project_team_permission_form[teams][1]'); + $team2->tick(); + + $client->submit($form); + $this->assertIsRedirect($client, $this->createUrl('/admin/project/')); + $client->followRedirect(); + $this->assertHasDataTable($client); + + /** @var Project $project */ + $project = $em->getRepository(Project::class)->find(1); + self::assertEquals(2, $project->getTeams()->count()); + } + public function testDeleteAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); diff --git a/tests/Controller/TeamControllerTest.php b/tests/Controller/TeamControllerTest.php new file mode 100644 index 00000000..e17045ce --- /dev/null +++ b/tests/Controller/TeamControllerTest.php @@ -0,0 +1,155 @@ +assertUrlIsSecured('/admin/teams/'); + $this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/teams/'); + } + + public function testIndexAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + $fixture = new TeamFixtures(); + $fixture->setAmount(5); + $this->importFixture($em, $fixture); + + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->assertAccessIsGranted($client, '/admin/teams/'); + $this->assertPageActions($client, ['create' => $this->createUrl('/admin/teams/create')]); + $this->assertHasDataTable($client); + $this->assertDataTableRowCount($client, 'datatable_admin_teams', 5); + } + + public function testCreateAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->assertAccessIsGranted($client, '/admin/teams/create'); + $form = $client->getCrawler()->filter('form[name=team_edit_form]')->form(); + + $editForm = $client->getCrawler()->filter('form[name=team_edit_form]')->form(); + $this->assertEquals('', $editForm->get('team_edit_form[name]')->getValue()); + $this->assertEquals('5', $editForm->get('team_edit_form[teamlead]')->getValue()); + + $client->submit($form, [ + 'team_edit_form' => [ + 'name' => 'Test Team', + ] + ]); + $this->assertIsRedirect($client, $this->createUrl('/admin/teams/1/edit')); + $client->followRedirect(); + $this->assertHasFlashSuccess($client); + $this->assertHasCustomerAndProjectPermissionBoxes($client); + } + + protected function assertHasCustomerAndProjectPermissionBoxes(Client $client) + { + $content = $client->getResponse()->getContent(); + $this->assertStringContainsString('Grant access to customers', $content); + $this->assertStringContainsString('Grant access to projects', $content); + $this->assertEquals(1, $client->getCrawler()->filter('form[name=team_customer_form]')->count()); + $this->assertEquals(1, $client->getCrawler()->filter('form[name=team_project_form]')->count()); + } + + public function testEditAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + $fixture = new TeamFixtures(); + $fixture->setAmount(2); + $this->importFixture($em, $fixture); + + $this->assertAccessIsGranted($client, '/admin/teams/1/edit'); + $form = $client->getCrawler()->filter('form[name=team_edit_form]')->form(); + $this->assertNotEmpty($form->get('team_edit_form[name]')->getValue()); + $client->submit($form, [ + 'team_edit_form' => [ + 'name' => 'Test Team 2' + ] + ]); + $this->assertIsRedirect($client, $this->createUrl('/admin/teams/1/edit')); + $client->followRedirect(); + $editForm = $client->getCrawler()->filter('form[name=team_edit_form]')->form(); + $this->assertEquals('Test Team 2', $editForm->get('team_edit_form[name]')->getValue()); + } + + public function testEditCustomerAccessAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + + /** @var EntityManager $em */ + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + + $fixture = new TeamFixtures(); + $fixture->setAmount(2); + $fixture->setAddCustomer(false); + $this->importFixture($em, $fixture); + + $team = $em->getRepository(Team::class)->find(1); + self::assertEquals(0, count($team->getCustomers())); + + $this->assertAccessIsGranted($client, '/admin/teams/1/edit'); + $form = $client->getCrawler()->filter('form[name=team_customer_form]')->form(); + + /** @var ChoiceFormField $customer */ + $customer = $form->get('team_customer_form[customers][0]'); + $customer->tick(); + + $client->submit($form); + $this->assertIsRedirect($client, $this->createUrl('/admin/teams/1/edit')); + + $team = $em->getRepository(Team::class)->find(1); + self::assertEquals(1, count($team->getCustomers())); + } + + public function testEditProjectAccessAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + + /** @var EntityManager $em */ + $em = $client->getContainer()->get('doctrine.orm.entity_manager'); + + $fixture = new TeamFixtures(); + $fixture->setAmount(2); + $fixture->setAddCustomer(false); + $this->importFixture($em, $fixture); + + $team = $em->getRepository(Team::class)->find(1); + self::assertEquals(0, count($team->getProjects())); + + $this->assertAccessIsGranted($client, '/admin/teams/1/edit'); + $form = $client->getCrawler()->filter('form[name=team_project_form]')->form(); + + /** @var ChoiceFormField $customer */ + $customer = $form->get('team_project_form[projects]'); + $customer->select([1]); + + $client->submit($form); + $this->assertIsRedirect($client, $this->createUrl('/admin/teams/1/edit')); + + $team = $em->getRepository(Team::class)->find(1); + self::assertEquals(1, count($team->getProjects())); + } +} diff --git a/tests/Controller/TimesheetTeamControllerTest.php b/tests/Controller/TimesheetTeamControllerTest.php index b311ea95..69d0d26f 100644 --- a/tests/Controller/TimesheetTeamControllerTest.php +++ b/tests/Controller/TimesheetTeamControllerTest.php @@ -47,7 +47,8 @@ class TimesheetTeamControllerTest extends ControllerBaseTest public function testIndexActionWithQuery() { - $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); + // Switching the user is not allowed for TEAMLEADs but ONLLY for admin and super-admins + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $start = new \DateTime('first day of this month'); $em = $client->getContainer()->get('doctrine.orm.entity_manager'); @@ -118,7 +119,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest public function testCreateAction() { - $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $this->request($client, '/team/timesheet/create'); $this->assertTrue($client->getResponse()->isSuccessful()); @@ -160,7 +161,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest $fixture->setStartDate('2017-05-01'); $this->importFixture($em, $fixture); - $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $this->request($client, '/team/timesheet/1/edit'); $response = $client->getResponse(); diff --git a/tests/Controller/UserControllerTest.php b/tests/Controller/UserControllerTest.php index 64a94122..b53637ac 100644 --- a/tests/Controller/UserControllerTest.php +++ b/tests/Controller/UserControllerTest.php @@ -52,7 +52,7 @@ class UserControllerTest extends ControllerBaseTest $this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode($username) . '/edit')); $client->followRedirect(); - $expectedTabs = ['#settings', '#password', '#api-token', '#roles']; + $expectedTabs = ['#settings', '#password', '#api-token', '#teams', '#roles']; $tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li'); $this->assertEquals(count($expectedTabs), $tabs->count()); @@ -210,6 +210,6 @@ class UserControllerTest extends ControllerBaseTest $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/admin/user/permissions'); $this->assertHasDataTable($client); - $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 69); + $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 84); } } diff --git a/tests/DataFixtures/TeamFixtures.php b/tests/DataFixtures/TeamFixtures.php new file mode 100644 index 00000000..d9f9bde5 --- /dev/null +++ b/tests/DataFixtures/TeamFixtures.php @@ -0,0 +1,144 @@ +addCustomer = $useCustomer; + } + + public function setAddUser(bool $useUser) + { + $this->addUser = $useUser; + } + + public function getAmount(): int + { + return $this->amount; + } + + public function setAmount(int $amount): TeamFixtures + { + $this->amount = $amount; + + return $this; + } + + public function addUserToIgnore(User $user) + { + $this->skipUser[] = $user; + } + + /** + * {@inheritdoc} + */ + public function load(ObjectManager $manager) + { + $faker = Factory::create(); + $user = $this->getAllUsers($manager); + $customer = $this->getAllCustomers($manager); + + for ($i = 0; $i < $this->amount; $i++) { + $lead = null; + while (null === $lead) { + $tmp = $user[array_rand($user)]; + if (!in_array($tmp, $this->skipUser)) { + $lead = $tmp; + } + } + + $entity = new Team(); + $entity + ->setName($faker->name) + ->setTeamLead($lead) + ; + + if ($this->addUser) { + $userToAdd = null; + while (null === $userToAdd) { + $tmp = $user[array_rand($user)]; + if (!in_array($tmp, $this->skipUser)) { + $userToAdd = $tmp; + } + } + $entity->addUser($userToAdd); + } + + if ($this->addCustomer) { + $entity->addCustomer($customer[array_rand($customer)]); + } + + $manager->persist($entity); + } + + $manager->flush(); + } + + /** + * @param ObjectManager $manager + * @return Customer[] + */ + protected function getAllCustomers(ObjectManager $manager) + { + $all = []; + /* @var Customer[] $entries */ + $entries = $manager->getRepository(Customer::class)->findAll(); + foreach ($entries as $temp) { + $all[$temp->getId()] = $temp; + } + + return $all; + } + + /** + * @param ObjectManager $manager + * @return User[] + */ + protected function getAllUsers(ObjectManager $manager) + { + $all = []; + /* @var User[] $entries */ + $entries = $manager->getRepository(User::class)->findAll(); + foreach ($entries as $temp) { + $all[$temp->getId()] = $temp; + } + + return $all; + } +} diff --git a/tests/DependencyInjection/AppExtensionTest.php b/tests/DependencyInjection/AppExtensionTest.php index 38c2ecf5..c6c96f32 100644 --- a/tests/DependencyInjection/AppExtensionTest.php +++ b/tests/DependencyInjection/AppExtensionTest.php @@ -95,6 +95,7 @@ class AppExtensionTest extends TestCase 'timezone' => null, 'language' => 'en', 'theme' => null, + 'currency' => 'EUR', ] ], diff --git a/tests/Entity/CustomerTest.php b/tests/Entity/CustomerTest.php index 20e76066..ef3fbf72 100644 --- a/tests/Entity/CustomerTest.php +++ b/tests/Entity/CustomerTest.php @@ -11,6 +11,7 @@ namespace App\Tests\Entity; use App\Entity\Customer; use App\Entity\CustomerMeta; +use App\Entity\Team; use Doctrine\Common\Collections\Collection; use PHPUnit\Framework\TestCase; @@ -49,6 +50,8 @@ class CustomerTest extends TestCase $this->assertInstanceOf(Collection::class, $sut->getMetaFields()); $this->assertEquals(0, $sut->getMetaFields()->count()); $this->assertNull($sut->getMetaField('foo')); + $this->assertInstanceOf(Collection::class, $sut->getTeams()); + $this->assertEquals(0, $sut->getTeams()->count()); } public function testSetterAndGetter() @@ -127,4 +130,23 @@ class CustomerTest extends TestCase self::assertEquals(3, $sut->getMetaFields()->count()); self::assertCount(2, $sut->getVisibleMetaFields()); } + + public function testTeams() + { + $sut = new Customer(); + $team = new Team(); + self::assertEmpty($sut->getTeams()); + self::assertEmpty($team->getCustomers()); + + $sut->addTeam($team); + self::assertCount(1, $sut->getTeams()); + self::assertCount(1, $team->getCustomers()); + self::assertSame($team, $sut->getTeams()[0]); + self::assertSame($sut, $team->getCustomers()[0]); + + $sut->removeTeam(new Team()); + $sut->removeTeam($team); + self::assertCount(0, $sut->getTeams()); + self::assertCount(0, $team->getCustomers()); + } } diff --git a/tests/Entity/ProjectTest.php b/tests/Entity/ProjectTest.php index 82091c7e..9cd0736b 100644 --- a/tests/Entity/ProjectTest.php +++ b/tests/Entity/ProjectTest.php @@ -12,6 +12,7 @@ namespace App\Tests\Entity; use App\Entity\Customer; use App\Entity\Project; use App\Entity\ProjectMeta; +use App\Entity\Team; use Doctrine\Common\Collections\Collection; use PHPUnit\Framework\TestCase; @@ -37,6 +38,8 @@ class ProjectTest extends TestCase $this->assertInstanceOf(Collection::class, $sut->getMetaFields()); $this->assertEquals(0, $sut->getMetaFields()->count()); $this->assertNull($sut->getMetaField('foo')); + $this->assertInstanceOf(Collection::class, $sut->getTeams()); + $this->assertEquals(0, $sut->getTeams()->count()); } public function testSetterAndGetter() @@ -101,4 +104,22 @@ class ProjectTest extends TestCase self::assertEquals(3, $sut->getMetaFields()->count()); self::assertCount(2, $sut->getVisibleMetaFields()); } + + public function testTeams() + { + $sut = new Project(); + $team = new Team(); + self::assertEmpty($sut->getTeams()); + self::assertEmpty($team->getProjects()); + + $sut->addTeam($team); + self::assertCount(1, $sut->getTeams()); + self::assertCount(1, $team->getProjects()); + self::assertSame($team, $sut->getTeams()[0]); + self::assertSame($sut, $team->getProjects()[0]); + + $sut->removeTeam($team); + self::assertCount(0, $sut->getTeams()); + self::assertCount(0, $team->getProjects()); + } } diff --git a/tests/Entity/TeamTest.php b/tests/Entity/TeamTest.php new file mode 100644 index 00000000..ab329bd5 --- /dev/null +++ b/tests/Entity/TeamTest.php @@ -0,0 +1,108 @@ +getId()); + self::assertNull($sut->getName()); + self::assertNull($sut->getTeamLead()); + self::assertInstanceOf(Collection::class, $sut->getUsers()); + self::assertEquals(0, $sut->getUsers()->count()); + self::assertInstanceOf(Collection::class, $sut->getCustomers()); + self::assertEquals(0, $sut->getCustomers()->count()); + self::assertInstanceOf(Collection::class, $sut->getProjects()); + self::assertEquals(0, $sut->getProjects()->count()); + } + + public function testSetterAndGetter() + { + $sut = new Team(); + self::assertInstanceOf(Team::class, $sut->setName('foo-bar')); + self::assertEquals('foo-bar', $sut->getName()); + self::assertEquals('foo-bar', (string) $sut); + + $user = (new User())->setAlias('Foo!'); + self::assertInstanceOf(Team::class, $sut->setTeamLead($user)); + self::assertSame($user, $sut->getTeamLead()); + + self::assertFalse($sut->isTeamlead(new User())); + self::assertTrue($sut->isTeamlead($user)); + } + + public function testCustomer() + { + $customer = new Customer(); + $customer->setName('foo'); + self::assertEmpty($customer->getTeams()); + + $sut = new Team(); + $sut->addCustomer($customer); + self::assertEquals(1, $sut->getCustomers()->count()); + $actual = $sut->getCustomers()[0]; + self::assertSame($actual, $customer); + self::assertSame($sut, $customer->getTeams()[0]); + $sut->removeCustomer(new Customer()); + self::assertEquals(1, $sut->getCustomers()->count()); + $sut->removeCustomer($customer); + self::assertEquals(0, $sut->getCustomers()->count()); + } + + public function testProject() + { + $project = new Project(); + $project->setName('foo'); + self::assertEmpty($project->getTeams()); + + $sut = new Team(); + $sut->addProject($project); + self::assertEquals(1, $sut->getProjects()->count()); + $actual = $sut->getProjects()[0]; + self::assertSame($actual, $project); + self::assertSame($sut, $project->getTeams()[0]); + $sut->removeProject(new Project()); + self::assertEquals(1, $sut->getProjects()->count()); + $sut->removeProject($project); + self::assertEquals(0, $sut->getProjects()->count()); + } + + public function testUsers() + { + $user = new User(); + $user->setAlias('foo'); + self::assertEmpty($user->getTeams()); + + $sut = new Team(); + $sut->addUser($user); + self::assertEquals(1, $sut->getUsers()->count()); + $actual = $sut->getUsers()[0]; + self::assertSame($actual, $user); + self::assertSame($sut, $user->getTeams()[0]); + self::assertFalse($sut->hasUser(new User())); + self::assertTrue($sut->hasUser($user)); + $sut->removeUser(new User()); + self::assertEquals(1, $sut->getUsers()->count()); + $sut->removeUser($user); + self::assertEquals(0, $sut->getUsers()->count()); + } +} diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php index 7eebf03a..3f1a1517 100644 --- a/tests/Entity/UserTest.php +++ b/tests/Entity/UserTest.php @@ -9,6 +9,7 @@ namespace App\Tests\Entity; +use App\Entity\Team; use App\Entity\User; use App\Entity\UserPreference; use Doctrine\Common\Collections\ArrayCollection; @@ -97,4 +98,44 @@ class UserTest extends TestCase $this->assertEquals('fr', $sut->getLocale()); } + + public function testTeams() + { + $sut = new User(); + $team = new Team(); + self::assertEmpty($sut->getTeams()); + self::assertEmpty($team->getUsers()); + + $sut->addTeam($team); + self::assertCount(1, $sut->getTeams()); + self::assertSame($team, $sut->getTeams()[0]); + self::assertSame($sut, $team->getUsers()[0]); + + self::assertFalse($sut->isTeamleadOf($team)); + self::assertTrue($sut->isInTeam($team)); + + $team2 = new Team(); + self::assertFalse($sut->isInTeam($team2)); + self::assertFalse($sut->isTeamleadOf($team2)); + $team2->setTeamLead($sut); + self::assertTrue($sut->isTeamleadOf($team2)); + self::assertTrue($sut->isInTeam($team2)); + + $sut->removeTeam(new Team()); + self::assertCount(2, $sut->getTeams()); + $sut->removeTeam($team); + self::assertCount(1, $sut->getTeams()); + $sut->removeTeam($team2); + self::assertCount(0, $sut->getTeams()); + } + + public function testRoles() + { + $sut = new User(); + self::assertFalse($sut->isTeamlead()); + $sut->addRole(User::ROLE_ADMIN); + self::assertFalse($sut->isTeamlead()); + $sut->addRole(User::ROLE_TEAMLEAD); + self::assertTrue($sut->isTeamlead()); + } } diff --git a/tests/Mocks/AbstractMockFactory.php b/tests/Mocks/AbstractMockFactory.php index 220b68ab..99a111c1 100644 --- a/tests/Mocks/AbstractMockFactory.php +++ b/tests/Mocks/AbstractMockFactory.php @@ -17,13 +17,18 @@ abstract class AbstractMockFactory /** * @var TestCase */ - protected $testCase; + private $testCase; public function __construct(TestCase $testCase) { $this->testCase = $testCase; } + protected function getTestCase(): TestCase + { + return $this->testCase; + } + protected function getMockBuilder(string $className): MockBuilder { return new MockBuilder($this->testCase, $className); diff --git a/tests/Mocks/Security/CurrentUserFactory.php b/tests/Mocks/Security/CurrentUserFactory.php new file mode 100644 index 00000000..7b4dd6b1 --- /dev/null +++ b/tests/Mocks/Security/CurrentUserFactory.php @@ -0,0 +1,46 @@ +getCurrentUserMock($user, $timezone); + } + + protected function getCurrentUserMock(User $user, ?string $timezone = null) + { + if (null !== $timezone) { + $pref = new UserPreference(); + $pref->setName('timezone'); + $pref->setValue($timezone); + $user->addPreference($pref); + } + + $repository = $this->getMockBuilder(UserRepository::class)->setMethods(['getUserById'])->disableOriginalConstructor()->getMock(); + $repository->expects(TestCase::atMost(1))->method('getUserById')->willReturn($user); + $token = $this->getMockBuilder(UsernamePasswordToken::class)->setMethods(['getUser'])->disableOriginalConstructor()->getMock(); + $token->expects(TestCase::atLeast(1))->method('getUser')->willReturn($user); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken($token); + + return new CurrentUser($tokenStorage, $repository); + } +} diff --git a/tests/Mocks/Security/UserDateTimeFactoryFactory.php b/tests/Mocks/Security/UserDateTimeFactoryFactory.php index f191bd13..0e56fc6d 100644 --- a/tests/Mocks/Security/UserDateTimeFactoryFactory.php +++ b/tests/Mocks/Security/UserDateTimeFactoryFactory.php @@ -10,38 +10,16 @@ namespace App\Tests\Mocks\Security; use App\Entity\User; -use App\Entity\UserPreference; -use App\Repository\UserRepository; -use App\Security\CurrentUser; use App\Tests\Mocks\AbstractMockFactory; use App\Timesheet\UserDateTimeFactory; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage; -use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; class UserDateTimeFactoryFactory extends AbstractMockFactory { public function create(?string $timezone = null): UserDateTimeFactory { - return new UserDateTimeFactory($this->getCurrentUserMock($timezone)); - } + $userFactory = new CurrentUserFactory($this->getTestCase()); + $currentUser = $userFactory->create(new User(), $timezone); - protected function getCurrentUserMock(?string $timezone = null) - { - $user = new User(); - if (null !== $timezone) { - $pref = new UserPreference(); - $pref->setName('timezone'); - $pref->setValue($timezone); - $user->addPreference($pref); - } - $repository = $this->getMockBuilder(UserRepository::class)->setMethods(['getUserById'])->disableOriginalConstructor()->getMock(); - $repository->expects(TestCase::exactly(1))->method('getUserById')->willReturn($user); - $token = $this->getMockBuilder(UsernamePasswordToken::class)->setMethods(['getUser'])->disableOriginalConstructor()->getMock(); - $token->expects(TestCase::exactly(1))->method('getUser')->willReturn($user); - $tokenStorage = new TokenStorage(); - $tokenStorage->setToken($token); - - return new CurrentUser($tokenStorage, $repository); + return new UserDateTimeFactory($currentUser); } } diff --git a/tests/Repository/Query/ActivityFormTypeQueryTest.php b/tests/Repository/Query/ActivityFormTypeQueryTest.php new file mode 100644 index 00000000..dea3f52a --- /dev/null +++ b/tests/Repository/Query/ActivityFormTypeQueryTest.php @@ -0,0 +1,44 @@ +isGlobalsOnly()); + + $project = new Project(); + self::assertNull($sut->getProject()); + self::assertInstanceOf(ActivityFormTypeQuery::class, $sut->setProject($project)); + self::assertSame($project, $sut->getProject()); + + $activity = new Activity(); + self::assertNull($sut->getActivity()); + self::assertInstanceOf(ActivityFormTypeQuery::class, $sut->setActivity($activity)); + self::assertSame($activity, $sut->getActivity()); + + self::assertFalse($sut->isGlobalsOnly()); + + $activity = new Activity(); + self::assertNull($sut->getActivityToIgnore()); + self::assertInstanceOf(ActivityFormTypeQuery::class, $sut->setActivityToIgnore($activity)); + self::assertSame($activity, $sut->getActivityToIgnore()); + } +} diff --git a/tests/Repository/Query/BaseQueryTest.php b/tests/Repository/Query/BaseQueryTest.php index 79ed4150..9e97e154 100644 --- a/tests/Repository/Query/BaseQueryTest.php +++ b/tests/Repository/Query/BaseQueryTest.php @@ -9,6 +9,7 @@ namespace App\Tests\Repository\Query; +use App\Entity\Team; use App\Repository\Query\BaseQuery; use PHPUnit\Framework\TestCase; @@ -29,6 +30,7 @@ class BaseQueryTest extends TestCase $this->assertPageSize($sut); $this->assertOrderBy($sut, $orderBy); $this->assertOrder($sut); + $this->assertTeams($sut); } protected function assertResultType(BaseQuery $sut) @@ -49,6 +51,14 @@ class BaseQueryTest extends TestCase } } + protected function assertTeams(BaseQuery $sut) + { + self::assertEmpty($sut->getTeams()); + + self::assertInstanceOf(BaseQuery::class, $sut->addTeam(new Team())); + self::assertEquals(1, count($sut->getTeams())); + } + protected function assertPage(BaseQuery $sut) { $this->assertEquals(BaseQuery::DEFAULT_PAGE, $sut->getPage()); diff --git a/tests/Repository/Query/CustomerFormTypeQueryTest.php b/tests/Repository/Query/CustomerFormTypeQueryTest.php new file mode 100644 index 00000000..bf90585d --- /dev/null +++ b/tests/Repository/Query/CustomerFormTypeQueryTest.php @@ -0,0 +1,45 @@ +getTeams()); + self::assertInstanceOf(CustomerFormTypeQuery::class, $sut->addTeam(new Team())); + self::assertCount(1, $sut->getTeams()); + + $customer = new Customer(); + self::assertNull($sut->getCustomer()); + self::assertInstanceOf(CustomerFormTypeQuery::class, $sut->setCustomer($customer)); + self::assertSame($customer, $sut->getCustomer()); + + $customer = new Customer(); + self::assertNull($sut->getCustomerToIgnore()); + self::assertInstanceOf(CustomerFormTypeQuery::class, $sut->setCustomerToIgnore($customer)); + self::assertSame($customer, $sut->getCustomerToIgnore()); + + $user = new User(); + self::assertNull($sut->getUser()); + self::assertInstanceOf(CustomerFormTypeQuery::class, $sut->setUser($user)); + self::assertSame($user, $sut->getUser()); + } +} diff --git a/tests/Repository/Query/ProjectFormTypeQueryTest.php b/tests/Repository/Query/ProjectFormTypeQueryTest.php new file mode 100644 index 00000000..978128ca --- /dev/null +++ b/tests/Repository/Query/ProjectFormTypeQueryTest.php @@ -0,0 +1,51 @@ +getTeams()); + self::assertInstanceOf(ProjectFormTypeQuery::class, $sut->addTeam(new Team())); + self::assertCount(1, $sut->getTeams()); + + $project = new Project(); + self::assertNull($sut->getProject()); + self::assertInstanceOf(ProjectFormTypeQuery::class, $sut->setProject($project)); + self::assertSame($project, $sut->getProject()); + + $project = new Project(); + self::assertNull($sut->getProjectToIgnore()); + self::assertInstanceOf(ProjectFormTypeQuery::class, $sut->setProjectToIgnore($project)); + self::assertSame($project, $sut->getProjectToIgnore()); + + $customer = new Customer(); + self::assertNull($sut->getCustomer()); + self::assertInstanceOf(ProjectFormTypeQuery::class, $sut->setCustomer($customer)); + self::assertSame($customer, $sut->getCustomer()); + + $user = new User(); + self::assertNull($sut->getUser()); + self::assertInstanceOf(ProjectFormTypeQuery::class, $sut->setUser($user)); + self::assertSame($user, $sut->getUser()); + } +} diff --git a/tests/Repository/Query/UserFormTypeQueryTest.php b/tests/Repository/Query/UserFormTypeQueryTest.php new file mode 100644 index 00000000..6b74f7d9 --- /dev/null +++ b/tests/Repository/Query/UserFormTypeQueryTest.php @@ -0,0 +1,34 @@ +getTeams()); + self::assertInstanceOf(UserFormTypeQuery::class, $sut->addTeam(new Team())); + self::assertCount(1, $sut->getTeams()); + + $user = new User(); + self::assertNull($sut->getUser()); + self::assertInstanceOf(UserFormTypeQuery::class, $sut->setUser($user)); + self::assertSame($user, $sut->getUser()); + } +} diff --git a/tests/Repository/WidgetRepositoryTest.php b/tests/Repository/WidgetRepositoryTest.php index 2e54d94a..d9c967f1 100644 --- a/tests/Repository/WidgetRepositoryTest.php +++ b/tests/Repository/WidgetRepositoryTest.php @@ -9,9 +9,10 @@ namespace App\Tests\Repository; +use App\Entity\User; use App\Repository\TimesheetRepository; use App\Repository\WidgetRepository; -use App\Security\CurrentUser; +use App\Tests\Mocks\Security\CurrentUserFactory; use PHPUnit\Framework\TestCase; /** @@ -22,7 +23,7 @@ class WidgetRepositoryTest extends TestCase public function testHasWidget() { $repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); - $userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock(); + $userMock = (new CurrentUserFactory($this))->create(new User()); $sut = new WidgetRepository($repoMock, $userMock, ['test' => []]); @@ -37,7 +38,7 @@ class WidgetRepositoryTest extends TestCase public function testGetWidgetThrowsExceptionOnNonExistingWidget() { $repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); - $userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock(); + $userMock = (new CurrentUserFactory($this))->create(new User()); $sut = new WidgetRepository($repoMock, $userMock, ['test' => []]); $sut->get('foo'); @@ -50,7 +51,7 @@ class WidgetRepositoryTest extends TestCase public function testGetWidgetThrowsExceptionOnInvalidType() { $repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); - $userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock(); + $userMock = (new CurrentUserFactory($this))->create(new User()); $sut = new WidgetRepository($repoMock, $userMock, ['test' => ['type' => 'FooBar', 'user' => false]]); $sut->get('test'); @@ -63,7 +64,7 @@ class WidgetRepositoryTest extends TestCase public function testGetWidgetTriggersExceptionOnWrongClass() { $repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); - $userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock(); + $userMock = (new CurrentUserFactory($this))->create(new User()); $sut = new WidgetRepository($repoMock, $userMock, ['test' => ['type' => 'CompoundChart', 'user' => false]]); $sut->get('test'); @@ -77,7 +78,7 @@ class WidgetRepositoryTest extends TestCase $repoMock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); $repoMock->method('getStatistic')->willReturn($data); - $userMock = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->getMock(); + $userMock = (new CurrentUserFactory($this))->create(new User()); $widget = [ 'color' => 'sunny', diff --git a/tests/Voter/AbstractVoterTest.php b/tests/Voter/AbstractVoterTest.php index 82db13d9..94c9dec4 100644 --- a/tests/Voter/AbstractVoterTest.php +++ b/tests/Voter/AbstractVoterTest.php @@ -52,6 +52,7 @@ abstract class AbstractVoterTest extends TestCase $user = $this->getMockBuilder(User::class)->getMock(); $user->method('getId')->willReturn($id); $user->method('getRoles')->willReturn($roles); + $user->method('getTeams')->willReturn([]); return $user; } @@ -65,8 +66,11 @@ abstract class AbstractVoterTest extends TestCase { if (!$overwrite) { $activities = ['view_activity', 'edit_activity', 'budget_activity', 'delete_activity', 'create_activity']; + $activitiesTeam = ['view_activity', 'create_activity', 'edit_teamlead_activity', 'budget_teamlead_activity']; $projects = ['view_project', 'edit_project', 'budget_project', 'delete_project', 'create_project']; + $projectsTeam = ['view_project', 'edit_teamlead_project', 'budget_teamlead_project', 'permissions_teamlead_project']; $customers = ['view_customer', 'edit_customer', 'budget_customer', 'delete_customer', 'create_customer']; + $customersTeam = ['view_customer', 'edit_teamlead_customer', 'budget_teamlead_customer']; $invoice = ['view_invoice', 'create_invoice']; $invoiceTemplate = ['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']; @@ -76,17 +80,18 @@ abstract class AbstractVoterTest extends TestCase $user = ['view_user', 'create_user', 'delete_user']; $rate = ['view_rate_own_timesheet', 'edit_rate_own_timesheet']; $rateOther = ['view_rate_other_timesheet', 'edit_rate_other_timesheet']; + $teams = ['view_team', 'create_team', 'edit_team', 'delete_team']; - $roleUser = []; + $roleUser = ['edit_team_activity', 'edit_team_project', 'edit_team_customer']; $roleTeamlead = ['view_rate_own_timesheet', 'view_rate_other_timesheet', 'hourly-rate_own_profile']; $roleAdmin = ['hourly-rate_own_profile', 'edit_exported_timesheet']; $roleSuperAdmin = ['hourly-rate_own_profile', 'hourly-rate_other_profile', 'delete_own_profile', 'roles_own_profile', 'system_information', 'system_configuration', 'plugins', 'edit_exported_timesheet']; $permissions = [ 'ROLE_USER' => array_merge($timesheet, $profile, $roleUser), - 'ROLE_TEAMLEAD' => array_merge($invoice, $timesheet, $timesheetOthers, $profile, $roleTeamlead), - 'ROLE_ADMIN' => array_merge($activities, $projects, $customers, $invoice, $invoiceTemplate, $timesheet, $timesheetOthers, $profile, $rate, $rateOther, $roleAdmin), - 'ROLE_SUPER_ADMIN' => array_merge($activities, $projects, $customers, $invoice, $invoiceTemplate, $timesheet, $timesheetOthers, $profile, $profileOther, $user, $rate, $rateOther, $roleSuperAdmin), + 'ROLE_TEAMLEAD' => array_merge($invoice, $timesheet, $timesheetOthers, $profile, $roleTeamlead, $activitiesTeam, $projectsTeam, $customersTeam), + 'ROLE_ADMIN' => array_merge($activities, $projects, $customers, $invoice, $invoiceTemplate, $timesheet, $timesheetOthers, $profile, $rate, $rateOther, $roleAdmin, $teams), + 'ROLE_SUPER_ADMIN' => array_merge($activities, $projects, $customers, $invoice, $invoiceTemplate, $timesheet, $timesheetOthers, $profile, $profileOther, $user, $rate, $rateOther, $roleSuperAdmin, $teams), ]; } diff --git a/tests/Voter/ActivityVoterTest.php b/tests/Voter/ActivityVoterTest.php index a779a36d..388aee3d 100644 --- a/tests/Voter/ActivityVoterTest.php +++ b/tests/Voter/ActivityVoterTest.php @@ -10,6 +10,9 @@ namespace App\Tests\Voter; use App\Entity\Activity; +use App\Entity\Customer; +use App\Entity\Project; +use App\Entity\Team; use App\Entity\User; use App\Voter\ActivityVoter; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; @@ -24,6 +27,11 @@ class ActivityVoterTest extends AbstractVoterTest * @dataProvider getTestData */ public function testVote(User $user, $subject, $attribute, $result) + { + $this->assertVote($user, $subject, $attribute, $result); + } + + protected function assertVote(User $user, $subject, $attribute, $result) { $token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles()); $sut = $this->getVoter(ActivityVoter::class, $user); @@ -47,9 +55,19 @@ class ActivityVoterTest extends AbstractVoterTest yield [$user, new Activity(), 'delete', $result]; } - $result = VoterInterface::ACCESS_DENIED; - foreach ([$user0, $user1, $user2] as $user) { + foreach ([$user2] as $user) { yield [$user, new Activity(), 'view', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ([$user0, $user1] as $user) { + yield [$user, new Activity(), 'view', $result]; + yield [$user, new Activity(), 'edit', $result]; + yield [$user, new Activity(), 'budget', $result]; + yield [$user, new Activity(), 'delete', $result]; + } + + foreach ([$user2] as $user) { yield [$user, new Activity(), 'edit', $result]; yield [$user, new Activity(), 'budget', $result]; yield [$user, new Activity(), 'delete', $result]; @@ -66,4 +84,65 @@ class ActivityVoterTest extends AbstractVoterTest yield [$user, $user, 'delete', $result]; } } + + public function testTeamlead() + { + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_TEAMLEAD); + $team->setTeamLead($user); + + $activity = new Activity(); + $project = new Project(); + $customer = new Customer(); + $project->setCustomer($customer); + $activity->setProject($project); + $customer->addTeam($team); + + $this->assertVote($user, $activity, 'edit', VoterInterface::ACCESS_GRANTED); + + $activity = new Activity(); + $project = new Project(); + $customer = new Customer(); + $project->setCustomer($customer); + $activity->setProject($project); + $project->addTeam($team); + + $this->assertVote($user, $activity, 'edit', VoterInterface::ACCESS_GRANTED); + + $activity = new Activity(); + + $this->assertVote($user, $activity, 'edit', VoterInterface::ACCESS_DENIED); + } + + public function testTeamMember() + { + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_USER); + $team->setTeamLead($user); + + $activity = new Activity(); + $project = new Project(); + $customer = new Customer(); + $customer->addTeam($team); + $project->setCustomer($customer); + $activity->setProject($project); + + $this->assertVote($user, $activity, 'edit', VoterInterface::ACCESS_GRANTED); + + $activity = new Activity(); + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_USER); + $team->addUser($user); + + $project = new Project(); + $customer = new Customer(); + $project->addTeam($team); + $project->setCustomer($customer); + $activity->setProject($project); + + $this->assertVote($user, $activity, 'edit', VoterInterface::ACCESS_GRANTED); + } } diff --git a/tests/Voter/CustomerVoterTest.php b/tests/Voter/CustomerVoterTest.php index 5aa6a547..91f29cde 100644 --- a/tests/Voter/CustomerVoterTest.php +++ b/tests/Voter/CustomerVoterTest.php @@ -10,6 +10,7 @@ namespace App\Tests\Voter; use App\Entity\Customer; +use App\Entity\Team; use App\Entity\User; use App\Voter\CustomerVoter; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; @@ -24,6 +25,11 @@ class CustomerVoterTest extends AbstractVoterTest * @dataProvider getTestData */ public function testVote(User $user, $subject, $attribute, $result) + { + $this->assertVote($user, $subject, $attribute, $result); + } + + protected function assertVote(User $user, $subject, $attribute, $result) { $token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles()); $sut = $this->getVoter(CustomerVoter::class, $user); @@ -47,9 +53,19 @@ class CustomerVoterTest extends AbstractVoterTest yield [$user, new Customer(), 'delete', $result]; } - $result = VoterInterface::ACCESS_DENIED; - foreach ([$user0, $user1, $user2] as $user) { + foreach ([$user2] as $user) { yield [$user, new Customer(), 'view', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ([$user0, $user1] as $user) { + yield [$user, new Customer(), 'view', $result]; + yield [$user, new Customer(), 'edit', $result]; + yield [$user, new Customer(), 'budget', $result]; + yield [$user, new Customer(), 'delete', $result]; + } + + foreach ([$user2] as $user) { yield [$user, new Customer(), 'edit', $result]; yield [$user, new Customer(), 'budget', $result]; yield [$user, new Customer(), 'delete', $result]; @@ -66,4 +82,40 @@ class CustomerVoterTest extends AbstractVoterTest yield [$user, $user, 'delete', $result]; } } + + public function testTeamlead() + { + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_TEAMLEAD); + $team->setTeamLead($user); + + $customer = new Customer(); + $customer->addTeam($team); + + $this->assertVote($user, $customer, 'edit', VoterInterface::ACCESS_GRANTED); + } + + public function testTeamMember() + { + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_USER); + $team->setTeamLead($user); + + $customer = new Customer(); + $customer->addTeam($team); + + $this->assertVote($user, $customer, 'edit', VoterInterface::ACCESS_GRANTED); + + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_USER); + $team->addUser($user); + + $customer = new Customer(); + $customer->addTeam($team); + + $this->assertVote($user, $customer, 'edit', VoterInterface::ACCESS_GRANTED); + } } diff --git a/tests/Voter/ProjectVoterTest.php b/tests/Voter/ProjectVoterTest.php index 21107586..0639b51f 100644 --- a/tests/Voter/ProjectVoterTest.php +++ b/tests/Voter/ProjectVoterTest.php @@ -9,7 +9,9 @@ namespace App\Tests\Voter; +use App\Entity\Customer; use App\Entity\Project; +use App\Entity\Team; use App\Entity\User; use App\Voter\ProjectVoter; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; @@ -24,10 +26,19 @@ class ProjectVoterTest extends AbstractVoterTest * @dataProvider getTestData */ public function testVote(User $user, $subject, $attribute, $result) + { + $this->assertVote($user, $subject, $attribute, $result); + } + + protected function assertVote(User $user, $subject, $attribute, $result) { $token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles()); $sut = $this->getVoter(ProjectVoter::class, $user); + if ($subject instanceof Project && null === $subject->getCustomer()) { + $subject->setCustomer(new Customer()); + } + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); } @@ -47,9 +58,19 @@ class ProjectVoterTest extends AbstractVoterTest yield [$user, new Project(), 'delete', $result]; } - $result = VoterInterface::ACCESS_DENIED; - foreach ([$user0, $user1, $user2] as $user) { + foreach ([$user2] as $user) { yield [$user, new Project(), 'view', $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + foreach ([$user0, $user1] as $user) { + yield [$user, new Project(), 'view', $result]; + yield [$user, new Project(), 'edit', $result]; + yield [$user, new Project(), 'budget', $result]; + yield [$user, new Project(), 'delete', $result]; + } + + foreach ([$user2] as $user) { yield [$user, new Project(), 'edit', $result]; yield [$user, new Project(), 'budget', $result]; yield [$user, new Project(), 'delete', $result]; @@ -67,4 +88,53 @@ class ProjectVoterTest extends AbstractVoterTest yield [$user, $user, 'delete', $result]; } } + + public function testTeamlead() + { + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_TEAMLEAD); + $team->setTeamLead($user); + + $project = new Project(); + $customer = new Customer(); + $project->setCustomer($customer); + $customer->addTeam($team); + + $this->assertVote($user, $project, 'edit', VoterInterface::ACCESS_GRANTED); + + $project = new Project(); + $customer = new Customer(); + $project->setCustomer($customer); + $project->addTeam($team); + + $this->assertVote($user, $project, 'edit', VoterInterface::ACCESS_GRANTED); + } + + public function testTeamMember() + { + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_USER); + $team->setTeamLead($user); + + $project = new Project(); + $customer = new Customer(); + $customer->addTeam($team); + $project->setCustomer($customer); + + $this->assertVote($user, $project, 'edit', VoterInterface::ACCESS_GRANTED); + + $team = new Team(); + $user = new User(); + $user->addRole(User::ROLE_USER); + $team->addUser($user); + + $project = new Project(); + $customer = new Customer(); + $project->addTeam($team); + $project->setCustomer($customer); + + $this->assertVote($user, $project, 'edit', VoterInterface::ACCESS_GRANTED); + } } diff --git a/tests/Voter/RolePermissionVoterTest.php b/tests/Voter/RolePermissionVoterTest.php index 7a841359..b8dd1583 100644 --- a/tests/Voter/RolePermissionVoterTest.php +++ b/tests/Voter/RolePermissionVoterTest.php @@ -57,7 +57,6 @@ class RolePermissionVoterTest extends AbstractVoterTest ]; $others = [ - 'create_activity' => null, 'create_customer' => null, 'create_project' => null, ]; @@ -101,6 +100,11 @@ class RolePermissionVoterTest extends AbstractVoterTest yield [$user, $entity, $permission, $result]; } } + foreach ([$user0, $user1] as $user) { + foreach (['view_activity' => null] as $permission => $entity) { + yield [$user, $entity, $permission, $result]; + } + } foreach ([$user0, $user1] as $user) { foreach ($invoice as $permission => $entity) { yield [$user, $entity, $permission, $result]; diff --git a/tests/Voter/TeamVoterTest.php b/tests/Voter/TeamVoterTest.php new file mode 100644 index 00000000..29f32dfc --- /dev/null +++ b/tests/Voter/TeamVoterTest.php @@ -0,0 +1,82 @@ +getRoles()); + $sut = $this->getVoter(TeamVoter::class, $user); + + $this->assertEquals($result, $sut->vote($token, $subject, [$attribute])); + } + + public function getTestData() + { + $user0 = $this->getUser(0, null); + $user1 = $this->getUser(1, User::ROLE_USER); + $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getUser(3, User::ROLE_ADMIN); + $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + + $team = new Team(); + + $result = VoterInterface::ACCESS_ABSTAIN; + + $allTeamPerms = ['view_team', 'create_team', 'edit_team', 'delete_team']; + + foreach ($allTeamPerms as $fullPerm) { + yield [$user0, [], $fullPerm, $result]; + yield [$user0, new \stdClass(), $fullPerm, $result]; + yield [$user0, $team, $fullPerm, $result]; + yield [$user1, $team, $fullPerm, $result]; + yield [$user2, $team, $fullPerm, $result]; + yield [$user3, $team, $fullPerm, $result]; + yield [$user4, $team, $fullPerm, $result]; + } + + $result = VoterInterface::ACCESS_DENIED; + + yield [$user0, $team, 'view', $result]; + yield [$user0, $team, 'edit', $result]; + yield [$user0, $team, 'delete', $result]; + + yield [$user1, $team, 'view', $result]; + yield [$user1, $team, 'edit', $result]; + yield [$user1, $team, 'delete', $result]; + + yield [$user2, $team, 'view', $result]; + yield [$user2, $team, 'edit', $result]; + yield [$user2, $team, 'delete', $result]; + + $result = VoterInterface::ACCESS_GRANTED; + + yield [$user3, $team, 'view', $result]; + yield [$user3, $team, 'edit', $result]; + yield [$user3, $team, 'delete', $result]; + + yield [$user4, $team, 'view', $result]; + yield [$user4, $team, 'edit', $result]; + yield [$user4, $team, 'delete', $result]; + } +} diff --git a/tests/Widget/Type/DailyWorkingTimeChartTest.php b/tests/Widget/Type/DailyWorkingTimeChartTest.php index b39d97e0..661e2171 100644 --- a/tests/Widget/Type/DailyWorkingTimeChartTest.php +++ b/tests/Widget/Type/DailyWorkingTimeChartTest.php @@ -12,7 +12,7 @@ namespace App\Tests\Widget\Type; use App\Entity\User; use App\Model\Statistic\Day; use App\Repository\TimesheetRepository; -use App\Security\CurrentUser; +use App\Tests\Mocks\Security\CurrentUserFactory; use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; use App\Widget\Type\AbstractWidgetType; use App\Widget\Type\DailyWorkingTimeChart; @@ -30,9 +30,9 @@ class DailyWorkingTimeChartTest extends TestCase public function createSut(): AbstractWidgetType { $repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock(); - $user = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->setMethods(['getUser'])->getMock(); - $user->expects($this->once())->method('getUser')->willReturn(new User()); $mockFactory = new UserDateTimeFactoryFactory($this); + $userFactory = new CurrentUserFactory($this); + $user = $userFactory->create(new User(), 'Europe/Berlin'); return new DailyWorkingTimeChart($repository, $user, $mockFactory->create('Europe/Berlin')); } @@ -106,8 +106,10 @@ class DailyWorkingTimeChartTest extends TestCase ['year' => '2019', 'month' => '1', 'day' => 1, 'rate' => 13.75, 'duration' => 1234] ]; }); - $user = $this->getMockBuilder(CurrentUser::class)->disableOriginalConstructor()->setMethods(['getUser'])->getMock(); - $user->expects($this->once())->method('getUser')->willReturn((new User())->setUsername('tralalala')); + + $userFactory = new CurrentUserFactory($this); + $user = $userFactory->create(new User(), 'Europe/Berlin'); + $mockFactory = new UserDateTimeFactoryFactory($this); $sut = new DailyWorkingTimeChart($repository, $user, $mockFactory->create('Europe/Berlin')); diff --git a/tests/phpstan.neon b/tests/phpstan.neon index a56b2db5..86776f6d 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -8,6 +8,7 @@ parameters: - '#Access to an undefined property Faker\\Generator::\$stateAbbr.#' - '#Access to an undefined property Faker\\Generator::\$catchPhrase.#' - '#Access to an undefined property Faker\\Generator::\$bs.#' + - '#Call to static method PHPUnit\\Framework\\Assert::assertSame\(\) with App\\Entity\\[a-zA-Z0-9]+ and null will always evaluate to false.#' excludes_analyse: - %rootDir%/../../../tests/Ldap/LdapDriverTest.php inferPrivatePropertyTypeFromConstructor: true \ No newline at end of file diff --git a/translations/actions.de.xliff b/translations/actions.de.xliff index d5edd241..9f841c61 100644 --- a/translations/actions.de.xliff +++ b/translations/actions.de.xliff @@ -70,6 +70,10 @@ create-timesheet Zeit erfassen + + permissions + Team Berechtigungen + diff --git a/translations/actions.en.xliff b/translations/actions.en.xliff index f4a12fd1..04f07254 100644 --- a/translations/actions.en.xliff +++ b/translations/actions.en.xliff @@ -70,6 +70,10 @@ create-timesheet Create timesheet + + permissions + Team permissions + diff --git a/translations/messages.de.xliff b/translations/messages.de.xliff index b21ab7f4..56bf9c47 100644 --- a/translations/messages.de.xliff +++ b/translations/messages.de.xliff @@ -132,6 +132,10 @@ menu.admin_user Benutzer + + menu.admin_team + Teams + menu.plugin Erweiterungen @@ -256,6 +260,14 @@ label.email E-Mail + + label.team + Team + + + label.teamlead + Teamleiter + label.create_more Weitere Einträge erstellen @@ -280,6 +292,10 @@ action.save Speichern + + action.reset + Zurücksetzen + action.back Zurück @@ -400,6 +416,10 @@ profile.roles Rollen + + profile.teams + Teams + profile.preferences Einstellungen diff --git a/translations/messages.en.xliff b/translations/messages.en.xliff index dfe0e7b1..4d85e5c1 100644 --- a/translations/messages.en.xliff +++ b/translations/messages.en.xliff @@ -132,6 +132,10 @@ menu.admin_user Users + + menu.admin_team + Teams + menu.plugin Plugins @@ -256,6 +260,14 @@ label.email Email + + label.team + Team + + + label.teamlead + Teamlead + label.create_more Create further entries @@ -280,6 +292,10 @@ action.save Save + + action.reset + Reset + action.back Back @@ -400,6 +416,10 @@ profile.roles Roles + + profile.teams + Teams + profile.preferences Preferences diff --git a/translations/teams.de.xliff b/translations/teams.de.xliff new file mode 100644 index 00000000..988a1340 --- /dev/null +++ b/translations/teams.de.xliff @@ -0,0 +1,23 @@ + + + + + + teams.title + Teams + + + teams.subtitle + Mit Teams können Sie die Berechtigungen von Benutzergruppen für Kunden und Projekte steuern + + + teams.project_access + Zugriff auf Projekte gewähren + + + teams.customer_access + Zugriff auf Kunden gewähren + + + + diff --git a/translations/teams.en.xliff b/translations/teams.en.xliff new file mode 100644 index 00000000..1591329b --- /dev/null +++ b/translations/teams.en.xliff @@ -0,0 +1,23 @@ + + + + + + teams.title + Teams + + + teams.subtitle + Teams allow you to control the permissions of user groups for customers and projects + + + teams.project_access + Grant access to projects + + + teams.customer_access + Grant access to customers + + + + diff --git a/var/data/kimai_test.sqlite b/var/data/kimai_test.sqlite index 52d4e23d3f555d7d4da64a878fe3a639748b2f47..4ac08c64f9a50a10b912ba8f38ae556033f8c219 100644 GIT binary patch delta 3352 zcmeHITWs586!x*5*tw^dEFJAymvyw+S{mC;9CzK;BzC4ORnm5?7!o4twrDM^m$hkk zCruel@Ip%f0}|36&>j$CmyQ}VBsK{lZBKgvX$XluGA(57lXScz?cuZi z|2gM;C*L{$f9B^q=jS^Y>#HOT!@fdz8etB@fLLnhof7t}kJh0b!gdcmv0b)@w)?hw zm*!5|Y-qW80zIk^phwlvWjpx?Q4P*_DQKj|@E-UOoSA0(ujZL5nFb<8B1S_ zgK+Vjn-$#x?`Aok=en}14!5s^oe7a3ra~GYg>hgwd(s(n7j_N3T?+*K~>y#yvY+r ze>Pi!&k){0xQg&4!WRfD2%jTdL0Cq(gm4kzf_lb|#~~a--!ekBj9F;cLA37GUJUosu#mQmO&m-d3rqXr0go(l!H~}{ zcx8zfJui=qkBo8g*_p)jOgxzw8K2n^3=PWuA(;tBC3zpSo)Z&`GL?=@TQ*93?4T=n zT1J7#fd;74WwVcG z1}H~nBW+IQb7^OC+bAj5;iHt(vjx(oq8NNYmZYE%@`VIR_DDjWDknpCuv~?5YmEvy z52h{qI-D3eh%{hb6LXYPKy;QI-EcHK5HCdV$Q&=oZm*V4e9s6Q>4JrFWCPUn<-<;l zj3->_wpq%#WeY6SXm!e2Xv6sOX%G1WeR82lSu16v4md+G!>mrqPj;}hv2M4EiN=PQ z=x|@3>v$50BtW{RD3ZW)vK;h_O1~32>Ei0F6I}JK)Xb`RQ>FQ>67#Qd=pY2_~* z!p2wojv#RacwJB~bl?u<91c2VKLMke7bv50n}BAaoe9RGdxr-7;ph+(io^@V{(vNP zc>^*NiVe!)-BBhYKaZ3j8&AxPCKAcai?ma@+ksm$!xk_5xF7+)#Cds^EekSoDCfX=J?om?Tsa0iVX*2=><`@W~I3!!fD8M=;qPVn8ll`bX%q)m?3-TVy99_IuX*&qI4d2 zK=ScEUs>S}%+4ey$BUP3P`U1a8f&SkvQ!U_t$Eq9ubgJsE2|W1N3B4iT&;nP`kZ}0 z8&5>{dyo(Dxp=XByg~xGn7&h#Qxsvrq%A|iBHvv3I+X49X719P=+%kAv?-AWXe`yY zq2PdsGy+FX>z#2iHyTm}i#$rS&Wku@`CXGL=sCLou}A1Te* z&^WFgjO2;O(f-#EEuS6*+JX-gYk4w6B|#&;rs1Cq(frK)-w#n04N)(gp9kjrBG}P% bB>T^jQzU{8L61No&x9zP#?q- delta 45273 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 Be%Js2