added team permissions (#996)

This commit is contained in:
Kevin Papst
2019-08-16 01:22:33 +02:00
committed by GitHub
parent 9611646bc6
commit 561ec3b1e9
124 changed files with 4122 additions and 362 deletions

View File

@@ -5,6 +5,7 @@
[![Travis Status](https://travis-ci.org/kevinpapst/kimai2.svg?branch=master)](https://travis-ci.org/kevinpapst/kimai2) [![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) [![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) [![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. 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. It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Doctrine, AdminLTE, Webpack, ES6 etc.

120
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "1b34c28fac76bd8fd3aeb4cc048e2f47", "content-hash": "c4acae186589f23fac807bdacdf372c5",
"packages": [ "packages": [
{ {
"name": "beberlei/doctrineextensions", "name": "beberlei/doctrineextensions",
@@ -2808,16 +2808,16 @@
}, },
{ {
"name": "kevinpapst/adminlte-bundle", "name": "kevinpapst/adminlte-bundle",
"version": "2.8.3", "version": "2.8.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/kevinpapst/AdminLTEBundle.git", "url": "https://github.com/kevinpapst/AdminLTEBundle.git",
"reference": "51ebd9e9a6d36885f0e4830527fbbfa46d87955e" "reference": "12c5deaad9276458a2536da281732d70e5255441"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/51ebd9e9a6d36885f0e4830527fbbfa46d87955e", "url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/12c5deaad9276458a2536da281732d70e5255441",
"reference": "51ebd9e9a6d36885f0e4830527fbbfa46d87955e", "reference": "12c5deaad9276458a2536da281732d70e5255441",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2855,6 +2855,10 @@
"MIT" "MIT"
], ],
"authors": [ "authors": [
{
"name": "Kevin Papst",
"homepage": "https://www.kevinpapst.de"
},
{ {
"name": "Marc Bach", "name": "Marc Bach",
"homepage": "http://www.avanzu.de" "homepage": "http://www.avanzu.de"
@@ -2862,14 +2866,10 @@
{ {
"name": "Ángel Guzmán Maeso", "name": "Ángel Guzmán Maeso",
"homepage": "https://shakaran.net/blog" "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", "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", "name": "kimai/kimai2-composer",
@@ -6227,16 +6227,16 @@
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/polyfill-ctype",
"version": "v1.11.0", "version": "v1.12.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git", "url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "82ebae02209c21113908c229e9883c419720738a" "reference": "550ebaac289296ce228a706d0867afc34687e3f4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/82ebae02209c21113908c229e9883c419720738a", "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/550ebaac289296ce228a706d0867afc34687e3f4",
"reference": "82ebae02209c21113908c229e9883c419720738a", "reference": "550ebaac289296ce228a706d0867afc34687e3f4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6248,7 +6248,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.11-dev" "dev-master": "1.12-dev"
} }
}, },
"autoload": { "autoload": {
@@ -6264,13 +6264,13 @@
"MIT" "MIT"
], ],
"authors": [ "authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
},
{ {
"name": "Gert de Pagter", "name": "Gert de Pagter",
"email": "BackEndTea@gmail.com" "email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
} }
], ],
"description": "Symfony polyfill for ctype functions", "description": "Symfony polyfill for ctype functions",
@@ -6281,25 +6281,25 @@
"polyfill", "polyfill",
"portable" "portable"
], ],
"time": "2019-02-06T07:57:58+00:00" "time": "2019-08-06T08:03:45+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-icu", "name": "symfony/polyfill-intl-icu",
"version": "v1.11.0", "version": "v1.12.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git", "url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "999878a3a09d73cae157b0cf89bb6fb2cc073057" "reference": "66810b9d6eb4af54d543867909d65ab9af654d7e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/999878a3a09d73cae157b0cf89bb6fb2cc073057", "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/66810b9d6eb4af54d543867909d65ab9af654d7e",
"reference": "999878a3a09d73cae157b0cf89bb6fb2cc073057", "reference": "66810b9d6eb4af54d543867909d65ab9af654d7e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.3", "php": ">=5.3.3",
"symfony/intl": "~2.3|~3.0|~4.0" "symfony/intl": "~2.3|~3.0|~4.0|~5.0"
}, },
"suggest": { "suggest": {
"ext-intl": "For best performance" "ext-intl": "For best performance"
@@ -6307,7 +6307,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.9-dev" "dev-master": "1.12-dev"
} }
}, },
"autoload": { "autoload": {
@@ -6339,20 +6339,20 @@
"portable", "portable",
"shim" "shim"
], ],
"time": "2019-01-07T19:39:47+00:00" "time": "2019-08-06T08:03:45+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.11.0", "version": "v1.12.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git", "url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "c766e95bec706cdd89903b1eda8afab7d7a6b7af" "reference": "6af626ae6fa37d396dc90a399c0ff08e5cfc45b2"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c766e95bec706cdd89903b1eda8afab7d7a6b7af", "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/6af626ae6fa37d396dc90a399c0ff08e5cfc45b2",
"reference": "c766e95bec706cdd89903b1eda8afab7d7a6b7af", "reference": "6af626ae6fa37d396dc90a399c0ff08e5cfc45b2",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6366,7 +6366,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.9-dev" "dev-master": "1.12-dev"
} }
}, },
"autoload": { "autoload": {
@@ -6382,13 +6382,13 @@
"MIT" "MIT"
], ],
"authors": [ "authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
},
{ {
"name": "Laurent Bassin", "name": "Laurent Bassin",
"email": "laurent@bassin.info" "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", "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
@@ -6401,20 +6401,20 @@
"portable", "portable",
"shim" "shim"
], ],
"time": "2019-03-04T13:44:35+00:00" "time": "2019-08-06T08:03:45+00:00"
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
"version": "v1.11.0", "version": "v1.12.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git", "url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "fe5e94c604826c35a32fa832f35bd036b6799609" "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fe5e94c604826c35a32fa832f35bd036b6799609", "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/b42a2f66e8f1b15ccf25652c3424265923eb4f17",
"reference": "fe5e94c604826c35a32fa832f35bd036b6799609", "reference": "b42a2f66e8f1b15ccf25652c3424265923eb4f17",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6426,7 +6426,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.11-dev" "dev-master": "1.12-dev"
} }
}, },
"autoload": { "autoload": {
@@ -6460,20 +6460,20 @@
"portable", "portable",
"shim" "shim"
], ],
"time": "2019-02-06T07:57:58+00:00" "time": "2019-08-06T08:03:45+00:00"
}, },
{ {
"name": "symfony/polyfill-php72", "name": "symfony/polyfill-php72",
"version": "v1.11.0", "version": "v1.12.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php72.git", "url": "https://github.com/symfony/polyfill-php72.git",
"reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c" "reference": "04ce3335667451138df4307d6a9b61565560199e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/ab50dcf166d5f577978419edd37aa2bb8eabce0c", "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/04ce3335667451138df4307d6a9b61565560199e",
"reference": "ab50dcf166d5f577978419edd37aa2bb8eabce0c", "reference": "04ce3335667451138df4307d6a9b61565560199e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6482,7 +6482,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.11-dev" "dev-master": "1.12-dev"
} }
}, },
"autoload": { "autoload": {
@@ -6515,20 +6515,20 @@
"portable", "portable",
"shim" "shim"
], ],
"time": "2019-02-06T07:57:58+00:00" "time": "2019-08-06T08:03:45+00:00"
}, },
{ {
"name": "symfony/polyfill-php73", "name": "symfony/polyfill-php73",
"version": "v1.11.0", "version": "v1.12.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php73.git", "url": "https://github.com/symfony/polyfill-php73.git",
"reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd" "reference": "2ceb49eaccb9352bff54d22570276bb75ba4a188"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/2ceb49eaccb9352bff54d22570276bb75ba4a188",
"reference": "d1fb4abcc0c47be136208ad9d68bf59f1ee17abd", "reference": "2ceb49eaccb9352bff54d22570276bb75ba4a188",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6537,7 +6537,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.11-dev" "dev-master": "1.12-dev"
} }
}, },
"autoload": { "autoload": {
@@ -6573,7 +6573,7 @@
"portable", "portable",
"shim" "shim"
], ],
"time": "2019-02-06T07:57:58+00:00" "time": "2019-08-06T08:03:45+00:00"
}, },
{ {
"name": "symfony/profiler-pack", "name": "symfony/profiler-pack",
@@ -9160,16 +9160,16 @@
}, },
{ {
"name": "nette/di", "name": "nette/di",
"version": "v3.0.0", "version": "v3.0.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nette/di.git", "url": "https://github.com/nette/di.git",
"reference": "19d83539245aaacb59470828919182411061841f" "reference": "4aff517a1c6bb5c36fa09733d4cea089f529de6d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nette/di/zipball/19d83539245aaacb59470828919182411061841f", "url": "https://api.github.com/repos/nette/di/zipball/4aff517a1c6bb5c36fa09733d4cea089f529de6d",
"reference": "19d83539245aaacb59470828919182411061841f", "reference": "4aff517a1c6bb5c36fa09733d4cea089f529de6d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9229,7 +9229,7 @@
"nette", "nette",
"static" "static"
], ],
"time": "2019-04-03T19:35:46+00:00" "time": "2019-08-07T12:11:33+00:00"
}, },
{ {
"name": "nette/finder", "name": "nette/finder",

View File

@@ -84,23 +84,33 @@ kimai:
# mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and sets"]) # mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and sets"])
sets: sets:
ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity'] ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity']
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project'] ACTIVITIES_TEAM: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity']
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer'] 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: ['view_invoice','create_invoice']
INVOICE_TEMPLATE: ['view_invoice_template','create_invoice_template','edit_invoice_template','delete_invoice_template'] 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: ['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'] 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: ['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'] TAGS: ['view_tag','delete_tag']
USER: ['view_user','create_user','delete_user','role_permissions'] USER: ['view_user','create_user','delete_user','role_permissions']
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet'] RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet'] RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
EXPORT: ['view_export','create_export','edit_export_own_timesheet','edit_export_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_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_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','@RATE','@RATE_OTHER','@EXPORT','@TAGS','hourly-rate_own_profile','edit_exported_timesheet'] 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','@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_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"]) # mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps: maps:
ROLE_USER: ['ROLE_USER'] 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 # DASHBOARD
# Using the configurable widget sections. # Using the configurable widget sections.
@@ -278,23 +276,6 @@ kimai:
date: 'd. m. Y' date: 'd. m. Y'
date_time: 'd. m. H:i' date_time: 'd. m. H:i'
duration: '%%h:%%m h' 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
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------

