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)
[![Code Coverage](https://codecov.io/gh/kevinpapst/kimai2/branch/master/graph/badge.svg)](https://codecov.io/gh/kevinpapst/kimai2)
[![Gitter](https://badges.gitter.im/kimai2/support.svg)](https://gitter.im/kimai2/support)
[![Bountysource](https://img.shields.io/bountysource/team/kimai2/activity)](https://www.bountysource.com/teams/kimai2)
Kimai is a free, open source and online time-tracking software designed for small businesses and freelancers.
It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Doctrine, AdminLTE, Webpack, ES6 etc.

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

View File

@@ -84,23 +84,33 @@ kimai:
# mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and sets"])
sets:
ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity']
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project']
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer']
ACTIVITIES_TEAM: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity']
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project','permissions_project']
PROJECTS_TEAM: ['view_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project']
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer']
CUSTOMERS_TEAM: ['view_customer','edit_teamlead_customer','budget_teamlead_customer']
INVOICE: ['view_invoice','create_invoice']
INVOICE_TEMPLATE: ['view_invoice_template','create_invoice_template','edit_invoice_template','delete_invoice_template']
TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet']
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile']
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile']
TAGS: ['view_tag','delete_tag']
USER: ['view_user','create_user','delete_user','role_permissions']
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
EXPORT: ['view_export','create_export','edit_export_own_timesheet','edit_export_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team']
# some single default definitions for roles
SINGLE_USER: []
SINGLE_TEAMLEAD: ['!delete_invoice_template','view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile']
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile']
# link above sets to one set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE']
ROLE_TEAMLEAD: ['@INVOICE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@EXPORT','@TAGS','@INVOICE_TEMPLATE','!delete_invoice_template','view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@RATE','@RATE_OTHER','@EXPORT','@TAGS','hourly-rate_own_profile','edit_exported_timesheet']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@RATE','@RATE_OTHER','@EXPORT','@TAGS','hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@INVOICE_TEMPLATE','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps:
ROLE_USER: ['ROLE_USER']
@@ -154,18 +164,6 @@ kimai:
# --------------------------------------------------------------------------------
# --------------------------------------------------------------------------------
# THEME Settings
# will be available as twig globals at "kimai_context.*".
# See documentation at https://www.kimai.org/documentation/theme.html
# --------------------------------------------------------------------------------
# theme:
# # BETA test: If you set this to 'selectpicker' the customer/project/activity select boxes will be transformed
# # into a searchable and javascript enhanced input type
# select_type: ~
# --------------------------------------------------------------------------------
# --------------------------------------------------------------------------------
# DASHBOARD
# Using the configurable widget sections.
@@ -278,23 +276,6 @@ kimai:
date: 'd. m. Y'
date_time: 'd. m. H:i'
duration: '%%h:%%m h'
# --------------------------------------------------------------------------------
# --------------------------------------------------------------------------------
# FORMS
# Default settings used to populate forms
# --------------------------------------------------------------------------------
# defaults:
# customer:
# timezone: Europe/Berlin
# country: DE
# currency: EUR
# user:
# timezone: Europe/Berlin
# language: de
# theme: blue-light
# --------------------------------------------------------------------------------

View File

@@ -21,6 +21,8 @@ nelmio_api_doc:
- { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] }
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
- { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] }
- { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team] }
- { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] }
- { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] }
areas:
path_patterns:

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]
arguments: ['App\Entity\User']
App\Repository\TeamRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Team']
App\Repository\ActivityRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]

View File

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

View File

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

View File

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

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');
}
public function getUserDefaultCurrency(): string
{
return $this->find('user.currency');
}
}

View File

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

View File

