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 52d4e23d..4ac08c64 100644 Binary files a/var/data/kimai_test.sqlite and b/var/data/kimai_test.sqlite differ