View File

@@ -21,6 +21,8 @@ nelmio_api_doc:
- { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] } - { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] }
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] } - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
- { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, 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] } - { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] }
areas: areas:
path_patterns: path_patterns:

View File

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

View File

@@ -154,6 +154,11 @@ services:
factory: ['@doctrine.orm.entity_manager', getRepository] factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\User'] arguments: ['App\Entity\User']
App\Repository\TeamRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Team']
App\Repository\ActivityRepository: App\Repository\ActivityRepository:
class: Doctrine\ORM\EntityRepository class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository] factory: ['@doctrine.orm.entity_manager', getRepository]

View File

@@ -68,6 +68,7 @@ class CustomerController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher) public function cgetAction(ParamFetcherInterface $paramFetcher)
{ {
$query = new CustomerQuery(); $query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
if (null !== ($order = $paramFetcher->get('order'))) { if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order); $query->setOrder($order);

View File

@@ -70,6 +70,7 @@ class ProjectController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher) public function cgetAction(ParamFetcherInterface $paramFetcher)
{ {
$query = new ProjectQuery(); $query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
if (null !== ($order = $paramFetcher->get('order'))) { if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order); $query->setOrder($order);

View File

@@ -104,9 +104,7 @@ class TagController extends BaseApiController
throw new NotFoundException(); throw new NotFoundException();
} }
$entityManager = $this->getDoctrine()->getManager(); $this->repository->deleteTag($tag);
$entityManager->remove($tag);
$entityManager->flush();
$view = new View(null, Response::HTTP_NO_CONTENT); $view = new View(null, Response::HTTP_NO_CONTENT);

133
src/API/TeamController.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API;
use App\Entity\Team;
use App\Repository\TeamRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Team")
*/
class TeamController extends BaseApiController
{
/**
* @var TeamRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
public function __construct(ViewHandlerInterface $viewHandler, TeamRepository $repository)
{
$this->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);
}
}

View File

@@ -47,4 +47,9 @@ class FormConfiguration implements SystemBundleConfiguration
{ {
return $this->find('user.language'); return $this->find('user.language');
} }
public function getUserDefaultCurrency(): string
{
return $this->find('user.currency');
}
} }

View File

@@ -9,6 +9,7 @@
namespace App\Controller; namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use App\Event\ActivityMetaDefinitionEvent; use App\Event\ActivityMetaDefinitionEvent;
@@ -40,14 +41,19 @@ class ActivityController extends AbstractController
* @var ActivityRepository * @var ActivityRepository
*/ */
private $repository; private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/** /**
* @var EventDispatcherInterface * @var EventDispatcherInterface
*/ */
protected $dispatcher; protected $dispatcher;
public function __construct(ActivityRepository $repository, EventDispatcherInterface $dispatcher) public function __construct(ActivityRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{ {
$this->repository = $repository; $this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
} }
@@ -68,6 +74,7 @@ class ActivityController extends AbstractController
public function indexAction($page, Request $request) public function indexAction($page, Request $request)
{ {
$query = new ActivityQuery(); $query = new ActivityQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page); $query->setPage($page);
$form = $this->getToolbarForm($query); $form = $this->getToolbarForm($query);
@@ -250,15 +257,20 @@ class ActivityController extends AbstractController
*/ */
private function createEditForm(Activity $activity) private function createEditForm(Activity $activity)
{ {
if ($activity->getId() === null) { $currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_activity_create'); $url = $this->generateUrl('admin_activity_create');
} else {
if ($activity->getId() !== null) {
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]); $url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
if (null !== $activity->getProject()) {
$currency = $activity->getProject()->getCustomer()->getCurrency();
}
} }
return $this->createForm(ActivityEditForm::class, $activity, [ return $this->createForm(ActivityEditForm::class, $activity, [
'action' => $url, 'action' => $url,
'method' => 'POST', 'method' => 'POST',
'currency' => $currency,
'create_more' => true, 'create_more' => true,
'customer' => true, 'customer' => true,
'include_budget' => $this->isGranted('budget', $activity) 'include_budget' => $this->isGranted('budget', $activity)

View File

@@ -13,6 +13,7 @@ use App\Configuration\FormConfiguration;
use App\Entity\Customer; use App\Entity\Customer;
use App\Event\CustomerMetaDefinitionEvent; use App\Event\CustomerMetaDefinitionEvent;
use App\Form\CustomerEditForm; use App\Form\CustomerEditForm;
use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm; use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType; use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
@@ -71,14 +72,11 @@ class CustomerController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"}) * @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"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"})
* @Security("is_granted('view_customer')") * @Security("is_granted('view_customer')")
*
* @param int $page
* @param Request $request
* @return Response
*/ */
public function indexAction($page, Request $request) public function indexAction($page, Request $request)
{ {
$query = new CustomerQuery(); $query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page); $query->setPage($page);
$form = $this->getToolbarForm($query); $form = $this->getToolbarForm($query);
@@ -98,9 +96,6 @@ class CustomerController extends AbstractController
/** /**
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"}) * @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')") * @Security("is_granted('create_customer')")
*
* @param Request $request
* @return RedirectResponse|Response
*/ */
public function createAction(Request $request) public function createAction(Request $request)
{ {
@@ -117,12 +112,39 @@ class CustomerController extends AbstractController
return $this->renderCustomerForm($customer, $request); 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"}) * @Route(path="/{id}/budget", name="admin_customer_budget", methods={"GET"})
* @Security("is_granted('budget', customer)") * @Security("is_granted('budget', customer)")
*
* @param Customer $customer
* @return Response
*/ */
public function budgetAction(Customer $customer) public function budgetAction(Customer $customer)
{ {
@@ -135,10 +157,6 @@ class CustomerController extends AbstractController
/** /**
* @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"}) * @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', customer)") * @Security("is_granted('edit', customer)")
*
* @param Customer $customer
* @param Request $request
* @return RedirectResponse|Response
*/ */
public function editAction(Customer $customer, Request $request) 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"}) * @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', customer)") * @Security("is_granted('delete', customer)")
*
* @param Customer $customer
* @param Request $request
* @return RedirectResponse|Response
*/ */
public function deleteAction(Customer $customer, Request $request) public function deleteAction(Customer $customer, Request $request)
{ {
@@ -169,6 +183,7 @@ class CustomerController extends AbstractController
'query_builder' => function (CustomerRepository $repo) use ($customer) { 'query_builder' => function (CustomerRepository $repo) use ($customer) {
$query = new CustomerFormTypeQuery(); $query = new CustomerFormTypeQuery();
$query->setCustomerToIgnore($customer); $query->setCustomerToIgnore($customer);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query); return $repo->getQueryBuilderForFormType($query);
}, },
@@ -229,11 +244,7 @@ class CustomerController extends AbstractController
]); ]);
} }
/** protected function getToolbarForm(CustomerQuery $query): FormInterface
* @param CustomerQuery $query
* @return FormInterface
*/
protected function getToolbarForm(CustomerQuery $query)
{ {
return $this->createForm(CustomerToolbarForm::class, $query, [ return $this->createForm(CustomerToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_customer', [ 'action' => $this->generateUrl('admin_customer', [
@@ -243,11 +254,7 @@ class CustomerController extends AbstractController
]); ]);
} }
/** private function createEditForm(Customer $customer): FormInterface
* @param Customer $customer
* @return FormInterface
*/
private function createEditForm(Customer $customer)
{ {
if ($customer->getId() === null) { if ($customer->getId() === null) {
$url = $this->generateUrl('admin_customer_create'); $url = $this->generateUrl('admin_customer_create');

View File

@@ -69,6 +69,7 @@ class ExportController extends AbstractController
$query->setEnd($end); $query->setEnd($end);
$query->setState(ExportQuery::STATE_STOPPED); $query->setState(ExportQuery::STATE_STOPPED);
$query->setExported(ExportQuery::STATE_NOT_EXPORTED); $query->setExported(ExportQuery::STATE_NOT_EXPORTED);
$query->setCurrentUser($this->getUser());
return $query; return $query;
} }

View File

@@ -71,6 +71,7 @@ class InvoiceController extends AbstractController
$query->setBegin($begin); $query->setBegin($begin);
$query->setEnd($end); $query->setEnd($end);
$query->setState(InvoiceQuery::STATE_STOPPED); $query->setState(InvoiceQuery::STATE_STOPPED);
$query->setCurrentUser($this->getUser());
return $query; return $query;
} }

View File

@@ -16,12 +16,14 @@ use App\Form\UserEditType;
use App\Form\UserPasswordType; use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm; use App\Form\UserPreferencesForm;
use App\Form\UserRolesType; use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Voter\UserVoter; use App\Voter\UserVoter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Form; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
@@ -175,6 +177,28 @@ class ProfileController extends AbstractController
return $this->getProfileView($profile, 'roles', null, null, $form); 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"}) * @Route(path="/{username}/prefs", name="user_profile_preferences", methods={"GET", "POST"})
* @Security("is_granted('preferences', profile)") * @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( protected function getProfileView(
User $user, User $user,
string $tab, string $tab,
Form $editForm = null, FormInterface $editForm = null,
Form $pwdForm = null, FormInterface $pwdForm = null,
Form $rolesForm = null, FormInterface $rolesForm = null,
Form $apiTokenForm = null FormInterface $apiTokenForm = null,
) { FormInterface $teamsForm = null
): Response {
$forms = []; $forms = [];
if ($this->isGranted(UserVoter::EDIT, $user)) { if ($this->isGranted(UserVoter::EDIT, $user)) {
@@ -268,6 +283,10 @@ class ProfileController extends AbstractController
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user); $apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
$forms['api-token'] = $apiTokenForm->createView(); $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)) { if ($this->isGranted(UserVoter::ROLES, $user)) {
$rolesForm = $rolesForm ?: $this->createRolesForm($user); $rolesForm = $rolesForm ?: $this->createRolesForm($user);
$forms['roles'] = $rolesForm->createView(); $forms['roles'] = $rolesForm->createView();
@@ -280,11 +299,7 @@ class ProfileController extends AbstractController
]); ]);
} }
/** private function createPreferencesForm(User $user): FormInterface
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createPreferencesForm(User $user)
{ {
// we need to prepare the user preferences, which is done via an EventSubscriber // we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($user); $event = new PrepareUserEvent($user);
@@ -300,11 +315,7 @@ class ProfileController extends AbstractController
); );
} }
/** private function createEditForm(User $user): FormInterface
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createEditForm(User $user)
{ {
return $this->createForm( return $this->createForm(
UserEditType::class, UserEditType::class,
@@ -317,11 +328,7 @@ class ProfileController extends AbstractController
); );
} }
/** private function createRolesForm(User $user): FormInterface
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createRolesForm(User $user)
{ {
return $this->createForm( return $this->createForm(
UserRolesType::class, UserRolesType::class,
@@ -333,11 +340,19 @@ class ProfileController extends AbstractController
); );
} }
/** private function createTeamsForm(User $user): FormInterface
* @param User $user {
* @return \Symfony\Component\Form\FormInterface return $this->createForm(
*/ UserTeamsType::class,
private function createPasswordForm(User $user) $user,
[
'action' => $this->generateUrl('user_profile_teams', ['username' => $user->getUsername()]),
'method' => 'POST',
]
);
}
private function createPasswordForm(User $user): FormInterface
{ {
return $this->createForm( return $this->createForm(
UserPasswordType::class, UserPasswordType::class,
@@ -350,11 +365,7 @@ class ProfileController extends AbstractController
); );
} }
/** private function createApiTokenForm(User $user): FormInterface
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createApiTokenForm(User $user)
{ {
return $this->createForm( return $this->createForm(
UserApiTokenType::class, UserApiTokenType::class,

View File

@@ -9,10 +9,12 @@
namespace App\Controller; namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Event\ProjectMetaDefinitionEvent; use App\Event\ProjectMetaDefinitionEvent;
use App\Form\ProjectEditForm; use App\Form\ProjectEditForm;
use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm; use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
@@ -40,14 +42,19 @@ class ProjectController extends AbstractController
* @var ProjectRepository * @var ProjectRepository
*/ */
private $repository; private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/** /**
* @var EventDispatcherInterface * @var EventDispatcherInterface
*/ */
protected $dispatcher; protected $dispatcher;
public function __construct(ProjectRepository $repository, EventDispatcherInterface $dispatcher) public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{ {
$this->repository = $repository; $this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
} }
@@ -60,14 +67,11 @@ class ProjectController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"}) * @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"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"})
* @Security("is_granted('view_project')") * @Security("is_granted('view_project')")
*
* @param int $page
* @param Request $request
* @return Response
*/ */
public function indexAction($page, Request $request) public function indexAction($page, Request $request)
{ {
$query = new ProjectQuery(); $query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page); $query->setPage($page);
$form = $this->getToolbarForm($query); $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", name="admin_project_create", methods={"GET", "POST"})
* @Route(path="/create/{customer}", name="admin_project_create_with_customer", methods={"GET", "POST"}) * @Route(path="/create/{customer}", name="admin_project_create_with_customer", methods={"GET", "POST"})
* @Security("is_granted('create_project')") * @Security("is_granted('create_project')")
*
* @param Request $request
* @param Customer|null $customer
* @return RedirectResponse|Response
*/ */
public function createAction(Request $request, ?Customer $customer = null) 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"}) * @Route(path="/{id}/budget", name="admin_project_budget", methods={"GET"})
* @Security("is_granted('budget', project)") * @Security("is_granted('budget', project)")
*
* @param Project $project
* @return Response
*/ */
public function budgetAction(Project $project) public function budgetAction(Project $project)
{ {
@@ -123,10 +150,6 @@ class ProjectController extends AbstractController
/** /**
* @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"}) * @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', project)") * @Security("is_granted('edit', project)")
*
* @param Project $project
* @param Request $request
* @return RedirectResponse|Response
*/ */
public function editAction(Project $project, Request $request) 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"}) * @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', project)") * @Security("is_granted('delete', project)")
*
* @param Project $project
* @param Request $request
* @return RedirectResponse|Response
*/ */
public function deleteAction(Project $project, Request $request) public function deleteAction(Project $project, Request $request)
{ {
@@ -158,6 +177,7 @@ class ProjectController extends AbstractController
$query = new ProjectFormTypeQuery(); $query = new ProjectFormTypeQuery();
$query->setCustomer($project->getCustomer()); $query->setCustomer($project->getCustomer());
$query->setProjectToIgnore($project); $query->setProjectToIgnore($project);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query); return $repo->getQueryBuilderForFormType($query);
}, },
@@ -225,11 +245,7 @@ class ProjectController extends AbstractController
]); ]);
} }
/** protected function getToolbarForm(ProjectQuery $query): FormInterface
* @param ProjectQuery $query
* @return FormInterface
*/
protected function getToolbarForm(ProjectQuery $query)
{ {
return $this->createForm(ProjectToolbarForm::class, $query, [ return $this->createForm(ProjectToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_project', [ 'action' => $this->generateUrl('admin_project', [
@@ -239,16 +255,12 @@ class ProjectController extends AbstractController
]); ]);
} }
/** private function createEditForm(Project $project): FormInterface
* @param Project $project
* @return FormInterface
*/
private function createEditForm(Project $project)
{ {
if ($project->getId() === null) { $currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_project_create'); $url = $this->generateUrl('admin_project_create');
$currency = Customer::DEFAULT_CURRENCY;
} else { if ($project->getId() !== null) {
$url = $this->generateUrl('admin_project_edit', ['id' => $project->getId()]); $url = $this->generateUrl('admin_project_edit', ['id' => $project->getId()]);
$currency = $project->getCustomer()->getCurrency(); $currency = $project->getCustomer()->getCurrency();
} }

View File

@@ -248,6 +248,10 @@ class SystemConfigurationController extends AbstractController
->setName('defaults.user.theme') ->setName('defaults.user.theme')
->setLabel('skin') ->setLabel('skin')
->setType(SkinType::class), ->setType(SkinType::class),
(new Configuration())
->setName('defaults.user.currency')
->setLabel('currency')
->setType(CurrencyType::class),
]), ]),
(new SystemConfigurationModel()) (new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_THEME) ->setSection(SystemConfigurationModel::SECTION_THEME)