@@ -13,6 +13,7 @@ use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Event\CustomerMetaDefinitionEvent;
use App\Form\CustomerEditForm;
use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository;
@@ -71,14 +72,11 @@ class CustomerController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"})
* @Security("is_granted('view_customer')")
*
* @param int $page
* @param Request $request
* @return Response
*/
public function indexAction($page, Request $request)
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
@@ -98,9 +96,6 @@ class CustomerController extends AbstractController
/**
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
*
* @param Request $request
* @return RedirectResponse|Response
*/
public function createAction(Request $request)
{
@@ -117,12 +112,39 @@ class CustomerController extends AbstractController
return $this->renderCustomerForm($customer, $request);
}
/**
* @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', customer)")
*/
public function teamPermissions(Customer $customer, Request $request)
{
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->getRepository()->saveCustomer($customer);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer');
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('customer/permissions.html.twig', [
'customer' => $customer,
'form' => $form->createView()
]);
}
/**
* @Route(path="/{id}/budget", name="admin_customer_budget", methods={"GET"})
* @Security("is_granted('budget', customer)")
*
* @param Customer $customer
* @return Response
*/
public function budgetAction(Customer $customer)
{
@@ -135,10 +157,6 @@ class CustomerController extends AbstractController
/**
* @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', customer)")
*
* @param Customer $customer
* @param Request $request
* @return RedirectResponse|Response
*/
public function editAction(Customer $customer, Request $request)
{
@@ -148,10 +166,6 @@ class CustomerController extends AbstractController
/**
* @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', customer)")
*
* @param Customer $customer
* @param Request $request
* @return RedirectResponse|Response
*/
public function deleteAction(Customer $customer, Request $request)
{
@@ -169,6 +183,7 @@ class CustomerController extends AbstractController
'query_builder' => function (CustomerRepository $repo) use ($customer) {
$query = new CustomerFormTypeQuery();
$query->setCustomerToIgnore($customer);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
@@ -229,11 +244,7 @@ class CustomerController extends AbstractController
]);
}
/**
* @param CustomerQuery $query
* @return FormInterface
*/
protected function getToolbarForm(CustomerQuery $query)
protected function getToolbarForm(CustomerQuery $query): FormInterface
{
return $this->createForm(CustomerToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_customer', [
@@ -243,11 +254,7 @@ class CustomerController extends AbstractController
]);
}
/**
* @param Customer $customer
* @return FormInterface
*/
private function createEditForm(Customer $customer)
private function createEditForm(Customer $customer): FormInterface
{
if ($customer->getId() === null) {
$url = $this->generateUrl('admin_customer_create');

View File

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

View File

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

View File

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

View File

@@ -9,10 +9,12 @@
namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Entity\Project;
use App\Event\ProjectMetaDefinitionEvent;
use App\Form\ProjectEditForm;
use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType;
use App\Repository\ProjectRepository;
@@ -40,14 +42,19 @@ class ProjectController extends AbstractController
* @var ProjectRepository
*/
private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(ProjectRepository $repository, EventDispatcherInterface $dispatcher)
public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
$this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher;
}
@@ -60,14 +67,11 @@ class ProjectController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"})
* @Security("is_granted('view_project')")
*
* @param int $page
* @param Request $request
* @return Response
*/
public function indexAction($page, Request $request)
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
@@ -85,14 +89,40 @@ class ProjectController extends AbstractController
]);
}
/**
* @Route(path="/{id}/permissions", name="admin_project_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', project)")
*/
public function teamPermissions(Project $project, Request $request)
{
$form = $this->createForm(ProjectTeamPermissionForm::class, $project, [
'action' => $this->generateUrl('admin_project_permissions', ['id' => $project->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->getRepository()->saveProject($project);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_project');
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('project/permissions.html.twig', [
'project' => $project,
'form' => $form->createView()
]);
}
/**
* @Route(path="/create", name="admin_project_create", methods={"GET", "POST"})
* @Route(path="/create/{customer}", name="admin_project_create_with_customer", methods={"GET", "POST"})
* @Security("is_granted('create_project')")
*
* @param Request $request
* @param Customer|null $customer
* @return RedirectResponse|Response
*/
public function createAction(Request $request, ?Customer $customer = null)
{
@@ -108,9 +138,6 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/budget", name="admin_project_budget", methods={"GET"})
* @Security("is_granted('budget', project)")
*
* @param Project $project
* @return Response
*/
public function budgetAction(Project $project)
{
@@ -123,10 +150,6 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*
* @param Project $project
* @param Request $request
* @return RedirectResponse|Response
*/
public function editAction(Project $project, Request $request)
{
@@ -136,10 +159,6 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', project)")
*
* @param Project $project
* @param Request $request
* @return RedirectResponse|Response
*/
public function deleteAction(Project $project, Request $request)
{
@@ -158,6 +177,7 @@ class ProjectController extends AbstractController
$query = new ProjectFormTypeQuery();
$query->setCustomer($project->getCustomer());
$query->setProjectToIgnore($project);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
@@ -225,11 +245,7 @@ class ProjectController extends AbstractController
]);
}
/**
* @param ProjectQuery $query
* @return FormInterface
*/
protected function getToolbarForm(ProjectQuery $query)
protected function getToolbarForm(ProjectQuery $query): FormInterface
{
return $this->createForm(ProjectToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_project', [
@@ -239,16 +255,12 @@ class ProjectController extends AbstractController
]);
}
/**
* @param Project $project
* @return FormInterface
*/
private function createEditForm(Project $project)
private function createEditForm(Project $project): FormInterface
{
if ($project->getId() === null) {
$url = $this->generateUrl('admin_project_create');
$currency = Customer::DEFAULT_CURRENCY;
} else {
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_project_create');
if ($project->getId() !== null) {
$url = $this->generateUrl('admin_project_edit', ['id' => $project->getId()]);
$currency = $project->getCustomer()->getCurrency();
}

View File

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

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

View File

@@ -13,6 +13,7 @@ use App\Entity\Timesheet;
use App\Form\TimesheetAdminEditForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -76,6 +77,11 @@ class TimesheetTeamController extends TimesheetAbstractController
return $this->create($request, 'timesheet-team/edit.html.twig', $projectRepository, $activityRepository, $tagRepository);
}
protected function prepareQuery(TimesheetQuery $query)
{
$query->setCurrentUser($this->getUser());
}
protected function getCreateFormClassName(): string
{
return TimesheetAdminEditForm::class;

View File

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

View File

@@ -160,9 +160,26 @@ class Customer implements EntityWithMetaFields
*/
private $meta;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", cascade={"remove", "persist"}, inversedBy="customers")
* @ORM\JoinTable(
* name="kimai2_customers_teams",
* joinColumns={
* @ORM\JoinColumn(name="customer_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
public function __construct()
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -399,6 +416,33 @@ class Customer implements EntityWithMetaFields
return $this;
}
public function addTeam(Team $team)
{
if ($this->teams->contains($team)) {
return $this;
}
$this->teams->add($team);
$team->addCustomer($this);
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeCustomer($this);
}
/**
* @return Collection<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
/**
* @return string
*/

View File

@@ -90,9 +90,26 @@ class Project implements EntityWithMetaFields
*/
private $meta;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", cascade={"remove", "persist"}, inversedBy="projects")
* @ORM\JoinTable(
* name="kimai2_projects_teams",
* joinColumns={
* @ORM\JoinColumn(name="project_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
public function __construct()
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -223,6 +240,33 @@ class Project implements EntityWithMetaFields
return $this;
}
public function addTeam(Team $team)
{
if ($this->teams->contains($team)) {
return;
}
$this->teams->add($team);
$team->addProject($this);
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeProject($this);
}
/**
* @return Collection<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
/**
* @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
*
* @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"persist"})
* @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"remove", "persist"})
* @ORM\JoinTable(
* name="kimai2_timesheet_tags",
* joinColumns={
* @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id")
* @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/

View File

@@ -95,6 +95,22 @@ class User extends BaseUser implements UserInterface
*/
private $preferences;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", inversedBy="users", cascade={"remove", "persist"})
* @ORM\JoinTable(
* name="kimai2_users_teams",
* joinColumns={
* @ORM\JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
/**
* User constructor.
*/
@@ -103,6 +119,7 @@ class User extends BaseUser implements UserInterface
parent::__construct();
$this->registeredAt = new \DateTime();
$this->preferences = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -274,6 +291,55 @@ class User extends BaseUser implements UserInterface
return $this;
}
public function addTeam(Team $team): User
{
if ($this->teams->contains($team)) {
return $this;
}
$this->teams->add($team);
$team->addUser($this);
return $this;
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeUser($this);
}
/**
* @return Collection<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
*/

View File

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

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

View File

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

View File

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

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,
])
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer));
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);

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

View File

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

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',
'class' => Customer::class,
'choice_label' => 'name',
'query_builder' => function (CustomerRepository $repo) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery());
},
'query_builder_for_user' => true,
'project_enabled' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (CustomerRepository $repo) use ($options) {
$query = new CustomerFormTypeQuery();
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
return $repo->getQueryBuilderForFormType($query);
};
});
$resolver->setDefault('api_data', function (Options $options) {
if (true === $options['project_enabled']) {
return [

View File

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

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

View File

@@ -10,8 +10,11 @@
namespace App\Form\Type;
use App\Entity\User;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\UserRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -35,6 +38,15 @@ class UserType extends AbstractType
return $user->getUsername();
},
]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (UserRepository $repo) use ($options) {
$query = new UserFormTypeQuery();
$query->setUser($options['user']);
return $repo->getQueryBuilderForFormType($query);
};
});
}
/**

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
{
$tags = $schema->getTable('kimai2_tags');
$tags->dropIndex('UNIQ_27CAF54C5E237E06');
$timesheetTags = $schema->getTable('kimai2_timesheet_tags');
$timesheetTags->dropIndex('IDX_E3284EFEABDD46BE');
$timesheetTags->dropIndex('IDX_E3284EFEBAD26311');
$schema->dropTable('kimai2_timesheet_tags');
$schema->dropTable('kimai2_tags');
}

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\Timesheet;
use App\Entity\User;
use App\Model\ActivityStatistic;
use App\Repository\Loader\ActivityLoader;
use App\Repository\Paginator\LoaderPaginator;
@@ -81,6 +82,47 @@ class ActivityRepository extends EntityRepository
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all activities
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$teams = array_merge($teams, $user->getTeams()->toArray());
}
$qb->leftJoin('p.teams', 'teams')
->leftJoin('c.teams', 'c_teams');
if (empty($teams)) {
$qb->andWhere($qb->expr()->isNull('c_teams'));
$qb->andWhere($qb->expr()->isNull('teams'));
return;
}
$orProject = $qb->expr()->orX(
$qb->expr()->isNull('teams'),
$qb->expr()->isMemberOf(':teams', 'p.teams')
);
$qb->andWhere($orProject);
$orCustomer = $qb->expr()->orX(
$qb->expr()->isNull('c_teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$qb->setParameter('teams', $teams);
}
/**
* @deprecated since 1.1
*/
@@ -181,16 +223,11 @@ class ActivityRepository extends EntityRepository
$qb
->select('a')
->from(Activity::class, 'a')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
->addOrderBy('a.' . $query->getOrderBy(), $query->getOrder())
;
if (!$query->isGlobalsOnly()) {
$qb
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
;
}
$where = $qb->expr()->andX();
if (in_array($query->getVisibility(), [ActivityQuery::SHOW_VISIBLE, ActivityQuery::SHOW_HIDDEN])) {
@@ -239,6 +276,8 @@ class ActivityRepository extends EntityRepository
$qb->andWhere($where);
}
$this->addPermissionCriteria($qb, $query->getCurrentUser());
return $qb;
}