View File

@@ -0,0 +1,185 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\Team;
use App\Form\TeamCustomerForm;
use App\Form\TeamEditForm;
use App\Form\TeamProjectForm;
use App\Form\Toolbar\TeamToolbarForm;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/admin/teams")
* @Security("is_granted('view_team')")
*/
class TeamController extends AbstractController
{
/**
* @var TeamRepository
*/
private $repository;
public function __construct(TeamRepository $repository)
{
$this->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',
]);
}
}

View File

@@ -112,9 +112,7 @@ abstract class TimesheetAbstractController extends AbstractController
$dirtyQuery = $query->isDirty(); $dirtyQuery = $query->isDirty();
if (!$this->includeUserInForms()) { $this->prepareQuery($query);
$query->setUser($this->getUser());
}
$pager = $this->getRepository()->getPagerfantaForQuery($query); $pager = $this->getRepository()->getPagerfantaForQuery($query);
@@ -253,9 +251,7 @@ abstract class TimesheetAbstractController extends AbstractController
} }
$query->getEnd()->setTime(23, 59, 59); $query->getEnd()->setTime(23, 59, 59);
if (!$this->includeUserInForms()) { $this->prepareQuery($query);
$query->setUser($this->getUser());
}
$entries = $this->getRepository()->getTimesheetsForQuery($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 protected function getCreateForm(Timesheet $entry, TrackingModeInterface $mode): FormInterface
{ {
return $this->createForm($this->getCreateFormClassName(), $entry, [ return $this->createForm($this->getCreateFormClassName(), $entry, [

View File

@@ -13,6 +13,7 @@ use App\Entity\Timesheet;
use App\Form\TimesheetAdminEditForm; use App\Form\TimesheetAdminEditForm;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository; use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request; 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); 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 protected function getCreateFormClassName(): string
{ {
return TimesheetAdminEditForm::class; return TimesheetAdminEditForm::class;

View File

@@ -9,6 +9,7 @@
namespace App\DependencyInjection; namespace App\DependencyInjection;
use App\Entity\Customer;
use App\Entity\User; use App\Entity\User;
use App\Timesheet\Rounding\RoundingInterface; use App\Timesheet\Rounding\RoundingInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
@@ -439,7 +440,7 @@ class Configuration implements ConfigurationInterface
->children() ->children()
->scalarNode('timezone')->defaultNull()->end() ->scalarNode('timezone')->defaultNull()->end()
->scalarNode('country')->defaultValue('DE')->end() ->scalarNode('country')->defaultValue('DE')->end()
->scalarNode('currency')->defaultValue('EUR')->end() ->scalarNode('currency')->defaultValue(Customer::DEFAULT_CURRENCY)->end()
->end() ->end()
->end() ->end()
->arrayNode('user') ->arrayNode('user')
@@ -448,9 +449,9 @@ class Configuration implements ConfigurationInterface
->scalarNode('timezone')->defaultNull()->end() ->scalarNode('timezone')->defaultNull()->end()
->scalarNode('language')->defaultValue(User::DEFAULT_LANGUAGE)->end() ->scalarNode('language')->defaultValue(User::DEFAULT_LANGUAGE)->end()
->scalarNode('theme')->defaultNull()->end() ->scalarNode('theme')->defaultNull()->end()
->scalarNode('currency')->defaultValue(Customer::DEFAULT_CURRENCY)->end()
->end() ->end()
->end() ->end()
->end() ->end()
; ;

View File

@@ -160,9 +160,26 @@ class Customer implements EntityWithMetaFields
*/ */
private $meta; 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() public function __construct()
{ {
$this->meta = new ArrayCollection(); $this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -399,6 +416,33 @@ class Customer implements EntityWithMetaFields
return $this; 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<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
/** /**
* @return string * @return string
*/ */

View File

@@ -90,9 +90,26 @@ class Project implements EntityWithMetaFields
*/ */
private $meta; 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() public function __construct()
{ {
$this->meta = new ArrayCollection(); $this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -223,6 +240,33 @@ class Project implements EntityWithMetaFields
return $this; 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<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
/** /**
* @return string * @return string
*/ */

210
src/Entity/Team.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_teams",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"name"})
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\TeamRepository")
* @UniqueEntity("name")
*/
class Team
{
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=100, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=100)
*/
private $name;
/**
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $teamlead;
/**
* @var User[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="User", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $users;
/**
* @var Customer[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Customer", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $customers;
/**
* @var Project[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Project", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $projects;
public function __construct()
{
$this->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<User>
*/
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<Customer>
*/
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<Project>
*/
public function getProjects(): iterable
{
return $this->projects;
}
/**
* @return string
*/
public function __toString()
{
return $this->getName();
}
}

View File

@@ -140,14 +140,14 @@ class Timesheet implements EntityWithMetaFields
/** /**
* @var Tag[]|ArrayCollection * @var Tag[]|ArrayCollection
* *
* @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"persist"}) * @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"remove", "persist"})
* @ORM\JoinTable( * @ORM\JoinTable(
* name="kimai2_timesheet_tags", * name="kimai2_timesheet_tags",
* joinColumns={ * joinColumns={
* @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id") * @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id", onDelete="CASCADE")
* }, * },
* inverseJoinColumns={ * inverseJoinColumns={
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id") * @ORM\JoinColumn(name="tag_id", referencedColumnName="id", onDelete="CASCADE")
* } * }
* ) * )
*/ */

View File

@@ -95,6 +95,22 @@ class User extends BaseUser implements UserInterface
*/ */
private $preferences; 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. * User constructor.
*/ */
@@ -103,6 +119,7 @@ class User extends BaseUser implements UserInterface
parent::__construct(); parent::__construct();
$this->registeredAt = new \DateTime(); $this->registeredAt = new \DateTime();
$this->preferences = new ArrayCollection(); $this->preferences = new ArrayCollection();
$this->teams = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -274,6 +291,55 @@ class User extends BaseUser implements UserInterface
return $this; 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<Team>
*/
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 * @return string
*/ */

View File

@@ -38,7 +38,6 @@ class UserPreference
* @ORM\Column(name="id", type="integer") * @ORM\Column(name="id", type="integer")
*/ */
private $id; private $id;
/** /**
* @var User * @var User
* *
@@ -47,7 +46,6 @@ class UserPreference
* @Assert\NotNull() * @Assert\NotNull()
*/ */
private $user; private $user;
/** /**
* @var string * @var string
* *
@@ -55,28 +53,29 @@ class UserPreference
* @Assert\Length(min=2, max=50) * @Assert\Length(min=2, max=50)
*/ */
private $name; private $name;
/** /**
* @var string * @var string
* *
* @ORM\Column(name="value", type="string", length=255, nullable=true) * @ORM\Column(name="value", type="string", length=255, nullable=true)
*/ */
private $value; private $value;
/** /**
* @var string * @var string
*/ */
protected $type; private $type;
/** /**
* @var bool * @var bool
*/ */
protected $enabled = true; private $enabled = true;
/** /**
* @var Constraint[] * @var Constraint[]
*/ */
protected $constraints = []; private $constraints = [];
/**
* An array of options for the form element
* @var array
*/
private $options = [];
/** /**
* @return int * @return int
@@ -237,4 +236,27 @@ class UserPreference
{ {
return $this->constraints; 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;
}
} }

View File

@@ -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')) { if ($auth->isGranted('plugins')) {
$menu->addChild( $menu->addChild(
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $this->getIcon('plugin')) new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $this->getIcon('plugin'))

View File

@@ -66,6 +66,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
return $this->formConfig->getUserDefaultTheme(); return $this->formConfig->getUserDefaultTheme();
} }
private function getDefaultCurrency(): ?string
{
return $this->formConfig->getUserDefaultCurrency();
}
private function getDefaultLanguage(): string private function getDefaultLanguage(): string
{ {
return $this->formConfig->getUserDefaultLanguage(); return $this->formConfig->getUserDefaultLanguage();
@@ -88,9 +93,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
public function getDefaultPreferences(User $user) public function getDefaultPreferences(User $user)
{ {
$enableHourlyRate = false; $enableHourlyRate = false;
$hourlyRateOptions = [];
if ($this->voter->isGranted('hourly-rate', $user)) { if ($this->voter->isGranted('hourly-rate', $user)) {
$enableHourlyRate = true; $enableHourlyRate = true;
$hourlyRateOptions = ['currency' => $this->getDefaultCurrency()];
} }
return [ return [
@@ -99,6 +106,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setValue(0) ->setValue(0)
->setType(MoneyType::class) ->setType(MoneyType::class)
->setEnabled($enableHourlyRate) ->setEnabled($enableHourlyRate)
->setOptions($hourlyRateOptions)
->addConstraint(new Range(['min' => 0])), ->addConstraint(new Range(['min' => 0])),
(new UserPreference()) (new UserPreference())
@@ -165,6 +173,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setType($preference->getType()) ->setType($preference->getType())
->setConstraints($preference->getConstraints()) ->setConstraints($preference->getConstraints())
->setEnabled($preference->isEnabled()) ->setEnabled($preference->isEnabled())
->setOptions($preference->getOptions())
; ;
} else { } else {
$prefs[$preference->getName()] = $preference; $prefs[$preference->getName()] = $preference;

View File

@@ -67,8 +67,11 @@ class ActivityEditForm extends AbstractType
if ($options['customer']) { if ($options['customer']) {
$builder $builder
->add('customer', CustomerType::class, [ ->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) { 'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer)); $query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
}, },
'data' => $customer ? $customer : null, 'data' => $customer ? $customer : null,
'required' => false, 'required' => false,
@@ -80,15 +83,18 @@ class ActivityEditForm extends AbstractType
$builder $builder
->add('project', ProjectType::class, [ ->add('project', ProjectType::class, [
'required' => false, 'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { 'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($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 // replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener( $builder->addEventListener(
FormEvents::PRE_SUBMIT, FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($project) { function (FormEvent $event) use ($builder, $project) {
$data = $event->getData(); $data = $event->getData();
if (!isset($data['customer']) || empty($data['customer'])) { if (!isset($data['customer']) || empty($data['customer'])) {
return; return;
@@ -96,8 +102,11 @@ class ActivityEditForm extends AbstractType
$event->getForm()->add('project', ProjectType::class, [ $event->getForm()->add('project', ProjectType::class, [
'group_by' => null, 'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($data, $project) { 'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $project) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $data['customer'])); $query = new ProjectFormTypeQuery($project, $data['customer']);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
}, },
]); ]);
} }

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Customer;
use App\Form\Type\TeamType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomerTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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'
],
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Extension;
use App\Entity\User;
use App\Security\CurrentUser;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class UserExtension extends AbstractTypeExtension
{
/**
* @var CurrentUser
*/
private $user;
public function __construct(CurrentUser $user)
{
$this->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());
}
}

View File

@@ -59,8 +59,11 @@ class ProjectEditForm extends AbstractType
'required' => false, 'required' => false,
]) ])
->add('customer', CustomerType::class, [ ->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) { 'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer)); $query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
}, },
]); ]);

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Project;
use App\Form\Type\TeamType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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'
],
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Team;
use App\Form\Type\CustomerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamCustomerForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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'
],
]);
}
}

61
src/Form/TeamEditForm.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Team;
use App\Form\Type\UserType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamEditForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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'
],
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Team;
use App\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamProjectForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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'
],
]);
}
}

View File

@@ -163,8 +163,11 @@ class TimesheetEditForm extends AbstractType
{ {
$builder $builder
->add('customer', CustomerType::class, [ ->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) { 'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer)); $query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
}, },
'data' => $customer ? $customer : '', 'data' => $customer ? $customer : '',
'required' => false, 'required' => false,
@@ -189,8 +192,11 @@ class TimesheetEditForm extends AbstractType
array_merge($projectOptions, [ array_merge($projectOptions, [
'placeholder' => '', 'placeholder' => '',
'activity_enabled' => true, 'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { 'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($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 // replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener( $builder->addEventListener(
FormEvents::PRE_SUBMIT, FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($project, $customer, $isNew) { function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
$data = $event->getData(); $data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null; $customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project; $project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
@@ -207,7 +213,7 @@ class TimesheetEditForm extends AbstractType
'placeholder' => '', 'placeholder' => '',
'activity_enabled' => true, 'activity_enabled' => true,
'group_by' => null, '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 ? // is there a better wa to prevent starting a record with a hidden project ?
if ($isNew && !is_object($project)) { if ($isNew && !is_object($project)) {
/** @var Project $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);
}, },
]); ]);
} }

View File

@@ -66,13 +66,14 @@ abstract class AbstractToolbarForm extends AbstractType
$builder->addEventListener( $builder->addEventListener(
FormEvents::PRE_SUBMIT, FormEvents::PRE_SUBMIT,
function (FormEvent $event) { function (FormEvent $event) use ($builder) {
$data = $event->getData(); $data = $event->getData();
$event->getForm()->add('customer', CustomerType::class, [ $event->getForm()->add('customer', CustomerType::class, [
'required' => false, 'required' => false,
'project_enabled' => true, 'project_enabled' => true,
'query_builder' => function (CustomerRepository $repo) use ($data) { 'query_builder' => function (CustomerRepository $repo) use ($builder, $data) {
$query = new CustomerFormTypeQuery(); $query = new CustomerFormTypeQuery();
$query->setUser($builder->getOption('user'));
if (isset($data['customer']) && !empty($data['customer'])) { if (isset($data['customer']) && !empty($data['customer'])) {
$query->setCustomer($data['customer']); $query->setCustomer($data['customer']);
} }
@@ -138,13 +139,14 @@ abstract class AbstractToolbarForm extends AbstractType
$builder->addEventListener( $builder->addEventListener(
FormEvents::PRE_SUBMIT, FormEvents::PRE_SUBMIT,
function (FormEvent $event) { function (FormEvent $event) use ($builder) {
$data = $event->getData(); $data = $event->getData();
$event->getForm()->add('project', ProjectType::class, [ $event->getForm()->add('project', ProjectType::class, [
'required' => false, 'required' => false,
'activity_enabled' => true, 'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($data) { 'query_builder' => function (ProjectRepository $repo) use ($builder, $data) {
$query = new ProjectFormTypeQuery(); $query = new ProjectFormTypeQuery();
$query->setUser($builder->getOption('user'));
if (isset($data['customer']) && !empty($data['customer'])) { if (isset($data['customer']) && !empty($data['customer'])) {
$query->setCustomer($data['customer']); $query->setCustomer($data['customer']);

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Toolbar;
use App\Repository\Query\TeamQuery;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamToolbarForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addPageSizeChoice($builder);
$this->addHiddenPagination($builder);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => TeamQuery::class,
'csrf_protection' => false,
]);
}
}

View File

@@ -37,13 +37,22 @@ class CustomerType extends AbstractType
'label' => 'label.customer', 'label' => 'label.customer',
'class' => Customer::class, 'class' => Customer::class,
'choice_label' => 'name', 'choice_label' => 'name',
'query_builder' => function (CustomerRepository $repo) { 'query_builder_for_user' => true,
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery());
},
'project_enabled' => false, 'project_enabled' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE, '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) { $resolver->setDefault('api_data', function (Options $options) {
if (true === $options['project_enabled']) { if (true === $options['project_enabled']) {
return [ return [

View File

@@ -62,13 +62,22 @@ class ProjectType extends AbstractType
'group_by' => function (Project $project, $key, $index) { 'group_by' => function (Project $project, $key, $index) {
return $project->getCustomer()->getName(); return $project->getCustomer()->getName();
}, },
'query_builder' => function (ProjectRepository $repo) { 'query_builder_for_user' => true,
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery());
},
'activity_enabled' => false, 'activity_enabled' => false,
'activity_visibility' => ActivityQuery::SHOW_VISIBLE, '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) { $resolver->setDefault('api_data', function (Options $options) {
if (true === $options['activity_enabled']) { if (true === $options['activity_enabled']) {
return [ return [

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Entity\Team;
use App\Repository\TeamRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->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;
}
}

View File

@@ -17,12 +17,23 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* Custom form field type to edit a user preference. * Custom form field type to edit a user preference.
*/ */
class UserPreferenceType extends AbstractType class UserPreferenceType extends AbstractType
{ {
/**
* @var TranslatorInterface
*/
private $translate;
public function __construct(TranslatorInterface $translator)
{
$this->translate = $translator;
}
/** /**
* @param FormBuilderInterface $builder * @param FormBuilderInterface $builder
* @param array $options * @param array $options
@@ -54,12 +65,22 @@ class UserPreferenceType extends AbstractType
$type = HiddenType::class; $type = HiddenType::class;
} }
$event->getForm()->add('value', $type, [ $transId = 'label.' . $preference->getName();
'label' => 'label.' . $preference->getName(), if ($this->translate->trans($transId) === $transId) {
'constraints' => $preference->getConstraints(), $transId = $preference->getName();
'required' => $required, }
'disabled' => !$preference->isEnabled(),
]); $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); $builder->add('name', HiddenType::class);

View File

@@ -10,8 +10,11 @@
namespace App\Form\Type; namespace App\Form\Type;
use App\Entity\User; use App\Entity\User;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\UserRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
@@ -35,6 +38,15 @@ class UserType extends AbstractType
return $user->getUsername(); 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);
};
});
} }
/** /**

View File

@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\User;
use App\Form\Type\TeamType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used to assign a User to teams.
*/
class UserTeamsType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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',
]);
}
}

View File

@@ -39,13 +39,6 @@ class Version20190510205245 extends AbstractMigration
public function down(Schema $schema): void 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_timesheet_tags');
$schema->dropTable('kimai2_tags'); $schema->dropTable('kimai2_tags');
} }

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Creates user team tables.
*
* @version 1.2
*/
final class Version20190729162655 extends AbstractMigration
{
public function getDescription(): string
{
return 'Fixes foreign keys on tag table';
}
public function up(Schema $schema): void
{
$timesheetTags = $schema->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
{
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Fixes foreign keys on tag table.
*
* @version 1.2
*/
final class Version20190730123324 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates user team and permission tables';
}
public function up(Schema $schema): void
{
$teams = $schema->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');
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Repository;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ActivityStatistic; use App\Model\ActivityStatistic;
use App\Repository\Loader\ActivityLoader; use App\Repository\Loader\ActivityLoader;
use App\Repository\Paginator\LoaderPaginator; use App\Repository\Paginator\LoaderPaginator;
@@ -81,6 +82,47 @@ class ActivityRepository extends EntityRepository
return $stats; 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 * @deprecated since 1.1
*/ */
@@ -181,16 +223,11 @@ class ActivityRepository extends EntityRepository
$qb $qb
->select('a') ->select('a')
->from(Activity::class, 'a') ->from(Activity::class, 'a')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
->addOrderBy('a.' . $query->getOrderBy(), $query->getOrder()) ->addOrderBy('a.' . $query->getOrderBy(), $query->getOrder())
; ;
if (!$query->isGlobalsOnly()) {
$qb
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
;
}
$where = $qb->expr()->andX(); $where = $qb->expr()->andX();
if (in_array($query->getVisibility(), [ActivityQuery::SHOW_VISIBLE, ActivityQuery::SHOW_HIDDEN])) { if (in_array($query->getVisibility(), [ActivityQuery::SHOW_VISIBLE, ActivityQuery::SHOW_HIDDEN])) {
@@ -239,6 +276,8 @@ class ActivityRepository extends EntityRepository
$qb->andWhere($where); $qb->andWhere($where);
} }
$this->addPermissionCriteria($qb, $query->getCurrentUser());
return $qb; return $qb;
} }

View File

@@ -13,6 +13,7 @@ use App\Entity\Activity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\CustomerStatistic; use App\Model\CustomerStatistic;
use App\Repository\Loader\CustomerLoader; use App\Repository\Loader\CustomerLoader;
use App\Repository\Paginator\LoaderPaginator; use App\Repository\Paginator\LoaderPaginator;
@@ -107,8 +108,41 @@ class CustomerRepository extends EntityRepository
return $stats; 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) public function builderForEntityType($customer)
{ {
@@ -145,6 +179,8 @@ class CustomerRepository extends EntityRepository
$qb->setParameter('ignored', $query->getCustomerToIgnore()); $qb->setParameter('ignored', $query->getCustomerToIgnore());
} }
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb; return $qb;
} }
@@ -152,7 +188,7 @@ class CustomerRepository extends EntityRepository
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('c', 'meta') $qb->select('c')
->from(Customer::class, 'c') ->from(Customer::class, 'c')
->leftJoin('c.meta', 'meta') ->leftJoin('c.meta', 'meta')
->orderBy('c.' . $query->getOrderBy(), $query->getOrder()); ->orderBy('c.' . $query->getOrderBy(), $query->getOrder());
@@ -165,6 +201,8 @@ class CustomerRepository extends EntityRepository
$qb->setParameter('visible', false, \PDO::PARAM_BOOL); $qb->setParameter('visible', false, \PDO::PARAM_BOOL);
} }
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
return $qb; return $qb;
} }