View File

@@ -13,6 +13,7 @@ use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\CustomerStatistic;
use App\Repository\Loader\CustomerLoader;
use App\Repository\Paginator\LoaderPaginator;
@@ -107,8 +108,41 @@ class CustomerRepository extends EntityRepository
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all customers
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all customers
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$teams = array_merge($teams, $user->getTeams()->toArray());
}
$qb->leftJoin('c.teams', 'teams');
if (empty($teams)) {
$qb->andWhere($qb->expr()->isNull('teams'));
return;
}
$or = $qb->expr()->orX(
$qb->expr()->isNull('teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($or);
$qb->setParameter('teams', $teams);
}
/**
* @deprecated since 1.1
* @deprecated since 1.1 - don't use this method, it ignores team permission checks
*/
public function builderForEntityType($customer)
{
@@ -145,6 +179,8 @@ class CustomerRepository extends EntityRepository
$qb->setParameter('ignored', $query->getCustomerToIgnore());
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb;
}
@@ -152,7 +188,7 @@ class CustomerRepository extends EntityRepository
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('c', 'meta')
$qb->select('c')
->from(Customer::class, 'c')
->leftJoin('c.meta', 'meta')
->orderBy('c.' . $query->getOrderBy(), $query->getOrder());
@@ -165,6 +201,8 @@ class CustomerRepository extends EntityRepository
$qb->setParameter('visible', false, \PDO::PARAM_BOOL);
}
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
return $qb;
}