View File

@@ -69,6 +69,27 @@ final class ActivityIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('p.id', $projectIds)) ->andWhere($qb->expr()->in('p.id', $projectIds))
->getQuery() ->getQuery()
->execute(); ->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();
} }
} }
} }

View File

@@ -42,5 +42,14 @@ final class CustomerIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('c.id', $ids)) ->andWhere($qb->expr()->in('c.id', $ids))
->getQuery() ->getQuery()
->execute(); ->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();
} }
} }

View File

@@ -50,5 +50,24 @@ final class ProjectIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('p.id', $ids)) ->andWhere($qb->expr()->in('p.id', $ids))
->getQuery() ->getQuery()
->execute(); ->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();
} }
} }

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Loader;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
final class TeamIdLoader implements LoaderInterface
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->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();
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Loader;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
final class TeamLoader implements LoaderInterface
{
/**
* @var TeamIdLoader
*/
private $loader;
public function __construct(EntityManagerInterface $entityManager)
{
$this->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);
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Repository;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ProjectStatistic; use App\Model\ProjectStatistic;
use App\Repository\Loader\ProjectLoader; use App\Repository\Loader\ProjectLoader;
use App\Repository\Paginator\LoaderPaginator; use App\Repository\Paginator\LoaderPaginator;
@@ -91,8 +92,49 @@ class ProjectRepository extends EntityRepository
return $stats; 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) public function builderForEntityType($project, $customer)
{ {
@@ -114,7 +156,7 @@ class ProjectRepository extends EntityRepository
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
$qb $qb
->select('p', 'c') ->select('p')
->from(Project::class, 'p') ->from(Project::class, 'p')
->leftJoin('p.customer', 'c') ->leftJoin('p.customer', 'c')
->addOrderBy('c.name', 'ASC') ->addOrderBy('c.name', 'ASC')
@@ -140,6 +182,8 @@ class ProjectRepository extends EntityRepository
$qb->setParameter('ignored', $query->getProjectToIgnore()); $qb->setParameter('ignored', $query->getProjectToIgnore());
} }
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb; return $qb;
} }
@@ -150,11 +194,11 @@ class ProjectRepository extends EntityRepository
$qb $qb
->select('p') ->select('p')
->from(Project::class, 'p') ->from(Project::class, 'p')
->leftJoin('p.customer', 'c')
; ;
if (in_array($query->getVisibility(), [ProjectQuery::SHOW_VISIBLE, ProjectQuery::SHOW_HIDDEN])) { if (in_array($query->getVisibility(), [ProjectQuery::SHOW_VISIBLE, ProjectQuery::SHOW_HIDDEN])) {
$qb $qb
->leftJoin('p.customer', 'c')
->andWhere($qb->expr()->eq('p.visible', ':visible')) ->andWhere($qb->expr()->eq('p.visible', ':visible'))
->andWhere($qb->expr()->eq('c.visible', ':customer_visible')) ->andWhere($qb->expr()->eq('c.visible', ':customer_visible'))
; ;
@@ -173,6 +217,8 @@ class ProjectRepository extends EntityRepository
->setParameter('customer', $query->getCustomer()); ->setParameter('customer', $query->getCustomer());
} }
$this->addPermissionCriteria($qb, $query->getCurrentUser());
$qb->orderBy('p.' . $query->getOrderBy(), $query->getOrder()); $qb->orderBy('p.' . $query->getOrderBy(), $query->getOrder());
return $qb; return $qb;

View File

@@ -9,6 +9,9 @@
namespace App\Repository\Query; namespace App\Repository\Query;
use App\Entity\Team;
use App\Entity\User;
/** /**
* Base class for advanced Repository queries. * Base class for advanced Repository queries.
*/ */
@@ -44,6 +47,45 @@ class BaseQuery
* @var string * @var string
*/ */
private $resultType = self::RESULT_TYPE_PAGER; 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 * @return int

View File

@@ -10,6 +10,8 @@
namespace App\Repository\Query; namespace App\Repository\Query;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Team;
use App\Entity\User;
/** /**
* Can be used for advanced queries with the: CustomerRepository * Can be used for advanced queries with the: CustomerRepository
@@ -24,6 +26,14 @@ final class CustomerFormTypeQuery
* @var Customer|null * @var Customer|null
*/ */
private $customerToIgnore; private $customerToIgnore;
/**
* @var User
*/
private $user;
/**
* @var array<Team>
*/
private $teams = [];
/** /**
* @param Customer|int|null $customer * @param Customer|int|null $customer
@@ -33,6 +43,33 @@ final class CustomerFormTypeQuery
$this->customer = $customer; $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 * @return Customer|int|null
*/ */

View File

@@ -11,6 +11,8 @@ namespace App\Repository\Query;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
final class ProjectFormTypeQuery final class ProjectFormTypeQuery
{ {
@@ -26,6 +28,14 @@ final class ProjectFormTypeQuery
* @var Project|null * @var Project|null
*/ */
private $projectToIgnore; private $projectToIgnore;
/**
* @var User
*/
private $user;
/**
* @var array<Team>
*/
private $teams = [];
/** /**
* @param Project|int|null $project * @param Project|int|null $project
@@ -37,6 +47,33 @@ final class ProjectFormTypeQuery
$this->customer = $customer; $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 * @return Customer|int|null
*/ */

View File

@@ -11,4 +11,8 @@ namespace App\Repository\Query;
class TagQuery extends BaseQuery class TagQuery extends BaseQuery
{ {
public function __construct()
{
$this->setOrderBy('name');
}
} }

View File

@@ -0,0 +1,18 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
class TeamQuery extends BaseQuery
{
public function __construct()
{
$this->setOrderBy('name');
}
}

View File

@@ -28,7 +28,7 @@ class TimesheetQuery extends ActivityQuery
/** /**
* @var User|null * @var User|null
*/ */
protected $user; protected $timesheetUser;
/** /**
* @var Activity|null * @var Activity|null
*/ */
@@ -63,7 +63,7 @@ class TimesheetQuery extends ActivityQuery
*/ */
public function getUser() public function getUser()
{ {
return $this->user; return $this->timesheetUser;
} }
/** /**
@@ -72,7 +72,7 @@ class TimesheetQuery extends ActivityQuery
*/ */
public function setUser($user = null) public function setUser($user = null)
{ {
$this->user = $user; $this->timesheetUser = $user;
return $this; return $this;
} }
@@ -256,7 +256,7 @@ class TimesheetQuery extends ActivityQuery
return true; return true;
} }
if ($this->user !== null) { if ($this->timesheetUser !== null) {
return true; return true;
} }

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
use App\Entity\Team;
use App\Entity\User;
/**
* Can be used for pre-filling form types with the: UserRepository
*/
final class UserFormTypeQuery
{
/**
* @var User
*/
private $user;
/**
* @var array<Team>
*/
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;
}
}

View File

@@ -9,12 +9,26 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\Tag;
use App\Repository\Query\TagQuery; use App\Repository\Query\TagQuery;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
class TagRepository extends EntityRepository 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 * Find ids of the given tagNames separated by comma
@@ -68,7 +82,7 @@ class TagRepository extends EntityRepository
* - amount * - amount
* *
* @param TagQuery $query * @param TagQuery $query
* @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta * @return Pagerfanta
*/ */
public function getTagCount(TagQuery $query) public function getTagCount(TagQuery $query)
{ {
@@ -79,9 +93,12 @@ class TagRepository extends EntityRepository
->leftJoin('tag.timesheets', 'timesheets') ->leftJoin('tag.timesheets', 'timesheets')
->addGroupBy('tag.id') ->addGroupBy('tag.id')
->addGroupBy('tag.name') ->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;
} }
} }

View File

@@ -0,0 +1,99 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Repository\Loader\TeamLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TeamQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Pagerfanta;
class TeamRepository extends EntityRepository
{
/**
* @param Team $team
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveTeam(Team $team)
{
$entityManager = $this->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;
}
}

View File

@@ -456,6 +456,36 @@ class TimesheetRepository extends EntityRepository
return $counter; 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 public function getPagerfantaForQuery(TimesheetQuery $query): Pagerfanta
{ {
$paginator = new Pagerfanta($this->getPaginatorForQuery($query)); $paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -500,11 +530,43 @@ class TimesheetRepository extends EntityRepository
$qb $qb
->select('t') ->select('t')
->from(Timesheet::class, 't') ->from(Timesheet::class, 't')
->leftJoin('t.project', 'p')
; ;
$user = [];
if (null !== $query->getUser()) { if (null !== $query->getUser()) {
$qb->andWhere('t.user = :user') $user[] = $query->getUser();
->setParameter('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()) { if (null !== $query->getBegin()) {
@@ -539,7 +601,6 @@ class TimesheetRepository extends EntityRepository
$qb->andWhere('t.project = :project') $qb->andWhere('t.project = :project')
->setParameter('project', $query->getProject()); ->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) { } elseif (null !== $query->getCustomer()) {
$qb->join('t.project', 'p');
$qb->andWhere('p.customer = :customer') $qb->andWhere('p.customer = :customer')
->setParameter('customer', $query->getCustomer()); ->setParameter('customer', $query->getCustomer());
} }
@@ -551,6 +612,8 @@ class TimesheetRepository extends EntityRepository
->setParameter('tags', $query->getTags()); ->setParameter('tags', $query->getTags());
} }
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
$qb->orderBy('t.' . $query->getOrderBy(), $query->getOrder()); $qb->orderBy('t.' . $query->getOrderBy(), $query->getOrder());
return $qb; return $qb;

View File

@@ -10,8 +10,10 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery; use App\Repository\Query\UserQuery;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface; use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
class UserRepository extends EntityRepository implements 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 * @param int $id
* @return null|User * @return null|User
*/ */
public function getUserById($id): ?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) public function loadUserByUsername($username)
{ {
return $this->createQueryBuilder('u') return $this->createQueryBuilder('u')
->select('u', 'p') ->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p') ->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.username = :username') ->where('u.username = :username')
->orWhere('u.email = :username') ->orWhere('u.email = :username')
->setParameter('username', $username) ->setParameter('username', $username)
->getQuery() ->getQuery()
->getSingleResult(); ->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);
}
}
} }