View File

@@ -69,6 +69,27 @@ final class ActivityIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('p.id', $projectIds))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'PARTIAL project.{id}', 'teams', 'teamlead')
->from(Activity::class, 'a')
->leftJoin('a.project', 'project')
->leftJoin('project.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'PARTIAL project.{id}', 'PARTIAL customer.{id}', 'teams', 'teamlead')
->from(Activity::class, 'a')
->leftJoin('a.project', 'project')
->leftJoin('project.customer', 'customer')
->leftJoin('customer.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
}
}
}

View File

@@ -42,5 +42,14 @@ final class CustomerIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('c.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'teams', 'teamlead')
->from(Customer::class, 'c')
->leftJoin('c.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('c.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -50,5 +50,24 @@ final class ProjectIdLoader implements LoaderInterface
->andWhere($qb->expr()->in('p.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL p.{id}', 'teams', 'teamlead')
->from(Project::class, 'p')
->leftJoin('p.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('p.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL p.{id}', 'PARTIAL customer.{id}', 'teams', 'teamlead')
->from(Project::class, 'p')
->leftJoin('p.customer', 'customer')
->leftJoin('customer.teams', 'teams')
->leftJoin('teams.teamlead', 'teamlead')
->andWhere($qb->expr()->in('p.id', $ids))
->getQuery()
->execute();
}
}

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\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ProjectStatistic;
use App\Repository\Loader\ProjectLoader;
use App\Repository\Paginator\LoaderPaginator;
@@ -91,8 +92,49 @@ class ProjectRepository extends EntityRepository
return $stats;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all projects
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$teams = array_merge($teams, $user->getTeams()->toArray());
}
$qb->leftJoin('p.teams', 'teams')
->leftJoin('c.teams', 'c_teams');
if (empty($teams)) {
$qb->andWhere($qb->expr()->isNull('c_teams'));
$qb->andWhere($qb->expr()->isNull('teams'));
return;
}
$orProject = $qb->expr()->orX(
$qb->expr()->isNull('teams'),
$qb->expr()->isMemberOf(':teams', 'p.teams')
);
$qb->andWhere($orProject);
$orCustomer = $qb->expr()->orX(
$qb->expr()->isNull('c_teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orCustomer);
$qb->setParameter('teams', $teams);
}
/**
* @deprecated since 1.1
* @deprecated since 1.1 - don't use this method, it ignores team permission checks
*/
public function builderForEntityType($project, $customer)
{
@@ -114,7 +156,7 @@ class ProjectRepository extends EntityRepository
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('p', 'c')
->select('p')
->from(Project::class, 'p')
->leftJoin('p.customer', 'c')
->addOrderBy('c.name', 'ASC')
@@ -140,6 +182,8 @@ class ProjectRepository extends EntityRepository
$qb->setParameter('ignored', $query->getProjectToIgnore());
}
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb;
}
@@ -150,11 +194,11 @@ class ProjectRepository extends EntityRepository
$qb
->select('p')
->from(Project::class, 'p')
->leftJoin('p.customer', 'c')
;
if (in_array($query->getVisibility(), [ProjectQuery::SHOW_VISIBLE, ProjectQuery::SHOW_HIDDEN])) {
$qb
->leftJoin('p.customer', 'c')
->andWhere($qb->expr()->eq('p.visible', ':visible'))
->andWhere($qb->expr()->eq('c.visible', ':customer_visible'))
;
@@ -173,6 +217,8 @@ class ProjectRepository extends EntityRepository
->setParameter('customer', $query->getCustomer());
}
$this->addPermissionCriteria($qb, $query->getCurrentUser());
$qb->orderBy('p.' . $query->getOrderBy(), $query->getOrder());
return $qb;

View File

@@ -9,6 +9,9 @@
namespace App\Repository\Query;
use App\Entity\Team;
use App\Entity\User;
/**
* Base class for advanced Repository queries.
*/
@@ -44,6 +47,45 @@ class BaseQuery
* @var string
*/
private $resultType = self::RESULT_TYPE_PAGER;
/**
* @var User
*/
private $user;
/**
* @var Team[]
*/
private $teams = [];
public function addTeam(Team $team): self
{
$this->teams[$team->getId()] = $team;
return $this;
}
/**
* @return Team[]
*/
public function getTeams(): array
{
return array_values($this->teams);
}
public function getCurrentUser(): ?User
{
return $this->user;
}
/**
* @param User $user
* @return $this
*/
public function setCurrentUser(User $user)
{
$this->user = $user;
return $this;
}
/**
* @return int

View File

@@ -10,6 +10,8 @@
namespace App\Repository\Query;
use App\Entity\Customer;
use App\Entity\Team;
use App\Entity\User;
/**
* Can be used for advanced queries with the: CustomerRepository
@@ -24,6 +26,14 @@ final class CustomerFormTypeQuery
* @var Customer|null
*/
private $customerToIgnore;
/**
* @var User
*/
private $user;
/**
* @var array<Team>
*/
private $teams = [];
/**
* @param Customer|int|null $customer
@@ -33,6 +43,33 @@ final class CustomerFormTypeQuery
$this->customer = $customer;
}
public function addTeam(Team $team): CustomerFormTypeQuery
{
$this->teams[$team->getId()] = $team;
return $this;
}
/**
* @return Team[]
*/
public function getTeams(): array
{
return array_values($this->teams);
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(User $user): CustomerFormTypeQuery
{
$this->user = $user;
return $this;
}
/**
* @return Customer|int|null
*/

View File

@@ -11,6 +11,8 @@ namespace App\Repository\Query;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
final class ProjectFormTypeQuery
{
@@ -26,6 +28,14 @@ final class ProjectFormTypeQuery
* @var Project|null
*/
private $projectToIgnore;
/**
* @var User
*/
private $user;
/**
* @var array<Team>
*/
private $teams = [];
/**
* @param Project|int|null $project
@@ -37,6 +47,33 @@ final class ProjectFormTypeQuery
$this->customer = $customer;
}
public function addTeam(Team $team): ProjectFormTypeQuery
{
$this->teams[$team->getId()] = $team;
return $this;
}
/**
* @return Team[]
*/
public function getTeams(): array
{
return array_values($this->teams);
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(User $user): ProjectFormTypeQuery
{
$this->user = $user;
return $this;
}
/**
* @return Customer|int|null
*/

View File

@@ -11,4 +11,8 @@ namespace App\Repository\Query;
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
*/
protected $user;
protected $timesheetUser;
/**
* @var Activity|null
*/
@@ -63,7 +63,7 @@ class TimesheetQuery extends ActivityQuery
*/
public function getUser()
{
return $this->user;
return $this->timesheetUser;
}
/**
@@ -72,7 +72,7 @@ class TimesheetQuery extends ActivityQuery
*/
public function setUser($user = null)
{
$this->user = $user;
$this->timesheetUser = $user;
return $this;
}
@@ -256,7 +256,7 @@ class TimesheetQuery extends ActivityQuery
return true;
}
if ($this->user !== null) {
if ($this->timesheetUser !== null) {
return true;
}

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;
use App\Entity\Tag;
use App\Repository\Query\TagQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
class TagRepository extends EntityRepository
{
use RepositoryTrait;
/**
* @param Tag $tag
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function deleteTag(Tag $tag)
{
$entityManager = $this->getEntityManager();
$entityManager->remove($tag);
$entityManager->flush();
}
/**
* Find ids of the given tagNames separated by comma
@@ -68,7 +82,7 @@ class TagRepository extends EntityRepository
* - amount
*
* @param TagQuery $query
* @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
* @return Pagerfanta
*/
public function getTagCount(TagQuery $query)
{
@@ -79,9 +93,12 @@ class TagRepository extends EntityRepository
->leftJoin('tag.timesheets', 'timesheets')
->addGroupBy('tag.id')
->addGroupBy('tag.name')
->orderBy('tag.name')
;
->orderBy('tag.' . $query->getOrderBy(), $query->getOrder());
return $this->getBaseQueryResult($qb, $query);
$paginator = new Pagerfanta(new DoctrineORMAdapter($qb->getQuery(), false));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
return $paginator;
}
}

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;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all projects
if (null === $user && empty($teams)) {
return;
}
if (empty($teams)) {
return;
}
// make sure that admins see all timesheet records
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
$qb
->leftJoin('p.customer', 'c')
->leftJoin('p.teams', 'teams')
->leftJoin('c.teams', 'c_teams');
$orTeam = $qb->expr()->orX(
$qb->expr()->isMemberOf(':teams', 'p.teams'),
$qb->expr()->isMemberOf(':teams', 'c.teams')
);
$qb->andWhere($orTeam);
$qb->setParameter('teams', $teams);
}
public function getPagerfantaForQuery(TimesheetQuery $query): Pagerfanta
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -500,11 +530,43 @@ class TimesheetRepository extends EntityRepository
$qb
->select('t')
->from(Timesheet::class, 't')
->leftJoin('t.project', 'p')
;
$user = [];
if (null !== $query->getUser()) {
$qb->andWhere('t.user = :user')
->setParameter('user', $query->getUser());
$user[] = $query->getUser();
}
if (null === $query->getUser() && null !== $query->getCurrentUser()) {
$currentUser = $query->getCurrentUser();
if (!$currentUser->isSuperAdmin() && !$currentUser->isAdmin()) {
foreach ($currentUser->getTeams() as $team) {
if ($currentUser->isTeamleadOf($team)) {
$query->addTeam($team);
}
}
}
}
if (!empty($query->getTeams())) {
foreach ($query->getTeams() as $team) {
$user = array_merge($user, $team->getUsers()->toArray());
}
}
$user = array_map(function ($user) {
if ($user instanceof User) {
return $user->getId();
}
return $user;
}, $user);
$user = array_unique($user);
if (!empty($user)) {
$qb->andWhere($qb->expr()->in('t.user', $user));
}
if (null !== $query->getBegin()) {
@@ -539,7 +601,6 @@ class TimesheetRepository extends EntityRepository
$qb->andWhere('t.project = :project')
->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$qb->join('t.project', 'p');
$qb->andWhere('p.customer = :customer')
->setParameter('customer', $query->getCustomer());
}
@@ -551,6 +612,8 @@ class TimesheetRepository extends EntityRepository
->setParameter('tags', $query->getTags());
}
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
$qb->orderBy('t.' . $query->getOrderBy(), $query->getOrder());
return $qb;

View File

@@ -10,8 +10,10 @@
namespace App\Repository;
use App\Entity\User;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
class UserRepository extends EntityRepository implements UserLoaderInterface
@@ -26,12 +28,28 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
}
/**
* Used to fetch the currently logged-in user.
*
* @param int $id
* @return null|User
*/
public function getUserById($id): ?User
{
return $this->find($id);
try {
return $this->createQueryBuilder('u')
->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.id = :id')
->setParameter('id', $id)
->getQuery()
->getSingleResult();
} catch (\Exception $ex) {
}
return null;
}
/**
@@ -103,12 +121,49 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
public function loadUserByUsername($username)
{
return $this->createQueryBuilder('u')
->select('u', 'p')
->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.username = :username')
->orWhere('u.email = :username')
->setParameter('username', $username)
->getQuery()
->getSingleResult();
}
public function getQueryBuilderForFormType(UserFormTypeQuery $query): QueryBuilder
{
$qb = $this->createQueryBuilder('u');
$qb->andWhere($qb->expr()->eq('u.enabled', ':enabled'));
$qb->setParameter('enabled', true, \PDO::PARAM_BOOL);
$qb->orderBy('u.username', 'ASC');
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all user
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all user
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$qb->leftJoin('u.teams', 'teams')
->leftJoin('teams.users', 'users')
->andWhere('teams.teamlead = :id')
->setParameter('id', $user);
}
}
}

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
namespace App\Voter;
use App\Entity\Activity;
use App\Entity\Team;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -65,6 +66,44 @@ class ActivityVoter extends AbstractVoter
return false;
}
return $this->hasRolePermission($user, $attribute . '_activity');
if ($this->hasRolePermission($user, $attribute . '_activity')) {
return true;
}
$project = $subject->getProject();
if (null === $project) {
return false;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_activity');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_activity');
if (!$hasTeamleadPermission && !$hasTeamPermission) {
return false;
}
/** @var Team $team */
foreach ($project->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
/** @var Team $team */
foreach ($project->getCustomer()->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
return false;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Voter;
use App\Entity\Customer;
use App\Entity\Team;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -22,6 +23,7 @@ class CustomerVoter extends AbstractVoter
public const EDIT = 'edit';
public const BUDGET = 'budget';
public const DELETE = 'delete';
public const PERMISSIONS = 'permissions';
/**
* support rules based on the given $subject (here: Customer)
@@ -31,6 +33,7 @@ class CustomerVoter extends AbstractVoter
self::EDIT,
self::BUDGET,
self::DELETE,
self::PERMISSIONS,
];
/**
@@ -65,6 +68,28 @@ class CustomerVoter extends AbstractVoter
return false;
}
return $this->hasRolePermission($user, $attribute . '_customer');
if ($this->hasRolePermission($user, $attribute . '_customer')) {
return true;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_customer');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_customer');
if (!$hasTeamleadPermission && !$hasTeamPermission) {
return false;
}
/** @var Team $team */
foreach ($subject->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
return false;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Voter;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -22,6 +23,7 @@ class ProjectVoter extends AbstractVoter
public const EDIT = 'edit';
public const BUDGET = 'budget';
public const DELETE = 'delete';
public const PERMISSIONS = 'permissions';
/**
* support rules based on the given $subject (here: Project)
@@ -31,6 +33,7 @@ class ProjectVoter extends AbstractVoter
self::EDIT,
self::BUDGET,
self::DELETE,
self::PERMISSIONS,
];
/**
@@ -65,6 +68,39 @@ class ProjectVoter extends AbstractVoter
return false;
}
return $this->hasRolePermission($user, $attribute . '_project');
if ($this->hasRolePermission($user, $attribute . '_project')) {
return true;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_project');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_project');
if (!$hasTeamleadPermission && !$hasTeamPermission) {
return false;
}
/** @var Team $team */
foreach ($subject->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
/** @var Team $team */
foreach ($subject->getCustomer()->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}
if ($hasTeamPermission && $user->isInTeam($team)) {
return true;
}
}
return false;
}
}

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

View File

@@ -38,13 +38,13 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{% if entry.project and entry.project.customer %}
{# only none-global activities have a project and customer assigned #}
{{ widgets.label_customer(entry.project.customer, path('admin_customer_edit', {'id' : entry.project.customer.id})) }}
{{ widgets.label_customer(entry.project.customer) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{% if entry.project %}
{# only none-global activities have a project and customer assigned #}
{{ widgets.label_project(entry.project, path('admin_project_edit', {'id' : entry.project.id})) }}
{{ widgets.label_project(entry.project) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td>

View File

@@ -9,6 +9,7 @@
'comment': 'hidden-xs',
'country': 'hidden-xs',
'number': 'hidden-xs',
'team': '',
'visible': 'hidden-xs',
'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, '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, '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="actions">
{{ 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">
{{ form_start(form) }}
<div class="box-header with-border">
<h3 class="box-title">
{{ title }}
@@ -7,7 +8,6 @@
{% endif %}
</h3>
</div>
{{ form_start(form) }}
<div class="box-body">
{{ form_widget(form) }}
</div>
@@ -16,6 +16,9 @@
{% if back|default(false) %}
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
{% endif %}
{% if reset|default(true) %}
<input type="reset" value="{{ 'action.reset'|trans }}" class="btn btn-link pull-right" />
{% endif %}
</div>
{{ form_end(form) }}
</div>

View File

@@ -145,6 +145,9 @@
{% if is_granted('budget', project) %}
{% set actions = actions|merge({'report': {'url': path('admin_project_budget', {'id': project.id})}}) %}
{% endif %}
{% if is_granted('permissions', project) %}
{% set actions = actions|merge({'permissions': {'url': path('admin_project_permissions', {'id': project.id})}}) %}
{% endif %}
{% if is_granted('view_activity') %}
{% set actions = actions|merge({'activity': path('admin_activity', {'customer': project.customer.id, 'project': project.id})}) %}
{% endif %}
@@ -198,6 +201,9 @@
{% if is_granted('budget', customer) %}
{% set actions = actions|merge({'report': {'url': path('admin_customer_budget', {'id': customer.id})}}) %}
{% endif %}
{% if is_granted('permissions', customer) %}
{% set actions = actions|merge({'permissions': {'url': path('admin_customer_permissions', {'id': customer.id})}}) %}
{% endif %}
{% if is_granted('view_project') %}
{% set actions = actions|merge({'project': path('admin_project', {'customer': customer.id})}) %}
{% endif %}
@@ -443,3 +449,40 @@
{{ widgets.page_actions(actions) }}
{% endmacro %}
{% macro teams(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('create_team') %}
{% set actions = actions|merge({'create': {'url': path('admin_team_create')}}) %}
{% endif %}
{% set event = trigger('actions.teams', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro team(team, view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if team.id is not empty %}
{% if is_granted('edit', team) %}
{% set class = '' %}
{% if view != 'edit' %}
{% set class = 'modal-ajax-form' %}
{% endif %}
{% set actions = actions|merge({'edit': {'url': path('admin_team_edit', {'id': team.id}), 'class': class}}) %}
{% endif %}
{% endif %}
{% if view == 'index' and is_granted('delete', team) %}
{% set actions = actions|merge({'trash': {'url': path('delete_team', {'id' : team.id}), 'class': 'api-link', 'attr': {'data-event': 'kimai.teamDelete kimai.teamUpdate', 'data-method': 'DELETE', 'data-question': 'confirm.delete', 'data-msg-error': 'action.delete.error', 'data-msg-success': 'action.delete.success'}}}) %}
{% endif %}
{% set event = trigger('actions.team', {'actions': actions, 'view': view, 'team': team}) %}
{% if view == 'index' %}
{{ widgets.table_actions(event.payload.actions) }}
{% else %}
{{ widgets.entity_actions(event.payload.actions) }}
{% endif %}
{% endmacro %}

View File

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

View File

@@ -8,6 +8,7 @@
'name': 'alwaysVisible',
'customer': 'hidden-xs',
'comment': 'hidden-xs hidden-sm',
'team': '',
'visible': '',
'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 %}>
<td>{{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }}</td>
<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 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="actions">
{{ 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()
{
$this->assertUrlIsSecured('/admin/activity/');
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/activity/');
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/activity/');
}
public function testIndexAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/admin/activity/');
$this->assertHasDataTable($client);
}

View File

@@ -9,13 +9,16 @@
namespace App\Tests\Controller;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
/**
* @group integration
@@ -25,12 +28,12 @@ class CustomerControllerTest extends ControllerBaseTest
public function testIsSecure()
{
$this->assertUrlIsSecured('/admin/customer/');
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/customer/');
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/customer/');
}
public function testIndexAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/admin/customer/');
$this->assertHasDataTable($client);
}
@@ -111,6 +114,39 @@ class CustomerControllerTest extends ControllerBaseTest
$this->assertEquals('Test Customer 2', $editForm->get('customer_edit_form[name]')->getValue());
}
public function testTeamPermissionAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Customer $customer */
$customer = $em->getRepository(Customer::class)->find(1);
self::assertEquals(0, $customer->getTeams()->count());
$fixture = new TeamFixtures();
$fixture->setAmount(2);
$fixture->setAddCustomer(false);
$this->importFixture($em, $fixture);
$this->assertAccessIsGranted($client, '/admin/customer/1/permissions');
$form = $client->getCrawler()->filter('form[name=customer_team_permission_form]')->form();
/** @var ChoiceFormField $team1 */
$team1 = $form->get('customer_team_permission_form[teams][0]');
$team1->tick();
/** @var ChoiceFormField $team2 */
$team2 = $form->get('customer_team_permission_form[teams][1]');
$team2->tick();
$client->submit($form);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/'));
$client->followRedirect();
$this->assertHasDataTable($client);
/** @var Customer $customer */
$customer = $em->getRepository(Customer::class)->find(1);
self::assertEquals(2, $customer->getTeams()->count());
}
public function testDeleteAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -12,8 +12,10 @@ namespace App\Tests\Controller;
use App\DataFixtures\UserFixtures;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
/**
@@ -95,7 +97,7 @@ class ProfileControllerTest extends ControllerBaseTest
return [
[User::ROLE_USER, UserFixtures::USERNAME_USER, ['#settings', '#password', '#api-token']],
[User::ROLE_SUPER_ADMIN, UserFixtures::USERNAME_SUPER_ADMIN, array_merge($userTabs, ['#roles'])],
[User::ROLE_SUPER_ADMIN, UserFixtures::USERNAME_SUPER_ADMIN, array_merge($userTabs, ['#teams', '#roles'])],
];
}
@@ -315,6 +317,52 @@ class ProfileControllerTest extends ControllerBaseTest
$this->assertEquals(['ROLE_TEAMLEAD', 'ROLE_SUPER_ADMIN', 'ROLE_USER'], $user->getRoles());
}
public function testTeamsActionIsSecured()
{
$this->assertUrlIsSecured('/profile/' . UserFixtures::USERNAME_USER . '/teams');
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/profile/' . UserFixtures::USERNAME_USER . '/teams');
}
public function testTeamsAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER);
$fixture = new TeamFixtures();
$fixture->setAmount(3);
$fixture->setAddCustomer(true);
$fixture->setAddUser(false);
$fixture->addUserToIgnore($user);
$this->importFixture($em, $fixture);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/teams');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER);
$this->assertEquals([], $user->getTeams()->toArray());
$form = $client->getCrawler()->filter('form[name=user_teams]')->form();
/** @var ChoiceFormField $team */
$team = $form->get('user_teams[teams][0]');
$team->tick();
$client->submit($form);
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER) . '/teams'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$user = $this->getUserByRole($em, User::ROLE_USER);
$this->assertEquals(1, $user->getTeams()->count());
}
public function getPreferencesTestData()
{
return [

View File

@@ -14,9 +14,11 @@ use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock;
use Doctrine\ORM\EntityManager;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
/**
* @group integration
@@ -26,12 +28,12 @@ class ProjectControllerTest extends ControllerBaseTest
public function testIsSecure()
{
$this->assertUrlIsSecured('/admin/project/');
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/admin/project/');
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/admin/project/');
}
public function testIndexAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/admin/project/');
$this->assertHasDataTable($client);
}
@@ -135,6 +137,39 @@ class ProjectControllerTest extends ControllerBaseTest
$this->assertEquals('Test 2', $editForm->get('project_edit_form[name]')->getValue());
}
public function testTeamPermissionAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Project $project */
$project = $em->getRepository(Project::class)->find(1);
self::assertEquals(0, $project->getTeams()->count());
$fixture = new TeamFixtures();
$fixture->setAmount(2);
$fixture->setAddCustomer(false);
$this->importFixture($em, $fixture);
$this->assertAccessIsGranted($client, '/admin/project/1/permissions');
$form = $client->getCrawler()->filter('form[name=project_team_permission_form]')->form();
/** @var ChoiceFormField $team1 */
$team1 = $form->get('project_team_permission_form[teams][0]');
$team1->tick();
/** @var ChoiceFormField $team2 */
$team2 = $form->get('project_team_permission_form[teams][1]');
$team2->tick();
$client->submit($form);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/'));
$client->followRedirect();
$this->assertHasDataTable($client);
/** @var Project $project */
$project = $em->getRepository(Project::class)->find(1);
self::assertEquals(2, $project->getTeams()->count());
}
public function testDeleteAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

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

View File

@@ -52,7 +52,7 @@ class UserControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode($username) . '/edit'));
$client->followRedirect();
$expectedTabs = ['#settings', '#password', '#api-token', '#roles'];
$expectedTabs = ['#settings', '#password', '#api-token', '#teams', '#roles'];
$tabs = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav-tabs li');
$this->assertEquals(count($expectedTabs), $tabs->count());
@@ -210,6 +210,6 @@ class UserControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/user/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 69);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 84);
}
}

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,
'language' => 'en',
'theme' => null,
'currency' => 'EUR',
]
],

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Entity;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\Team;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
@@ -49,6 +50,8 @@ class CustomerTest extends TestCase
$this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo'));
$this->assertInstanceOf(Collection::class, $sut->getTeams());
$this->assertEquals(0, $sut->getTeams()->count());
}
public function testSetterAndGetter()
@@ -127,4 +130,23 @@ class CustomerTest extends TestCase
self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields());
}
public function testTeams()
{
$sut = new Customer();
$team = new Team();
self::assertEmpty($sut->getTeams());
self::assertEmpty($team->getCustomers());
$sut->addTeam($team);
self::assertCount(1, $sut->getTeams());
self::assertCount(1, $team->getCustomers());
self::assertSame($team, $sut->getTeams()[0]);
self::assertSame($sut, $team->getCustomers()[0]);
$sut->removeTeam(new Team());
$sut->removeTeam($team);
self::assertCount(0, $sut->getTeams());
self::assertCount(0, $team->getCustomers());
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Entity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Team;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
@@ -37,6 +38,8 @@ class ProjectTest extends TestCase
$this->assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo'));
$this->assertInstanceOf(Collection::class, $sut->getTeams());
$this->assertEquals(0, $sut->getTeams()->count());
}
public function testSetterAndGetter()
@@ -101,4 +104,22 @@ class ProjectTest extends TestCase
self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields());
}
public function testTeams()
{
$sut = new Project();
$team = new Team();
self::assertEmpty($sut->getTeams());
self::assertEmpty($team->getProjects());
$sut->addTeam($team);
self::assertCount(1, $sut->getTeams());
self::assertCount(1, $team->getProjects());
self::assertSame($team, $sut->getTeams()[0]);
self::assertSame($sut, $team->getProjects()[0]);
$sut->removeTeam($team);
self::assertCount(0, $sut->getTeams());
self::assertCount(0, $team->getProjects());
}
}

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;
use App\Entity\Team;
use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\Common\Collections\ArrayCollection;
@@ -97,4 +98,44 @@ class UserTest extends TestCase
$this->assertEquals('fr', $sut->getLocale());
}
public function testTeams()
{
$sut = new User();
$team = new Team();
self::assertEmpty($sut->getTeams());
self::assertEmpty($team->getUsers());
$sut->addTeam($team);
self::assertCount(1, $sut->getTeams());
self::assertSame($team, $sut->getTeams()[0]);
self::assertSame($sut, $team->getUsers()[0]);
self::assertFalse($sut->isTeamleadOf($team));
self::assertTrue($sut->isInTeam($team));
$team2 = new Team();
self::assertFalse($sut->isInTeam($team2));
self::assertFalse($sut->isTeamleadOf($team2));
$team2->setTeamLead($sut);
self::assertTrue($sut->isTeamleadOf($team2));
self::assertTrue($sut->isInTeam($team2));
$sut->removeTeam(new Team());
self::assertCount(2, $sut->getTeams());
$sut->removeTeam($team);
self::assertCount(1, $sut->getTeams());
$sut->removeTeam($team2);
self::assertCount(0, $sut->getTeams());
}
public function testRoles()
{
$sut = new User();
self::assertFalse($sut->isTeamlead());
$sut->addRole(User::ROLE_ADMIN);
self::assertFalse($sut->isTeamlead());
$sut->addRole(User::ROLE_TEAMLEAD);
self::assertTrue($sut->isTeamlead());
}
}

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