View File

@@ -13,16 +13,20 @@ use App\Entity\User;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class CurrentUser final class CurrentUser
{ {
/** /**
* @var TokenStorageInterface * @var TokenStorageInterface
*/ */
protected $storage; private $storage;
/** /**
* @var UserRepository * @var UserRepository
*/ */
protected $repository; private $repository;
/**
* @var User|null
*/
private $user;
/** /**
* @param TokenStorageInterface $storage * @param TokenStorageInterface $storage
@@ -43,6 +47,11 @@ class CurrentUser
return null; return null;
} }
// some inline caching to prevent multiple DB lookups
if (null !== $this->user) {
return $this->user;
}
/** @var User $user */ /** @var User $user */
$user = $this->storage->getToken()->getUser(); $user = $this->storage->getToken()->getUser();
@@ -50,6 +59,8 @@ class CurrentUser
return null; return null;
} }
return $this->repository->getUserById($user->getId()); $this->user = $this->repository->getUserById($user->getId());
return $this->user;
} }
} }

View File

@@ -45,7 +45,8 @@ final class IconExtension extends AbstractExtension
'stop-small' => 'far fa-stop-circle', 'stop-small' => 'far fa-stop-circle',
'timesheet' => 'fas fa-clock', 'timesheet' => 'fas fa-clock',
'trash' => 'far fa-trash-alt', 'trash' => 'far fa-trash-alt',
'user' => 'fas fa-users', 'team' => 'fas fa-users',
'user' => 'fas fa-user-friends',
'visibility' => 'far fa-eye', 'visibility' => 'far fa-eye',
'settings' => 'fas fa-cog', 'settings' => 'fas fa-cog',
'export' => 'fas fa-file-export', 'export' => 'fas fa-file-export',
@@ -64,6 +65,7 @@ final class IconExtension extends AbstractExtension
'profile' => 'fas fa-user-edit', 'profile' => 'fas fa-user-edit',
'warning' => 'fas fa-exclamation-triangle', 'warning' => 'fas fa-exclamation-triangle',
'permissions' => 'fas fa-user-lock', 'permissions' => 'fas fa-user-lock',
'unlocked' => 'fas fa-unlock-alt',
'back' => 'fas fa-long-arrow-alt-left', 'back' => 'fas fa-long-arrow-alt-left',
'tag' => 'fas fa-tags', 'tag' => 'fas fa-tags',
'avatar' => 'fas fa-user', 'avatar' => 'fas fa-user',

View File

@@ -10,6 +10,7 @@
namespace App\Voter; namespace App\Voter;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Team;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -65,6 +66,44 @@ class ActivityVoter extends AbstractVoter
return false; 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;
} }
} }

View File

@@ -10,6 +10,7 @@
namespace App\Voter; namespace App\Voter;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Team;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -22,6 +23,7 @@ class CustomerVoter extends AbstractVoter
public const EDIT = 'edit'; public const EDIT = 'edit';
public const BUDGET = 'budget'; public const BUDGET = 'budget';
public const DELETE = 'delete'; public const DELETE = 'delete';
public const PERMISSIONS = 'permissions';
/** /**
* support rules based on the given $subject (here: Customer) * support rules based on the given $subject (here: Customer)
@@ -31,6 +33,7 @@ class CustomerVoter extends AbstractVoter
self::EDIT, self::EDIT,
self::BUDGET, self::BUDGET,
self::DELETE, self::DELETE,
self::PERMISSIONS,
]; ];
/** /**
@@ -65,6 +68,28 @@ class CustomerVoter extends AbstractVoter
return false; 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;
} }
} }

View File

@@ -10,6 +10,7 @@
namespace App\Voter; namespace App\Voter;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -22,6 +23,7 @@ class ProjectVoter extends AbstractVoter
public const EDIT = 'edit'; public const EDIT = 'edit';
public const BUDGET = 'budget'; public const BUDGET = 'budget';
public const DELETE = 'delete'; public const DELETE = 'delete';
public const PERMISSIONS = 'permissions';
/** /**
* support rules based on the given $subject (here: Project) * support rules based on the given $subject (here: Project)
@@ -31,6 +33,7 @@ class ProjectVoter extends AbstractVoter
self::EDIT, self::EDIT,
self::BUDGET, self::BUDGET,
self::DELETE, self::DELETE,
self::PERMISSIONS,
]; ];
/** /**
@@ -65,6 +68,39 @@ class ProjectVoter extends AbstractVoter
return false; 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;
} }
} }

65
src/Voter/TeamVoter.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Voter;
use App\Entity\Team;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
class TeamVoter extends AbstractVoter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
/**
* support rules based on the given $subject (here: Team)
*/
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
self::DELETE,
];
/**
* @param string $attribute
* @param Team $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!($subject instanceof Team)) {
return false;
}
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
}
/**
* @param string $attribute
* @param Team $subject
* @param TokenInterface $token
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $this->hasRolePermission($user, $attribute . '_team');
}
}

View File

@@ -22,6 +22,7 @@ class UserVoter extends AbstractVoter
public const DELETE = 'delete'; public const DELETE = 'delete';
public const PASSWORD = 'password'; public const PASSWORD = 'password';
public const ROLES = 'roles'; public const ROLES = 'roles';
public const TEAMS = 'teams';
public const PREFERENCES = 'preferences'; public const PREFERENCES = 'preferences';
public const API_TOKEN = 'api-token'; public const API_TOKEN = 'api-token';
public const HOURLY_RATE = 'hourly-rate'; public const HOURLY_RATE = 'hourly-rate';
@@ -30,6 +31,7 @@ class UserVoter extends AbstractVoter
self::VIEW, self::VIEW,
self::EDIT, self::EDIT,
self::ROLES, self::ROLES,
self::TEAMS,
self::PASSWORD, self::PASSWORD,
self::DELETE, self::DELETE,
self::PREFERENCES, self::PREFERENCES,
@@ -93,6 +95,7 @@ class UserVoter extends AbstractVoter
case self::PASSWORD: case self::PASSWORD:
case self::API_TOKEN: case self::API_TOKEN:
case self::ROLES: case self::ROLES:
case self::TEAMS:
case self::HOURLY_RATE: case self::HOURLY_RATE:
$permission .= $attribute; $permission .= $attribute;
break; break;

View File

@@ -38,13 +38,13 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{% if entry.project and entry.project.customer %} {% if entry.project and entry.project.customer %}
{# only none-global activities have a project and customer assigned #} {# 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 %} {% endif %}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{% if entry.project %} {% if entry.project %}
{# only none-global activities have a project and customer assigned #} {# 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 %} {% endif %}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td>

View File

@@ -9,6 +9,7 @@
'comment': 'hidden-xs', 'comment': 'hidden-xs',
'country': 'hidden-xs', 'country': 'hidden-xs',
'number': 'hidden-xs', 'number': 'hidden-xs',
'team': '',
'visible': 'hidden-xs', 'visible': 'hidden-xs',
'actions': 'actions alwaysVisible', 'actions': 'actions alwaysVisible',
} %} } %}
@@ -37,6 +38,13 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'country') }}">{{ entry.country|country }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'country') }}">{{ entry.country|country }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'number') }}">{{ entry.number }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'number') }}">{{ entry.number }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}">
{% if entry.teams|length > 0 %}
{{ widgets.badge_counter(entry.teams|length) }}
{% else %}
{{ widgets.icon('unlocked') }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
<td class="actions"> <td class="actions">
{{ actions.customer(entry, 'index') }} {{ actions.customer(entry, 'index') }}

View File

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

View File

@@ -1,4 +1,5 @@
<div class="box box-primary"> <div class="box box-primary">
{{ form_start(form) }}
<div class="box-header with-border"> <div class="box-header with-border">
<h3 class="box-title"> <h3 class="box-title">
{{ title }} {{ title }}
@@ -7,7 +8,6 @@
{% endif %} {% endif %}
</h3> </h3>
</div> </div>
{{ form_start(form) }}
<div class="box-body"> <div class="box-body">
{{ form_widget(form) }} {{ form_widget(form) }}
</div> </div>
@@ -16,6 +16,9 @@
{% if back|default(false) %} {% if back|default(false) %}
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a> <a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
{% endif %} {% endif %}
{% if reset|default(true) %}
<input type="reset" value="{{ 'action.reset'|trans }}" class="btn btn-link pull-right" />
{% endif %}
</div> </div>
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>

View File

@@ -145,6 +145,9 @@
{% if is_granted('budget', project) %} {% if is_granted('budget', project) %}
{% set actions = actions|merge({'report': {'url': path('admin_project_budget', {'id': project.id})}}) %} {% set actions = actions|merge({'report': {'url': path('admin_project_budget', {'id': project.id})}}) %}
{% endif %} {% 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') %} {% if is_granted('view_activity') %}
{% set actions = actions|merge({'activity': path('admin_activity', {'customer': project.customer.id, 'project': project.id})}) %} {% set actions = actions|merge({'activity': path('admin_activity', {'customer': project.customer.id, 'project': project.id})}) %}
{% endif %} {% endif %}
@@ -198,6 +201,9 @@
{% if is_granted('budget', customer) %} {% if is_granted('budget', customer) %}
{% set actions = actions|merge({'report': {'url': path('admin_customer_budget', {'id': customer.id})}}) %} {% set actions = actions|merge({'report': {'url': path('admin_customer_budget', {'id': customer.id})}}) %}
{% endif %} {% 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') %} {% if is_granted('view_project') %}
{% set actions = actions|merge({'project': path('admin_project', {'customer': customer.id})}) %} {% set actions = actions|merge({'project': path('admin_project', {'customer': customer.id})}) %}
{% endif %} {% endif %}
@@ -443,3 +449,40 @@
{{ widgets.page_actions(actions) }} {{ widgets.page_actions(actions) }}
{% endmacro %} {% 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 %}

View File

@@ -42,6 +42,8 @@
{% import _self as macro %} {% import _self as macro %}
{% if role == 'ROLE_SUPER_ADMIN' %} {% if role == 'ROLE_SUPER_ADMIN' %}
{{ macro.label(role, 'danger') }} {{ macro.label(role, 'danger') }}
{% elseif role == 'ROLE_ADMIN' %}
{{ macro.label(role, 'warning') }}
{% else %} {% else %}
{{ macro.label(role, 'primary') }} {{ macro.label(role, 'primary') }}
{% endif %} {% endif %}

View File

@@ -8,6 +8,7 @@
'name': 'alwaysVisible', 'name': 'alwaysVisible',
'customer': 'hidden-xs', 'customer': 'hidden-xs',
'comment': 'hidden-xs hidden-sm', 'comment': 'hidden-xs hidden-sm',
'team': '',
'visible': '', 'visible': '',
'actions': 'actions alwaysVisible', 'actions': 'actions alwaysVisible',
} %} } %}
@@ -34,9 +35,16 @@
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_project_edit', {'id': entry.id}) }}"{% endif %}> <tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_project_edit', {'id': entry.id}) }}"{% endif %}>
<td>{{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }}</td> <td>{{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{{ widgets.label_customer(entry.customer, path('admin_customer_edit', {'id' : entry.customer.id})) }} {{ widgets.label_customer(entry.customer) }}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}">
{% if entry.teams|length > 0 %}
{{ widgets.badge_counter(entry.teams|length) }}
{% else %}
{{ widgets.icon('unlocked') }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
<td class="actions"> <td class="actions">
{{ actions.project(entry, 'index') }} {{ actions.project(entry, 'index') }}

View File

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

View File

@@ -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 %}
<div class="row">
<div class="col-md-6">
{{ include('default/_form.html.twig', {
'title': team.name|default('create'|trans),
'form': form,
'back': path('admin_team')
}) }}
</div>
<div class="col-md-6">
{% 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 %}
</div>
</div>
{% endblock %}

View File

@@ -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 %}
<tr{% if is_granted('edit', team) %} class="open-edit alternative-link" data-href="{{ path('admin_team_edit', {'id': team.id}) }}"{% endif %}>
<td>{{ team.name }}</td>
<td>{{ widgets.label_user(team.teamlead) }}</td>
<td>
{% for user in team.users %}
{{ widgets.label_user(user) }}&nbsp;
{% endfor %}
</td>
<td class="actions">
{{ actions.team(team, 'index') }}
</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(teams, 'admin_team_paginated') }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,102 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\API;
use App\Entity\User;
use App\Tests\DataFixtures\TeamFixtures;
use Symfony\Component\HttpFoundation\Response;
/**
* @group integration
*/
class TeamControllerTest extends APIControllerBaseTest
{
protected function setUp(): void
{
$client = $this->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');
}
}

View File

@@ -26,12 +26,12 @@ class ActivityControllerTest extends ControllerBaseTest
public function testIsSecure() public function testIsSecure()
{ {
$this->assertUrlIsSecured('/admin/activity/'); $this->assertUrlIsSecured('/admin/activity/');
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/activity/'); $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/activity/');
} }
public function testIndexAction() public function testIndexAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/admin/activity/'); $this->assertAccessIsGranted($client, '/admin/activity/');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
} }

View File

@@ -9,13 +9,16 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock; use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
/** /**
* @group integration * @group integration
@@ -25,12 +28,12 @@ class CustomerControllerTest extends ControllerBaseTest
public function testIsSecure() public function testIsSecure()
{ {
$this->assertUrlIsSecured('/admin/customer/'); $this->assertUrlIsSecured('/admin/customer/');
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/customer/'); $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/customer/');
} }
public function testIndexAction() public function testIndexAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/admin/customer/'); $this->assertAccessIsGranted($client, '/admin/customer/');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
} }
@@ -111,6 +114,39 @@ class CustomerControllerTest extends ControllerBaseTest
$this->assertEquals('Test Customer 2', $editForm->get('customer_edit_form[name]')->getValue()); $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() public function testDeleteAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -12,8 +12,10 @@ namespace App\Tests\Controller;
use App\DataFixtures\UserFixtures; use App\DataFixtures\UserFixtures;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use Symfony\Bundle\FrameworkBundle\Client; use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface; use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
/** /**
@@ -95,7 +97,7 @@ class ProfileControllerTest extends ControllerBaseTest
return [ return [
[User::ROLE_USER, UserFixtures::USERNAME_USER, ['#settings', '#password', '#api-token']], [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()); $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() public function getPreferencesTestData()
{ {
return [ return [

View File

@@ -14,9 +14,11 @@ use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures; use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock; use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
/** /**
* @group integration * @group integration
@@ -26,12 +28,12 @@ class ProjectControllerTest extends ControllerBaseTest
public function testIsSecure() public function testIsSecure()
{ {
$this->assertUrlIsSecured('/admin/project/'); $this->assertUrlIsSecured('/admin/project/');
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/project/'); $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/project/');
} }
public function testIndexAction() public function testIndexAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/admin/project/'); $this->assertAccessIsGranted($client, '/admin/project/');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
} }
@@ -135,6 +137,39 @@ class ProjectControllerTest extends ControllerBaseTest
$this->assertEquals('Test 2', $editForm->get('project_edit_form[name]')->getValue()); $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() public function testDeleteAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -0,0 +1,155 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Controller;
use App\Entity\Team;
use App\Entity\User;
use App\Tests\DataFixtures\TeamFixtures;
use Doctrine\ORM\EntityManager;
use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
/**
* @group integration
*/
class TeamControllerTest extends ControllerBaseTest
{
public function testIsSecure()
{
$this->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()));
}
}

View File

@@ -47,7 +47,8 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
public function testIndexActionWithQuery() 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'); $start = new \DateTime('first day of this month');
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
@@ -118,7 +119,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
public function testCreateAction() public function testCreateAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/team/timesheet/create'); $this->request($client, '/team/timesheet/create');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
@@ -160,7 +161,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$fixture->setStartDate('2017-05-01'); $fixture->setStartDate('2017-05-01');
$this->importFixture($em, $fixture); $this->importFixture($em, $fixture);
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/team/timesheet/1/edit'); $this->request($client, '/team/timesheet/1/edit');
$response = $client->getResponse(); $response = $client->getResponse();

View File

@@ -52,7 +52,7 @@ class UserControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode($username) . '/edit')); $this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode($username) . '/edit'));
$client->followRedirect(); $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'); $tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li');
$this->assertEquals(count($expectedTabs), $tabs->count()); $this->assertEquals(count($expectedTabs), $tabs->count());
@@ -210,6 +210,6 @@ class UserControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/user/permissions'); $this->assertAccessIsGranted($client, '/admin/user/permissions');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 69); $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 84);
} }
} }

View File

@@ -0,0 +1,144 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\DataFixtures;
use App\Entity\Customer;
use App\Entity\Team;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory;
/**
* Defines the sample data to load in during controller tests.
*/
class TeamFixtures extends Fixture
{
/**
* @var int
*/
protected $amount = 0;
/**
* @var bool
*/
protected $addCustomer = true;
/**
* @var User[]
*/
protected $skipUser = [];
/**
* @var bool
*/
protected $addUser = true;
public function setAddCustomer(bool $useCustomer)
{
$this->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;
}
}

View File

@@ -95,6 +95,7 @@ class AppExtensionTest extends TestCase
'timezone' => null, 'timezone' => null,
'language' => 'en', 'language' => 'en',
'theme' => null, 'theme' => null,
'currency' => 'EUR',
] ]
], ],

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Entity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerMeta; use App\Entity\CustomerMeta;
use App\Entity\Team;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -49,6 +50,8 @@ class CustomerTest extends TestCase
$this->assertInstanceOf(Collection::class, $sut->getMetaFields()); $this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count()); $this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo')); $this->assertNull($sut->getMetaField('foo'));
$this->assertInstanceOf(Collection::class, $sut->getTeams());
$this->assertEquals(0, $sut->getTeams()->count());
} }
public function testSetterAndGetter() public function testSetterAndGetter()
@@ -127,4 +130,23 @@ class CustomerTest extends TestCase
self::assertEquals(3, $sut->getMetaFields()->count()); self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields()); 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());
}
} }

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Entity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectMeta; use App\Entity\ProjectMeta;
use App\Entity\Team;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -37,6 +38,8 @@ class ProjectTest extends TestCase
$this->assertInstanceOf(Collection::class, $sut->getMetaFields()); $this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count()); $this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo')); $this->assertNull($sut->getMetaField('foo'));
$this->assertInstanceOf(Collection::class, $sut->getTeams());
$this->assertEquals(0, $sut->getTeams()->count());
} }
public function testSetterAndGetter() public function testSetterAndGetter()
@@ -101,4 +104,22 @@ class ProjectTest extends TestCase
self::assertEquals(3, $sut->getMetaFields()->count()); self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields()); 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());
}
} }

108
tests/Entity/TeamTest.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Entity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\Team
*/
class TeamTest extends TestCase
{
public function testDefaultValues()
{
$sut = new Team();
self::assertNull($sut->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());
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Entity; namespace App\Tests\Entity;
use App\Entity\Team;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -97,4 +98,44 @@ class UserTest extends TestCase
$this->assertEquals('fr', $sut->getLocale()); $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());
}
} }

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