Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)
This commit is contained in:
147
src/API/ActionsController.php
Normal file
147
src/API/ActionsController.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?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\API;
|
||||
|
||||
use App\API\Model\PageAction;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Event\PageActionsEvent;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
#[Route(path: '/actions')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Actions')]
|
||||
final class ActionsController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private TranslatorInterface $translator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PageActionsEvent $event
|
||||
* @param string $locale
|
||||
* @return array<PageAction>
|
||||
*/
|
||||
private function convertEvent(PageActionsEvent $event, string $locale): array
|
||||
{
|
||||
$this->dispatcher->dispatch($event, $event->getEventName());
|
||||
|
||||
$translator = $this->translator;
|
||||
|
||||
$all = [];
|
||||
foreach ($event->getActions() as $name => $action) {
|
||||
$action = $action === null ? [] : $action;
|
||||
$domain = \array_key_exists('translation_domain', $action) ? $action['translation_domain'] : 'messages';
|
||||
if (!\array_key_exists('title', $action)) {
|
||||
$action['title'] = $translator->trans($name, [], $domain, $locale);
|
||||
} else {
|
||||
$action['title'] = $translator->trans($action['title'], [], $domain, $locale);
|
||||
}
|
||||
$all[] = new PageAction($name, $action);
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item actions for the given Timesheet [for internal use]
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns item actions for the timesheet', content: new OA\JsonContent(ref: new Model(type: PageAction::class)))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet ID to fetch', required: true)]
|
||||
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[Rest\Get(path: '/timesheet/{id}/{view}/{locale}', name: 'get_timesheet_actions', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getTimesheetActions(Timesheet $timesheet, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['timesheet' => $timesheet], 'timesheet', $view);
|
||||
$actions = $this->convertEvent($event, $locale);
|
||||
|
||||
$view = new View($actions, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item actions for the given Activity [for internal use]
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns item actions for the activity', content: new OA\JsonContent(ref: new Model(type: PageAction::class)))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to fetch', required: true)]
|
||||
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[Rest\Get(path: '/activity/{id}/{view}/{locale}', name: 'get_activity_actions', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getActivityActions(Activity $activity, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['activity' => $activity], 'activity', $view);
|
||||
$actions = $this->convertEvent($event, $locale);
|
||||
|
||||
$view = new View($actions, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item actions for the given Project [for internal use]
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns item actions for the project', content: new OA\JsonContent(ref: new Model(type: PageAction::class)))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Project ID to fetch', required: true)]
|
||||
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[Rest\Get(path: '/project/{id}/{view}/{locale}', name: 'get_project_actions', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getProjectActions(Project $project, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['project' => $project], 'project', $view);
|
||||
$actions = $this->convertEvent($event, $locale);
|
||||
|
||||
$view = new View($actions, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item actions for the given Customer [for internal use]
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns item actions for the customer', content: new OA\JsonContent(ref: new Model(type: PageAction::class)))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Customer ID to fetch', required: true)]
|
||||
#[OA\Parameter(name: 'view', in: 'path', description: 'View to display the actions at (e.g. index, custom)', required: true)]
|
||||
#[OA\Parameter(name: 'locale', in: 'path', description: 'Language to translate the action title to (e.g. de, en)', required: true)]
|
||||
#[Rest\Get(path: '/customer/{id}/{view}/{locale}', name: 'get_customer_actions', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getCustomerActions(Customer $customer, string $view, string $locale): Response
|
||||
{
|
||||
$event = new PageActionsEvent($this->getUser(), ['customer' => $customer], 'customer', $view);
|
||||
$actions = $this->convertEvent($event, $locale);
|
||||
|
||||
$view = new View($actions, 200);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -19,83 +17,55 @@ use App\Form\API\ActivityApiEditForm;
|
||||
use App\Form\API\ActivityRateApiForm;
|
||||
use App\Repository\ActivityRateRepository;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Utils\SearchTerm;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use HandcraftedInTheAlps\RestRoutingBundle\Controller\Annotations\RouteResource;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @RouteResource("Activity")
|
||||
* @SWG\Tag(name="Activity")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class ActivityController extends BaseApiController
|
||||
#[Route(path: '/activities')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Activity')]
|
||||
final class ActivityController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Activity', 'Activity_Entity'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'Activity'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Activity'];
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Activity_Rate'];
|
||||
|
||||
/**
|
||||
* @var ActivityRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var ActivityRateRepository
|
||||
*/
|
||||
private $activityRateRepository;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository, EventDispatcherInterface $dispatcher, ActivityRateRepository $activityRateRepository)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->activityRateRepository = $activityRateRepository;
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private ActivityRepository $repository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private ActivityRateRepository $activityRateRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of activities
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns a collection of activity entities",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/ActivityCollection")
|
||||
* )
|
||||
* )
|
||||
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter activities")
|
||||
* @Rest\QueryParam(name="projects", requirements="[\d|,]+", strict=true, nullable=true, description="Comma separated list of project IDs to filter activities")
|
||||
* @Rest\QueryParam(name="visible", requirements="1|2|3", strict=true, nullable=true, description="Visibility status to filter activities. Allowed values: 1=visible, 2=hidden, 3=all (default: 1)")
|
||||
* @Rest\QueryParam(name="globals", requirements="true", strict=true, nullable=true, description="Use if you want to fetch only global activities. Allowed values: true (default: false)")
|
||||
* @Rest\QueryParam(name="globalsFirst", requirements="true|false", strict=true, nullable=true, description="Deprecated parameter, value is not used any more")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|name|project", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, project (default: name)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="term", description="Free search term")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Returns a collection of activities (which are visible to the user)
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of activities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityCollection')))]
|
||||
#[Rest\Get(path: '', name: 'get_activities')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\QueryParam(name: 'project', requirements: '\d+', strict: true, nullable: true, description: 'Project ID to filter activities')]
|
||||
#[Rest\QueryParam(name: 'projects', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of project IDs to filter activities, e.g.: projects[]=1&projects[]=2')]
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter activities: 1=visible, 2=hidden, 3=all')]
|
||||
#[Rest\QueryParam(name: 'globals', strict: true, nullable: true, description: 'Use if you want to fetch only global activities. Allowed values: true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|project', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, project (default: name)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, ProjectRepository $projectRepository): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
@@ -103,11 +73,13 @@ class ActivityController extends BaseApiController
|
||||
$query = new ActivityQuery();
|
||||
$query->setCurrentUser($user);
|
||||
|
||||
if (null !== ($order = $paramFetcher->get('order'))) {
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
$query->setOrder($order);
|
||||
}
|
||||
|
||||
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
|
||||
$orderBy = $paramFetcher->get('orderBy');
|
||||
if (\is_string($orderBy) && $orderBy !== '') {
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
|
||||
@@ -115,26 +87,28 @@ class ActivityController extends BaseApiController
|
||||
$query->setGlobalsOnly(true);
|
||||
}
|
||||
|
||||
if (null !== $paramFetcher->get('globalsFirst')) {
|
||||
@trigger_error('API parameter globalsFirst is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
/** @var array<int> $projects */
|
||||
$projects = $paramFetcher->get('projects');
|
||||
$project = $paramFetcher->get('project');
|
||||
if (\is_string($project) && $project !== '') {
|
||||
$projects[] = $project;
|
||||
}
|
||||
|
||||
if (!empty($projects = $paramFetcher->get('projects'))) {
|
||||
if (!\is_array($projects)) {
|
||||
$projects = explode(',', $projects);
|
||||
foreach (array_unique($projects) as $projectId) {
|
||||
$project = $projectRepository->find($projectId);
|
||||
if ($project === null) {
|
||||
throw $this->createNotFoundException('Unknown project: ' . $projectId);
|
||||
}
|
||||
$query->setProjects($projects);
|
||||
}
|
||||
|
||||
if (!empty($project = $paramFetcher->get('project'))) {
|
||||
$query->addProject($project);
|
||||
}
|
||||
|
||||
if (null !== ($visible = $paramFetcher->get('visible'))) {
|
||||
$query->setVisibility($visible);
|
||||
$visible = $paramFetcher->get('visible');
|
||||
if (\is_string($visible) && $visible !== '') {
|
||||
$query->setVisibility((int) $visible);
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$term = $paramFetcher->get('term');
|
||||
if (\is_string($term) && $term !== '') {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
@@ -147,32 +121,15 @@ class ActivityController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns one activity
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns one activity entity",
|
||||
* @SWG\Schema(ref="#/definitions/ActivityEntity"),
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Activity ID to fetch",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction(int $id): Response
|
||||
#[OA\Response(response: 200, description: 'Returns one activity entity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to fetch', required: true)]
|
||||
#[Rest\Get(path: '/{id}', name: 'get_activity', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getAction(Activity $activity): Response
|
||||
{
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
$view = new View($data, 200);
|
||||
$view = new View($activity, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
@@ -180,29 +137,16 @@ class ActivityController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Creates a new activity
|
||||
*
|
||||
* @SWG\Post(
|
||||
* description="Creates a new activity and returns it afterwards",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created activity",
|
||||
* @SWG\Schema(ref="#/definitions/ActivityEntity"),
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/ActivityEditForm")
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Post(description: 'Creates a new activity and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created activity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))]
|
||||
#[Rest\Post(path: '', name: 'post_activity')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
if (!$this->isGranted('create_activity')) {
|
||||
throw new AccessDeniedHttpException('User cannot create activities');
|
||||
throw $this->createAccessDeniedException('User cannot create activities');
|
||||
}
|
||||
|
||||
$activity = new Activity();
|
||||
@@ -234,44 +178,16 @@ class ActivityController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Update an existing activity
|
||||
*
|
||||
* @SWG\Patch(
|
||||
* description="Update an existing activity, you can pass all or just a subset of all attributes",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the updated activity",
|
||||
* @SWG\Schema(ref="#/definitions/ActivityEntity")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/ActivityEditForm")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Activity ID to update",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
#[Security("is_granted('edit', activity)")]
|
||||
#[OA\Patch(description: 'Update an existing activity, you can pass all or just a subset of all attributes', responses: [new OA\Response(response: 200, description: 'Returns the updated activity', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityEditForm'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity ID to update', required: true)]
|
||||
#[Rest\Patch(path: '/{id}', name: 'patch_activity', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function patchAction(Request $request, Activity $activity): Response
|
||||
{
|
||||
$activity = $this->repository->find($id);
|
||||
|
||||
if (null === $activity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $activity)) {
|
||||
throw new AccessDeniedHttpException('User cannot update activity');
|
||||
}
|
||||
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
@@ -300,37 +216,17 @@ class ActivityController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing activity
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.",
|
||||
* @SWG\Schema(ref="#/definitions/ActivityEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Activity record ID to set the meta-field value for",
|
||||
* required=true,
|
||||
* )
|
||||
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
|
||||
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
|
||||
#[Security("is_granted('edit', activity)")]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ActivityEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Activity record ID to set the meta-field value for', required: true)]
|
||||
#[Rest\Patch(path: '/{id}/meta', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
|
||||
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
|
||||
public function metaAction(Activity $activity, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$activity = $this->repository->find($id);
|
||||
|
||||
if (null === $activity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $activity)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to update this activity');
|
||||
}
|
||||
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
@@ -338,7 +234,7 @@ class ActivityController extends BaseApiController
|
||||
$value = $paramFetcher->get('value');
|
||||
|
||||
if (null === ($meta = $activity->getMetaField($name))) {
|
||||
throw new \InvalidArgumentException('Unknown meta-field requested');
|
||||
throw $this->createNotFoundException('Unknown meta-field requested');
|
||||
}
|
||||
|
||||
$meta->setValue($value);
|
||||
@@ -353,39 +249,15 @@ class ActivityController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns a collection of all rates for one activity
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns a collection of activity rate entities",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/ActivityRate")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The activity whose rates will be returned",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getRatesAction(int $id): Response
|
||||
#[Security("is_granted('edit', activity)")]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of activity rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ActivityRate')))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity whose rates will be returned', required: true)]
|
||||
#[Rest\Get(path: '/{id}/rates', name: 'get_activity_rates', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getRatesAction(Activity $activity): Response
|
||||
{
|
||||
/** @var Activity|null $activity */
|
||||
$activity = $this->repository->find($id);
|
||||
|
||||
if (null === $activity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $activity)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
$rates = $this->activityRateRepository->getRatesForActivity($activity);
|
||||
|
||||
$view = new View($rates, 200);
|
||||
@@ -396,49 +268,19 @@ class ActivityController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Deletes one rate for an activity
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=204,
|
||||
* description="Returns no content: 204 on successful delete"
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The activity whose rate will be removed",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="rateId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The rate to remove",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteRateAction(string $id, string $rateId): Response
|
||||
#[Security("is_granted('edit', activity)")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity whose rate will be removed', required: true)]
|
||||
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}/rates/{rateId}', name: 'delete_activity_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
|
||||
#[Entity('rate', expr: 'repository.find(rateId)')]
|
||||
public function deleteRateAction(Activity $activity, ActivityRate $rate): Response
|
||||
{
|
||||
/** @var Activity|null $activity */
|
||||
$activity = $this->repository->find($id);
|
||||
|
||||
if (null === $activity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $activity)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
/** @var ActivityRate|null $rate */
|
||||
$rate = $this->activityRateRepository->find($rateId);
|
||||
|
||||
if (null === $rate || $rate->getActivity() !== $activity) {
|
||||
throw new NotFoundException();
|
||||
if ($rate->getActivity() !== $activity) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$this->activityRateRepository->deleteRate($rate);
|
||||
@@ -450,44 +292,16 @@ class ActivityController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Adds a new rate to an activity
|
||||
*
|
||||
* @SWG\Post(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created rate",
|
||||
* @SWG\Schema(ref="#/definitions/ActivityRate")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The activity to add the rate for",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/ActivityRateForm")
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postRateAction(int $id, Request $request): Response
|
||||
#[Security("is_granted('edit', activity)")]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the new created rate', content: new OA\JsonContent(ref: '#/components/schemas/ActivityRate'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The activity to add the rate for', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ActivityRateForm'))]
|
||||
#[Rest\Post(path: '/{id}/rates', name: 'post_activity_rate', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postRateAction(Activity $activity, Request $request): Response
|
||||
{
|
||||
/** @var Activity|null $activity */
|
||||
$activity = $this->repository->find($id);
|
||||
|
||||
if (null === $activity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $activity)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
$rate = new ActivityRate();
|
||||
$rate->setActivity($activity);
|
||||
|
||||
|
||||
31
src/API/Authentication/ApiRequestMatcher.php
Normal file
31
src/API/Authentication/ApiRequestMatcher.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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\API\Authentication;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
|
||||
|
||||
final class ApiRequestMatcher implements RequestMatcherInterface
|
||||
{
|
||||
public function matches(Request $request): bool
|
||||
{
|
||||
if (str_contains($request->getRequestUri(), '/api/doc')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_contains($request->getRequestUri(), '/api/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$request->headers->has(SessionAuthenticator::HEADER_JAVASCRIPT) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_USERNAME) &&
|
||||
$request->headers->has(TokenAuthenticator::HEADER_TOKEN);
|
||||
}
|
||||
}
|
||||
59
src/API/Authentication/ApiTokenMigratingListener.php
Normal file
59
src/API/Authentication/ApiTokenMigratingListener.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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\API\Authentication;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
|
||||
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
|
||||
|
||||
final class ApiTokenMigratingListener implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private PasswordHasherFactoryInterface $hasherFactory)
|
||||
{
|
||||
}
|
||||
|
||||
public function onLoginSuccess(LoginSuccessEvent $event): void
|
||||
{
|
||||
$passport = $event->getPassport();
|
||||
if (!$passport->hasBadge(ApiTokenUpgradeBadge::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var ApiTokenUpgradeBadge $badge */
|
||||
$badge = $passport->getBadge(ApiTokenUpgradeBadge::class);
|
||||
$plaintextApiToken = $badge->getAndErasePlaintextApiToken();
|
||||
|
||||
if ('' === $plaintextApiToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $passport->getUser();
|
||||
if (!($user instanceof User)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $user->getApiToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$passwordHasher = $this->hasherFactory->getPasswordHasher($user);
|
||||
if (!$passwordHasher->needsRehash($user->getApiToken())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$badge->getPasswordUpgrader()->upgradePassword($user, $passwordHasher->hash($plaintextApiToken));
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [LoginSuccessEvent::class => 'onLoginSuccess'];
|
||||
}
|
||||
}
|
||||
43
src/API/Authentication/ApiTokenUpgradeBadge.php
Normal file
43
src/API/Authentication/ApiTokenUpgradeBadge.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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\API\Authentication;
|
||||
|
||||
use Symfony\Component\Security\Core\Exception\LogicException;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
|
||||
|
||||
final class ApiTokenUpgradeBadge implements BadgeInterface
|
||||
{
|
||||
public function __construct(private ?string $plaintextApiToken, private PasswordUpgraderInterface $passwordUpgrader)
|
||||
{
|
||||
}
|
||||
|
||||
public function getAndErasePlaintextApiToken(): string
|
||||
{
|
||||
$password = $this->plaintextApiToken;
|
||||
if (null === $password) {
|
||||
throw new LogicException('The api token is erased as another listener already used this badge.');
|
||||
}
|
||||
|
||||
$this->plaintextApiToken = null;
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
public function getPasswordUpgrader(): PasswordUpgraderInterface
|
||||
{
|
||||
return $this->passwordUpgrader;
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
55
src/API/Authentication/SessionAuthenticator.php
Normal file
55
src/API/Authentication/SessionAuthenticator.php
Normal 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\API\Authentication;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
|
||||
final class SessionAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public const HEADER_JAVASCRIPT = 'X-AUTH-SESSION';
|
||||
|
||||
public function __construct(private TokenAuthenticator $authenticator)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
if (str_contains($request->getRequestUri(), '/api/')) {
|
||||
// API docs can only be access, when the user is logged in
|
||||
if (str_contains($request->getRequestUri(), '/api/doc')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$request->headers->has(self::HEADER_JAVASCRIPT);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
return $this->authenticator->authenticate($request);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName);
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
return $this->authenticator->onAuthenticationFailure($request, $exception);
|
||||
}
|
||||
}
|
||||
106
src/API/Authentication/TokenAuthenticator.php
Normal file
106
src/API/Authentication/TokenAuthenticator.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?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\API\Authentication;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\ApiUserRepository;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
|
||||
final class TokenAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public const HEADER_USERNAME = 'X-AUTH-USER';
|
||||
public const HEADER_TOKEN = 'X-AUTH-TOKEN';
|
||||
|
||||
public function __construct(private ApiUserRepository $userProvider, private PasswordHasherFactoryInterface $passwordHasherFactory)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
if (str_contains($request->getRequestUri(), '/api/')) {
|
||||
return !str_contains($request->getRequestUri(), '/api/doc');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getCredentials(Request $request): array
|
||||
{
|
||||
$apiUser = $request->headers->get(self::HEADER_USERNAME);
|
||||
if (null === $apiUser || '' === $apiUser) {
|
||||
throw new CustomUserMessageAuthenticationException('Authentication required, missing user header: ' . self::HEADER_USERNAME);
|
||||
}
|
||||
|
||||
$apiToken = $request->headers->get(self::HEADER_TOKEN);
|
||||
if (null === $apiToken || '' === $apiToken) {
|
||||
throw new CustomUserMessageAuthenticationException('Authentication required, missing token header: ' . self::HEADER_TOKEN);
|
||||
}
|
||||
|
||||
return [
|
||||
'username' => $apiUser,
|
||||
'password' => $apiToken
|
||||
];
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$credentials = $this->getCredentials($request);
|
||||
|
||||
$checkCredentials = function (?string $presentedPassword, User $user) {
|
||||
if ('' === $presentedPassword) {
|
||||
throw new BadCredentialsException('The presented password cannot be empty.');
|
||||
}
|
||||
|
||||
if (null === $user->getApiToken()) {
|
||||
throw new BadCredentialsException('The user has no activated API account.');
|
||||
}
|
||||
|
||||
if ($this->passwordHasherFactory->getPasswordHasher($user)->verify($user->getApiToken(), $presentedPassword)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new BadCredentialsException('The presented password is invalid.');
|
||||
};
|
||||
|
||||
$passport = new Passport(
|
||||
new UserBadge($credentials['username'], [$this->userProvider, 'loadUserByIdentifier']),
|
||||
new CustomCredentials($checkCredentials, $credentials['password'])
|
||||
);
|
||||
|
||||
$passport->addBadge(new ApiTokenUpgradeBadge($credentials['password'], $this->userProvider));
|
||||
|
||||
return $passport;
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
$data = [
|
||||
'message' => $exception instanceof CustomUserMessageAuthenticationException ? $exception->getMessage() : 'Invalid credentials'
|
||||
];
|
||||
|
||||
return new JsonResponse($data, Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -13,17 +11,29 @@ namespace App\API;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\Pagination;
|
||||
use FOS\RestBundle\View\View;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
|
||||
/**
|
||||
* @method null|User getUser()
|
||||
*/
|
||||
abstract class BaseApiController extends AbstractController
|
||||
{
|
||||
public const DATE_ONLY_FORMAT = 'yyyy-MM-dd';
|
||||
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
|
||||
public const DATE_FORMAT_PHP = 'Y-m-d\TH:i:s';
|
||||
|
||||
protected function createSearchForm(string $type = FormType::class, $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->container
|
||||
->get('form.factory')
|
||||
->createNamed('', $type, $data, array_merge(['method' => 'GET'], $options));
|
||||
}
|
||||
|
||||
protected function getDateTimeFactory(?User $user = null): DateTimeFactory
|
||||
{
|
||||
if (null === $user) {
|
||||
@@ -32,4 +42,12 @@ abstract class BaseApiController extends AbstractController
|
||||
|
||||
return DateTimeFactory::createByUser($user);
|
||||
}
|
||||
|
||||
protected function addPagination(View $view, Pagination $pagination): void
|
||||
{
|
||||
$view->setHeader('X-Page', (string) $pagination->getCurrentPage());
|
||||
$view->setHeader('X-Total-Count', (string) $pagination->getNbResults());
|
||||
$view->setHeader('X-Total-Pages', (string) $pagination->getNbPages());
|
||||
$view->setHeader('X-Per-Page', (string) $pagination->getMaxPerPage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -11,99 +9,40 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\API;
|
||||
|
||||
use App\API\Model\I18nConfig;
|
||||
use App\API\Model\TimesheetConfig;
|
||||
use App\Configuration\LanguageFormattings;
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @SWG\Tag(name="Default")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Default')]
|
||||
final class ConfigurationController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler)
|
||||
public function __construct(private ViewHandlerInterface $viewHandler)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user specific locale configuration
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the locale specific configurations for this user",
|
||||
* @SWG\Schema(ref=@Model(type=I18nConfig::class))
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/config/i18n")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function i18nAction(LanguageFormattings $formats): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$locale = $user->getLocale();
|
||||
|
||||
$model = new I18nConfig();
|
||||
$model
|
||||
->setFormDate($formats->getDateTypeFormat($locale))
|
||||
->setDateTime($formats->getDateTimeFormat($locale))
|
||||
->setDate($formats->getDateFormat($locale))
|
||||
->setDuration($formats->getDurationFormat($locale))
|
||||
->setTime($formats->getTimeFormat($locale))
|
||||
->setIs24hours($user->is24Hour())
|
||||
->setNow($this->getDateTimeFactory()->createDateTime())
|
||||
;
|
||||
|
||||
$view = new View($model, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Config']);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timesheet configuration
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the instance specific timesheet configuration",
|
||||
* @SWG\Schema(ref=@Model(type=TimesheetConfig::class))
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/config/timesheet")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
|
||||
#[Rest\Get(path: '/config/timesheet')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function timesheetConfigAction(SystemConfiguration $configuration): Response
|
||||
{
|
||||
$model = new TimesheetConfig();
|
||||
$model
|
||||
->setTrackingMode($configuration->getTimesheetTrackingMode())
|
||||
->setDefaultBeginTime($configuration->getTimesheetDefaultBeginTime())
|
||||
->setActiveEntriesHardLimit($configuration->getTimesheetActiveEntriesHardLimit())
|
||||
->setActiveEntriesSoftLimit($configuration->getTimesheetActiveEntriesSoftLimit())
|
||||
->setIsAllowFutureTimes($configuration->isTimesheetAllowFutureTimes())
|
||||
->setIsAllowOverlapping($configuration->isTimesheetAllowOverlappingRecords())
|
||||
;
|
||||
$model->setTrackingMode($configuration->getTimesheetTrackingMode());
|
||||
$model->setDefaultBeginTime($configuration->getTimesheetDefaultBeginTime());
|
||||
$model->setActiveEntriesHardLimit($configuration->getTimesheetActiveEntriesHardLimit());
|
||||
$model->setIsAllowFutureTimes($configuration->isTimesheetAllowFutureTimes());
|
||||
$model->setIsAllowOverlapping($configuration->isTimesheetAllowOverlappingRecords());
|
||||
|
||||
$view = new View($model, 200);
|
||||
$view->getContext()->setGroups(['Default', 'Config']);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -11,6 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\API;
|
||||
|
||||
use App\Customer\CustomerService;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerRate;
|
||||
use App\Entity\User;
|
||||
@@ -25,72 +24,44 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use HandcraftedInTheAlps\RestRoutingBundle\Controller\Annotations\RouteResource;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @RouteResource("Customer")
|
||||
* @SWG\Tag(name="Customer")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class CustomerController extends BaseApiController
|
||||
#[Route(path: '/customers')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Customer')]
|
||||
final class CustomerController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Customer', 'Customer_Entity'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'Customer'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Customer'];
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate'];
|
||||
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var CustomerRateRepository
|
||||
*/
|
||||
private $customerRateRepository;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository, EventDispatcherInterface $dispatcher, CustomerRateRepository $customerRateRepository)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->customerRateRepository = $customerRateRepository;
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private CustomerRepository $repository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private CustomerRateRepository $customerRateRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of customers
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns a collection of customer entities",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/CustomerCollection")
|
||||
* )
|
||||
* )
|
||||
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|name", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name (default: name)")
|
||||
* @Rest\QueryParam(name="term", description="Free search term")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Returns a collection of customers (which are visible to the user)
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of customers', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerCollection')))]
|
||||
#[Rest\Get(path: '', name: 'get_customers')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter customers: 1=visible, 2=hidden, 3=both')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name (default: name)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
@@ -99,19 +70,23 @@ class CustomerController extends BaseApiController
|
||||
$query = new CustomerQuery();
|
||||
$query->setCurrentUser($user);
|
||||
|
||||
if (null !== ($order = $paramFetcher->get('order'))) {
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
$query->setOrder($order);
|
||||
}
|
||||
|
||||
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
|
||||
$orderBy = $paramFetcher->get('orderBy');
|
||||
if (\is_string($orderBy) && $orderBy !== '') {
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
|
||||
if (null !== ($visible = $paramFetcher->get('visible'))) {
|
||||
$query->setVisibility($visible);
|
||||
$visible = $paramFetcher->get('visible');
|
||||
if (\is_string($visible) && $visible !== '') {
|
||||
$query->setVisibility((int) $visible);
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$term = $paramFetcher->get('term');
|
||||
if (\is_string($term) && $term !== '') {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
@@ -124,25 +99,14 @@ class CustomerController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns one customer
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns one customer entity",
|
||||
* @SWG\Schema(ref="#/definitions/CustomerEntity"),
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction(int $id): Response
|
||||
#[OA\Response(response: 200, description: 'Returns one customer entity', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))]
|
||||
#[Rest\Get(path: '/{id}', name: 'get_customer', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getAction(Customer $customer): Response
|
||||
{
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
$view = new View($data, 200);
|
||||
$view = new View($customer, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
@@ -150,32 +114,19 @@ class CustomerController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Creates a new customer
|
||||
*
|
||||
* @SWG\Post(
|
||||
* description="Creates a new customer and returns it afterwards",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created customer",
|
||||
* @SWG\Schema(ref="#/definitions/CustomerEntity"),
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/CustomerEditForm")
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postAction(Request $request): Response
|
||||
#[OA\Post(description: 'Creates a new customer and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created customer', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))]
|
||||
#[Rest\Post(path: '', name: 'post_customer')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request, CustomerService $customerService): Response
|
||||
{
|
||||
if (!$this->isGranted('create_customer')) {
|
||||
throw new AccessDeniedHttpException('User cannot create customers');
|
||||
throw $this->createAccessDeniedException('User cannot create customers');
|
||||
}
|
||||
|
||||
$customer = new Customer();
|
||||
$customer = $customerService->createNewCustomer('');
|
||||
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -204,44 +155,16 @@ class CustomerController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Update an existing customer
|
||||
*
|
||||
* @SWG\Patch(
|
||||
* description="Update an existing customer, you can pass all or just a subset of all attributes",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the updated customer",
|
||||
* @SWG\Schema(ref="#/definitions/CustomerEntity")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/CustomerEditForm")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Customer ID to update",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
#[Security("is_granted('edit', customer)")]
|
||||
#[OA\Patch(description: 'Update an existing customer, you can pass all or just a subset of all attributes', responses: [new OA\Response(response: 200, description: 'Returns the updated customer', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerEditForm'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Customer ID to update', required: true)]
|
||||
#[Rest\Patch(path: '/{id}', name: 'patch_customer', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function patchAction(Request $request, Customer $customer): Response
|
||||
{
|
||||
$customer = $this->repository->find($id);
|
||||
|
||||
if (null === $customer) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $customer)) {
|
||||
throw new AccessDeniedHttpException('User cannot update customer');
|
||||
}
|
||||
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
@@ -270,37 +193,17 @@ class CustomerController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing customer
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.",
|
||||
* @SWG\Schema(ref="#/definitions/CustomerEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Customer record ID to set the meta-field value for",
|
||||
* required=true,
|
||||
* )
|
||||
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
|
||||
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
|
||||
#[Security("is_granted('edit', customer)")]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/CustomerEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Customer record ID to set the meta-field value for', required: true)]
|
||||
#[Rest\Patch(path: '/{id}/meta', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
|
||||
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
|
||||
public function metaAction(Customer $customer, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$customer = $this->repository->find($id);
|
||||
|
||||
if (null === $customer) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $customer)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to update this customer');
|
||||
}
|
||||
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
@@ -308,7 +211,7 @@ class CustomerController extends BaseApiController
|
||||
$value = $paramFetcher->get('value');
|
||||
|
||||
if (null === ($meta = $customer->getMetaField($name))) {
|
||||
throw new \InvalidArgumentException('Unknown meta-field requested');
|
||||
throw $this->createNotFoundException('Unknown meta-field requested');
|
||||
}
|
||||
|
||||
$meta->setValue($value);
|
||||
@@ -323,39 +226,15 @@ class CustomerController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns a collection of all rates for one customer
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns a collection of customer rate entities",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/CustomerRate")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The customer whose rates will be returned",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getRatesAction(int $id): Response
|
||||
#[Security("is_granted('edit', customer)")]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of customer rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/CustomerRate')))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer whose rates will be returned', required: true)]
|
||||
#[Rest\Get(path: '/{id}/rates', name: 'get_customer_rates', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getRatesAction(Customer $customer): Response
|
||||
{
|
||||
/** @var Customer|null $customer */
|
||||
$customer = $this->repository->find($id);
|
||||
|
||||
if (null === $customer) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $customer)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
$rates = $this->customerRateRepository->getRatesForCustomer($customer);
|
||||
|
||||
$view = new View($rates, 200);
|
||||
@@ -365,50 +244,20 @@ class CustomerController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one rate for an customer
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=204,
|
||||
* description="Returns no content: 204 on successful delete"
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The customer whose rate will be removed",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="rateId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The rate to remove",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Deletes one rate for a customer
|
||||
*/
|
||||
public function deleteRateAction(string $id, string $rateId): Response
|
||||
#[Security("is_granted('edit', customer)")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer whose rate will be removed', required: true)]
|
||||
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}/rates/{rateId}', name: 'delete_customer_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
|
||||
#[Entity('rate', expr: 'repository.find(rateId)')]
|
||||
public function deleteRateAction(Customer $customer, CustomerRate $rate): Response
|
||||
{
|
||||
/** @var Customer|null $customer */
|
||||
$customer = $this->repository->find($id);
|
||||
|
||||
if (null === $customer) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $customer)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
/** @var CustomerRate|null $rate */
|
||||
$rate = $this->customerRateRepository->find($rateId);
|
||||
|
||||
if (null === $rate || $rate->getCustomer() !== $customer) {
|
||||
throw new NotFoundException();
|
||||
if ($rate->getCustomer() !== $customer) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$this->customerRateRepository->deleteRate($rate);
|
||||
@@ -420,44 +269,16 @@ class CustomerController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Adds a new rate to a customer
|
||||
*
|
||||
* @SWG\Post(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created rate",
|
||||
* @SWG\Schema(ref="#/definitions/CustomerRate")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The customer to add the rate for",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/CustomerRateForm")
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postRateAction(int $id, Request $request): Response
|
||||
#[Security("is_granted('edit', customer)")]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the new created rate', content: new OA\JsonContent(ref: '#/components/schemas/CustomerRate'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The customer to add the rate for', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/CustomerRateForm'))]
|
||||
#[Rest\Post(path: '/{id}/rates', name: 'post_customer_rate', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postRateAction(Customer $customer, Request $request): Response
|
||||
{
|
||||
/** @var Customer|null $customer */
|
||||
$customer = $this->repository->find($id);
|
||||
|
||||
if (null === $customer) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $customer)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
$rate = new CustomerRate();
|
||||
$rate->setCustomer($customer);
|
||||
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
<?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\Model;
|
||||
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
|
||||
/**
|
||||
* @Serializer\ExclusionPolicy("all")
|
||||
*/
|
||||
final class I18nConfig
|
||||
{
|
||||
/**
|
||||
* Format used for toolbar queries
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $formDate = '';
|
||||
/**
|
||||
* Format used to display date-time values (see PHP function date_format)
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $dateTime = '';
|
||||
/**
|
||||
* Format used to display date values (see PHP function date_format)
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $date = '';
|
||||
/**
|
||||
* Format used to display times (see PHP function date_format)
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $time = '';
|
||||
/**
|
||||
* Format used to display durations (replace: %h with hours, %m with minutes, %s with seconds)
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $duration = '';
|
||||
/**
|
||||
* Whether a twenty-four hour format is used (true) or 12-hours AM/PM format (false)
|
||||
*
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="boolean")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $is24hours = true;
|
||||
/**
|
||||
* The current time of the user
|
||||
*
|
||||
* @var \DateTime
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="DateTime")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $now;
|
||||
|
||||
public function setNow(\DateTime $now): I18nConfig
|
||||
{
|
||||
$this->now = $now;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setFormDate(string $formDate): I18nConfig
|
||||
{
|
||||
$this->formDate = $formDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDateTime(string $dateTime): I18nConfig
|
||||
{
|
||||
$this->dateTime = $dateTime;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDate(string $date): I18nConfig
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDuration(string $duration): I18nConfig
|
||||
{
|
||||
$this->duration = $duration;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setTime(string $time): I18nConfig
|
||||
{
|
||||
$this->time = $time;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setIs24hours(bool $is24hours): I18nConfig
|
||||
{
|
||||
$this->is24hours = $is24hours;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
73
src/API/Model/PageAction.php
Normal file
73
src/API/Model/PageAction.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?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\API\Model;
|
||||
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
final class PageAction
|
||||
{
|
||||
/**
|
||||
* ID of the action
|
||||
*/
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
public readonly string $id;
|
||||
/**
|
||||
* Translated title to show the user
|
||||
*/
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
public readonly string $title;
|
||||
/**
|
||||
* URL of the action
|
||||
*/
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
public readonly ?string $url;
|
||||
/**
|
||||
* HTML classes to be used
|
||||
*/
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
public readonly ?string $class;
|
||||
/**
|
||||
* HTML (data) attributes to render the action
|
||||
*/
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'array<string, string>')]
|
||||
public readonly array $attr;
|
||||
/**
|
||||
* Whether to render a divider before this item
|
||||
*/
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'boolean')]
|
||||
public readonly bool $divider;
|
||||
|
||||
public function __construct(string $title, array $settings = [])
|
||||
{
|
||||
$this->id = $title;
|
||||
$this->title = $settings['title'] ?? $title;
|
||||
$this->url = $settings['url'] ?? null;
|
||||
$this->class = $settings['class'] ?? null;
|
||||
$this->attr = $settings['attr'] ?? [];
|
||||
|
||||
$this->divider = ($title === 'trash' || (str_contains($title, 'divider') && ($this->url === null || $this->url === '')));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -14,31 +12,23 @@ namespace App\API\Model;
|
||||
use App\Plugin\Plugin as CorePlugin;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
|
||||
/**
|
||||
* @Serializer\ExclusionPolicy("all")
|
||||
*/
|
||||
class Plugin
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
final class Plugin
|
||||
{
|
||||
/**
|
||||
* The plugin name, eg. "ExpensesBundle"
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
*/
|
||||
protected $name;
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
private ?string $name = null;
|
||||
/**
|
||||
* The plugin version, eg. "1.14"
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
*/
|
||||
protected $version;
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
private ?string $version = null;
|
||||
|
||||
public function __construct(CorePlugin $plugin)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -13,117 +11,67 @@ namespace App\API\Model;
|
||||
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
|
||||
/**
|
||||
* @Serializer\ExclusionPolicy("none")
|
||||
*/
|
||||
#[Serializer\ExclusionPolicy('none')]
|
||||
final class TimesheetConfig
|
||||
{
|
||||
/**
|
||||
* The time-tracking mode, see also: https://www.kimai.org/documentation/timesheet.html#tracking-modes
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $trackingMode = 'default';
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
public string $trackingMode = 'default';
|
||||
/**
|
||||
* Default begin datetime in PHP format
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $defaultBeginTime = 'now';
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
public string $defaultBeginTime = 'now';
|
||||
/**
|
||||
* How many running timesheets a user is allowed to have at the same time
|
||||
*
|
||||
* @var int
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="integer")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $activeEntriesHardLimit = 1;
|
||||
/**
|
||||
* How many running timesheets a user is allowed before a warning is shown
|
||||
*
|
||||
* @var int
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="integer")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $activeEntriesSoftLimit = 1;
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'integer')]
|
||||
public int $activeEntriesHardLimit = 1;
|
||||
/**
|
||||
* Whether entries for future times are allowed
|
||||
*
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="boolean")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $isAllowFutureTimes = true;
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'boolean')]
|
||||
public bool $isAllowFutureTimes = true;
|
||||
/**
|
||||
* Whether overlapping entries are allowed
|
||||
*
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="boolean")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $isAllowOverlapping = true;
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'boolean')]
|
||||
public bool $isAllowOverlapping = true;
|
||||
|
||||
public function setTrackingMode(string $trackingMode): TimesheetConfig
|
||||
public function setTrackingMode(string $trackingMode): void
|
||||
{
|
||||
$this->trackingMode = $trackingMode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDefaultBeginTime(string $defaultBeginTime): TimesheetConfig
|
||||
public function setDefaultBeginTime(string $defaultBeginTime): void
|
||||
{
|
||||
$this->defaultBeginTime = $defaultBeginTime;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setActiveEntriesHardLimit(int $activeEntriesHardLimit): TimesheetConfig
|
||||
public function setActiveEntriesHardLimit(int $activeEntriesHardLimit): void
|
||||
{
|
||||
$this->activeEntriesHardLimit = $activeEntriesHardLimit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setActiveEntriesSoftLimit(int $activeEntriesSoftLimit): TimesheetConfig
|
||||
{
|
||||
$this->activeEntriesSoftLimit = $activeEntriesSoftLimit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setIsAllowFutureTimes(bool $isAllowFutureTimes): TimesheetConfig
|
||||
public function setIsAllowFutureTimes(bool $isAllowFutureTimes): void
|
||||
{
|
||||
$this->isAllowFutureTimes = $isAllowFutureTimes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setIsAllowOverlapping(bool $isAllowOverlapping): TimesheetConfig
|
||||
public function setIsAllowOverlapping(bool $isAllowOverlapping): void
|
||||
{
|
||||
$this->isAllowOverlapping = $isAllowOverlapping;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -14,71 +12,37 @@ namespace App\API\Model;
|
||||
use App\Constants;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
|
||||
/**
|
||||
* @Serializer\ExclusionPolicy("all")
|
||||
*/
|
||||
class Version
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
final class Version
|
||||
{
|
||||
/**
|
||||
* Kimai Version, eg. "1.14"
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
* Kimai Version, eg. "2.0.0"
|
||||
*/
|
||||
protected $version = Constants::VERSION;
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
public readonly string $version;
|
||||
/**
|
||||
* Kimai Version as integer, eg. 11400
|
||||
* Kimai Version as integer, eg. 20000
|
||||
*
|
||||
* Follows the same logic as PHP_VERSION_ID, see https://www.php.net/manual/de/function.phpversion.php
|
||||
*
|
||||
* @var int
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="integer")
|
||||
*/
|
||||
protected $versionId = Constants::VERSION_ID;
|
||||
/**
|
||||
* Candidate: either "prod" or "dev"
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
*/
|
||||
protected $candidate = Constants::STATUS;
|
||||
/**
|
||||
* Full version including status, eg: "1.9-prod"
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
*/
|
||||
protected $semver = Constants::VERSION . '-' . Constants::STATUS;
|
||||
/**
|
||||
* The version name
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
*/
|
||||
protected $name = Constants::NAME;
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'integer')]
|
||||
public readonly int $versionId;
|
||||
/**
|
||||
* A full copyright notice
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
* @Serializer\Type(name="string")
|
||||
*/
|
||||
protected $copyright = Constants::SOFTWARE . ' ' . Constants::VERSION . ' by Kevin Papst and contributors.';
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[Serializer\Type(name: 'string')]
|
||||
public readonly string $copyright;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->version = Constants::VERSION;
|
||||
$this->versionId = Constants::VERSION_ID;
|
||||
$this->copyright = Constants::SOFTWARE . ' ' . Constants::VERSION . ' by Kevin Papst.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -13,7 +11,7 @@ namespace App\API;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class NotFoundException extends NotFoundHttpException
|
||||
final class NotFoundException extends NotFoundHttpException
|
||||
{
|
||||
public function __construct(string $message = 'Not found', \Exception $previous = null, int $code = 404, array $headers = [])
|
||||
{
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -18,6 +16,7 @@ use App\Event\ProjectMetaDefinitionEvent;
|
||||
use App\Form\API\ProjectApiEditForm;
|
||||
use App\Form\API\ProjectRateApiForm;
|
||||
use App\Project\ProjectService;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRateRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
@@ -26,85 +25,53 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use HandcraftedInTheAlps\RestRoutingBundle\Controller\Annotations\RouteResource;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
|
||||
/**
|
||||
* @RouteResource("Project")
|
||||
* @SWG\Tag(name="Project")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class ProjectController extends BaseApiController
|
||||
#[Route(path: '/projects')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Project')]
|
||||
final class ProjectController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Project', 'Project_Entity'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'Project'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Project'];
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate'];
|
||||
|
||||
/**
|
||||
* @var ProjectRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var ProjectRateRepository
|
||||
*/
|
||||
private $projectRateRepository;
|
||||
/**
|
||||
* @var ProjectService
|
||||
*/
|
||||
private $projectService;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher, ProjectRateRepository $projectRateRepository, ProjectService $projectService)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->projectRateRepository = $projectRateRepository;
|
||||
$this->projectService = $projectService;
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private ProjectRepository $repository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private ProjectRateRepository $projectRateRepository,
|
||||
private ProjectService $projectService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of projects.
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns a collection of project entities",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/ProjectCollection")
|
||||
* )
|
||||
* )
|
||||
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects")
|
||||
* @Rest\QueryParam(name="customers", requirements="[\d|,]+", strict=true, nullable=true, description="Comma separated list of customer IDs to filter projects")
|
||||
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects. Allowed values: 1=visible, 2=hidden, 3=both (default: 1)")
|
||||
* @Rest\QueryParam(name="start", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only projects that started before this date will be included. Allowed format: HTML5 (default: now, if end is also empty)")
|
||||
* @Rest\QueryParam(name="end", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only projects that ended after this date will be included. Allowed format: HTML5 (default: now, if start is also empty)")
|
||||
* @Rest\QueryParam(name="ignoreDates", requirements="1", strict=true, nullable=true, description="If set, start and end are completely ignored. Allowed values: 1 (default: off)")
|
||||
* @Rest\QueryParam(name="globalActivities", requirements="0|1", strict=true, nullable=true, description="If given, filters projects by their 'global activity' support. Allowed values: 1 (supports global activities) and 0 (without global activities) (default: all)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|name|customer", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, customer (default: name)")
|
||||
* @Rest\QueryParam(name="term", description="Free search term")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Returns a collection of projects (which are visible to the user)
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of projects', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectCollection')))]
|
||||
#[Rest\Get(path: '', name: 'get_projects')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter projects')]
|
||||
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter projects: 1=visible, 2=hidden, 3=both')]
|
||||
#[Rest\QueryParam(name: 'start', requirements: [new Constraints\AtLeastOneOf(constraints: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s', message: 'This value is not a valid datetime, expected format: Y-m-d (2022-01-27T20:13:57).'), new Constraints\DateTime(format: 'Y-m-d', message: 'This value is not a valid datetime, expected format: Y-m-d (2022-01-27).')])], strict: true, nullable: true, description: 'Only projects that started before this date will be included. Allowed format: HTML5 (default: now, if end is also empty)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\AtLeastOneOf(constraints: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s', message: 'This value is not a valid datetime, expected format: Y-m-d (2022-01-27T20:13:57).'), new Constraints\DateTime(format: 'Y-m-d', message: 'This value is not a valid datetime, expected format: Y-m-d (2022-01-27).')])], strict: true, nullable: true, description: 'Only projects that ended after this date will be included. Allowed format: HTML5 (default: now, if start is also empty)')]
|
||||
#[Rest\QueryParam(name: 'ignoreDates', requirements: 1, strict: true, nullable: true, description: 'If set, start and end are completely ignored. Allowed values: 1 (default: off)')]
|
||||
#[Rest\QueryParam(name: 'globalActivities', requirements: '0|1', strict: true, nullable: true, description: "If given, filters projects by their 'global activity' support. Allowed values: 1 (supports global activities) and 0 (without global activities) (default: all)")]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|customer', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, customer (default: name)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
@@ -112,30 +79,38 @@ class ProjectController extends BaseApiController
|
||||
$query = new ProjectQuery();
|
||||
$query->setCurrentUser($user);
|
||||
|
||||
if (null !== ($order = $paramFetcher->get('order'))) {
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
$query->setOrder($order);
|
||||
}
|
||||
|
||||
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
|
||||
$orderBy = $paramFetcher->get('orderBy');
|
||||
if (\is_string($orderBy) && $orderBy !== '') {
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
|
||||
if (!empty($customers = $paramFetcher->get('customers'))) {
|
||||
if (!\is_array($customers)) {
|
||||
$customers = explode(',', $customers);
|
||||
}
|
||||
$query->setCustomers($customers);
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
$customer = $paramFetcher->get('customer');
|
||||
if (\is_string($customer) && $customer !== '') {
|
||||
$customers[] = $customer;
|
||||
}
|
||||
|
||||
if (!empty($customer = $paramFetcher->get('customer'))) {
|
||||
foreach (array_unique($customers) as $customerId) {
|
||||
$customer = $customerRepository->find($customerId);
|
||||
if ($customer === null) {
|
||||
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
if (null !== ($visible = $paramFetcher->get('visible'))) {
|
||||
$query->setVisibility($visible);
|
||||
$visible = $paramFetcher->get('visible');
|
||||
if (\is_string($visible) && $visible !== '') {
|
||||
$query->setVisibility((int) $visible);
|
||||
}
|
||||
|
||||
if (null !== ($globalActivities = $paramFetcher->get('globalActivities'))) {
|
||||
$globalActivities = $paramFetcher->get('globalActivities');
|
||||
if ($globalActivities !== null) {
|
||||
$query->setGlobalActivities((bool) $globalActivities);
|
||||
}
|
||||
|
||||
@@ -146,22 +121,26 @@ class ProjectController extends BaseApiController
|
||||
|
||||
if (!$ignoreDates) {
|
||||
$factory = $this->getDateTimeFactory();
|
||||
if (null !== ($begin = $paramFetcher->get('start')) && !empty($begin)) {
|
||||
$now = $factory->createDateTime();
|
||||
$begin = $paramFetcher->get('start');
|
||||
$end = $paramFetcher->get('end');
|
||||
|
||||
if (\is_string($begin) && $begin !== '') {
|
||||
$query->setProjectStart($factory->createDateTime($begin));
|
||||
}
|
||||
|
||||
if (null !== ($end = $paramFetcher->get('end')) && !empty($end)) {
|
||||
if (\is_string($end) && $end !== '') {
|
||||
$query->setProjectEnd($factory->createDateTime($end));
|
||||
}
|
||||
|
||||
if (empty($begin) && empty($end)) {
|
||||
$now = $factory->createDateTime();
|
||||
if ($query->getProjectStart() === null && $query->getProjectEnd() === null) {
|
||||
$query->setProjectStart($now);
|
||||
$query->setProjectEnd($now);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$term = $paramFetcher->get('term');
|
||||
if (\is_string($term) && $term !== '') {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
@@ -174,25 +153,14 @@ class ProjectController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns one project
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns one project entity",
|
||||
* @SWG\Schema(ref="#/definitions/ProjectEntity"),
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction(int $id): Response
|
||||
#[OA\Response(response: 200, description: 'Returns one project entity', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))]
|
||||
#[Rest\Get(path: '/{id}', name: 'get_project', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getAction(Project $project): Response
|
||||
{
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
$view = new View($data, 200);
|
||||
$view = new View($project, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
@@ -200,36 +168,23 @@ class ProjectController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Creates a new project
|
||||
*
|
||||
* @SWG\Post(
|
||||
* description="Creates a new project and returns it afterwards",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created project",
|
||||
* @SWG\Schema(ref="#/definitions/ProjectEntity"),
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/ProjectEditForm")
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Post(description: 'Creates a new project and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created project', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))]
|
||||
#[Rest\Post(path: '', name: 'post_project')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
if (!$this->isGranted('create_project')) {
|
||||
throw new AccessDeniedHttpException('User cannot create projects');
|
||||
throw $this->createAccessDeniedException('User cannot create projects');
|
||||
}
|
||||
|
||||
$project = $this->projectService->createNewProject();
|
||||
|
||||
$form = $this->createForm(ProjectApiEditForm::class, $project, [
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
'date_format' => self::DATE_FORMAT,
|
||||
'date_format' => self::DATE_ONLY_FORMAT,
|
||||
'include_budget' => $this->isGranted('budget', $project),
|
||||
'include_time' => $this->isGranted('time', $project),
|
||||
]);
|
||||
@@ -253,44 +208,16 @@ class ProjectController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*
|
||||
* @SWG\Patch(
|
||||
* description="Update an existing project, you can pass all or just a subset of all attributes",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the updated project",
|
||||
* @SWG\Schema(ref="#/definitions/ProjectEntity")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/ProjectEditForm")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Project ID to update",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
#[Security("is_granted('edit', project)")]
|
||||
#[OA\Patch(description: 'Update an existing project, you can pass all or just a subset of all attributes', responses: [new OA\Response(response: 200, description: 'Returns the updated project', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectEditForm'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Project ID to update', required: true)]
|
||||
#[Rest\Patch(path: '/{id}', name: 'patch_project', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function patchAction(Request $request, Project $project): Response
|
||||
{
|
||||
$project = $this->repository->find($id);
|
||||
|
||||
if (null === $project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $project)) {
|
||||
throw new AccessDeniedHttpException('User cannot update project');
|
||||
}
|
||||
|
||||
$event = new ProjectMetaDefinitionEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
@@ -321,37 +248,17 @@ class ProjectController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing project
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.",
|
||||
* @SWG\Schema(ref="#/definitions/ProjectEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Project record ID to set the meta-field value for",
|
||||
* required=true,
|
||||
* )
|
||||
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
|
||||
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function metaAction(int $id, ParamFetcherInterface $paramFetcher): Response
|
||||
#[Security("is_granted('edit', project)")]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/ProjectEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Project record ID to set the meta-field value for', required: true)]
|
||||
#[Rest\Patch(path: '/{id}/meta', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
|
||||
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
|
||||
public function metaAction(Project $project, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$project = $this->repository->find($id);
|
||||
|
||||
if (null === $project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $project)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to update this project');
|
||||
}
|
||||
|
||||
$event = new ProjectMetaDefinitionEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
@@ -359,7 +266,7 @@ class ProjectController extends BaseApiController
|
||||
$value = $paramFetcher->get('value');
|
||||
|
||||
if (null === ($meta = $project->getMetaField($name))) {
|
||||
throw new \InvalidArgumentException('Unknown meta-field requested');
|
||||
throw $this->createNotFoundException('Unknown meta-field requested');
|
||||
}
|
||||
|
||||
$meta->setValue($value);
|
||||
@@ -374,39 +281,15 @@ class ProjectController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns a collection of all rates for one project
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns a collection of project rate entities",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/ProjectRate")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The project whose rates will be returned",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getRatesAction(int $id): Response
|
||||
#[Security("is_granted('edit', project)")]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of project rate entities', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/ProjectRate')))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The project whose rates will be returned', required: true)]
|
||||
#[Rest\Get(path: '/{id}/rates', name: 'get_project_rates', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getRatesAction(Project $project): Response
|
||||
{
|
||||
/** @var Project|null $project */
|
||||
$project = $this->repository->find($id);
|
||||
|
||||
if (null === $project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $project)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
$rates = $this->projectRateRepository->getRatesForProject($project);
|
||||
|
||||
$view = new View($rates, 200);
|
||||
@@ -416,50 +299,20 @@ class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one rate for an project
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=204,
|
||||
* description="Returns no content: 204 on successful delete"
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The project whose rate will be removed",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="rateId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The rate to remove",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Deletes one rate for a project
|
||||
*/
|
||||
public function deleteRateAction(string $id, string $rateId): Response
|
||||
#[Security("is_granted('edit', project)")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Returns no content: 204 on successful delete')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The project whose rate will be removed', required: true)]
|
||||
#[OA\Parameter(name: 'rateId', in: 'path', description: 'The rate to remove', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}/rates/{rateId}', name: 'delete_project_rate', requirements: ['id' => '\d+', 'rateId' => '\d+'])]
|
||||
#[Entity('rate', expr: 'repository.find(rateId)')]
|
||||
public function deleteRateAction(Project $project, ProjectRate $rate): Response
|
||||
{
|
||||
/** @var Project|null $project */
|
||||
$project = $this->repository->find($id);
|
||||
|
||||
if (null === $project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $project)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
/** @var ProjectRate|null $rate */
|
||||
$rate = $this->projectRateRepository->find($rateId);
|
||||
|
||||
if (null === $rate || $rate->getProject() !== $project) {
|
||||
throw new NotFoundException();
|
||||
if ($rate->getProject() !== $project) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$this->projectRateRepository->deleteRate($rate);
|
||||
@@ -470,45 +323,17 @@ class ProjectController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new rate to an project
|
||||
*
|
||||
* @SWG\Post(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created rate",
|
||||
* @SWG\Schema(ref="#/definitions/ProjectRate")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The project to add the rate for",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/ProjectRateForm")
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Adds a new rate to a project
|
||||
*/
|
||||
public function postRateAction(int $id, Request $request): Response
|
||||
#[Security("is_granted('edit', project)")]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Returns the new created rate', content: new OA\JsonContent(ref: '#/components/schemas/ProjectRate'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The project to add the rate for', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/ProjectRateForm'))]
|
||||
#[Rest\Post(path: '/{id}/rates', name: 'post_project_rate', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postRateAction(Project $project, Request $request): Response
|
||||
{
|
||||
/** @var Project|null $project */
|
||||
$project = $this->repository->find($id);
|
||||
|
||||
if (null === $project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $project)) {
|
||||
throw new AccessDeniedHttpException('Access denied.');
|
||||
}
|
||||
|
||||
$rate = new ProjectRate();
|
||||
$rate->setProject($project);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\API\Serializer;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use FOS\RestBundle\Serializer\Normalizer\FlattenExceptionHandler;
|
||||
use JMS\Serializer\Context;
|
||||
@@ -16,27 +17,17 @@ use JMS\Serializer\GraphNavigatorInterface;
|
||||
use JMS\Serializer\Handler\SubscribingHandlerInterface;
|
||||
use JMS\Serializer\JsonSerializationVisitor;
|
||||
use Symfony\Component\ErrorHandler\Exception\FlattenException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ValidationFailedExceptionErrorHandler implements SubscribingHandlerInterface
|
||||
final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerInterface
|
||||
{
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
/**
|
||||
* @var FlattenExceptionHandler
|
||||
*/
|
||||
private $exceptionHandler;
|
||||
|
||||
public function __construct(TranslatorInterface $translator, FlattenExceptionHandler $exceptionHandler)
|
||||
public function __construct(private TranslatorInterface $translator, private FlattenExceptionHandler $exceptionHandler, private Security $security)
|
||||
{
|
||||
$this->translator = $translator;
|
||||
$this->exceptionHandler = $exceptionHandler;
|
||||
}
|
||||
|
||||
public static function getSubscribingMethods()
|
||||
public static function getSubscribingMethods(): array
|
||||
{
|
||||
return [[
|
||||
'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
|
||||
@@ -87,10 +78,18 @@ class ValidationFailedExceptionErrorHandler implements SubscribingHandlerInterfa
|
||||
|
||||
private function getErrorMessage(ConstraintViolationInterface $error): string
|
||||
{
|
||||
if (null !== $error->getPlural()) {
|
||||
return $this->translator->trans($error->getMessageTemplate(), ['%count%' => $error->getPlural()] + $error->getParameters(), 'validators');
|
||||
$locale = \Locale::getDefault();
|
||||
/** @var User $user */
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if ($user !== null) {
|
||||
$locale = $user->getLocale();
|
||||
}
|
||||
|
||||
return $this->translator->trans($error->getMessageTemplate(), $error->getParameters(), 'validators');
|
||||
if (null !== $error->getPlural()) {
|
||||
return $this->translator->trans($error->getMessageTemplate(), ['%count%' => $error->getPlural()] + $error->getParameters(), 'validators', $locale);
|
||||
}
|
||||
|
||||
return $this->translator->trans($error->getMessageTemplate(), $error->getParameters(), 'validators', $locale);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -19,41 +17,25 @@ use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @SWG\Tag(name="Default")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class StatusController extends BaseApiController
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Default')]
|
||||
final class StatusController extends BaseApiController
|
||||
{
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler)
|
||||
public function __construct(private ViewHandlerInterface $viewHandler)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* A testing route for the API
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="A simple route that returns a 'pong', which you can use for testing the API",
|
||||
* examples={"{'message': 'pong'}"}
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/ping")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Response(response: 200, description: "A simple route that returns a 'pong', which you can use for testing the API", content: new OA\JsonContent(example: "{'message': 'pong'}"))]
|
||||
#[Rest\Get(path: '/ping')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function pingAction(): Response
|
||||
{
|
||||
$view = new View(['message' => 'pong'], 200);
|
||||
@@ -63,18 +45,11 @@ class StatusController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns information about the Kimai release
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns version information about the current release",
|
||||
* @SWG\Schema(ref=@Model(type=Version::class))
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/version")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns version information about the current release', content: new OA\JsonContent(ref: new Model(type: Version::class)))]
|
||||
#[Rest\Get(path: '/version')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function versionAction(): Response
|
||||
{
|
||||
return $this->viewHandler->handle(new View(new Version(), 200));
|
||||
@@ -82,21 +57,11 @@ class StatusController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns information about installed Plugins
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns a list of plugin names and versions",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref=@Model(type=Plugin::class))
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/plugins")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns a list of plugin names and versions', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: new Model(type: Plugin::class))))]
|
||||
#[Rest\Get(path: '/plugins')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function pluginAction(PluginManager $pluginManager): Response
|
||||
{
|
||||
$plugins = [];
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -18,58 +16,34 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use HandcraftedInTheAlps\RestRoutingBundle\Controller\Annotations\RouteResource;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @RouteResource("Tag")
|
||||
* @SWG\Tag(name="Tag")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
#[Route(path: '/tags')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Tag')]
|
||||
final class TagController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Tag'];
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Tag'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'Tag'];
|
||||
|
||||
/**
|
||||
* @var TagRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler, TagRepository $repository)
|
||||
public function __construct(private ViewHandlerInterface $viewHandler, private TagRepository $repository)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all existing tags
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the collection of all existing tags as string array",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(type="string")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @Rest\QueryParam(name="name", strict=true, nullable=true, description="Search term to filter tag list")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of all existing tags as string array', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'string')))]
|
||||
#[Rest\Get(name: 'get_tags')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$filter = $paramFetcher->get('name');
|
||||
@@ -84,29 +58,16 @@ final class TagController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Creates a new tag
|
||||
*
|
||||
* @SWG\Post(
|
||||
* description="Creates a new tag and returns it afterwards",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created tag",
|
||||
* @SWG\Schema(ref="#/definitions/TagEntity"),
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/TagEditForm")
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Post(description: 'Creates a new tag and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created tag', content: new OA\JsonContent(ref: '#/components/schemas/TagEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TagEditForm'))]
|
||||
#[Rest\Post(name: 'post_tag')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
if (!$this->isGranted('manage_tag')) {
|
||||
throw new AccessDeniedHttpException('User cannot create tags');
|
||||
if (!$this->isGranted('manage_tag') && !$this->isGranted('create_tag')) {
|
||||
throw $this->createAccessDeniedException('User cannot create tags');
|
||||
}
|
||||
|
||||
$tag = new Tag();
|
||||
@@ -132,34 +93,15 @@ final class TagController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=204,
|
||||
* description="HTTP code 204 for a successful delete"
|
||||
* ),
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Tag ID to delete",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('delete_tag')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteAction(int $id): Response
|
||||
#[Security("is_granted('delete_tag')")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'HTTP code 204 for a successful delete')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Tag ID to delete', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}', name: 'delete_tag')]
|
||||
public function deleteAction(Tag $tag): Response
|
||||
{
|
||||
$tag = $this->repository->find($id);
|
||||
|
||||
if (null === $tag) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
$this->repository->deleteTag($tag);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -17,65 +15,40 @@ use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\Form\API\TeamApiEditForm;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use HandcraftedInTheAlps\RestRoutingBundle\Controller\Annotations\RouteResource;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @RouteResource("Team")
|
||||
* @SWG\Tag(name="Team")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
#[Route(path: '/teams')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Team')]
|
||||
final class TeamController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Team', 'Team_Entity', 'Not_Expanded'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'Team', 'Team_Entity', 'Not_Expanded'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Team'];
|
||||
|
||||
/**
|
||||
* @var TeamRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler, TeamRepository $repository)
|
||||
public function __construct(private ViewHandlerInterface $viewHandler, private 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')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Fetch all existing teams (which are visible to the user)
|
||||
*/
|
||||
#[Security("is_granted('view_team')")]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of teams', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TeamCollection')))]
|
||||
#[Rest\Get(path: '', name: 'get_teams')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function cgetAction(): Response
|
||||
{
|
||||
$data = $this->repository->findAll();
|
||||
@@ -88,27 +61,15 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns one team
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns one team entity",
|
||||
* @SWG\Schema(ref="#/definitions/Team"),
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('view_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction(int $id): Response
|
||||
#[Security("is_granted('view_team')")]
|
||||
#[OA\Response(response: 200, description: 'Returns one team entity', content: new OA\JsonContent(ref: '#/components/schemas/Team'))]
|
||||
#[Rest\Get(path: '/{id}', name: 'get_team', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getAction(Team $team): Response
|
||||
{
|
||||
$data = $this->repository->find($id);
|
||||
|
||||
if (null === $data) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
$view = new View($data, 200);
|
||||
$view = new View($team, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
@@ -116,34 +77,15 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* 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')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteAction(int $id): Response
|
||||
#[Security("is_granted('delete_team')")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one team')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to delete', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
|
||||
public function deleteAction(Team $team): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
$this->repository->deleteTeam($team);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
@@ -153,30 +95,16 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Creates a new team
|
||||
*
|
||||
* @SWG\Post(
|
||||
* description="Creates a new team and returns it afterwards",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created team",
|
||||
* @SWG\Schema(ref="#/definitions/Team"),
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/TeamEditForm")
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('create_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[Security("is_granted('create_team')")]
|
||||
#[OA\Post(description: 'Creates a new team and returns it afterwards', responses: [new OA\Response(response: 200, description: 'Returns the new created team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
|
||||
#[Rest\Post(path: '', name: 'post_team')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
$team = new Team();
|
||||
$team = new Team('');
|
||||
|
||||
$form = $this->createForm(TeamApiEditForm::class, $team);
|
||||
$form->submit($request->request->all());
|
||||
@@ -198,42 +126,16 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Update an existing team
|
||||
*
|
||||
* @SWG\Patch(
|
||||
* description="Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the updated team",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/TeamEditForm")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Team ID to update",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Patch(description: 'Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)', responses: [new OA\Response(response: 200, description: 'Returns the updated team', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TeamEditForm'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Team ID to update', required: true)]
|
||||
#[Rest\Patch(path: '/{id}', name: 'patch_team', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function patchAction(Request $request, Team $team): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if ($request->request->has('members')) {
|
||||
foreach ($team->getMembers() as $member) {
|
||||
$team->removeMember($member);
|
||||
@@ -264,54 +166,22 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Add a new member to a team
|
||||
*
|
||||
* @SWG\Post(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Adds a new user to a team.",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team which will receive the new member",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="userId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team member to add (User ID)",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postMemberAction(int $id, int $userId, UserRepository $repository): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new user to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team which will receive the new member', required: true)]
|
||||
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to add (User ID)', required: true)]
|
||||
#[Rest\Post(path: '/{id}/members/{userId}', name: 'post_team_member', requirements: ['id' => '\d+', 'userId' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Entity('member', expr: 'repository.find(userId)')]
|
||||
public function postMemberAction(Team $team, User $member): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException('Team not found');
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = $repository->find($userId);
|
||||
|
||||
if (null === $user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if ($user->isInTeam($team)) {
|
||||
if ($member->isInTeam($team)) {
|
||||
throw new BadRequestHttpException('User is already member of the team');
|
||||
}
|
||||
|
||||
$team->addUser($user);
|
||||
$team->addUser($member);
|
||||
|
||||
$this->repository->saveTeam($team);
|
||||
|
||||
@@ -323,58 +193,26 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Removes a member from the team
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Removes a user from the team. The teamlead cannot be removed.",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team from which the member will be removed",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="userId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team member to remove (User ID)",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteMemberAction(int $id, int $userId, UserRepository $repository): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a user from the team. The teamlead cannot be removed.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team from which the member will be removed', required: true)]
|
||||
#[OA\Parameter(name: 'userId', in: 'path', description: 'The team member to remove (User ID)', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}/members/{userId}', name: 'delete_team_member', requirements: ['id' => '\d+', 'userId' => '\d+'])]
|
||||
#[Entity('member', expr: 'repository.find(userId)')]
|
||||
public function deleteMemberAction(Team $team, User $member): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException('Team not found');
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = $repository->find($userId);
|
||||
|
||||
if (null === $user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!$user->isInTeam($team)) {
|
||||
if (!$member->isInTeam($team)) {
|
||||
throw new BadRequestHttpException('User is not a member of the team');
|
||||
}
|
||||
|
||||
if ($team->isTeamlead($user)) {
|
||||
if ($team->isTeamlead($member)) {
|
||||
throw new BadRequestHttpException('Cannot remove teamlead');
|
||||
}
|
||||
|
||||
$team->removeUser($user);
|
||||
$team->removeUser($member);
|
||||
|
||||
$this->repository->saveTeam($team);
|
||||
|
||||
@@ -386,49 +224,17 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Grant the team access to a customer
|
||||
*
|
||||
* @SWG\Post(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Adds a new customer to a team.",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team that is granted access",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="customerId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The customer to grant acecess to (Customer ID)",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postCustomerAction(int $id, int $customerId, CustomerRepository $repository): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new customer to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to grant acecess to (Customer ID)', required: true)]
|
||||
#[Rest\Post(path: '/{id}/customers/{customerId}', name: 'post_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Entity('customer', expr: 'repository.find(customerId)')]
|
||||
public function postCustomerAction(Team $team, Customer $customer): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException('Team not found');
|
||||
}
|
||||
|
||||
/** @var Customer|null $customer */
|
||||
$customer = $repository->find($customerId);
|
||||
|
||||
if (null === $customer) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
|
||||
if ($team->hasCustomer($customer)) {
|
||||
throw new BadRequestHttpException('Team has already access to customer');
|
||||
}
|
||||
@@ -445,49 +251,17 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Revokes access for a customer from a team
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Removes a customer from the team.",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team whose permission will be revoked",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="customerId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The customer to remove (Customer ID)",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteCustomerAction(int $id, int $customerId, CustomerRepository $repository): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a customer from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
|
||||
#[OA\Parameter(name: 'customerId', in: 'path', description: 'The customer to remove (Customer ID)', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}/customers/{customerId}', name: 'delete_team_customer', requirements: ['id' => '\d+', 'customerId' => '\d+'])]
|
||||
#[Entity('customer', expr: 'repository.find(customerId)')]
|
||||
public function deleteCustomerAction(Team $team, Customer $customer): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException('Team not found');
|
||||
}
|
||||
|
||||
/** @var Customer|null $customer */
|
||||
$customer = $repository->find($customerId);
|
||||
|
||||
if (null === $customer) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
|
||||
if (!$team->hasCustomer($customer)) {
|
||||
throw new BadRequestHttpException('Customer is not assigned to the team');
|
||||
}
|
||||
@@ -504,49 +278,17 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Grant the team access to a project
|
||||
*
|
||||
* @SWG\Post(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Adds a new project to a team.",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team that is granted access",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="projectId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The project to grant acecess to (Project ID)",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postProjectAction(int $id, int $projectId, ProjectRepository $repository): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new project to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to grant acecess to (Project ID)', required: true)]
|
||||
#[Rest\Post(path: '/{id}/projects/{projectId}', name: 'post_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Entity('project', expr: 'repository.find(projectId)')]
|
||||
public function postProjectAction(Team $team, Project $project): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException('Team not found');
|
||||
}
|
||||
|
||||
/** @var Project|null $project */
|
||||
$project = $repository->find($projectId);
|
||||
|
||||
if (null === $project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
if ($team->hasProject($project)) {
|
||||
throw new BadRequestHttpException('Team has already access to project');
|
||||
}
|
||||
@@ -563,49 +305,17 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Revokes access for a project from a team
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Removes a project from the team.",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team whose permission will be revoked",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="projectId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The project to remove (Project ID)",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteProjectAction(int $id, int $projectId, ProjectRepository $repository): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a project from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
|
||||
#[OA\Parameter(name: 'projectId', in: 'path', description: 'The project to remove (Project ID)', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}/projects/{projectId}', name: 'delete_team_project', requirements: ['id' => '\d+', 'projectId' => '\d+'])]
|
||||
#[Entity('project', expr: 'repository.find(projectId)')]
|
||||
public function deleteProjectAction(Team $team, Project $project): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException('Team not found');
|
||||
}
|
||||
|
||||
/** @var Project|null $project */
|
||||
$project = $repository->find($projectId);
|
||||
|
||||
if (null === $project) {
|
||||
throw new NotFoundException('Project not found');
|
||||
}
|
||||
|
||||
if (!$team->hasProject($project)) {
|
||||
throw new BadRequestHttpException('Project is not assigned to the team');
|
||||
}
|
||||
@@ -622,49 +332,17 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Grant the team access to an activity
|
||||
*
|
||||
* @SWG\Post(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Adds a new activity to a team.",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team that is granted access",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="activityId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The activity to grant acecess to (Activity ID)",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function postActivityAction(int $id, int $activityId, ActivityRepository $repository): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Post(responses: [new OA\Response(response: 200, description: 'Adds a new activity to a team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team that is granted access', required: true)]
|
||||
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to grant acecess to (Activity ID)', required: true)]
|
||||
#[Rest\Post(path: '/{id}/activities/{activityId}', name: 'post_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Entity('activity', expr: 'repository.find(activityId)')]
|
||||
public function postActivityAction(Team $team, Activity $activity): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException('Team not found');
|
||||
}
|
||||
|
||||
/** @var Activity|null $activity */
|
||||
$activity = $repository->find($activityId);
|
||||
|
||||
if (null === $activity) {
|
||||
throw new NotFoundException('Activity not found');
|
||||
}
|
||||
|
||||
if ($team->hasActivity($activity)) {
|
||||
throw new BadRequestHttpException('Team has already access to activity');
|
||||
}
|
||||
@@ -681,49 +359,17 @@ final class TeamController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Revokes access for an activity from a team
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Removes a activity from the team.",
|
||||
* @SWG\Schema(ref="#/definitions/Team")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The team whose permission will be revoked",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="activityId",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="The activity to remove (Activity ID)",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('edit_team')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function deleteActivityAction(int $id, int $activityId, ActivityRepository $repository): Response
|
||||
#[Security("is_granted('edit_team')")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 200, description: 'Removes a activity from the team.', content: new OA\JsonContent(ref: '#/components/schemas/Team'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'The team whose permission will be revoked', required: true)]
|
||||
#[OA\Parameter(name: 'activityId', in: 'path', description: 'The activity to remove (Activity ID)', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}/activities/{activityId}', name: 'delete_team_activity', requirements: ['id' => '\d+', 'activityId' => '\d+'])]
|
||||
#[Entity('activity', expr: 'repository.find(activityId)')]
|
||||
public function deleteActivityAction(Team $team, Activity $activity): Response
|
||||
{
|
||||
$team = $this->repository->find($id);
|
||||
|
||||
if (null === $team) {
|
||||
throw new NotFoundException('Team not found');
|
||||
}
|
||||
|
||||
/** @var Activity|null $activity */
|
||||
$activity = $repository->find($activityId);
|
||||
|
||||
if (null === $activity) {
|
||||
throw new NotFoundException('Activity not found');
|
||||
}
|
||||
|
||||
if (!$team->hasActivity($activity)) {
|
||||
throw new BadRequestHttpException('Activity is not assigned to the team');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -18,36 +16,35 @@ use App\Event\TimesheetDuplicatePostEvent;
|
||||
use App\Event\TimesheetDuplicatePreEvent;
|
||||
use App\Event\TimesheetMetaDefinitionEvent;
|
||||
use App\Form\API\TimesheetApiEditForm;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetService;
|
||||
use App\Timesheet\TrackingMode\TrackingModeInterface;
|
||||
use App\Utils\SearchTerm;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use HandcraftedInTheAlps\RestRoutingBundle\Controller\Annotations\RouteResource;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @RouteResource("Timesheet")
|
||||
* @SWG\Tag(name="Timesheet")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class TimesheetController extends BaseApiController
|
||||
#[Route(path: '/timesheets')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'Timesheet')]
|
||||
final class TimesheetController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'Timesheet', 'Timesheet_Entity', 'Not_Expanded'];
|
||||
public const GROUPS_ENTITY_FULL = ['Default', 'Entity', 'Timesheet', 'Timesheet_Entity', 'Expanded'];
|
||||
@@ -55,39 +52,13 @@ class TimesheetController extends BaseApiController
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Timesheet', 'Not_Expanded'];
|
||||
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'Timesheet', 'Expanded'];
|
||||
|
||||
/**
|
||||
* @var TimesheetRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
/**
|
||||
* @var TagRepository
|
||||
*/
|
||||
private $tagRepository;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var TimesheetService
|
||||
*/
|
||||
private $service;
|
||||
|
||||
public function __construct(
|
||||
ViewHandlerInterface $viewHandler,
|
||||
TimesheetRepository $repository,
|
||||
TagRepository $tagRepository,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
TimesheetService $service
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private TimesheetRepository $repository,
|
||||
private TagRepository $tagRepository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private TimesheetService $service
|
||||
) {
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
$this->tagRepository = $tagRepository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
protected function getTrackingMode(): TrackingModeInterface
|
||||
@@ -96,122 +67,140 @@ class TimesheetController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of timesheet records
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns a collection of timesheets records. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/TimesheetCollection")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @Rest\QueryParam(name="user", requirements="\d+|all", strict=true, nullable=true, description="User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")
|
||||
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="DEPRECATED: Customer ID to filter timesheets (will be removed with 2.0)")
|
||||
* @Rest\QueryParam(name="customers", requirements="[\d|,]+", strict=true, nullable=true, description="Comma separated list of customer IDs to filter timesheets")
|
||||
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="DEPRECATED: Project ID to filter timesheets (will be removed with 2.0)")
|
||||
* @Rest\QueryParam(name="projects", requirements="[\d|,]+", strict=true, nullable=true, description="Comma separated list of project IDs to filter timesheets")
|
||||
* @Rest\QueryParam(name="activity", requirements="\d+", strict=true, nullable=true, description="DEPRECATED: Activity ID to filter timesheets (will be removed with 2.0)")
|
||||
* @Rest\QueryParam(name="activities", requirements="[\d|,]+", strict=true, nullable=true, description="Comma separated list of activity IDs to filter timesheets")
|
||||
* @Rest\QueryParam(name="page", requirements="\d+", strict=true, nullable=true, description="The page to display, renders a 404 if not found (default: 1)")
|
||||
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries for each page (default: 50)")
|
||||
* @Rest\QueryParam(name="tags", strict=true, nullable=true, description="Comma separated list of tag names")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: DESC)")
|
||||
* @Rest\QueryParam(name="begin", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records after this date will be included (format: HTML5)")
|
||||
* @Rest\QueryParam(name="end", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records before this date will be included (format: HTML5)")
|
||||
* @Rest\QueryParam(name="exported", requirements="0|1", strict=true, nullable=true, description="Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)")
|
||||
* @Rest\QueryParam(name="active", requirements="0|1", strict=true, nullable=true, description="Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)")
|
||||
* @Rest\QueryParam(name="billable", requirements="0|1", strict=true, nullable=true, description="Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)")
|
||||
* @Rest\QueryParam(name="full", requirements="true", strict=true, nullable=true, description="Allows to fetch fully serialized objects including subresources. Allowed values: true (default: false)")
|
||||
* @Rest\QueryParam(name="term", description="Free search term")
|
||||
* @Rest\QueryParam(name="modified_after", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.")
|
||||
*
|
||||
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Returns a collection of timesheet records (which are visible to the user)
|
||||
*/
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
#[Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")]
|
||||
#[OA\Response(response: 200, description: 'Returns a collection of timesheet records. The datetime fields are given in the users local time including the timezone offset (ISO-8601).', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollection')))]
|
||||
#[Rest\Get(path: '', name: 'get_timesheets')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\QueryParam(name: 'user', requirements: '\d+|all', strict: true, nullable: true, description: "User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")]
|
||||
#[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter timesheets')]
|
||||
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
|
||||
#[Rest\QueryParam(name: 'project', requirements: '\d+', strict: true, nullable: true, description: 'Project ID to filter timesheets')]
|
||||
#[Rest\QueryParam(name: 'projects', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of project IDs to filter, e.g.: projects[]=1&projects[]=2')]
|
||||
#[Rest\QueryParam(name: 'activity', requirements: '\d+', strict: true, nullable: true, description: 'Activity ID to filter timesheets')]
|
||||
#[Rest\QueryParam(name: 'activities', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of activity IDs to filter, e.g.: activities[]=1&activities[]=2')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
|
||||
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5)')]
|
||||
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
|
||||
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
|
||||
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
|
||||
#[Rest\QueryParam(name: 'full', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources. Allowed values: true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
|
||||
{
|
||||
$query = new TimesheetQuery(false);
|
||||
$query->setUser($this->getUser());
|
||||
|
||||
if ($this->isGranted('view_other_timesheet') && null !== ($user = $paramFetcher->get('user'))) {
|
||||
if ('all' === $user) {
|
||||
$user = null;
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$userId = $paramFetcher->get('user');
|
||||
if (\is_string($userId) && $userId !== '') {
|
||||
if ('all' === $userId) {
|
||||
$query->setUser(null);
|
||||
} else {
|
||||
$user = $userRepository->find($userId);
|
||||
if ($user === null) {
|
||||
throw $this->createNotFoundException('Unknown user: ' . $userId);
|
||||
}
|
||||
$query->setUser($user);
|
||||
}
|
||||
}
|
||||
$query->setUser($user);
|
||||
}
|
||||
|
||||
if (!empty($customers = $paramFetcher->get('customers'))) {
|
||||
if (!\is_array($customers)) {
|
||||
$customers = explode(',', $customers);
|
||||
}
|
||||
$query->setCustomers($customers);
|
||||
/** @var array<int> $customers */
|
||||
$customers = $paramFetcher->get('customers');
|
||||
$customer = $paramFetcher->get('customer');
|
||||
if (\is_string($customer) && $customer !== '') {
|
||||
$customers[] = $customer;
|
||||
}
|
||||
|
||||
if (!empty($customer = $paramFetcher->get('customer'))) {
|
||||
foreach (array_unique($customers) as $customerId) {
|
||||
$customer = $customerRepository->find($customerId);
|
||||
if ($customer === null) {
|
||||
throw $this->createNotFoundException('Unknown customer: ' . $customerId);
|
||||
}
|
||||
$query->addCustomer($customer);
|
||||
}
|
||||
|
||||
if (!empty($projects = $paramFetcher->get('projects'))) {
|
||||
if (!\is_array($projects)) {
|
||||
$projects = explode(',', $projects);
|
||||
}
|
||||
$query->setProjects($projects);
|
||||
/** @var array<int> $projects */
|
||||
$projects = $paramFetcher->get('projects');
|
||||
$project = $paramFetcher->get('project');
|
||||
if (\is_string($project) && $project !== '') {
|
||||
$projects[] = $project;
|
||||
}
|
||||
|
||||
if (!empty($project = $paramFetcher->get('project'))) {
|
||||
foreach (array_unique($projects) as $projectId) {
|
||||
$project = $projectRepository->find($projectId);
|
||||
if ($project === null) {
|
||||
throw $this->createNotFoundException('Unknown project: ' . $project);
|
||||
}
|
||||
$query->addProject($project);
|
||||
}
|
||||
|
||||
if (!empty($activities = $paramFetcher->get('activities'))) {
|
||||
if (!\is_array($activities)) {
|
||||
$activities = explode(',', $activities);
|
||||
}
|
||||
$query->setActivities($activities);
|
||||
/** @var array<int> $activities */
|
||||
$activities = $paramFetcher->get('activities');
|
||||
$activity = $paramFetcher->get('activity');
|
||||
if (\is_string($activity) && $activity !== '') {
|
||||
$activities[] = $activity;
|
||||
}
|
||||
|
||||
if (!empty($activity = $paramFetcher->get('activity'))) {
|
||||
foreach (array_unique($activities) as $activityId) {
|
||||
$activity = $activityRepository->find($activityId);
|
||||
if ($activity === null) {
|
||||
throw $this->createNotFoundException('Unknown activity: ' . $activity);
|
||||
}
|
||||
$query->addActivity($activity);
|
||||
}
|
||||
|
||||
if (null !== ($page = $paramFetcher->get('page'))) {
|
||||
$query->setPage($page);
|
||||
$page = $paramFetcher->get('page');
|
||||
if (\is_string($page) && $page !== '') {
|
||||
$query->setPage((int) $page);
|
||||
}
|
||||
|
||||
if (null !== ($size = $paramFetcher->get('size'))) {
|
||||
$query->setPageSize($size);
|
||||
$size = $paramFetcher->get('size');
|
||||
if (\is_string($size) && $size !== '') {
|
||||
$query->setPageSize((int) $size);
|
||||
}
|
||||
|
||||
if (null !== ($tags = $paramFetcher->get('tags'))) {
|
||||
$ids = $this->tagRepository->findIdsByTagNameList($tags);
|
||||
if ($ids !== null && \count($ids) > 0) {
|
||||
$query->setTags(new ArrayCollection($ids));
|
||||
$tags = $paramFetcher->get('tags');
|
||||
if (\is_array($tags) && \count($tags) > 0) {
|
||||
$tags = $this->tagRepository->findTagsByName($tags);
|
||||
foreach ($tags as $tag) {
|
||||
$query->addTag($tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== ($order = $paramFetcher->get('order'))) {
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
$query->setOrder($order);
|
||||
}
|
||||
|
||||
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
|
||||
$orderBy = $paramFetcher->get('orderBy');
|
||||
if (\is_string($orderBy) && $orderBy !== '') {
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
|
||||
$factory = $this->getDateTimeFactory();
|
||||
|
||||
if (null !== ($begin = $paramFetcher->get('begin'))) {
|
||||
$begin = $paramFetcher->get('begin');
|
||||
if (\is_string($begin) && $begin !== '') {
|
||||
$query->setBegin($factory->createDateTime($begin));
|
||||
}
|
||||
|
||||
if (null !== ($end = $paramFetcher->get('end'))) {
|
||||
$end = $paramFetcher->get('end');
|
||||
if (\is_string($end) && $end !== '') {
|
||||
$query->setEnd($factory->createDateTime($end));
|
||||
}
|
||||
|
||||
if (null !== ($active = $paramFetcher->get('active'))) {
|
||||
$active = $paramFetcher->get('active');
|
||||
if (\is_string($active) && $active !== '') {
|
||||
$active = (int) $active;
|
||||
if ($active === 1) {
|
||||
$query->setState(TimesheetQuery::STATE_RUNNING);
|
||||
@@ -220,7 +209,8 @@ class TimesheetController extends BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== ($billable = $paramFetcher->get('billable'))) {
|
||||
$billable = $paramFetcher->get('billable');
|
||||
if (\is_string($billable) && $billable !== '') {
|
||||
$billable = (int) $billable;
|
||||
if ($billable === 1) {
|
||||
$query->setBillable(true);
|
||||
@@ -229,7 +219,8 @@ class TimesheetController extends BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== ($exported = $paramFetcher->get('exported'))) {
|
||||
$exported = $paramFetcher->get('exported');
|
||||
if (\is_string($exported) && $exported !== '') {
|
||||
$exported = (int) $exported;
|
||||
if ($exported === 1) {
|
||||
$query->setExported(TimesheetQuery::STATE_EXPORTED);
|
||||
@@ -238,7 +229,8 @@ class TimesheetController extends BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$term = $paramFetcher->get('term');
|
||||
if (\is_string($term) && $term !== '') {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
@@ -246,12 +238,13 @@ class TimesheetController extends BaseApiController
|
||||
$query->setModifiedAfter($factory->createDateTime($modifiedAfter));
|
||||
}
|
||||
|
||||
/** @var Pagerfanta $data */
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
$data = (array) $data->getCurrentPageResults();
|
||||
$results = (array) $data->getCurrentPageResults();
|
||||
|
||||
$view = new View($data, 200);
|
||||
if ('true' === $paramFetcher->get('full')) {
|
||||
$view = new View($results, 200);
|
||||
$this->addPagination($view, $data);
|
||||
|
||||
if (null !== $paramFetcher->get('full')) {
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL);
|
||||
} else {
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
@@ -262,28 +255,15 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns one timesheet record
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns one timesheet record. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Timesheet record ID to fetch",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*
|
||||
* @Security("is_granted('view', id)")
|
||||
*/
|
||||
public function getAction(Timesheet $id): Response
|
||||
#[Security("is_granted('view', timesheet)")]
|
||||
#[OA\Response(response: 200, description: 'Returns one timesheet record. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to fetch', required: true)]
|
||||
#[Rest\Get(path: '/{id}', name: 'get_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$timesheet = $id; // cannot be changed due to BC reasons, routes use 'id'
|
||||
$view = new View($timesheet, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
@@ -292,29 +272,14 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Creates a new timesheet record
|
||||
*
|
||||
* @SWG\Post(
|
||||
* description="Creates a new timesheet record for the current user and returns it afterwards.",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created timesheet",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEntity"),
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEditForm")
|
||||
* )
|
||||
*
|
||||
* @Rest\QueryParam(name="full", requirements="true", strict=true, nullable=true, description="Allows to fetch fully serialized objects including subresources (TimesheetEntityExpanded). Allowed values: true (default: false)")
|
||||
*
|
||||
* @Security("is_granted('create_own_timesheet')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[Security("is_granted('create_own_timesheet')")]
|
||||
#[OA\Post(description: 'Creates a new timesheet record for the current user and returns it afterwards.', responses: [new OA\Response(response: 200, description: 'Returns the new created timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
|
||||
#[Rest\Post(path: '', name: 'post_timesheet')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\QueryParam(name: 'full', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources (TimesheetExpanded). Allowed values: true (default: false)')]
|
||||
public function postAction(Request $request, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
@@ -339,23 +304,19 @@ class TimesheetController extends BaseApiController
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$this->service->saveNewTimesheet($timesheet);
|
||||
} catch (\Exception $ex) {
|
||||
if ($ex->getMessage() === 'timesheet.start.exceeded_limit') {
|
||||
throw new BadRequestHttpException('Too many active timesheets');
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
|
||||
if (null !== $paramFetcher->get('full')) {
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY_FULL);
|
||||
} else {
|
||||
throw $ex;
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
}
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
} catch (ValidationFailedException $ex) {
|
||||
$form->addError(new FormError($ex->getMessage()));
|
||||
}
|
||||
|
||||
$view = new View($timesheet, 200);
|
||||
|
||||
if ('true' === $paramFetcher->get('full')) {
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY_FULL);
|
||||
} else {
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
}
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
$view = new View($form);
|
||||
@@ -366,37 +327,16 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Update an existing timesheet record
|
||||
*
|
||||
* @SWG\Patch(
|
||||
* description="Update an existing timesheet record, you can pass all or just a subset of the attributes.",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the updated timesheet",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Timesheet record ID to update",
|
||||
* required=true,
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEditForm")
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*
|
||||
* @Security("is_granted('edit', id)")
|
||||
*/
|
||||
public function patchAction(Request $request, Timesheet $id): Response
|
||||
#[Security("is_granted('edit', timesheet)")]
|
||||
#[OA\Patch(description: 'Update an existing timesheet record, you can pass all or just a subset of the attributes.', responses: [new OA\Response(response: 200, description: 'Returns the updated timesheet', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to update', required: true)]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEditForm'))]
|
||||
#[Rest\Patch(path: '/{id}', name: 'patch_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function patchAction(Request $request, Timesheet $timesheet): Response
|
||||
{
|
||||
$timesheet = $id;
|
||||
$event = new TimesheetMetaDefinitionEvent($timesheet);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
@@ -432,29 +372,16 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Delete an existing timesheet record
|
||||
*
|
||||
* @SWG\Delete(
|
||||
* @SWG\Response(
|
||||
* response=204,
|
||||
* description="Delete one timesheet record"
|
||||
* ),
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Timesheet record ID to delete",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*
|
||||
* @Security("is_granted('delete', id)")
|
||||
*/
|
||||
public function deleteAction(Timesheet $id): Response
|
||||
#[Security("is_granted('delete', timesheet)")]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one timesheet record')])]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to delete', required: true)]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\Delete(path: '/{id}', name: 'delete_timesheet', requirements: ['id' => '\d+'])]
|
||||
public function deleteAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$this->service->deleteTimesheet($id);
|
||||
$this->service->deleteTimesheet($timesheet);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
@@ -463,50 +390,32 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns the collection of recent user activities
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/TimesheetCollectionExpanded")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @Rest\QueryParam(name="user", requirements="\d+|all", strict=true, nullable=true, description="User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")
|
||||
* @Rest\QueryParam(name="begin", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records after this date will be included. Default: today - 1 year (format: HTML5)")
|
||||
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries (default: 10)")
|
||||
*
|
||||
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[Security("is_granted('view_own_timesheet')")]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[Rest\Get(path: '/recent', name: 'recent_timesheet')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
|
||||
public function recentAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$factory = $this->getDateTimeFactory();
|
||||
$begin = $factory->createDateTime('-1 year');
|
||||
$begin = null;
|
||||
$limit = 10;
|
||||
|
||||
if ($this->isGranted('view_other_timesheet') && null !== ($reqUser = $paramFetcher->get('user'))) {
|
||||
if ('all' === $reqUser) {
|
||||
$reqUser = null;
|
||||
}
|
||||
$user = $reqUser;
|
||||
}
|
||||
|
||||
if (null !== ($reqLimit = $paramFetcher->get('size'))) {
|
||||
$reqLimit = $paramFetcher->get('size');
|
||||
if (\is_string($reqLimit) && $reqLimit !== '') {
|
||||
$limit = (int) $reqLimit;
|
||||
}
|
||||
|
||||
if (null !== ($reqBegin = $paramFetcher->get('begin'))) {
|
||||
$begin = $factory->createDateTime($reqBegin);
|
||||
$begin = $this->getDateTimeFactory($user)->createDateTime($reqBegin);
|
||||
}
|
||||
|
||||
$data = $this->repository->getRecentActivities($user, $begin, $limit);
|
||||
|
||||
$recentActivity = new RecentActivityEvent($this->getUser(), $data);
|
||||
$recentActivity = new RecentActivityEvent($user, $data);
|
||||
$this->dispatcher->dispatch($recentActivity);
|
||||
|
||||
$view = new View($recentActivity->getRecentActivities(), 200);
|
||||
@@ -517,21 +426,12 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Returns the collection of active timesheet records
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the collection of active timesheet records for the current user",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/TimesheetCollectionExpanded")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('view_own_timesheet')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[Security("is_granted('view_own_timesheet')")]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of active timesheet records for the current user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[Rest\Get(path: '/active', name: 'active_timesheet')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function activeAction(): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
@@ -547,30 +447,18 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Stops an active timesheet record
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Stops an active timesheet record and returns it afterwards.",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Timesheet record ID to stop",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*
|
||||
* @Security("is_granted('stop', id)")
|
||||
*/
|
||||
public function stopAction(Timesheet $id): Response
|
||||
#[Security("is_granted('stop', timesheet)")]
|
||||
#[OA\Response(response: 200, description: 'Stops an active timesheet record and returns it afterwards.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to stop', required: true)]
|
||||
#[Rest\Patch(path: '/{id}/stop', name: 'stop_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function stopAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$this->service->stopTimesheet($id);
|
||||
$this->service->stopTimesheet($timesheet);
|
||||
|
||||
$view = new View($id, 200);
|
||||
$view = new View($timesheet, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
@@ -578,31 +466,17 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Restarts a previously stopped timesheet record for the current user
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Restarts a timesheet record for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Timesheet record ID to restart",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @Rest\RequestParam(name="copy", requirements="all|tags|rates|meta|description", strict=true, nullable=true, description="Whether data should be copied to the new entry. Allowed values: all, tags (deprecated), rates (deprecated), description (deprecated), meta (deprecated) (default: nothing is copied)")
|
||||
* @Rest\RequestParam(name="begin", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Changes the restart date to the given one (default: now)")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*
|
||||
* @Security("is_granted('start', id)")
|
||||
*/
|
||||
public function restartAction(Timesheet $id, ParamFetcherInterface $paramFetcher): Response
|
||||
#[Security("is_granted('start', timesheet)")]
|
||||
#[OA\Response(response: 200, description: 'Restarts a timesheet record for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to restart', required: true)]
|
||||
#[Rest\Patch(path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\RequestParam(name: 'copy', requirements: 'all', strict: true, nullable: true, description: 'Whether data should be copied to the new entry. Allowed values: all (default: nothing is copied)')]
|
||||
#[Rest\RequestParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Changes the restart date to the given one (default: now)')]
|
||||
public function restartAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$timesheet = $id;
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
@@ -621,11 +495,8 @@ class TimesheetController extends BaseApiController
|
||||
->setProject($timesheet->getProject())
|
||||
;
|
||||
|
||||
if (null !== ($copy = $paramFetcher->get('copy'))) {
|
||||
if ($copy !== 'all') {
|
||||
@trigger_error('Setting the "copy" attribute in "restart timesheet" API to something else then "all" is deprecated', E_USER_DEPRECATED);
|
||||
}
|
||||
|
||||
$copy = $paramFetcher->get('copy');
|
||||
if ($copy === 'all') {
|
||||
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
|
||||
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
|
||||
$copyTimesheet->setDescription($timesheet->getDescription());
|
||||
@@ -657,28 +528,15 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Duplicates an existing timesheet record
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Duplicates a timesheet record, resetting the export state only.",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Timesheet record ID to duplicate",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*
|
||||
* @Security("is_granted('duplicate', id)")
|
||||
*/
|
||||
public function duplicateAction(Timesheet $id): Response
|
||||
#[Security("is_granted('duplicate', timesheet)")]
|
||||
#[OA\Response(response: 200, description: 'Duplicates a timesheet record, resetting the export state only.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to duplicate', required: true)]
|
||||
#[Rest\Patch(path: '/{id}/duplicate', name: 'duplicate_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function duplicateAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$timesheet = $id;
|
||||
$copyTimesheet = clone $timesheet;
|
||||
|
||||
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
|
||||
@@ -693,31 +551,17 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Switch the export state of a timesheet record to (un-)lock it
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Switches the exported state on the record and therefor locks / unlocks it for further updates. Needs edit_export_*_timesheet permission.",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Timesheet record ID to switch export state",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*
|
||||
* @Security("is_granted('edit_export', id)")
|
||||
*/
|
||||
public function exportAction(Timesheet $id): Response
|
||||
#[Security("is_granted('edit_export', timesheet)")]
|
||||
#[OA\Response(response: 200, description: 'Switches the exported state on the record and therefor locks / unlocks it for further updates. Needs edit_export_*_timesheet permission.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to switch export state', required: true)]
|
||||
#[Rest\Patch(path: '/{id}/export', name: 'export_timesheet', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function exportAction(Timesheet $timesheet): Response
|
||||
{
|
||||
$timesheet = $id;
|
||||
|
||||
if ($timesheet->isExported() && !$this->isGranted('edit_exported_timesheet')) {
|
||||
throw new AccessDeniedHttpException('User cannot edit an exported timesheet');
|
||||
throw $this->createAccessDeniedException('User cannot edit an exported timesheet');
|
||||
}
|
||||
|
||||
$timesheet->setExported(!$timesheet->isExported());
|
||||
@@ -732,41 +576,27 @@ class TimesheetController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing timesheet.
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.",
|
||||
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="Timesheet record ID to set the meta-field value for",
|
||||
* required=true,
|
||||
* )
|
||||
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
|
||||
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*
|
||||
* @Security("is_granted('edit', id)")
|
||||
*/
|
||||
public function metaAction(Timesheet $id, ParamFetcherInterface $paramFetcher): Response
|
||||
#[Security("is_granted('edit', timesheet)")]
|
||||
#[OA\Response(response: 200, description: 'Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to set the meta-field value for', required: true)]
|
||||
#[Rest\Patch(path: '/{id}/meta', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\RequestParam(name: 'name', strict: true, nullable: false, description: 'The meta-field name')]
|
||||
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
|
||||
public function metaAction(Timesheet $timesheet, ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$timesheet = $id;
|
||||
$event = new TimesheetMetaDefinitionEvent($timesheet);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$name = $paramFetcher->get('name');
|
||||
$value = $paramFetcher->get('value');
|
||||
|
||||
if (null === ($meta = $timesheet->getMetaField($name))) {
|
||||
throw new \InvalidArgumentException('Unknown meta-field requested');
|
||||
if (!\is_string($name) || null === ($meta = $timesheet->getMetaField($name))) {
|
||||
throw $this->createNotFoundException('Unknown meta-field requested');
|
||||
}
|
||||
|
||||
$meta->setValue($value);
|
||||
$meta->setValue($paramFetcher->get('value'));
|
||||
|
||||
$this->service->updateTimesheet($timesheet);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
@@ -23,93 +21,67 @@ use FOS\RestBundle\Controller\Annotations as Rest;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use HandcraftedInTheAlps\RestRoutingBundle\Controller\Annotations\RouteResource;
|
||||
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @RouteResource("User")
|
||||
* @SWG\Tag(name="User")
|
||||
*
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
#[Route(path: '/users')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
#[OA\Tag(name: 'User')]
|
||||
final class UserController extends BaseApiController
|
||||
{
|
||||
public const GROUPS_ENTITY = ['Default', 'Entity', 'User', 'User_Entity'];
|
||||
public const GROUPS_FORM = ['Default', 'Entity', 'User', 'User_Entity'];
|
||||
public const GROUPS_COLLECTION = ['Default', 'Collection', 'User'];
|
||||
|
||||
/**
|
||||
* @var UserRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var ViewHandlerInterface
|
||||
*/
|
||||
private $viewHandler;
|
||||
/**
|
||||
* @var UserPasswordEncoderInterface
|
||||
*/
|
||||
private $encoder;
|
||||
/**
|
||||
* @var SystemConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
|
||||
public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository, UserPasswordEncoderInterface $encoder, SystemConfiguration $config)
|
||||
{
|
||||
$this->viewHandler = $viewHandler;
|
||||
$this->repository = $repository;
|
||||
$this->encoder = $encoder;
|
||||
$this->configuration = $config;
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private UserRepository $repository,
|
||||
private UserPasswordHasherInterface $passwordHasher,
|
||||
private SystemConfiguration $configuration
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection of all registered users
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the collection of all registered users. Required permission: view_user",
|
||||
* @SWG\Schema(
|
||||
* type="array",
|
||||
* @SWG\Items(ref="#/definitions/UserCollection")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @Rest\QueryParam(name="visible", requirements="1|2|3", strict=true, nullable=true, description="Visibility status to filter users. Allowed values: 1=visible, 2=hidden, 3=all (default: 1)")
|
||||
* @Rest\QueryParam(name="orderBy", requirements="id|username|alias|email", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)")
|
||||
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
|
||||
* @Rest\QueryParam(name="term", description="Free search term")
|
||||
*
|
||||
* @Security("is_granted('view_user')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
* Returns the collection of users (which are visible to the user)
|
||||
*/
|
||||
#[Security("is_granted('view_user')")]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of users. Required permission: view_user', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/UserCollection')))]
|
||||
#[Rest\Get(path: '', name: 'get_users')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
if (null !== ($visible = $paramFetcher->get('visible'))) {
|
||||
$query->setVisibility($visible);
|
||||
$visible = $paramFetcher->get('visible');
|
||||
if (\is_string($visible) && $visible !== '') {
|
||||
$query->setVisibility((int) $visible);
|
||||
}
|
||||
|
||||
if (null !== ($order = $paramFetcher->get('order'))) {
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
$query->setOrder($order);
|
||||
}
|
||||
|
||||
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
|
||||
$orderBy = $paramFetcher->get('orderBy');
|
||||
if (\is_string($orderBy) && $orderBy !== '') {
|
||||
$query->setOrderBy($orderBy);
|
||||
}
|
||||
|
||||
if (!empty($term = $paramFetcher->get('term'))) {
|
||||
$term = $paramFetcher->get('term');
|
||||
if (\is_string($term) && $term !== '') {
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
@@ -122,40 +94,20 @@ final class UserController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Return one user entity
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Return one user entity.",
|
||||
* @SWG\Schema(ref="#/definitions/UserEntity"),
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="User ID to fetch",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function getAction(int $id, EventDispatcherInterface $dispatcher): Response
|
||||
#[Security("is_granted('view', profile)")]
|
||||
#[OA\Response(response: 200, description: 'Return one user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to fetch', required: true)]
|
||||
#[Rest\Get(path: '/{id}', name: 'get_user', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function getAction(User $profile, EventDispatcherInterface $dispatcher): Response
|
||||
{
|
||||
$user = $this->repository->find($id);
|
||||
|
||||
if (null === $user) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('view', $user)) {
|
||||
throw new AccessDeniedHttpException('You are not allowed to view this profile');
|
||||
}
|
||||
|
||||
// we need to prepare the user preferences, which is done via an EventSubscriber
|
||||
$event = new PrepareUserEvent($user);
|
||||
$event = new PrepareUserEvent($profile);
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$view = new View($user, 200);
|
||||
$view = new View($profile, 200);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
@@ -163,18 +115,11 @@ final class UserController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Return the current user entity
|
||||
*
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Return the current user entity.",
|
||||
* @SWG\Schema(ref="#/definitions/UserEntity"),
|
||||
* )
|
||||
*
|
||||
* @Rest\Get(path="/users/me")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Return the current user entity.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
|
||||
#[Rest\Get(path: '/me', name: 'me_user')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function meAction(): Response
|
||||
{
|
||||
$view = new View($this->getUser(), 200);
|
||||
@@ -185,27 +130,13 @@ final class UserController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Creates a new user
|
||||
*
|
||||
* @SWG\Post(
|
||||
* description="Creates a new user and returns it afterwards",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the new created user",
|
||||
* @SWG\Schema(ref="#/definitions/UserEntity",),
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/UserCreateForm")
|
||||
* )
|
||||
*
|
||||
* @Security("is_granted('create_user')")
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
#[Security("is_granted('create_user')")]
|
||||
#[OA\Post(description: 'Creates a new user and returns it afterwards')]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserCreateForm'))]
|
||||
#[Rest\Post(path: '', name: 'post_user')]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function postAction(Request $request): Response
|
||||
{
|
||||
$user = new User();
|
||||
@@ -223,11 +154,15 @@ final class UserController extends BaseApiController
|
||||
$form->submit($request->request->all());
|
||||
|
||||
if ($form->isValid()) {
|
||||
$password = $this->encoder->encodePassword($user, $user->getPlainPassword());
|
||||
$plainPassword = $user->getPlainPassword();
|
||||
if ($plainPassword === null) {
|
||||
throw new BadRequestHttpException('Password cannot be empty');
|
||||
}
|
||||
$password = $this->passwordHasher->hashPassword($user, $plainPassword);
|
||||
$user->setPassword($password);
|
||||
|
||||
if ($user->getPlainApiToken() !== null) {
|
||||
$user->setApiToken($this->encoder->encodePassword($user, $user->getPlainApiToken()));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, $user->getPlainApiToken()));
|
||||
}
|
||||
|
||||
$this->repository->saveUser($user);
|
||||
@@ -248,51 +183,23 @@ final class UserController extends BaseApiController
|
||||
|
||||
/**
|
||||
* Update an existing user
|
||||
*
|
||||
* @SWG\Patch(
|
||||
* description="Update an existing user, you can pass all or just a subset of all attributes (passing roles will replace all existing ones)",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the updated user",
|
||||
* @SWG\Schema(ref="#/definitions/UserEntity")
|
||||
* )
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="body",
|
||||
* in="body",
|
||||
* required=true,
|
||||
* @SWG\Schema(ref="#/definitions/UserEditForm")
|
||||
* )
|
||||
* @SWG\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* type="integer",
|
||||
* description="User ID to update",
|
||||
* required=true,
|
||||
* )
|
||||
*
|
||||
* @ApiSecurity(name="apiUser")
|
||||
* @ApiSecurity(name="apiToken")
|
||||
*/
|
||||
public function patchAction(Request $request, int $id): Response
|
||||
#[Security("is_granted('edit', profile)")]
|
||||
#[OA\Patch(description: 'Update an existing user, you can pass all or just a subset of all attributes (passing roles will replace all existing ones)', responses: [new OA\Response(response: 200, description: 'Returns the updated user', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))])]
|
||||
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/UserEditForm'))]
|
||||
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to update', required: true)]
|
||||
#[Rest\Patch(path: '/{id}', name: 'patch_user', requirements: ['id' => '\d+'])]
|
||||
#[ApiSecurity(name: 'apiUser')]
|
||||
#[ApiSecurity(name: 'apiToken')]
|
||||
public function patchAction(Request $request, User $profile): Response
|
||||
{
|
||||
$user = $this->repository->getUserById($id);
|
||||
|
||||
if (null === $user) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (!$this->isGranted('edit', $user)) {
|
||||
throw new AccessDeniedHttpException('Not allowed to edit user');
|
||||
}
|
||||
|
||||
$form = $this->createForm(UserApiEditForm::class, $user, [
|
||||
'include_roles' => $this->isGranted('roles', $user),
|
||||
'include_active_flag' => ($user->getId() !== $this->getUser()->getId()),
|
||||
'include_preferences' => $this->isGranted('preferences', $user),
|
||||
$form = $this->createForm(UserApiEditForm::class, $profile, [
|
||||
'include_roles' => $this->isGranted('roles', $profile),
|
||||
'include_active_flag' => ($profile->getId() !== $this->getUser()->getId()),
|
||||
'include_preferences' => $this->isGranted('preferences', $profile),
|
||||
]);
|
||||
|
||||
$form->setData($user);
|
||||
$form->setData($profile);
|
||||
$form->submit($request->request->all(), false);
|
||||
|
||||
if (false === $form->isValid()) {
|
||||
@@ -302,9 +209,9 @@ final class UserController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
$this->repository->saveUser($user);
|
||||
$this->repository->saveUser($profile);
|
||||
|
||||
$view = new View($user, Response::HTTP_OK);
|
||||
$view = new View($profile, Response::HTTP_OK);
|
||||
$view->getContext()->setGroups(self::GROUPS_ENTITY);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
|
||||
@@ -28,24 +28,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
*/
|
||||
class ActivityService
|
||||
{
|
||||
/**
|
||||
* @var ActivityRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var ValidatorInterface
|
||||
*/
|
||||
private $validator;
|
||||
|
||||
public function __construct(ActivityRepository $activityRepository, EventDispatcherInterface $dispatcher, ValidatorInterface $validator)
|
||||
public function __construct(private ActivityRepository $repository, private EventDispatcherInterface $dispatcher, private ValidatorInterface $validator)
|
||||
{
|
||||
$this->repository = $activityRepository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->validator = $validator;
|
||||
}
|
||||
|
||||
public function createNewActivity(?Project $project = null): Activity
|
||||
@@ -104,6 +88,6 @@ class ActivityService
|
||||
|
||||
public function findActivityByName(string $name, ?Project $project = null): ?Activity
|
||||
{
|
||||
return $this->repository->findOneBy(['project' => $project->getId(), 'name' => $name]);
|
||||
return $this->repository->findOneBy(['project' => $project?->getId(), 'name' => $name]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,8 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
*/
|
||||
class ActivityStatisticService
|
||||
{
|
||||
private $timesheetRepository;
|
||||
private $dispatcher;
|
||||
|
||||
public function __construct(TimesheetRepository $timesheetRepository, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(private TimesheetRepository $timesheetRepository, private EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
$this->timesheetRepository = $timesheetRepository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,30 +16,13 @@ use App\Event\CalendarDragAndDropSourceEvent;
|
||||
use App\Event\CalendarGoogleSourceEvent;
|
||||
use App\Event\RecentActivityEvent;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\Color;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class CalendarService
|
||||
{
|
||||
/**
|
||||
* @var SystemConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
/**
|
||||
* @var TimesheetRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration, TimesheetRepository $repository, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(private SystemConfiguration $configuration, private TimesheetRepository $repository, private EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
$this->repository = $repository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,11 +39,7 @@ final class CalendarService
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $this->repository->getRecentActivities(
|
||||
$user,
|
||||
DateTimeFactory::createByUser($user)->createDateTime('-1 year'),
|
||||
$maxAmount
|
||||
);
|
||||
$data = $this->repository->getRecentActivities($user, null, $maxAmount);
|
||||
|
||||
$recentActivity = new RecentActivityEvent($user, $data);
|
||||
$this->dispatcher->dispatch($recentActivity);
|
||||
@@ -108,7 +87,6 @@ final class CalendarService
|
||||
'dayLimit' => $this->configuration->getCalendarDayLimit(),
|
||||
'showWeekNumbers' => $this->configuration->isCalendarShowWeekNumbers(),
|
||||
'showWeekends' => $this->configuration->isCalendarShowWeekends(),
|
||||
'businessDays' => $this->configuration->getCalendarBusinessDays(),
|
||||
'businessTimeBegin' => $this->configuration->getCalendarBusinessTimeBegin(),
|
||||
'businessTimeEnd' => $this->configuration->getCalendarBusinessTimeEnd(),
|
||||
'slotDuration' => $this->configuration->getCalendarSlotDuration(),
|
||||
|
||||
@@ -11,23 +11,12 @@ namespace App\Calendar;
|
||||
|
||||
final class Google
|
||||
{
|
||||
/**
|
||||
* @var GoogleSource[]
|
||||
*/
|
||||
private $sources;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $apiKey;
|
||||
|
||||
/**
|
||||
* @param string $apiKey
|
||||
* @param GoogleSource[] $sources
|
||||
*/
|
||||
public function __construct(string $apiKey, array $sources = [])
|
||||
public function __construct(private string $apiKey, private array $sources = [])
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
$this->sources = $sources;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,24 +11,8 @@ namespace App\Calendar;
|
||||
|
||||
final class GoogleSource
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $id;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $uri;
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
private $color;
|
||||
|
||||
public function __construct(string $id, string $uri, ?string $color = null)
|
||||
public function __construct(private string $id, private string $uri, private ?string $color = null)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->uri = $uri;
|
||||
$this->color = $color;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
|
||||
@@ -9,19 +9,13 @@
|
||||
|
||||
namespace App\Calendar;
|
||||
|
||||
class RecentActivitiesSource implements DragAndDropSource
|
||||
final class RecentActivitiesSource implements DragAndDropSource
|
||||
{
|
||||
/**
|
||||
* @var DragAndDropEntry[]
|
||||
*/
|
||||
private $entries;
|
||||
|
||||
/**
|
||||
* @param DragAndDropEntry[] $entries
|
||||
*/
|
||||
public function __construct(array $entries)
|
||||
public function __construct(private array $entries)
|
||||
{
|
||||
$this->entries = $entries;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
|
||||
@@ -18,36 +18,25 @@ use App\Entity\Timesheet;
|
||||
*/
|
||||
final class TimesheetEntry implements DragAndDropEntry
|
||||
{
|
||||
/**
|
||||
* @var Timesheet
|
||||
*/
|
||||
private $timesheet;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $color;
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $copy;
|
||||
|
||||
public function __construct(Timesheet $timesheet, string $color, bool $copy = false)
|
||||
public function __construct(private Timesheet $timesheet, private string $color, private bool $copy = false)
|
||||
{
|
||||
$this->timesheet = $timesheet;
|
||||
$this->color = $color;
|
||||
$this->copy = $copy;
|
||||
}
|
||||
|
||||
public function getData(): array
|
||||
{
|
||||
$data = [
|
||||
'activity' => $this->timesheet->getActivity() !== null ? $this->timesheet->getActivity()->getId() : null,
|
||||
'project' => $this->timesheet->getProject() !== null ? $this->timesheet->getProject()->getId() : null,
|
||||
'activity' => $this->timesheet->getActivity()?->getId(),
|
||||
'project' => $this->timesheet->getProject()?->getId(),
|
||||
];
|
||||
|
||||
if ($this->copy) {
|
||||
$tags = null;
|
||||
if (!empty($this->timesheet->getTagsAsArray())) {
|
||||
$tags = implode(',', $this->timesheet->getTagsAsArray());
|
||||
}
|
||||
|
||||
$data['description'] = $this->timesheet->getDescription();
|
||||
$data['tags'] = implode(',', $this->timesheet->getTagsAsArray());
|
||||
$data['tags'] = $tags;
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@@ -94,10 +94,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
return $parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName($this->getInstallerCommandName())
|
||||
@@ -106,12 +103,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -132,7 +124,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
sprintf('Failed to install database for bundle %s. %s', $bundleName, $ex->getMessage())
|
||||
);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->hasAssets()) {
|
||||
@@ -143,7 +135,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
sprintf('Failed to install assets for bundle %s. %s', $bundleName, $ex->getMessage())
|
||||
);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +145,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
sprintf('Congratulations! Plugin was successful installed: %s', $bundleName)
|
||||
);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function installAssets(SymfonyStyle $io, OutputInterface $output)
|
||||
|
||||
@@ -26,17 +26,19 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*/
|
||||
abstract class AbstractResetCommand extends Command
|
||||
{
|
||||
public function __construct(private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:reset:' . $this->getEnvName())
|
||||
->setAliases(['kimai:reset-' . $this->getEnvName()])
|
||||
->setDescription('Resets the "' . $this->getEnvName() . '" environment')
|
||||
->setHelp(
|
||||
<<<EOT
|
||||
This command will drop and re-create the database and its schemas, load data and clear the cache.
|
||||
Use the <info>-n</info> switch to skip the question.
|
||||
EOT
|
||||
This command will drop and re-create the database and its schemas, load data and clear the cache.
|
||||
Use the <info>-n</info> switch to skip the question.
|
||||
EOT
|
||||
)
|
||||
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache flushing')
|
||||
;
|
||||
@@ -44,16 +46,7 @@ EOT
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->getEnv() !== 'prod';
|
||||
}
|
||||
|
||||
private function getEnv(): string
|
||||
{
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
$kernel = $application->getKernel();
|
||||
|
||||
return $kernel->getEnvironment();
|
||||
return $this->kernelEnvironment !== 'prod';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
@@ -68,12 +61,12 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create database: ' . $ex->getMessage());
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->askConfirmation($input, $output, 'Do you want to drop and re-create the schema y/N ?')) {
|
||||
if (($result = $this->dropSchema($io, $output)) !== 0) {
|
||||
if (($result = $this->dropSchema($io, $output)) !== Command::SUCCESS) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -85,7 +78,7 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to execute a migrations: ' . $ex->getMessage());
|
||||
|
||||
return 5;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +87,7 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import data: ' . $ex->getMessage());
|
||||
|
||||
return 6;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$input->getOption('no-cache')) {
|
||||
@@ -104,11 +97,11 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to clear cache: ' . $ex->getMessage());
|
||||
|
||||
return 7;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function dropSchema(SymfonyStyle $io, OutputInterface $output): int
|
||||
@@ -119,7 +112,7 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop database schema: ' . $ex->getMessage());
|
||||
|
||||
return 2;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -128,7 +121,7 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop migration_versions table: ' . $ex->getMessage());
|
||||
|
||||
return 3;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -137,10 +130,10 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop kimai2_sessions table: ' . $ex->getMessage());
|
||||
|
||||
return 4;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function askConfirmation(InputInterface $input, OutputInterface $output, string $question): bool
|
||||
@@ -156,7 +149,5 @@ EOT
|
||||
return $questionHelper->ask($input, $output, $question);
|
||||
}
|
||||
|
||||
abstract protected function getEnvName(): string;
|
||||
|
||||
abstract protected function loadData(InputInterface $input, OutputInterface $output): void;
|
||||
}
|
||||
|
||||
@@ -20,18 +20,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
abstract class AbstractRoleCommand extends Command
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDefinition([
|
||||
@@ -41,10 +35,7 @@ abstract class AbstractRoleCommand extends Command
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
$role = $input->getArgument('role');
|
||||
@@ -62,7 +53,7 @@ abstract class AbstractRoleCommand extends Command
|
||||
|
||||
$this->executeRoleCommand($this->userService, new SymfonyStyle($input, $output), $user, $super, $role);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
abstract protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role);
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
abstract class AbstractUserCommand extends Command
|
||||
{
|
||||
@@ -37,4 +39,17 @@ abstract class AbstractUserCommand extends Command
|
||||
|
||||
return $helper->ask($input, $output, $passwordQuestion);
|
||||
}
|
||||
|
||||
protected function validationError(ValidationFailedException $exception, SymfonyStyle $style): void
|
||||
{
|
||||
$errors = $exception->getViolations();
|
||||
if ($errors->count() > 0) {
|
||||
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
|
||||
foreach ($errors as $error) {
|
||||
$style->error(
|
||||
$error->getPropertyPath() . ': ' . $error->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,47 +10,38 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class ActivateUserCommand extends Command
|
||||
#[AsCommand(name: 'kimai:user:activate')]
|
||||
final class ActivateUserCommand extends Command
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:user:activate')
|
||||
->setAliases(['fos:user:activate'])
|
||||
->setDescription('Activate a user')
|
||||
->setDefinition([
|
||||
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
|
||||
])
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):
|
||||
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):
|
||||
|
||||
<info>php %command.full_name% susan_super</info>
|
||||
EOT
|
||||
<info>php %command.full_name% susan_super</info>
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
$user = $this->userService->findUserByUsernameOrThrowException($username);
|
||||
@@ -65,6 +56,6 @@ EOT
|
||||
$io->warning(sprintf('User "%s" is already active.', $username));
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,27 +10,25 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\User\UserService;
|
||||
use App\Utils\CommandStyle;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'kimai:user:password')]
|
||||
final class ChangePasswordCommand extends AbstractUserCommand
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:user:password')
|
||||
->setAliases(['fos:user:change-password'])
|
||||
->setDescription('Change the password of a user.')
|
||||
->setDefinition([
|
||||
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
|
||||
@@ -38,24 +36,21 @@ final class ChangePasswordCommand extends AbstractUserCommand
|
||||
])
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:password</info> command changes the password of a user:
|
||||
The <info>kimai:user:password</info> command changes the password of a user:
|
||||
|
||||
<info>php %command.full_name% matthieu</info>
|
||||
<info>php %command.full_name% matthieu</info>
|
||||
|
||||
This interactive shell will first ask you for a password.
|
||||
This interactive shell will first ask you for a password.
|
||||
|
||||
You can alternatively specify the password as a second argument:
|
||||
You can alternatively specify the password as a second argument:
|
||||
|
||||
<info>php %command.full_name% susan_super newpassword</info>
|
||||
<info>php %command.full_name% susan_super newpassword</info>
|
||||
|
||||
EOT
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
|
||||
@@ -67,18 +62,18 @@ EOT
|
||||
|
||||
$user = $this->userService->findUserByUsernameOrThrowException($username);
|
||||
|
||||
$io = new CommandStyle($input, $output);
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$user->setPlainPassword($password);
|
||||
$this->userService->updateUser($user, ['PasswordUpdate']);
|
||||
$io->success(sprintf('Changed password for user "%s".', $username));
|
||||
} catch (ValidationFailedException $ex) {
|
||||
$io->validationError($ex);
|
||||
$this->validationError($ex, $io);
|
||||
|
||||
return 2;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,32 +11,27 @@ namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\User\UserService;
|
||||
use App\Utils\CommandStyle;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'kimai:user:create')]
|
||||
final class CreateUserCommand extends AbstractUserCommand
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]);
|
||||
|
||||
$this
|
||||
->setName('kimai:user:create')
|
||||
->setAliases(['kimai:create-user'])
|
||||
->setDescription('Create a new user')
|
||||
->setHelp('This command allows you to create a new user.')
|
||||
->addArgument('username', InputArgument::REQUIRED, 'A name for the new user (must be unique)')
|
||||
@@ -51,12 +46,9 @@ final class CreateUserCommand extends AbstractUserCommand
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new CommandStyle($input, $output);
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$username = $input->getArgument('username');
|
||||
$email = $input->getArgument('email');
|
||||
@@ -71,7 +63,7 @@ final class CreateUserCommand extends AbstractUserCommand
|
||||
$role = $role ?: User::DEFAULT_ROLE;
|
||||
|
||||
$user = $this->userService->createNewUser();
|
||||
$user->setUsername($username);
|
||||
$user->setUserIdentifier($username);
|
||||
$user->setPlainPassword($password);
|
||||
$user->setEmail($email);
|
||||
$user->setEnabled(true);
|
||||
@@ -81,11 +73,11 @@ final class CreateUserCommand extends AbstractUserCommand
|
||||
$this->userService->saveNewUser($user);
|
||||
$io->success(sprintf('Success! Created user: %s', $username));
|
||||
} catch (ValidationFailedException $ex) {
|
||||
$io->validationError($ex);
|
||||
$this->validationError($ex, $io);
|
||||
|
||||
return 2;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,47 +10,38 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class DeactivateUserCommand extends Command
|
||||
#[AsCommand(name: 'kimai:user:deactivate')]
|
||||
final class DeactivateUserCommand extends Command
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:user:deactivate')
|
||||
->setAliases(['fos:user:deactivate'])
|
||||
->setDescription('Deactivate a user')
|
||||
->setDefinition([
|
||||
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
|
||||
])
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)
|
||||
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)
|
||||
|
||||
<info>php %command.full_name% susan_super</info>
|
||||
EOT
|
||||
<info>php %command.full_name% susan_super</info>
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
$user = $this->userService->findUserByUsernameOrThrowException($username);
|
||||
@@ -65,6 +56,6 @@ EOT
|
||||
$io->warning(sprintf('User "%s" is already deactivated.', $username));
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,28 +11,25 @@ namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class DemoteUserCommand extends AbstractRoleCommand
|
||||
#[AsCommand(name: 'kimai:user:demote')]
|
||||
final class DemoteUserCommand extends AbstractRoleCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('kimai:user:demote')
|
||||
->setAliases(['fos:user:demote'])
|
||||
->setDescription('Demote a user by removing a role')
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:demote</info> command demotes a user by removing a role
|
||||
The <info>kimai:user:demote</info> command demotes a user by removing a role
|
||||
|
||||
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
|
||||
<info>php %command.full_name% --super susan_super</info>
|
||||
EOT
|
||||
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
|
||||
<info>php %command.full_name% --super susan_super</info>
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +38,7 @@ EOT
|
||||
*/
|
||||
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
|
||||
{
|
||||
$username = $user->getUsername();
|
||||
$username = $user->getUserIdentifier();
|
||||
if ($super) {
|
||||
if ($user->isSuperAdmin()) {
|
||||
$user->setSuperAdmin(false);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Export\ServiceExport;
|
||||
use App\Mail\KimaiMailer;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
@@ -19,6 +18,7 @@ use App\Repository\TeamRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
@@ -27,35 +27,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Contracts\Translation\LocaleAwareInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ExportCreateCommand extends Command
|
||||
#[AsCommand(name: 'kimai:export:create')]
|
||||
final class ExportCreateCommand extends Command
|
||||
{
|
||||
private $serviceExport;
|
||||
private $customerRepository;
|
||||
private $projectRepository;
|
||||
private $teamRepository;
|
||||
private $userRepository;
|
||||
private $translator;
|
||||
private $mailer;
|
||||
|
||||
public function __construct(
|
||||
ServiceExport $serviceExport,
|
||||
CustomerRepository $customerRepository,
|
||||
ProjectRepository $projectRepository,
|
||||
TeamRepository $teamRepository,
|
||||
UserRepository $userRepository,
|
||||
TranslatorInterface $translator,
|
||||
KimaiMailer $mailer
|
||||
private ServiceExport $serviceExport,
|
||||
private CustomerRepository $customerRepository,
|
||||
private ProjectRepository $projectRepository,
|
||||
private TeamRepository $teamRepository,
|
||||
private UserRepository $userRepository,
|
||||
private TranslatorInterface $translator,
|
||||
private MailerInterface $mailer
|
||||
) {
|
||||
$this->serviceExport = $serviceExport;
|
||||
$this->customerRepository = $customerRepository;
|
||||
$this->projectRepository = $projectRepository;
|
||||
$this->teamRepository = $teamRepository;
|
||||
$this->userRepository = $userRepository;
|
||||
$this->translator = $translator;
|
||||
$this->mailer = $mailer;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -65,7 +52,6 @@ class ExportCreateCommand extends Command
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:export:create')
|
||||
->setDescription('Create exports')
|
||||
->setHelp('Create exports by several different filters and sent them via email.')
|
||||
->addOption('username', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the export (e.g. used for permissions and decimal setting)')
|
||||
@@ -107,7 +93,7 @@ class ExportCreateCommand extends Command
|
||||
default:
|
||||
$io->error('Unknown "exported" filter given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$locale = $input->getOption('locale');
|
||||
@@ -151,18 +137,18 @@ class ExportCreateCommand extends Command
|
||||
if ($template === null) {
|
||||
$io->error('You must pass the "template" option');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$renderer = $this->serviceExport->getRendererById($template);
|
||||
if ($renderer === null) {
|
||||
$io->error('Unknown export "template", available are:');
|
||||
$rows = [];
|
||||
foreach ($this->serviceExport->getRenderer() as $renderer) {
|
||||
$rows[] = [$renderer->getId()];
|
||||
foreach ($this->serviceExport->getRenderer() as $tmp) {
|
||||
$rows[] = [$tmp->getId()];
|
||||
}
|
||||
$io->table(['ID'], $rows);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$start = $input->getOption('start');
|
||||
@@ -172,7 +158,7 @@ class ExportCreateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid start date given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
if (!$start instanceof \DateTime) {
|
||||
@@ -187,7 +173,7 @@ class ExportCreateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid end date given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,10 +181,6 @@ class ExportCreateCommand extends Command
|
||||
$end = $dateFactory->getEndOfMonth($start);
|
||||
}
|
||||
|
||||
if (!$end instanceof \DateTime) {
|
||||
$end = $dateFactory->getEndOfMonth();
|
||||
}
|
||||
|
||||
$end->setTime(23, 59, 59);
|
||||
|
||||
$directory = rtrim(sys_get_temp_dir(), '/') . '/';
|
||||
@@ -209,7 +191,7 @@ class ExportCreateCommand extends Command
|
||||
if (!is_dir($directory) || !is_writable($directory)) {
|
||||
$io->error('Invalid "directory" given: ' . $directory);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$subject = 'Export data available';
|
||||
@@ -223,7 +205,7 @@ class ExportCreateCommand extends Command
|
||||
if ($result === false) {
|
||||
$io->error('Invalid "email" given: ' . $email);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$emails[] = $email;
|
||||
}
|
||||
@@ -240,14 +222,15 @@ class ExportCreateCommand extends Command
|
||||
$query = new ExportQuery();
|
||||
|
||||
$username = $input->getOption('username');
|
||||
if (!empty($username)) {
|
||||
$user = $this->userRepository->loadUserByUsername($username);
|
||||
if (null === $user) {
|
||||
if (\is_string($username) && !empty($username)) {
|
||||
try {
|
||||
$user = $this->userRepository->loadUserByIdentifier($username);
|
||||
} catch(\Exception) {
|
||||
$io->error(
|
||||
sprintf('The given username "%s" could not be resolved', $username)
|
||||
);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$query->setCurrentUser($user);
|
||||
}
|
||||
@@ -268,7 +251,7 @@ class ExportCreateCommand extends Command
|
||||
if (\count($entries) === 0) {
|
||||
$io->success('No entries found, skipping');
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$response = $renderer->render($entries, $query);
|
||||
@@ -299,7 +282,7 @@ class ExportCreateCommand extends Command
|
||||
$io->success('Saved export to: ' . $file);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function savePreview(Response $response, string $directory): string
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Importer\ImporterService;
|
||||
use App\Importer\ImportNotFoundException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* This command can change anytime, don't rely on its API for the future!
|
||||
*/
|
||||
class ImportCustomerCommand extends Command
|
||||
{
|
||||
private $importer;
|
||||
|
||||
public function __construct(ImporterService $importer)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->importer = $importer;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:import:customer')
|
||||
->setDescription('Import customer from CSV file')
|
||||
->setHelp(
|
||||
'Import customers from a CSV file.' . PHP_EOL .
|
||||
'Customer will be matched by name or number, and if not found created on the fly.' . PHP_EOL
|
||||
)
|
||||
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
|
||||
->addOption('importer', null, InputOption::VALUE_REQUIRED, 'The importer to use (supported: default, grandtotal)', 'default')
|
||||
->addOption('reader', null, InputOption::VALUE_REQUIRED, 'The reader to use (supported: csv, csv-semicolon)', 'csv')
|
||||
->addOption('no-update', null, InputOption::VALUE_NONE, 'If you want to create new customers, but not update existing ones')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai importer: Customers');
|
||||
|
||||
$skipUpdate = $input->getOption('no-update');
|
||||
$doImport = true;
|
||||
$row = 1;
|
||||
$errors = 0;
|
||||
$customers = [];
|
||||
$importer = null;
|
||||
|
||||
try {
|
||||
$importer = $this->importer->getCustomerImporter($input->getOption('importer'));
|
||||
$reader = $this->importer->getReader($input->getOption('reader'));
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$importerFile = $input->getArgument('file');
|
||||
|
||||
try {
|
||||
$records = $reader->read($importerFile);
|
||||
} catch (ImportNotFoundException $ex) {
|
||||
$io->error('File not existing or not readable: ' . $importerFile);
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$amount = iterator_count($records);
|
||||
$records->rewind();
|
||||
$io->text(sprintf('Found %s rows to process, converting now ...', $amount));
|
||||
|
||||
$progressBar = new ProgressBar($output, $amount);
|
||||
|
||||
foreach ($records as $record) {
|
||||
try {
|
||||
$customers[] = $importer->convertEntryToCustomer($record);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Invalid row %s: %s', $row, $ex->getMessage()));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
$progressBar->advance();
|
||||
|
||||
$row++;
|
||||
}
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
|
||||
if (!$doImport) {
|
||||
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
$amount = \count($customers);
|
||||
$io->text(sprintf('Converted %s customers, importing into Kimai now ...', $amount));
|
||||
|
||||
$progressBar = new ProgressBar($output, $amount);
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$noUpdatedCustomers = 0;
|
||||
|
||||
foreach ($customers as $customer) {
|
||||
try {
|
||||
$progressBar->advance();
|
||||
|
||||
if ($customer->getId() === null) {
|
||||
$this->importer->importCustomer($customer);
|
||||
$created++;
|
||||
} elseif ($skipUpdate === false) {
|
||||
$this->importer->importCustomer($customer);
|
||||
$updated++;
|
||||
} else {
|
||||
$noUpdatedCustomers++;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed importing customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
|
||||
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
|
||||
if ($created > 0) {
|
||||
$io->success(sprintf('Imported %s customer', $created));
|
||||
}
|
||||
if ($updated > 0) {
|
||||
$io->success(sprintf('Updated %s customer', $updated));
|
||||
}
|
||||
if ($noUpdatedCustomers > 0) {
|
||||
$io->success(sprintf('Skipped %s existing customer', $noUpdatedCustomers));
|
||||
}
|
||||
|
||||
if ($updated === 0 && $created === 0) {
|
||||
$io->text('Nothing was imported');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Team;
|
||||
use App\Importer\ImporterService;
|
||||
use App\Importer\ImportNotFoundException;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* This command can change anytime, don't rely on its API for the future!
|
||||
*/
|
||||
class ImportProjectCommand extends Command
|
||||
{
|
||||
private $importerService;
|
||||
private $teams;
|
||||
private $users;
|
||||
|
||||
public function __construct(ImporterService $importerService, TeamRepository $teams, UserRepository $users)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->importerService = $importerService;
|
||||
$this->teams = $teams;
|
||||
$this->users = $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:import:project')
|
||||
->setDescription('Import projects from CSV file')
|
||||
->setHelp(
|
||||
'Import projects from a CSV file, creating customers (if not existing) and optional empty teams for each project.' . PHP_EOL .
|
||||
'Imported customer will be matched by name and optionally created on the fly.' . PHP_EOL
|
||||
)
|
||||
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
|
||||
->addOption('importer', null, InputOption::VALUE_REQUIRED, 'The importer to use (supported: default)', 'default')
|
||||
->addOption('reader', null, InputOption::VALUE_REQUIRED, 'The reader to use (supported: csv, csv-semicolon)', 'csv')
|
||||
->addOption('teamlead', null, InputOption::VALUE_REQUIRED, 'If you want to create empty teams for each project, give the username of the teamlead to be assigned')
|
||||
->addOption('no-update', null, InputOption::VALUE_NONE, 'If you want to create new project, but not update existing ones')
|
||||
->addOption('date-format', null, InputOption::VALUE_REQUIRED, 'Date format for imports', 'Y-m-d')
|
||||
->addOption('timezone', null, InputOption::VALUE_REQUIRED, 'Timezone for imports', date_default_timezone_get())
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai importer: Projects');
|
||||
|
||||
// validate teamlead
|
||||
$teamlead = $input->getOption('teamlead');
|
||||
if (null !== $teamlead) {
|
||||
$tmpUser = $this->users->findOneBy(['username' => $teamlead]);
|
||||
if ($tmpUser === null) {
|
||||
$tmpUser = $this->users->findOneBy(['email' => $teamlead]);
|
||||
if ($tmpUser === null) {
|
||||
$io->error(
|
||||
sprintf(
|
||||
'You requested to create empty teams for each project, but the given teamlead cannot be found.' . PHP_EOL .
|
||||
'Please create a user with the name (or email) %s first, before continuing.' . PHP_EOL,
|
||||
$teamlead
|
||||
)
|
||||
);
|
||||
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
$teamlead = $tmpUser;
|
||||
}
|
||||
|
||||
$skipUpdate = $input->getOption('no-update');
|
||||
$doImport = true;
|
||||
$row = 1;
|
||||
$errors = 0;
|
||||
$projects = [];
|
||||
|
||||
try {
|
||||
$importer = $this->importerService->getProjectImporter($input->getOption('importer'));
|
||||
$reader = $this->importerService->getReader($input->getOption('reader'));
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$importerFile = $input->getArgument('file');
|
||||
|
||||
$io->text('Reading import file ...');
|
||||
|
||||
try {
|
||||
$records = $reader->read($importerFile);
|
||||
} catch (ImportNotFoundException $ex) {
|
||||
$io->error('File not existing or not readable: ' . $importerFile);
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$amount = iterator_count($records);
|
||||
$records->rewind();
|
||||
$io->text(sprintf('Found %s rows to process, converting now ...', $amount));
|
||||
|
||||
$progressBar = new ProgressBar($output, $amount);
|
||||
|
||||
$options = [];
|
||||
if (null !== ($dateFormat = $input->getOption('date-format'))) {
|
||||
$options['dateformat'] = $dateFormat;
|
||||
}
|
||||
if (null !== ($timezone = $input->getOption('timezone'))) {
|
||||
$options['timezone'] = $timezone;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
try {
|
||||
$projects[] = $importer->convertEntryToProject($record, $options);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Invalid row %s: %s', $row, $ex->getMessage()));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
$progressBar->advance();
|
||||
|
||||
$row++;
|
||||
}
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
|
||||
if (!$doImport) {
|
||||
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
$createdProjects = 0;
|
||||
$updatedProjects = 0;
|
||||
$noUpdatedProjects = 0;
|
||||
$createdCustomers = 0;
|
||||
$createdTeams = 0;
|
||||
|
||||
$amount = \count($projects);
|
||||
$io->text(sprintf('Converted %s projects, importing into Kimai now ...', $amount));
|
||||
|
||||
$progressBar = new ProgressBar($output, $amount);
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$progressBar->advance();
|
||||
try {
|
||||
if ($project->getCustomer()->getId() === null) {
|
||||
$this->importerService->importCustomer($project->getCustomer());
|
||||
$createdCustomers++;
|
||||
}
|
||||
|
||||
$createTeam = false;
|
||||
|
||||
if ($project->getId() === null) {
|
||||
$this->importerService->importProject($project);
|
||||
$createdProjects++;
|
||||
$createTeam = (null !== $teamlead);
|
||||
} elseif ($skipUpdate === false) {
|
||||
$this->importerService->importProject($project);
|
||||
$updatedProjects++;
|
||||
} else {
|
||||
$noUpdatedProjects++;
|
||||
}
|
||||
|
||||
if (!$createTeam) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$team = new Team();
|
||||
$team->setName($project->getName());
|
||||
$team->addTeamlead($teamlead);
|
||||
|
||||
$this->teams->saveTeam($team);
|
||||
|
||||
$project->addTeam($team);
|
||||
$team->addProject($project);
|
||||
|
||||
$this->teams->saveTeam($team);
|
||||
$createdTeams++;
|
||||
} catch (ValidationFailedException $ex) {
|
||||
$io->error(sprintf('Failed importing project "%s" with: %s', $project->getName(), $ex->getMessage()));
|
||||
for ($i = 0; $i < $ex->getViolations()->count(); $i++) {
|
||||
$violation = $ex->getViolations()->get($i);
|
||||
$io->error(sprintf('Failed validating field "%s" with value "%s": %s', $violation->getPropertyPath(), $violation->getInvalidValue(), $violation->getMessage()));
|
||||
}
|
||||
|
||||
return 4;
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed importing project "%s" with: %s', $project->getName(), $ex->getMessage()));
|
||||
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
|
||||
if ($createdCustomers === 0 && $updatedProjects === 0 && $createdProjects === 0) {
|
||||
if ($noUpdatedProjects > 0) {
|
||||
$io->success(sprintf('Skipped %s existing projects', $noUpdatedProjects));
|
||||
} else {
|
||||
$io->text('Nothing was imported');
|
||||
}
|
||||
} else {
|
||||
if ($createdCustomers > 0) {
|
||||
$io->success(sprintf('Imported %s customers', $createdCustomers));
|
||||
}
|
||||
if ($updatedProjects > 0) {
|
||||
$io->success(sprintf('Updated %s projects', $updatedProjects));
|
||||
}
|
||||
if ($noUpdatedProjects > 0) {
|
||||
$io->success(sprintf('Skipped %s existing projects', $noUpdatedProjects));
|
||||
}
|
||||
if ($createdProjects > 0) {
|
||||
$io->success(sprintf('Imported %s projects', $createdProjects));
|
||||
}
|
||||
if ($createdTeams > 0) {
|
||||
$io->success(sprintf('Created %s teams', $createdTeams));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,726 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Importer\InvalidFieldsException;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Utils\Duration;
|
||||
use League\Csv\Reader;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
|
||||
/**
|
||||
* This command can change anytime, don't rely on its API for the future!
|
||||
*
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class ImportTimesheetCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'kimai:import:timesheet';
|
||||
|
||||
// if we use 00:00 we might run into summer/winter time problems which happen between 02:00 and 03:00
|
||||
public const DEFAULT_BEGIN = '04:00';
|
||||
public const DEFAULT_CUSTOMER = 'Imported customer - %s';
|
||||
|
||||
private static $supportedHeader = [
|
||||
'Date',
|
||||
'From',
|
||||
'To',
|
||||
'Duration',
|
||||
'Rate',
|
||||
'User',
|
||||
'Customer',
|
||||
'Project',
|
||||
'Activity',
|
||||
'Description',
|
||||
'Exported',
|
||||
'Tags',
|
||||
'Hourly rate',
|
||||
'Fixed rate',
|
||||
];
|
||||
|
||||
private $customers;
|
||||
private $projects;
|
||||
private $activities;
|
||||
private $users;
|
||||
private $tagRepository;
|
||||
private $timesheets;
|
||||
private $configuration;
|
||||
private $encoder;
|
||||
|
||||
/**
|
||||
* @var Customer
|
||||
*/
|
||||
private $customerFallback;
|
||||
/**
|
||||
* @var Customer[]
|
||||
*/
|
||||
private $customerCache = [];
|
||||
/**
|
||||
* @var Project[]
|
||||
*/
|
||||
private $projectCache = [];
|
||||
/**
|
||||
* @var User[]
|
||||
*/
|
||||
private $userCache = [];
|
||||
/**
|
||||
* @var Tag[]
|
||||
*/
|
||||
private $tagCache = [];
|
||||
/**
|
||||
* Comment that will be added to new customers, projects and activities.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $comment = '';
|
||||
/**
|
||||
* The datetime of this import as formatted string.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $dateTime = '';
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $begin = self::DEFAULT_BEGIN;
|
||||
// some statistics to display to the user
|
||||
private $createdProjects = 0;
|
||||
private $createdUsers = 0;
|
||||
private $createdCustomers = 0;
|
||||
private $createdActivities = 0;
|
||||
|
||||
public function __construct(
|
||||
CustomerRepository $customers,
|
||||
ProjectRepository $projects,
|
||||
ActivityRepository $activities,
|
||||
UserRepository $users,
|
||||
TagRepository $tagRepository,
|
||||
TimesheetRepository $timesheets,
|
||||
SystemConfiguration $configuration,
|
||||
UserPasswordEncoderInterface $encoder
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->customers = $customers;
|
||||
$this->projects = $projects;
|
||||
$this->activities = $activities;
|
||||
$this->users = $users;
|
||||
$this->tagRepository = $tagRepository;
|
||||
$this->timesheets = $timesheets;
|
||||
$this->configuration = $configuration;
|
||||
$this->encoder = $encoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName(self::$defaultName)
|
||||
->setDescription('Import timesheets from CSV file')
|
||||
->setHelp(
|
||||
'This command allows to import timesheets from a CSV file, which are formatted like CSV exports.' . PHP_EOL .
|
||||
'Imported customer, projects and activities will be matched by name.' . PHP_EOL .
|
||||
'Supported columns names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
|
||||
)
|
||||
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'The timezone to be used. Supports: "valid timezone names", the string "user" (using the configured users timezone) and the string "server" (PHP default timezone)', 'user')
|
||||
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'A customer ID or name to assign for empty entries. Defaults to creating a new customer which is used for all un-linked projects')
|
||||
->addOption('activity', null, InputOption::VALUE_OPTIONAL, 'Whether new activities should be "global" or "project" specific. Allowed values are "global" and "project"', 'project')
|
||||
->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
|
||||
->addOption('begin', null, InputOption::VALUE_OPTIONAL, 'Default begin if none was provided in the format HH:MM', self::DEFAULT_BEGIN)
|
||||
->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers, projects and activities. %s will be replaced with the current datetime', 'Created by import at %s')
|
||||
->addOption('create-users', null, InputOption::VALUE_NONE, 'If set, accounts for not found users will be created')
|
||||
->addOption('ignore-errors', null, InputOption::VALUE_NONE, 'If set, invalid rows will be skipped')
|
||||
->addOption('batch', null, InputOption::VALUE_NONE, 'If set, timesheets will be written in batches of 100')
|
||||
->addOption('domain', null, InputOption::VALUE_OPTIONAL, 'Domain name used for email addresses of new created users. If provided usernames already include a domain, this option will be skipped.', 'example.com')
|
||||
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password for new created users.', 'password')
|
||||
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai importer: Timesheets');
|
||||
|
||||
$csvFile = $input->getArgument('file');
|
||||
if (!file_exists($csvFile)) {
|
||||
$io->error('File not existing: ' . $csvFile);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!is_readable($csvFile)) {
|
||||
$io->error('File cannot be read: ' . $csvFile);
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$this->dateTime = (new \DateTime())->format('Y.m.d H:i');
|
||||
$this->comment = sprintf($input->getOption('comment'), $this->dateTime);
|
||||
$this->begin = $input->getOption('begin');
|
||||
|
||||
$timezone = $input->getOption('timezone');
|
||||
switch ($timezone) {
|
||||
case 'server':
|
||||
$timezone = new \DateTimeZone(date_default_timezone_get());
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
// null means fetch from user
|
||||
$timezone = null;
|
||||
break;
|
||||
|
||||
default:
|
||||
try {
|
||||
if (!\in_array($timezone, \DateTimeZone::listIdentifiers())) {
|
||||
throw new \InvalidArgumentException('Not a known PHP timezone');
|
||||
}
|
||||
$timezone = new \DateTimeZone($timezone);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid timezone given, import canceled.');
|
||||
|
||||
return 3;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$activityType = $input->getOption('activity');
|
||||
$allowedActivityTypes = ['project', 'global'];
|
||||
if (!\in_array($activityType, $allowedActivityTypes)) {
|
||||
$io->error(sprintf('Invalid activity type "%s" given, allowed values are: %s', $activityType, implode(', ', $allowedActivityTypes)));
|
||||
|
||||
return 4;
|
||||
}
|
||||
|
||||
$csv = Reader::createFromPath($csvFile, 'r');
|
||||
$csv->setDelimiter($input->getOption('delimiter'));
|
||||
$csv->setHeaderOffset(0);
|
||||
$header = $csv->getHeader();
|
||||
if (!$this->validateHeader($header)) {
|
||||
$io->error(
|
||||
sprintf(
|
||||
'Found invalid CSV. The header: ' . PHP_EOL .
|
||||
'%s' . PHP_EOL .
|
||||
'did not match the expected structure: ' . PHP_EOL .
|
||||
'%s',
|
||||
implode(', ', $header),
|
||||
implode(', ', self::$supportedHeader)
|
||||
)
|
||||
);
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
$all = $csv->getRecords();
|
||||
$total = iterator_count($all);
|
||||
|
||||
$io->text(sprintf('Found %s timesheets to import, pre-validating now', $total));
|
||||
|
||||
$records = [];
|
||||
$doImport = true;
|
||||
$row = 1;
|
||||
$errors = 0;
|
||||
|
||||
$createUsers = $input->getOption('create-users');
|
||||
$ignoreErrors = $input->getOption('ignore-errors');
|
||||
|
||||
// ======================= validate rows =======================
|
||||
$progressBar = new ProgressBar($output, $total);
|
||||
|
||||
$countAll = 0;
|
||||
foreach ($all as $record) {
|
||||
$this->convertRow($record);
|
||||
try {
|
||||
$this->validateRow($record);
|
||||
} catch (InvalidFieldsException $ex) {
|
||||
$io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
|
||||
if (!$createUsers) {
|
||||
if (null === $this->getUser($record['User'])) {
|
||||
if (!$ignoreErrors) {
|
||||
$io->error(sprintf('Unknown user %s in row %s', $record['User'], $row));
|
||||
}
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
$row++;
|
||||
|
||||
if ($doImport) {
|
||||
$records[] = $record;
|
||||
}
|
||||
$countAll++;
|
||||
$progressBar->advance();
|
||||
}
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
|
||||
if (!$ignoreErrors && !$doImport) {
|
||||
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
$io->writeln('');
|
||||
$io->text(sprintf('Processing %s of %s rows, skipping %s with pre-validation errors.', \count($records), iterator_count($all), $errors));
|
||||
|
||||
// values for new users
|
||||
$password = $input->getOption('password');
|
||||
$domain = $input->getOption('domain');
|
||||
|
||||
$progressBar = new ProgressBar($output, \count($records));
|
||||
|
||||
$durationParser = new Duration();
|
||||
$row = 0;
|
||||
$imported = 0;
|
||||
$failed = 0;
|
||||
|
||||
$isBatchUpdate = $input->getOption('batch');
|
||||
$batches = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$row++;
|
||||
try {
|
||||
$project = $this->getProject($record['Project'], $record['Customer'], $input->getOption('customer'));
|
||||
$activity = $this->getActivity($record['Activity'], $project, $activityType);
|
||||
|
||||
$user = $this->getUser($record['User']);
|
||||
if (null === $user) {
|
||||
$user = $this->createUser($record['User'], $domain, $password);
|
||||
}
|
||||
|
||||
$begin = null;
|
||||
$end = null;
|
||||
$duration = 0;
|
||||
|
||||
if (!empty($record['Duration'])) {
|
||||
if (\is_int($record['Duration'])) {
|
||||
$duration = $record['Duration'];
|
||||
} else {
|
||||
$duration = $durationParser->parseDurationString($record['Duration']);
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $timezone) {
|
||||
$timezone = new \DateTimeZone($user->getTimezone());
|
||||
}
|
||||
|
||||
if (empty($record['From']) && empty($record['To'])) {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $this->begin, $timezone);
|
||||
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
|
||||
} elseif (empty($record['From'])) {
|
||||
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
|
||||
$begin = (new \DateTime())->setTimezone($timezone)->setTimestamp($end->getTimestamp() - $duration);
|
||||
} elseif (empty($record['To'])) {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
|
||||
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
|
||||
} else {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
|
||||
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
|
||||
|
||||
// fix dates, which are running over midnight
|
||||
if ($end < $begin) {
|
||||
if ($duration > 0) {
|
||||
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
|
||||
} else {
|
||||
$end->add(new \DateInterval('P1D'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setActivity($activity);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setBegin($begin);
|
||||
$timesheet->setEnd($end);
|
||||
$timesheet->setUser($user);
|
||||
$timesheet->setDescription($record['Description']);
|
||||
$timesheet->setExported((bool) $record['Exported']);
|
||||
|
||||
if (!empty($record['Tags'])) {
|
||||
foreach (explode(',', $record['Tags']) as $tagName) {
|
||||
if (empty($tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = $this->getTag($tagName);
|
||||
|
||||
$timesheet->addTag($tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($record['Rate'])) {
|
||||
$timesheet->setRate($record['Rate']);
|
||||
}
|
||||
if (!empty($record['Hourly rate'])) {
|
||||
$timesheet->setHourlyRate($record['Hourly rate']);
|
||||
}
|
||||
if (!empty($record['Fixed rate'])) {
|
||||
$timesheet->setFixedRate($record['Fixed rate']);
|
||||
}
|
||||
|
||||
if ($isBatchUpdate) {
|
||||
$batches[] = $timesheet;
|
||||
|
||||
if ($row % 100 === 0) {
|
||||
$this->timesheets->saveMultiple($batches);
|
||||
$batches = [];
|
||||
}
|
||||
} else {
|
||||
$this->timesheets->save($timesheet);
|
||||
}
|
||||
|
||||
$imported++;
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed importing timesheet row %s with: %s', $row, $ex->getMessage()));
|
||||
$failed++;
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
if ($isBatchUpdate && \count($batches) > 0) {
|
||||
$this->timesheets->saveMultiple($batches);
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
|
||||
if ($this->createdUsers > 0) {
|
||||
$io->success(sprintf('Created %s users', $this->createdUsers));
|
||||
}
|
||||
if ($this->createdCustomers > 0) {
|
||||
$io->success(sprintf('Created %s customers', $this->createdCustomers));
|
||||
}
|
||||
if ($this->createdProjects > 0) {
|
||||
$io->success(sprintf('Created %s projects', $this->createdProjects));
|
||||
}
|
||||
if ($this->createdActivities > 0) {
|
||||
$io->success(sprintf('Created %s activities', $this->createdActivities));
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
$io->warning(sprintf('Failed validating %s rows', $failed));
|
||||
}
|
||||
|
||||
if ($imported > 0) {
|
||||
$io->success(sprintf('Imported %s rows', $imported));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function createUser($username, $domain, $password): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
if (stripos($username, '@') === false) {
|
||||
$email = preg_replace('/[[:^print:]]/', '', $username) . '@' . $domain;
|
||||
$email = strtolower($email);
|
||||
} else {
|
||||
$email = $username;
|
||||
}
|
||||
$user->setEmail($email);
|
||||
$user->setPassword($this->encoder->encodePassword($user, $password));
|
||||
|
||||
$this->users->saveUser($user);
|
||||
$this->createdUsers++;
|
||||
|
||||
$this->userCache[$username] = $user;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function getUser($user): ?User
|
||||
{
|
||||
if (!\array_key_exists($user, $this->userCache)) {
|
||||
$tmpUser = $this->users->findOneBy(['username' => $user]);
|
||||
if (null === $tmpUser) {
|
||||
$tmpUser = $this->users->findOneBy(['email' => $user]);
|
||||
if (null === $tmpUser) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
$this->userCache[$user] = $tmpUser;
|
||||
}
|
||||
|
||||
return $this->userCache[$user];
|
||||
}
|
||||
|
||||
private function getTag(string $tagName): Tag
|
||||
{
|
||||
if (\array_key_exists($tagName, $this->tagCache)) {
|
||||
return $this->tagCache[$tagName];
|
||||
}
|
||||
|
||||
$tag = $this->tagRepository->findTagByName($tagName);
|
||||
|
||||
if ($tag === null) {
|
||||
$tag = (new Tag())->setName($tagName);
|
||||
}
|
||||
|
||||
$this->tagCache[$tagName] = $tag;
|
||||
|
||||
return $this->tagCache[$tagName];
|
||||
}
|
||||
|
||||
private function getActivity($activity, Project $project, $activityType): Activity
|
||||
{
|
||||
$tmpActivity = null;
|
||||
|
||||
$tmpActivities = $this->activities->findBy(['project' => $project->getId(), 'name' => $activity]);
|
||||
|
||||
if (\count($tmpActivities) === 0) {
|
||||
$tmpActivity = $this->activities->findOneBy(['project' => null, 'name' => $activity]);
|
||||
} elseif (\count($tmpActivities) === 1) {
|
||||
$tmpActivity = $tmpActivities[0];
|
||||
}
|
||||
|
||||
if (null === $tmpActivity) {
|
||||
$tmpActivity = new Activity();
|
||||
$tmpActivity->setName($activity);
|
||||
$tmpActivity->setComment($this->comment);
|
||||
if ($activityType === 'project') {
|
||||
$tmpActivity->setProject($project);
|
||||
}
|
||||
$this->activities->saveActivity($tmpActivity);
|
||||
$this->createdActivities++;
|
||||
}
|
||||
|
||||
return $tmpActivity;
|
||||
}
|
||||
|
||||
private function getProject($project, $customer, $fallbackCustomer): Project
|
||||
{
|
||||
$cacheKey = $project . '_____' . $customer;
|
||||
|
||||
if (!\array_key_exists($cacheKey, $this->projectCache)) {
|
||||
$tmpCustomer = $this->getCustomer($customer, $fallbackCustomer);
|
||||
/** @var Project $tmpProject */
|
||||
$tmpProject = null;
|
||||
/** @var Project[] $tmpProjects */
|
||||
$tmpProjects = $this->projects->findBy(['name' => $project]);
|
||||
|
||||
if (\count($tmpProjects) > 1) {
|
||||
/** @var Project $prj */
|
||||
foreach ($tmpProjects as $prj) {
|
||||
if (strcasecmp($prj->getCustomer()->getName(), $tmpCustomer->getName()) !== 0) {
|
||||
continue;
|
||||
}
|
||||
$tmpProject = $prj;
|
||||
break;
|
||||
}
|
||||
} elseif (\count($tmpProjects) === 1) {
|
||||
$tmpProject = $tmpProjects[0];
|
||||
}
|
||||
|
||||
if (null !== $tmpProject) {
|
||||
if (strcasecmp($tmpProject->getCustomer()->getName(), $tmpCustomer->getName()) !== 0) {
|
||||
$tmpProject = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($tmpProject === null) {
|
||||
$tmpProject = new Project();
|
||||
$tmpProject->setName($project);
|
||||
$tmpProject->setComment($this->comment);
|
||||
$tmpProject->setCustomer($tmpCustomer);
|
||||
$this->projects->saveProject($tmpProject);
|
||||
$this->createdProjects++;
|
||||
}
|
||||
|
||||
$this->projectCache[$cacheKey] = $tmpProject;
|
||||
}
|
||||
|
||||
return $this->projectCache[$cacheKey];
|
||||
}
|
||||
|
||||
private function getCustomer($customer, $fallback): Customer
|
||||
{
|
||||
if (!empty($customer)) {
|
||||
if (!\array_key_exists($customer, $this->customerCache)) {
|
||||
$tmpCustomer = $this->customers->findBy(['name' => $customer]);
|
||||
if (\count($tmpCustomer) > 1) {
|
||||
throw new \Exception(sprintf('Found multiple customers with the name: %s', $customer));
|
||||
} elseif (\count($tmpCustomer) === 1) {
|
||||
$tmpCustomer = $tmpCustomer[0];
|
||||
}
|
||||
|
||||
if ($tmpCustomer instanceof Customer) {
|
||||
$this->customerCache[$customer] = $tmpCustomer;
|
||||
}
|
||||
}
|
||||
|
||||
if (\array_key_exists($customer, $this->customerCache)) {
|
||||
return $this->customerCache[$customer];
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $this->customerFallback && !empty($fallback)) {
|
||||
return $this->customerFallback;
|
||||
}
|
||||
|
||||
$tmpFallback = null;
|
||||
|
||||
if (!empty($fallback)) {
|
||||
if (is_numeric($fallback)) {
|
||||
$tmpFallback = $this->customers->find((int) $fallback);
|
||||
} else {
|
||||
/** @var Customer|null $tmpFallback */
|
||||
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $tmpFallback) {
|
||||
$newName = $customer;
|
||||
if (empty($customer)) {
|
||||
$newName = self::DEFAULT_CUSTOMER;
|
||||
if (!empty($fallback) && \is_string($fallback)) {
|
||||
$newName = $fallback;
|
||||
}
|
||||
}
|
||||
$tmpFallback = new Customer();
|
||||
$tmpFallback->setName(sprintf($newName, $this->dateTime));
|
||||
$tmpFallback->setComment($this->comment);
|
||||
$tmpFallback->setCountry($this->configuration->getCustomerDefaultCountry());
|
||||
$timezone = date_default_timezone_get();
|
||||
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
|
||||
$timezone = $this->configuration->getCustomerDefaultTimezone();
|
||||
}
|
||||
$tmpFallback->setTimezone($timezone);
|
||||
$this->customers->saveCustomer($tmpFallback);
|
||||
$this->createdCustomers++;
|
||||
}
|
||||
|
||||
$this->customerFallback = $tmpFallback;
|
||||
|
||||
return $this->customerFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $row
|
||||
* @return bool
|
||||
* @throws InvalidFieldsException
|
||||
*/
|
||||
private function validateRow(array $row)
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
if (empty($row['Project'])) {
|
||||
$fields[] = 'Project';
|
||||
}
|
||||
|
||||
if (empty($row['Activity'])) {
|
||||
$fields[] = 'Activity';
|
||||
}
|
||||
|
||||
if (empty($row['Date'])) {
|
||||
$fields[] = 'Date';
|
||||
}
|
||||
|
||||
if ((empty($row['From']) || empty($row['To'])) && empty($row['Duration'])) {
|
||||
$fields[] = 'Duration';
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
throw new InvalidFieldsException($fields);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateHeader(array $header)
|
||||
{
|
||||
$result = array_diff(self::$supportedHeader, $header);
|
||||
|
||||
return empty($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add project specific conversion logic here
|
||||
*
|
||||
* @param array $row
|
||||
*/
|
||||
private function convertRow(array &$row)
|
||||
{
|
||||
// negative durations
|
||||
if ($row['Duration'][0] === '-') {
|
||||
$row['Duration'] = substr($row['Duration'], 1);
|
||||
}
|
||||
|
||||
if (!\array_key_exists('Tags', $row)) {
|
||||
$row['Tags'] = null;
|
||||
}
|
||||
if (empty($row['Date'])) {
|
||||
$row['Date'] = '1970-01-01';
|
||||
}
|
||||
if (!\array_key_exists('Exported', $row)) {
|
||||
$row['Exported'] = false;
|
||||
}
|
||||
if (!\array_key_exists('Rate', $row)) {
|
||||
$row['Rate'] = null;
|
||||
}
|
||||
if (!\array_key_exists('Hourly rate', $row)) {
|
||||
$row['Hourly rate'] = null;
|
||||
}
|
||||
if (!\array_key_exists('Fixed rate', $row)) {
|
||||
$row['Fixed rate'] = null;
|
||||
}
|
||||
if (!empty($row['From'])) {
|
||||
$len = \strlen($row['From']);
|
||||
if ($len === 1) {
|
||||
$row['From'] = '0' . $row['From'] . ':00';
|
||||
} elseif ($len == 2) {
|
||||
$row['From'] = $row['From'] . ':00';
|
||||
}
|
||||
}
|
||||
if (!empty($row['To'])) {
|
||||
$len = \strlen($row['To']);
|
||||
if ($len === 1) {
|
||||
$row['To'] = '0' . $row['To'] . ':00';
|
||||
} elseif ($len == 2) {
|
||||
$row['To'] = $row['To'] . ':00';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
@@ -20,69 +20,42 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
/**
|
||||
* Command used to do the basic installation steps for Kimai.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:install')]
|
||||
final class InstallCommand extends Command
|
||||
{
|
||||
public const ERROR_PERMISSIONS = 1;
|
||||
public const ERROR_CACHE_CLEAN = 2;
|
||||
public const ERROR_CACHE_WARMUP = 4;
|
||||
public const ERROR_DATABASE = 8;
|
||||
public const ERROR_MIGRATIONS = 32;
|
||||
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
private $connection;
|
||||
|
||||
public function __construct(Connection $connection)
|
||||
public function __construct(private Connection $connection, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:install')
|
||||
->setDescription('Basic installation for Kimai')
|
||||
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
|
||||
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache re-generation')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai installation running ...');
|
||||
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
/** @var KernelInterface $kernel */
|
||||
$kernel = $application->getKernel();
|
||||
$environment = $kernel->getEnvironment();
|
||||
|
||||
// create the database, in case it is not yet existing
|
||||
try {
|
||||
$this->createDatabase($io, $input, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to create database: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
|
||||
@@ -91,22 +64,22 @@ final class InstallCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to set migration status: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_MIGRATIONS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$input->getOption('no-cache')) {
|
||||
// flush the cache, just to make sure ... and ignore result
|
||||
$this->rebuildCaches($environment, $io, $input, $output);
|
||||
$this->rebuildCaches($this->kernelEnvironment, $io, $input, $output);
|
||||
}
|
||||
|
||||
$io->success(
|
||||
sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
|
||||
);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
@@ -116,7 +89,7 @@ final class InstallCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to clear cache: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_CLEAN;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
@@ -125,13 +98,13 @@ final class InstallCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to warmup cache: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_WARMUP;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function importMigrations(SymfonyStyle $io, OutputInterface $output)
|
||||
private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
|
||||
{
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
|
||||
@@ -141,16 +114,21 @@ final class InstallCommand extends Command
|
||||
$io->writeln('');
|
||||
}
|
||||
|
||||
protected function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
if ($this->connection->isConnected()) {
|
||||
$io->note(sprintf('Database is existing and connection could be established'));
|
||||
try {
|
||||
if ($this->connection->isConnected()) {
|
||||
$io->note(sprintf('Database is existing and connection could be established'));
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->askConfirmation($input, $output, sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
|
||||
throw new \Exception('Skipped database creation, aborting installation');
|
||||
if (!$this->askConfirmation($input, $output, sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
|
||||
throw new \Exception('Skipped database creation, aborting installation');
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
// this likely means that the database does not exist. the latest doctrine release
|
||||
// changed the behavior: in previous version this code did not throw an exception.
|
||||
}
|
||||
|
||||
$options = ['--if-not-exists' => true];
|
||||
@@ -170,7 +148,7 @@ final class InstallCommand extends Command
|
||||
* @param bool $default
|
||||
* @return bool
|
||||
*/
|
||||
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false)
|
||||
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false): bool
|
||||
{
|
||||
/** @var QuestionHelper $questionHelper */
|
||||
$questionHelper = $this->getHelperSet()->get('question');
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\SearchTerm;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -33,62 +34,26 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class InvoiceCreateCommand extends Command
|
||||
#[AsCommand(name: 'kimai:invoice:create')]
|
||||
final class InvoiceCreateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var ServiceInvoice
|
||||
*/
|
||||
private $serviceInvoice;
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
private $customerRepository;
|
||||
/**
|
||||
* @var ProjectRepository
|
||||
*/
|
||||
private $projectRepository;
|
||||
/**
|
||||
* @var InvoiceTemplateRepository
|
||||
*/
|
||||
private $invoiceTemplateRepository;
|
||||
/**
|
||||
* @var UserRepository
|
||||
*/
|
||||
private $userRepository;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $eventDispatcher;
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
private $previewDirectory;
|
||||
private $previewUniqueFile = false;
|
||||
private ?string $previewDirectory = null;
|
||||
private bool $previewUniqueFile = false;
|
||||
|
||||
public function __construct(
|
||||
ServiceInvoice $serviceInvoice,
|
||||
CustomerRepository $customerRepository,
|
||||
ProjectRepository $projectRepository,
|
||||
InvoiceTemplateRepository $invoiceTemplateRepository,
|
||||
UserRepository $userRepository,
|
||||
EventDispatcherInterface $eventDispatcher
|
||||
private ServiceInvoice $serviceInvoice,
|
||||
private CustomerRepository $customerRepository,
|
||||
private ProjectRepository $projectRepository,
|
||||
private InvoiceTemplateRepository $invoiceTemplateRepository,
|
||||
private UserRepository $userRepository,
|
||||
private EventDispatcherInterface $eventDispatcher
|
||||
) {
|
||||
$this->serviceInvoice = $serviceInvoice;
|
||||
$this->customerRepository = $customerRepository;
|
||||
$this->projectRepository = $projectRepository;
|
||||
$this->invoiceTemplateRepository = $invoiceTemplateRepository;
|
||||
$this->userRepository = $userRepository;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:invoice:create')
|
||||
->setDescription('Create invoices')
|
||||
->setHelp('This command allows to create invoices by several different filters.')
|
||||
->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices')
|
||||
@@ -101,7 +66,6 @@ class InvoiceCreateCommand extends Command
|
||||
->addOption('by-project', null, InputOption::VALUE_NONE, 'If set, one invoice for each active project in the given timerange is created')
|
||||
->addOption('set-exported', null, InputOption::VALUE_NONE, 'Whether the invoice items should be marked as exported')
|
||||
->addOption('template', null, InputOption::VALUE_OPTIONAL, 'Invoice template', null)
|
||||
->addOption('template-meta', null, InputOption::VALUE_OPTIONAL, 'Fetch invoice template from a meta-field', null)
|
||||
->addOption('search', null, InputOption::VALUE_OPTIONAL, 'Search term to filter invoice entries', null)
|
||||
->addOption('exported', null, InputOption::VALUE_OPTIONAL, 'Exported filter for invoice entries (possible values: exported, all), by default only "not exported" items are fetched', null)
|
||||
->addOption('preview', null, InputOption::VALUE_OPTIONAL, 'Absolute path for a rendered preview of the invoice, which will neither be saved nor the items be marked as exported.', null)
|
||||
@@ -109,10 +73,7 @@ class InvoiceCreateCommand extends Command
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -122,16 +83,17 @@ class InvoiceCreateCommand extends Command
|
||||
if (empty($username)) {
|
||||
$io->error('You must set a "user" to create invoices');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->userRepository->loadUserByUsername($username);
|
||||
if (null === $user) {
|
||||
try {
|
||||
$user = $this->userRepository->loadUserByIdentifier($username);
|
||||
} catch (\Exception $exception) {
|
||||
$io->error(
|
||||
sprintf('The given username "%s" could not be resolved', $username)
|
||||
);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$exportedFilter = TimesheetQuery::STATE_NOT_EXPORTED;
|
||||
@@ -150,7 +112,7 @@ class InvoiceCreateCommand extends Command
|
||||
default:
|
||||
$io->error('Unknown "exported" filter given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$timezone = $input->getOption('timezone');
|
||||
@@ -164,7 +126,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (!empty($input->getOption('start')) && empty($input->getOption('end'))) {
|
||||
$io->error('You need to supply a end date if a start date was given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$byActiveCustomer = $input->getOption('by-customer');
|
||||
@@ -173,7 +135,7 @@ class InvoiceCreateCommand extends Command
|
||||
if ($byActiveCustomer && $byActiveProject) {
|
||||
$io->error('You cannot mix "by-customer" and "by-project"');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$customersIDs = $input->getOption('customer');
|
||||
@@ -181,13 +143,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (!$byActiveCustomer && !$byActiveProject && empty($customersIDs) && empty($projectIDs)) {
|
||||
$io->error('Could not determine generation mode, you need to set one of: customer, project, by-customer, by-project');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (null === $input->getOption('template') && null === $input->getOption('template-meta')) {
|
||||
$io->error('You must either pass the "template" or "template-meta" option');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$start = $input->getOption('start');
|
||||
@@ -197,7 +153,7 @@ class InvoiceCreateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid start date given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
if (!$start instanceof \DateTime) {
|
||||
@@ -212,7 +168,7 @@ class InvoiceCreateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid end date given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
if (!$end instanceof \DateTime) {
|
||||
@@ -227,12 +183,12 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
$markAsExported = false;
|
||||
if ($input->getOption('preview') !== null) {
|
||||
$this->previewUniqueFile = $input->getOption('preview-unique');
|
||||
$this->previewUniqueFile = (bool) $input->getOption('preview-unique');
|
||||
$this->previewDirectory = rtrim($input->getOption('preview'), '/') . '/';
|
||||
if (!is_dir($this->previewDirectory) || !is_writable($this->previewDirectory)) {
|
||||
$io->error('Invalid preview directory given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} elseif ($input->getOption('set-exported')) {
|
||||
$markAsExported = true;
|
||||
@@ -245,7 +201,6 @@ class InvoiceCreateCommand extends Command
|
||||
$defaultQuery->setEnd($end);
|
||||
$defaultQuery->setCurrentUser($user);
|
||||
$defaultQuery->setSearchTerm($searchTerm);
|
||||
$defaultQuery->setMarkAsExported($markAsExported);
|
||||
$defaultQuery->setExported($exportedFilter);
|
||||
|
||||
/** @var Invoice[] $invoices */
|
||||
@@ -261,7 +216,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (null === $tmp) {
|
||||
$io->error('Unknown customer ID: ' . $id);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$customers[] = $tmp;
|
||||
}
|
||||
@@ -276,7 +231,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (null === $tmp) {
|
||||
$io->error('Unknown project ID: ' . $id);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$projects[] = $tmp;
|
||||
}
|
||||
@@ -290,7 +245,7 @@ class InvoiceCreateCommand extends Command
|
||||
} else {
|
||||
$io->error('Could not determine generation mode'); //-///9==8=//99/96//////-*/-*//96* <= by Ayumi
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return $this->renderInvoiceResult($input, $output, $invoices);
|
||||
@@ -312,11 +267,16 @@ class InvoiceCreateCommand extends Command
|
||||
$invoices = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$customer = $project->getCustomer();
|
||||
if ($customer === null) {
|
||||
throw new \Exception('Project has no customer: ' . $project->getId());
|
||||
}
|
||||
|
||||
$query = clone $defaultQuery;
|
||||
$query->addProject($project);
|
||||
$query->addCustomer($project->getCustomer());
|
||||
$query->addCustomer($customer);
|
||||
|
||||
$tpl = $this->getTemplateForProject($input, $project);
|
||||
$tpl = $this->getTemplateForCustomer($input, $customer);
|
||||
if (null === $tpl) {
|
||||
$io->warning(sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
|
||||
continue;
|
||||
@@ -325,9 +285,9 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
try {
|
||||
if (null !== $this->previewDirectory) {
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($query, $this->eventDispatcher));
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher));
|
||||
} else {
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
|
||||
@@ -396,9 +356,9 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
try {
|
||||
if (null !== $this->previewDirectory) {
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($query, $this->eventDispatcher));
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher));
|
||||
} else {
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
|
||||
@@ -421,7 +381,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (empty($invoices)) {
|
||||
$io->warning('No invoice was generated');
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (null !== $this->previewDirectory) {
|
||||
@@ -437,7 +397,7 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
$table->render();
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$columns = ['ID', 'Customer', 'Total', 'Filename'];
|
||||
@@ -465,56 +425,24 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
$table->render();
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function getTemplateForCustomer(InputInterface $input, Customer $customer): ?InvoiceTemplate
|
||||
{
|
||||
$template = $input->getOption('template');
|
||||
|
||||
$meta = $input->getOption('template-meta');
|
||||
if (!empty($meta)) {
|
||||
$metaField = $customer->getMetaField($meta);
|
||||
if (null !== $metaField && !empty($metaField->getValue())) {
|
||||
$template = $metaField->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $template) {
|
||||
return null;
|
||||
return $customer->getInvoiceTemplate();
|
||||
}
|
||||
|
||||
return $this->findTemplate($template);
|
||||
}
|
||||
|
||||
private function findTemplate(string $idOrName): ?InvoiceTemplate
|
||||
{
|
||||
$tpl = $this->invoiceTemplateRepository->find($idOrName);
|
||||
$tpl = $this->invoiceTemplateRepository->find($template);
|
||||
|
||||
if (null !== $tpl) {
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
return $this->invoiceTemplateRepository->findOneBy(['name' => $idOrName]);
|
||||
}
|
||||
|
||||
private function getTemplateForProject(InputInterface $input, Project $project): ?InvoiceTemplate
|
||||
{
|
||||
$template = $this->getTemplateForCustomer($input, $project->getCustomer());
|
||||
|
||||
$meta = $input->getOption('template-meta');
|
||||
if (!empty($meta)) {
|
||||
$metaField = $project->getMetaField($meta);
|
||||
if (null !== $metaField && !empty($metaField->getValue())) {
|
||||
$template = $metaField->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findTemplate($template);
|
||||
return $this->invoiceTemplateRepository->findOneBy(['name' => $template]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Plugin\PluginManager;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -18,35 +19,23 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
/**
|
||||
* Command used to fetch plugin information.
|
||||
*/
|
||||
class PluginCommand extends Command
|
||||
#[AsCommand(name: 'kimai:plugins')]
|
||||
final class PluginCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var PluginManager
|
||||
*/
|
||||
private $plugins;
|
||||
|
||||
public function __construct(PluginManager $plugins)
|
||||
public function __construct(private PluginManager $plugins)
|
||||
{
|
||||
$this->plugins = $plugins;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:plugins')
|
||||
->setDescription('Receive plugin information')
|
||||
->setHelp('This command prints detailed plugin information.')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -54,7 +43,7 @@ class PluginCommand extends Command
|
||||
if (empty($plugins)) {
|
||||
$io->warning('No plugins installed');
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
@@ -70,6 +59,6 @@ class PluginCommand extends Command
|
||||
}
|
||||
$io->table(['Name', 'Version', 'Requires', 'Directory'], $rows);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,37 +11,31 @@ namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class PromoteUserCommand extends AbstractRoleCommand
|
||||
#[AsCommand(name: 'kimai:user:promote')]
|
||||
final class PromoteUserCommand extends AbstractRoleCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('kimai:user:promote')
|
||||
->setAliases(['fos:user:promote'])
|
||||
->setDescription('Promotes a user by adding a role')
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:promote</info> command promotes a user by adding a role
|
||||
The <info>kimai:user:promote</info> command promotes a user by adding a role
|
||||
|
||||
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
|
||||
<info>php %command.full_name% --super susan_super</info>
|
||||
EOT
|
||||
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
|
||||
<info>php %command.full_name% --super susan_super</info>
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
|
||||
{
|
||||
$username = $user->getUsername();
|
||||
$username = $user->getUserIdentifier();
|
||||
if ($super) {
|
||||
if (!$user->isSuperAdmin()) {
|
||||
$user->setSuperAdmin(true);
|
||||
|
||||
133
src/Command/RegenerateLocalesCommand.php
Normal file
133
src/Command/RegenerateLocalesCommand.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?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\Command;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Intl\Locales;
|
||||
|
||||
/**
|
||||
* Command used to create the locale definition.
|
||||
*
|
||||
* We do NOT calculate that on every system again, because we want to make sure that we have the same
|
||||
* settings in every environment. Some environments (e.g. Github-Actions) have diverging settings from
|
||||
* the local system.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:reset:locales')]
|
||||
final class RegenerateLocalesCommand extends Command
|
||||
{
|
||||
private string $defaultDate = 'dd.MM.y';
|
||||
private string $defaultTime = 'HH:mm';
|
||||
private array $rtlLocales = [
|
||||
'ar' => true,
|
||||
'fa' => true,
|
||||
'he' => true,
|
||||
];
|
||||
|
||||
public function __construct(private LocaleService $localeService, private string $projectDirectory, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->kernelEnvironment !== 'prod';
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Regenerate the locale definition file');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$locales = $this->localeService->getAllLocales();
|
||||
|
||||
// detect all registered locales and allow to choose them as well, so people get to
|
||||
// choose the language for translation with the correct format of their location
|
||||
/*
|
||||
$secondLevel = [];
|
||||
foreach (Locales::getLocales() as $locale) {
|
||||
if (substr_count($locale, '_') === 1) {
|
||||
$baseLocale = substr($locale, 0, strpos($locale, '_'));
|
||||
if (in_array($baseLocale, $locales)) {
|
||||
$subLocale = substr($locale, strpos($locale, '_') + 1);
|
||||
if (!is_numeric($subLocale)) {
|
||||
$secondLevel[] = $locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$locales = array_merge($locales, $secondLevel);
|
||||
*/
|
||||
|
||||
$appLocales = [];
|
||||
$defaults = [
|
||||
'date' => $this->defaultDate,
|
||||
'time' => $this->defaultTime,
|
||||
'rtl' => false,
|
||||
];
|
||||
|
||||
// make sure all allowed locales are registered
|
||||
foreach ($locales as $locale) {
|
||||
if (!Locales::exists($locale)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$appLocales[$locale] = $defaults;
|
||||
}
|
||||
|
||||
// make sure all keys are registered for every locale
|
||||
foreach ($appLocales as $locale => $settings) {
|
||||
// these are completely new since v2
|
||||
// calculate everything with IntlFormatter
|
||||
$shortDate = new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE);
|
||||
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
|
||||
|
||||
$settings['date'] = $shortDate->getPattern();
|
||||
$settings['time'] = $shortTime->getPattern();
|
||||
|
||||
// make sure that sub-locales of a RTL language are also flagged as RTL
|
||||
$rtlLocale = $locale;
|
||||
if (substr_count($rtlLocale, '_') === 1) {
|
||||
$rtlLocale = substr($rtlLocale, 0, strpos($rtlLocale, '_'));
|
||||
}
|
||||
|
||||
if (\array_key_exists($rtlLocale, $this->rtlLocales)) {
|
||||
$settings['rtl'] = $this->rtlLocales[$rtlLocale];
|
||||
}
|
||||
|
||||
// pre-fill all formats with the default locale settings
|
||||
$appLocales[$locale] = $settings;
|
||||
}
|
||||
|
||||
ksort($appLocales);
|
||||
|
||||
$filename = 'config/locales.php';
|
||||
$targetFile = $this->projectDirectory . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
$content = '<?php return ' . var_export($appLocales, true) . ';';
|
||||
$content = str_replace('array (', '[', $content);
|
||||
$content = str_replace(')', ']', $content);
|
||||
|
||||
file_put_contents($targetFile, $content);
|
||||
|
||||
$io->success('Created new locale definition at: ' . $filename);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -9,26 +9,26 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\StringInput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
/**
|
||||
* Command used to update a Kimai installation.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:reload')]
|
||||
final class ReloadCommand extends Command
|
||||
{
|
||||
public const ERROR_CACHE_CLEAN = 2;
|
||||
public const ERROR_CACHE_WARMUP = 4;
|
||||
public const ERROR_LINT_CONFIG = 8;
|
||||
public const ERROR_LINT_TRANSLATIONS = 16;
|
||||
public function __construct(private string $projectDirectory, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base directory to the Kimai installation.
|
||||
@@ -37,30 +37,18 @@ final class ReloadCommand extends Command
|
||||
*/
|
||||
protected function getRootDirectory(): string
|
||||
{
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
|
||||
return $application->getKernel()->getProjectDir();
|
||||
return $this->projectDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:reload')
|
||||
->setDescription('Reload Kimai caches')
|
||||
->setHelp('This command will validate the configurations and translations and then clear and rebuild the application cache.')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -82,7 +70,7 @@ final class ReloadCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_LINT_CONFIG;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -97,14 +85,10 @@ final class ReloadCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_LINT_TRANSLATIONS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
/** @var KernelInterface $kernel */
|
||||
$kernel = $application->getKernel();
|
||||
$environment = $kernel->getEnvironment();
|
||||
$environment = $this->kernelEnvironment;
|
||||
|
||||
// flush the cache, in case values from the database are cached
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
@@ -122,17 +106,17 @@ final class ReloadCommand extends Command
|
||||
]
|
||||
);
|
||||
|
||||
return $cacheResult;
|
||||
return (int) $cacheResult;
|
||||
}
|
||||
|
||||
$io->success(
|
||||
sprintf('Kimai config was reloaded')
|
||||
);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
@@ -144,7 +128,7 @@ final class ReloadCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_CLEAN;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
@@ -155,9 +139,9 @@ final class ReloadCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_WARMUP;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -21,11 +21,12 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
* This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class ResetDevelopmentCommand extends AbstractResetCommand
|
||||
#[AsCommand(name: 'kimai:reset:dev', description: 'Resets the "development" environment')]
|
||||
final class ResetDevelopmentCommand extends AbstractResetCommand
|
||||
{
|
||||
protected function getEnvName(): string
|
||||
public function __construct(string $kernelEnvironment)
|
||||
{
|
||||
return 'dev';
|
||||
parent::__construct($kernelEnvironment);
|
||||
}
|
||||
|
||||
protected function loadData(InputInterface $input, OutputInterface $output): void
|
||||
|
||||
@@ -17,6 +17,8 @@ use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -29,19 +31,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
* This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class ResetTestCommand extends AbstractResetCommand
|
||||
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
|
||||
final class ResetTestCommand extends AbstractResetCommand
|
||||
{
|
||||
private $entityManager;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(private EntityManagerInterface $entityManager, string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
protected function getEnvName(): string
|
||||
{
|
||||
return 'test';
|
||||
parent::__construct($kernelEnvironment);
|
||||
}
|
||||
|
||||
protected function loadData(InputInterface $input, OutputInterface $output): void
|
||||
@@ -54,13 +49,12 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
$activity->setBudget(1000);
|
||||
$this->entityManager->persist($activity);
|
||||
|
||||
$customer = new Customer();
|
||||
$customer = new Customer('Test');
|
||||
$customer->setNumber('1');
|
||||
$customer->setComment('Test comment');
|
||||
$customer->setContact('Test');
|
||||
$customer->setAddress('Test');
|
||||
$customer->setCompany('Test');
|
||||
$customer->setName('Test');
|
||||
$customer->setCountry('DE');
|
||||
$customer->setCurrency('EUR');
|
||||
$customer->setPhone('111');
|
||||
@@ -83,19 +77,174 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
|
||||
$users = [
|
||||
// 0=id, 1=hourly rate, 2=Alias, 3=registration date, 4=title, 5=avatar, 6=enabled, 7=password, 8=roles, 9=username, 10=username canonical, 11=email, 12=email canonical, 13=salt, 14=last login, 15=confirmation token, 16=password requested at, 17=api_token
|
||||
[1, 53, 'Clara Haynes', '2018-02-06 23:28:57', 'CFO', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y', 1, '$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42', ['ROLE_CUSTOMER'], 'clara_customer', 'clara_customer', 'clara_customer@example.com', 'clara_customer@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[2, 82, 'John Doe', '2018-02-06 23:28:57', 'Developer', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', 1, '$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK', [], 'john_user', 'john_user', 'john_user@example.com', 'john_user@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[3, 35, 'Chris Deactive', '2018-02-06 23:28:57', 'Developer (left company)', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', 0, '$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi', [], 'chris_user', 'chris_user', 'chris_user@example.com', 'chris_user@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[4, 35, 'Tony Maier', '2018-02-06 23:28:57', 'Head of Development', 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', 1, '$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i', ['ROLE_TEAMLEAD'], 'tony_teamlead', 'tony_teamlead', 'tony_teamlead@example.com', 'tony_teamlead@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[5, 81, 'Anna Smith', '2018-02-06 23:28:57', 'Administrator', null, 1, '$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G', ['ROLE_ADMIN'], 'anna_admin', 'anna_admin', 'anna_admin@example.com', 'anna_admin@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[6, 46, null, '2018-02-06 23:28:57', 'Super Administrator', '/bundles/avanzuadmintheme/img/avatar.png', 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', ['ROLE_SUPER_ADMIN'], 'susan_super', 'susan_super', 'susan_super@example.com', 'susan_super@example.com', null, '2020-04-14 09:50:38', null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[7, null, 'Test User 1', null, 'Quality Tester 1', null, 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', [], 'test_user_1', 'test_user_1', 'test_user_1@example.com', 'test_user_1@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[8, null, 'Test User 2', null, 'Quality Tester 2', null, 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', [], 'test_user_2', 'test_user_2', 'test_user_2@example.com', 'test_user_2@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[
|
||||
1,
|
||||
53,
|
||||
'Clara Haynes',
|
||||
'2018-02-06 23:28:57',
|
||||
'CFO',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y',
|
||||
1,
|
||||
'$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42',
|
||||
['ROLE_CUSTOMER'],
|
||||
'clara_customer',
|
||||
'clara_customer',
|
||||
'clara_customer@example.com',
|
||||
'clara_customer@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
2,
|
||||
82,
|
||||
'John Doe',
|
||||
'2018-02-06 23:28:57',
|
||||
'Developer',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
|
||||
1,
|
||||
'$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK',
|
||||
[],
|
||||
'john_user',
|
||||
'john_user',
|
||||
'john_user@example.com',
|
||||
'john_user@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
3,
|
||||
35,
|
||||
'Chris Deactive',
|
||||
'2018-02-06 23:28:57',
|
||||
'Developer (left company)',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
|
||||
0,
|
||||
'$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi',
|
||||
[],
|
||||
'chris_user',
|
||||
'chris_user',
|
||||
'chris_user@example.com',
|
||||
'chris_user@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
4,
|
||||
35,
|
||||
'Tony Maier',
|
||||
'2018-02-06 23:28:57',
|
||||
'Head of Development',
|
||||
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
|
||||
1,
|
||||
'$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i',
|
||||
['ROLE_TEAMLEAD'],
|
||||
'tony_teamlead',
|
||||
'tony_teamlead',
|
||||
'tony_teamlead@example.com',
|
||||
'tony_teamlead@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
5,
|
||||
81,
|
||||
'Anna Smith',
|
||||
'2018-02-06 23:28:57',
|
||||
'Administrator',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G',
|
||||
['ROLE_ADMIN'],
|
||||
'anna_admin',
|
||||
'anna_admin',
|
||||
'anna_admin@example.com',
|
||||
'anna_admin@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
6,
|
||||
46,
|
||||
null,
|
||||
'2018-02-06 23:28:57',
|
||||
'Super Administrator',
|
||||
'/bundles/avanzuadmintheme/img/avatar.png',
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
['ROLE_SUPER_ADMIN'],
|
||||
'susan_super',
|
||||
'susan_super',
|
||||
'susan_super@example.com',
|
||||
'susan_super@example.com',
|
||||
null,
|
||||
'2020-04-14 09:50:38',
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
7,
|
||||
null,
|
||||
'Test User 1',
|
||||
null,
|
||||
'Quality Tester 1',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
[],
|
||||
'test_user_1',
|
||||
'test_user_1',
|
||||
'test_user_1@example.com',
|
||||
'test_user_1@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
8,
|
||||
null,
|
||||
'Test User 2',
|
||||
null,
|
||||
'Quality Tester 2',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
[],
|
||||
'test_user_2',
|
||||
'test_user_2',
|
||||
'test_user_2@example.com',
|
||||
'test_user_2@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
];
|
||||
|
||||
$userEntities = [];
|
||||
foreach ($users as $userConf) {
|
||||
$user = new User();
|
||||
foreach (User::WIZARDS as $wizard) {
|
||||
$user->setWizardAsSeen($wizard);
|
||||
}
|
||||
if ($userConf[1] !== null) {
|
||||
$user->setPreferenceValue(UserPreference::HOURLY_RATE, $userConf[1]);
|
||||
}
|
||||
@@ -120,7 +269,7 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
} else {
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
}
|
||||
$user->setUsername($userConf[9]);
|
||||
$user->setUserIdentifier($userConf[9]);
|
||||
if ($userConf[10] !== null) {
|
||||
// removed field: UsernameCanonical
|
||||
}
|
||||
@@ -138,8 +287,7 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
$userEntities[] = $user;
|
||||
}
|
||||
|
||||
$team = new Team();
|
||||
$team->setName('Test team');
|
||||
$team = new Team('Test team');
|
||||
$team->addTeamlead($userEntities[6]);
|
||||
$team->addUser($userEntities[7]);
|
||||
$this->entityManager->persist($team);
|
||||
@@ -155,9 +303,9 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop database schema: ' . $ex->getMessage());
|
||||
|
||||
return 2;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Timesheet\TimesheetService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -18,23 +19,20 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
/**
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class TimesheetStopAllCommand extends Command
|
||||
#[AsCommand(name: 'kimai:timesheet:stop-all')]
|
||||
final class TimesheetStopAllCommand extends Command
|
||||
{
|
||||
private $timesheetService;
|
||||
|
||||
public function __construct(TimesheetService $timesheetService)
|
||||
public function __construct(private TimesheetService $timesheetService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->timesheetService = $timesheetService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('kimai:timesheet:stop-all');
|
||||
$this->setDescription('Stop all running timesheets immediately');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$amount = $this->timesheetService->stopAll();
|
||||
|
||||
@@ -43,6 +41,6 @@ class TimesheetStopAllCommand extends Command
|
||||
$io->success(sprintf('Stopped %s timesheet records.', $amount));
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Kernel;
|
||||
use App\Utils\LanguageService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -24,34 +25,24 @@ use Symfony\Component\HttpClient\HttpClient;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class TranslationCommand extends Command
|
||||
#[AsCommand(name: 'kimai:translations')]
|
||||
final class TranslationCommand extends Command
|
||||
{
|
||||
private $projectDirectory;
|
||||
private $environment;
|
||||
private $languageService;
|
||||
|
||||
public function __construct(string $projectDirectory, string $kernelEnvironment, LanguageService $languageService)
|
||||
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private LocaleService $localeService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->projectDirectory = $projectDirectory;
|
||||
$this->environment = $kernelEnvironment;
|
||||
$this->languageService = $languageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:translations')
|
||||
->setDescription('Translation adjustments')
|
||||
->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute')
|
||||
->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys')
|
||||
->addOption('delete-resname', null, InputOption::VALUE_REQUIRED, 'Deletes the translation by resname')
|
||||
->addOption('extension', null, InputOption::VALUE_NONE, 'Find translation files with wrong extensions')
|
||||
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
|
||||
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty kyes and files which have no translated key at all')
|
||||
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
|
||||
// DEEPL TRANSLATION FEATURE - UNTESTED
|
||||
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
|
||||
// @see https://www.deepl.com/de/pro#developer
|
||||
@@ -61,18 +52,16 @@ class TranslationCommand extends Command
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->environment !== 'prod';
|
||||
return $this->kernelEnvironment !== 'prod';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$bases = [
|
||||
'core' => $this->projectDirectory . '/translations/*.xlf',
|
||||
'core_xliff' => $this->projectDirectory . '/translations/*.xliff',
|
||||
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
|
||||
'plugins_xliff' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xliff',
|
||||
];
|
||||
|
||||
if ($input->getOption('delete-resname')) {
|
||||
@@ -123,7 +112,7 @@ class TranslationCommand extends Command
|
||||
if (!file_exists($fromLocaleName)) {
|
||||
$io->error('Could not find translation file: ' . $fromLocaleName);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$translations[$fromLocale][$name] = $this->getTranslations($fromLocaleName);
|
||||
}
|
||||
@@ -200,13 +189,13 @@ class TranslationCommand extends Command
|
||||
if ($locale !== null && $deepl === null) {
|
||||
$io->error('Missing "DeepL API Free" auth-key');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($locale === null && $deepl !== null) {
|
||||
$io->error('Missing translation locale');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($locale !== null && $deepl !== null) {
|
||||
@@ -227,16 +216,16 @@ class TranslationCommand extends Command
|
||||
];
|
||||
|
||||
$locale = strtolower($locale);
|
||||
if (!$this->languageService->isKnownLanguage($locale)) {
|
||||
if (!$this->localeService->isKnownLocale($locale)) {
|
||||
$io->error('Unknown locale given: ' . $locale);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!\array_key_exists($locale, $deeplySupportedLanguages)) {
|
||||
$io->error('Locale not supported by Deeply: ' . $locale);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$allKeys = 0;
|
||||
@@ -307,7 +296,7 @@ class TranslationCommand extends Command
|
||||
} catch (\Exception $exception) {
|
||||
$io->error($exception->getMessage());
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$json = json_decode($rawResponseData->getContent(), true);
|
||||
@@ -323,7 +312,7 @@ class TranslationCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function getTranslations(string $file): array
|
||||
@@ -364,7 +353,7 @@ class TranslationCommand extends Command
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($xml->asXML());
|
||||
|
||||
$xpath = new \DOMXpath($xmlDocument);
|
||||
$xpath = new \DOMXPath($xmlDocument);
|
||||
$xpath->registerNamespace('ns', $xmlDocument->documentElement->namespaceURI);
|
||||
|
||||
$xmlContent = '';
|
||||
@@ -379,7 +368,7 @@ class TranslationCommand extends Command
|
||||
}
|
||||
|
||||
$fragment = $xmlDocument->createDocumentFragment();
|
||||
$fragment->appendXml('<body>' . $xmlContent . '</body>');
|
||||
$fragment->appendXML('<body>' . $xmlContent . '</body>');
|
||||
|
||||
/** @var \DOMElement $element */
|
||||
$element = $xpath->evaluate('/ns:xliff/ns:file')->item(0);
|
||||
|
||||
@@ -11,63 +11,39 @@ namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
/**
|
||||
* Command used to update a Kimai installation.
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:update')]
|
||||
final class UpdateCommand extends Command
|
||||
{
|
||||
public const ERROR_CACHE_CLEAN = 2;
|
||||
public const ERROR_CACHE_WARMUP = 4;
|
||||
public const ERROR_DATABASE = 8;
|
||||
public const ERROR_MIGRATIONS = 32;
|
||||
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
private $connection;
|
||||
|
||||
public function __construct(Connection $connection)
|
||||
public function __construct(private Connection $connection, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:update')
|
||||
->setDescription('Update your Kimai installation')
|
||||
->setHelp('This command will execute all required steps to update your Kimai installation.')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai updates running ...');
|
||||
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
/** @var KernelInterface $kernel */
|
||||
$kernel = $application->getKernel();
|
||||
$environment = $kernel->getEnvironment();
|
||||
$environment = $this->kernelEnvironment;
|
||||
|
||||
// make sure database is available, Kimai running and installed
|
||||
try {
|
||||
@@ -77,21 +53,21 @@ final class UpdateCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
|
||||
$io->error('Tables missing. Did you run the installer already?');
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) {
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) {
|
||||
$io->error('Unknown migration status, aborting database update');
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to validate database: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// execute latest doctrine migrations
|
||||
@@ -107,13 +83,13 @@ final class UpdateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_MIGRATIONS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// flush the cache, in case values from the database are cached
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
|
||||
if ($cacheResult !== 0) {
|
||||
if ($cacheResult !== Command::SUCCESS) {
|
||||
$io->warning(
|
||||
[
|
||||
sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
|
||||
@@ -128,10 +104,10 @@ final class UpdateCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
@@ -143,7 +119,7 @@ final class UpdateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_CLEAN;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
@@ -154,9 +130,9 @@ final class UpdateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_WARMUP;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,77 +10,44 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Command used to fetch Kimai version information.
|
||||
*/
|
||||
class VersionCommand extends Command
|
||||
#[AsCommand(name: 'kimai:version')]
|
||||
final class VersionCommand extends Command
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:version')
|
||||
->setDescription('Receive version information')
|
||||
->setHelp('This command allows you to fetch various version information about Kimai.')
|
||||
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
|
||||
->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only')
|
||||
// @deprecated since 1.14.1
|
||||
->addOption('name', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the major release name')
|
||||
->addOption('candidate', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the current version candidate (e.g. "stable" or "dev")')
|
||||
->addOption('semver', null, InputOption::VALUE_NONE, 'DEPRECATED: Semantical versioning (SEMVER) compatible version string')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if ($input->getOption('semver')) {
|
||||
@trigger_error('bin/console kimai:version --semver is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::VERSION . '-' . Constants::STATUS);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($input->getOption('short')) {
|
||||
$io->writeln(Constants::VERSION);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($input->getOption('name')) {
|
||||
@trigger_error('bin/console kimai:version --name is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::NAME);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($input->getOption('candidate')) {
|
||||
@trigger_error('bin/console kimai:version --candidate is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::STATUS);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($input->getOption('number')) {
|
||||
$io->writeln((string) Constants::VERSION_ID);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst and contributors.', Constants::SOFTWARE, Constants::VERSION));
|
||||
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst.', Constants::SOFTWARE, Constants::VERSION));
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
/**
|
||||
* @deprecated since 1.11 - use SystemConfiguration instead
|
||||
*/
|
||||
class CalendarConfiguration implements SystemBundleConfiguration
|
||||
{
|
||||
private $configuration;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function find(string $key)
|
||||
{
|
||||
if (strpos($key, $this->getPrefix() . '.') === false) {
|
||||
$key = $this->getPrefix() . '.' . $key;
|
||||
}
|
||||
|
||||
return $this->configuration->find($key);
|
||||
}
|
||||
|
||||
public function getPrefix(): string
|
||||
{
|
||||
return 'calendar';
|
||||
}
|
||||
|
||||
public function getBusinessDays(): array
|
||||
{
|
||||
return $this->configuration->getCalendarBusinessDays();
|
||||
}
|
||||
|
||||
public function getBusinessTimeBegin(): string
|
||||
{
|
||||
return $this->configuration->getCalendarBusinessTimeBegin();
|
||||
}
|
||||
|
||||
public function getBusinessTimeEnd(): string
|
||||
{
|
||||
return $this->configuration->getCalendarBusinessTimeEnd();
|
||||
}
|
||||
|
||||
public function getTimeframeBegin(): string
|
||||
{
|
||||
return $this->configuration->getCalendarTimeframeBegin();
|
||||
}
|
||||
|
||||
public function getTimeframeEnd(): string
|
||||
{
|
||||
return $this->configuration->getCalendarTimeframeEnd();
|
||||
}
|
||||
|
||||
public function getDayLimit(): int
|
||||
{
|
||||
return $this->configuration->getCalendarDayLimit();
|
||||
}
|
||||
|
||||
public function isShowWeekNumbers(): bool
|
||||
{
|
||||
return $this->configuration->isCalendarShowWeekNumbers();
|
||||
}
|
||||
|
||||
public function isShowWeekends(): bool
|
||||
{
|
||||
return $this->configuration->isCalendarShowWeekends();
|
||||
}
|
||||
|
||||
public function getGoogleApiKey(): ?string
|
||||
{
|
||||
return $this->configuration->getCalendarGoogleApiKey();
|
||||
}
|
||||
|
||||
public function getGoogleSources(): ?array
|
||||
{
|
||||
return $this->configuration->getCalendarGoogleSources();
|
||||
}
|
||||
|
||||
public function getSlotDuration(): string
|
||||
{
|
||||
return $this->configuration->getCalendarSlotDuration();
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,13 @@ use App\Entity\Configuration;
|
||||
interface ConfigLoaderInterface
|
||||
{
|
||||
/**
|
||||
* @param null|string $prefix
|
||||
* @param string $name
|
||||
* @return ?Configuration
|
||||
*/
|
||||
public function getConfiguration(string $name): ?Configuration;
|
||||
|
||||
/**
|
||||
* @return Configuration[]
|
||||
*/
|
||||
public function getConfiguration(?string $prefix = null): array;
|
||||
public function getConfigurations(): array;
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
/**
|
||||
* @deprecated will be removed with 2.0, use SystemConfiguration instead
|
||||
*/
|
||||
class FormConfiguration implements SystemBundleConfiguration
|
||||
{
|
||||
private $configuration;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function find(string $key)
|
||||
{
|
||||
if (strpos($key, $this->getPrefix() . '.') === false) {
|
||||
$key = $this->getPrefix() . '.' . $key;
|
||||
}
|
||||
|
||||
return $this->configuration->find($key);
|
||||
}
|
||||
|
||||
public function getPrefix(): string
|
||||
{
|
||||
return 'defaults';
|
||||
}
|
||||
|
||||
public function getCustomerDefaultTimezone(): ?string
|
||||
{
|
||||
return $this->configuration->getCustomerDefaultTimezone();
|
||||
}
|
||||
|
||||
public function getCustomerDefaultCurrency(): string
|
||||
{
|
||||
return $this->configuration->getCustomerDefaultCurrency();
|
||||
}
|
||||
|
||||
public function getCustomerDefaultCountry(): string
|
||||
{
|
||||
return $this->configuration->getCustomerDefaultCountry();
|
||||
}
|
||||
|
||||
public function getUserDefaultTimezone(): ?string
|
||||
{
|
||||
return $this->configuration->getUserDefaultTimezone();
|
||||
}
|
||||
|
||||
public function getUserDefaultTheme(): ?string
|
||||
{
|
||||
return $this->configuration->getUserDefaultTheme();
|
||||
}
|
||||
|
||||
public function getUserDefaultLanguage(): string
|
||||
{
|
||||
return $this->configuration->getUserDefaultLanguage();
|
||||
}
|
||||
|
||||
public function getUserDefaultCurrency(): string
|
||||
{
|
||||
return $this->configuration->getUserDefaultCurrency();
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,8 @@ namespace App\Configuration;
|
||||
|
||||
final class LdapConfiguration
|
||||
{
|
||||
private $configuration;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration)
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function isActivated(): bool
|
||||
@@ -25,16 +22,16 @@ final class LdapConfiguration
|
||||
|
||||
public function getRoleParameters(): array
|
||||
{
|
||||
return $this->configuration->getLdapRoleParameters();
|
||||
return $this->configuration->findArray('ldap.role');
|
||||
}
|
||||
|
||||
public function getUserParameters(): array
|
||||
{
|
||||
return $this->configuration->getLdapUserParameters();
|
||||
return $this->configuration->findArray('ldap.user');
|
||||
}
|
||||
|
||||
public function getConnectionParameters(): array
|
||||
{
|
||||
return $this->configuration->getLdapConnectionParameters();
|
||||
return $this->configuration->findArray('ldap.connection');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,23 +9,12 @@
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
use App\Utils\MomentFormatConverter;
|
||||
use App\Constants;
|
||||
|
||||
final class LanguageFormattings
|
||||
final class LocaleService
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $settings;
|
||||
/**
|
||||
* @var MomentFormatConverter
|
||||
*/
|
||||
private $momentFormatter;
|
||||
|
||||
public function __construct(array $languageSettings)
|
||||
public function __construct(private array $languageSettings)
|
||||
{
|
||||
$this->settings = $languageSettings;
|
||||
$this->momentFormatter = new MomentFormatConverter();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,31 +22,19 @@ final class LanguageFormattings
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAvailableLanguages(): array
|
||||
public function getAllLocales(): array
|
||||
{
|
||||
return array_keys($this->settings);
|
||||
return array_keys($this->languageSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the format which is used by the form component to handle date values.
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
*/
|
||||
public function getDateTypeFormat(string $locale): string
|
||||
public function isKnownLocale(string $language): bool
|
||||
{
|
||||
return $this->getConfig('date_type', $locale);
|
||||
return \in_array($language, $this->getAllLocales());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the format which is used by the Javascript component to handle date values.
|
||||
*
|
||||
* @param string $locale
|
||||
* @return string
|
||||
*/
|
||||
public function getDatePickerFormat(string $locale): string
|
||||
public function getDefaultLocale(): string
|
||||
{
|
||||
return $this->momentFormatter->convert($this->getDateTypeFormat($locale));
|
||||
return Constants::DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +67,7 @@ final class LanguageFormattings
|
||||
*/
|
||||
public function getDateTimeFormat(string $locale): string
|
||||
{
|
||||
return $this->getConfig('date_time', $locale);
|
||||
return $this->getDateFormat($locale) . ' ' . $this->getTimeFormat($locale);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,24 +78,31 @@ final class LanguageFormattings
|
||||
*/
|
||||
public function getDurationFormat(string $locale): string
|
||||
{
|
||||
return $this->getConfig('duration', $locale);
|
||||
return '%h:%m';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @param string $locale
|
||||
* @return string
|
||||
*/
|
||||
private function getConfig(string $key, string $locale): string
|
||||
public function isRightToLeft(string $locale): bool
|
||||
{
|
||||
if (!isset($this->settings[$locale])) {
|
||||
return $this->getConfig('rtl', $locale);
|
||||
}
|
||||
|
||||
public function is24Hour(string $locale): bool
|
||||
{
|
||||
$format = $this->getTimeFormat($locale);
|
||||
|
||||
return stripos($format, 'a') === false;
|
||||
}
|
||||
|
||||
private function getConfig(string $key, string $locale): string|bool
|
||||
{
|
||||
if (!isset($this->languageSettings[$locale])) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown locale given: %s', $locale));
|
||||
}
|
||||
|
||||
if (!isset($this->settings[$locale][$key])) {
|
||||
if (!isset($this->languageSettings[$locale][$key])) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown setting for locale %s: %s', $locale, $key));
|
||||
}
|
||||
|
||||
return $this->settings[$locale][$key];
|
||||
return $this->languageSettings[$locale][$key];
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,8 @@ namespace App\Configuration;
|
||||
|
||||
final class MailConfiguration
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $mailFrom;
|
||||
|
||||
public function __construct(string $mailFrom)
|
||||
public function __construct(private string $mailFrom)
|
||||
{
|
||||
$this->mailFrom = $mailFrom;
|
||||
}
|
||||
|
||||
public function getFromAddress(): ?string
|
||||
|
||||
@@ -14,11 +14,8 @@ namespace App\Configuration;
|
||||
*/
|
||||
final class SamlConfiguration implements SamlConfigurationInterface
|
||||
{
|
||||
private $configuration;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration)
|
||||
public function __construct(private SystemConfiguration $configuration)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function isActivated(): bool
|
||||
@@ -31,19 +28,29 @@ final class SamlConfiguration implements SamlConfigurationInterface
|
||||
return $this->configuration->getSamlTitle();
|
||||
}
|
||||
|
||||
public function getProvider(): string
|
||||
{
|
||||
return $this->configuration->getSamlProvider();
|
||||
}
|
||||
|
||||
public function getAttributeMapping(): array
|
||||
{
|
||||
return $this->configuration->getSamlAttributeMapping();
|
||||
return $this->configuration->findArray('saml.mapping');
|
||||
}
|
||||
|
||||
public function getRolesAttribute(): ?string
|
||||
{
|
||||
return $this->configuration->getSamlRolesAttribute();
|
||||
$attr = $this->configuration->find('saml.roles.attribute');
|
||||
if (empty($attr)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $attr;
|
||||
}
|
||||
|
||||
public function getRolesMapping(): array
|
||||
{
|
||||
return $this->configuration->getSamlRolesMapping();
|
||||
return $this->configuration->findArray('saml.roles.mapping');
|
||||
}
|
||||
|
||||
public function isRolesResetOnLogin(): bool
|
||||
@@ -53,6 +60,6 @@ final class SamlConfiguration implements SamlConfigurationInterface
|
||||
|
||||
public function getConnection(): array
|
||||
{
|
||||
return $this->configuration->getSamlConnection();
|
||||
return $this->configuration->findArray('saml.connection');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface SamlConfigurationInterface
|
||||
|
||||
public function getTitle(): string;
|
||||
|
||||
public function getProvider(): string;
|
||||
|
||||
public function getAttributeMapping(): array;
|
||||
|
||||
public function getRolesAttribute(): ?string;
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
use App\Entity\Configuration;
|
||||
|
||||
/**
|
||||
* @internal do NOT use this trait, but access your configs via SystemConfiguration
|
||||
*/
|
||||
trait StringAccessibleConfigTrait
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $settings;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $original;
|
||||
/**
|
||||
* @var ConfigLoaderInterface
|
||||
*/
|
||||
protected $repository;
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $initialized = false;
|
||||
|
||||
public function __construct(ConfigLoaderInterface $repository, array $settings)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->original = $this->settings = $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ConfigLoaderInterface $repository
|
||||
* @return Configuration[]
|
||||
*/
|
||||
protected function getConfigurations(ConfigLoaderInterface $repository): array
|
||||
{
|
||||
return $repository->getConfiguration($this->getPrefix());
|
||||
}
|
||||
|
||||
protected function prepare()
|
||||
{
|
||||
if ($this->initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->getConfigurations($this->repository) as $configuration) {
|
||||
$this->set($configuration->getName(), $configuration->getValue());
|
||||
}
|
||||
|
||||
$this->initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
abstract protected function getPrefix(): string;
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @return mixed
|
||||
*/
|
||||
public function default(string $key)
|
||||
{
|
||||
$key = $this->prepareSearchKey($key);
|
||||
|
||||
return $this->get($key, $this->original);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @return string|int|bool|float|null|array
|
||||
*/
|
||||
public function find(string $key)
|
||||
{
|
||||
$this->prepare();
|
||||
$key = $this->prepareSearchKey($key);
|
||||
|
||||
return $this->get($key, $this->settings);
|
||||
}
|
||||
|
||||
private function prepareSearchKey(string $key): string
|
||||
{
|
||||
$prefix = $this->getPrefix() . '.';
|
||||
$length = \strlen($prefix);
|
||||
|
||||
if (substr($key, 0, $length) === $prefix) {
|
||||
$key = substr($key, $length);
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @param array $config
|
||||
* @return mixed
|
||||
*/
|
||||
private function get(string $key, array $config)
|
||||
{
|
||||
$keys = explode('.', $key);
|
||||
$search = array_shift($keys);
|
||||
|
||||
if (!\array_key_exists($search, $config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (\is_array($config[$search]) && !empty($keys)) {
|
||||
return $this->get(implode('.', $keys), $config[$search]);
|
||||
}
|
||||
|
||||
return $config[$search];
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
$this->prepare();
|
||||
$key = $this->prepareSearchKey($key);
|
||||
|
||||
$keys = explode('.', $key);
|
||||
$search = array_shift($keys);
|
||||
|
||||
if (!\array_key_exists($search, $this->settings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return $this->has($offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $offset
|
||||
* @return array|bool|float|int|string|null
|
||||
*/
|
||||
#[\ReturnTypeWillChange]
|
||||
public function offsetGet($offset)
|
||||
{
|
||||
return $this->find($offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $offset
|
||||
* @param mixed $value
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetSet($offset, $value)
|
||||
{
|
||||
$this->set($offset, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $offset
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetUnset($offset)
|
||||
{
|
||||
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an array item to a given value using "dot" notation.
|
||||
*
|
||||
* If no key is given to the method, the entire array will be replaced.
|
||||
*
|
||||
* @see https://github.com/divineomega/array_undot
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function set(string $key, $value): array
|
||||
{
|
||||
$array = &$this->settings;
|
||||
$keys = explode('.', $key);
|
||||
while (\count($keys) > 1) {
|
||||
$key = array_shift($keys);
|
||||
if (!isset($array[$key]) || !\is_array($array[$key])) {
|
||||
$array[$key] = [];
|
||||
}
|
||||
|
||||
$array = &$array[$key];
|
||||
}
|
||||
|
||||
$k = array_shift($keys);
|
||||
|
||||
if (\array_key_exists($k, $array)) {
|
||||
if (\is_bool($array[$k])) {
|
||||
$value = (bool) $value;
|
||||
} elseif (\is_int($array[$k])) {
|
||||
$value = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
$array[$k] = $value;
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
interface SystemBundleConfiguration
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getPrefix(): string;
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @return mixed
|
||||
*/
|
||||
public function find(string $key);
|
||||
}
|
||||
@@ -9,21 +9,152 @@
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
class SystemConfiguration implements SystemBundleConfiguration
|
||||
final class SystemConfiguration
|
||||
{
|
||||
use StringAccessibleConfigTrait;
|
||||
private bool $initialized = false;
|
||||
|
||||
public function getPrefix(): string
|
||||
public function __construct(private ConfigLoaderInterface $repository, private ?array $settings)
|
||||
{
|
||||
return 'kimai';
|
||||
}
|
||||
|
||||
protected function getConfigurations(ConfigLoaderInterface $repository): array
|
||||
private function prepare(): void
|
||||
{
|
||||
return $repository->getConfiguration();
|
||||
if ($this->initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->repository->getConfigurations() as $configuration) {
|
||||
$this->set($configuration->getName(), $configuration->getValue());
|
||||
}
|
||||
|
||||
$this->initialized = true;
|
||||
}
|
||||
|
||||
// ========== Login form ==========
|
||||
/**
|
||||
* Set an array item to a given value using "dot" notation.
|
||||
*
|
||||
* If no key is given to the method, the entire array will be replaced.
|
||||
*
|
||||
* @see https://github.com/divineomega/array_undot
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
private function set(string $key, $value): void
|
||||
{
|
||||
if (\array_key_exists($key, $this->settings)) {
|
||||
if (\is_bool($this->settings[$key])) {
|
||||
$value = (bool) $value;
|
||||
} elseif (\is_int($this->settings[$key])) {
|
||||
$value = (int) $value;
|
||||
}
|
||||
}
|
||||
$this->settings[$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @return string|int|bool|float|null
|
||||
*/
|
||||
public function find(string $key): string|int|bool|float|null
|
||||
{
|
||||
$this->prepare();
|
||||
|
||||
if (\array_key_exists($key, $this->settings)) {
|
||||
return $this->settings[$key];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method should be avoided if possible, use plain keys instead.
|
||||
*
|
||||
* @param string $key
|
||||
* @return array
|
||||
*/
|
||||
public function findArray(string $key): array
|
||||
{
|
||||
$this->prepare();
|
||||
|
||||
$result = array_filter($this->settings, function ($settingName) use ($key): bool {
|
||||
return str_starts_with($settingName, $key);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
$replaced = [];
|
||||
foreach ($result as $settingName => $value) {
|
||||
if (\is_bool($this->settings[$settingName])) {
|
||||
$value = (bool) $value;
|
||||
} elseif (\is_int($this->settings[$settingName])) {
|
||||
$value = (int) $value;
|
||||
}
|
||||
|
||||
$baseName = str_replace($key . '.', '', $settingName);
|
||||
|
||||
$keys = explode('.', $baseName);
|
||||
$array = &$replaced;
|
||||
while (\count($keys) > 1) {
|
||||
$search = array_shift($keys);
|
||||
/* @phpstan-ignore-next-line */
|
||||
if (!\array_key_exists($search, $array) || !\is_array($array[$search])) {
|
||||
$array[$search] = [];
|
||||
}
|
||||
|
||||
$array = &$array[$search];
|
||||
}
|
||||
$array[array_shift($keys)] = $value;
|
||||
}
|
||||
|
||||
return $replaced;
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
$this->prepare();
|
||||
|
||||
if (\array_key_exists($key, $this->settings)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$result = array_filter($this->settings, function ($settingName) use ($key): bool {
|
||||
return str_starts_with($settingName, $key);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
return \count($result) > 0;
|
||||
}
|
||||
|
||||
// ========== Array access methods ==========
|
||||
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return $this->has($offset);
|
||||
}
|
||||
|
||||
public function offsetGet($offset): mixed
|
||||
{
|
||||
return $this->find($offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $offset
|
||||
* @param mixed $value
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetSet($offset, $value)
|
||||
{
|
||||
$this->set($offset, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $offset
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetUnset($offset)
|
||||
{
|
||||
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
|
||||
}
|
||||
|
||||
// ========== Authentication configurations ==========
|
||||
|
||||
public function isLoginFormActive(): bool
|
||||
{
|
||||
@@ -41,6 +172,10 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
|
||||
public function isSelfRegistrationActive(): bool
|
||||
{
|
||||
if (!$this->isLoginFormActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->find('user.registration');
|
||||
}
|
||||
|
||||
@@ -56,11 +191,13 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
|
||||
public function isPasswordResetActive(): bool
|
||||
{
|
||||
if (!$this->isLoginFormActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->find('user.password_reset');
|
||||
}
|
||||
|
||||
// ========== SAML configurations ==========
|
||||
|
||||
public function isSamlActive(): bool
|
||||
{
|
||||
return (bool) $this->find('saml.activate');
|
||||
@@ -71,19 +208,9 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return (string) $this->find('saml.title');
|
||||
}
|
||||
|
||||
public function getSamlAttributeMapping(): array
|
||||
public function getSamlProvider(): ?string
|
||||
{
|
||||
return (array) $this->find('saml.mapping');
|
||||
}
|
||||
|
||||
public function getSamlRolesAttribute(): ?string
|
||||
{
|
||||
return (string) $this->find('saml.roles.attribute');
|
||||
}
|
||||
|
||||
public function getSamlRolesMapping(): array
|
||||
{
|
||||
return (array) $this->find('saml.roles.mapping');
|
||||
return $this->find('saml.provider');
|
||||
}
|
||||
|
||||
public function isSamlRolesResetOnLogin(): bool
|
||||
@@ -91,40 +218,13 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return (bool) $this->find('saml.roles.resetOnLogin');
|
||||
}
|
||||
|
||||
public function getSamlConnection(): array
|
||||
{
|
||||
return (array) $this->find('saml.connection');
|
||||
}
|
||||
|
||||
// ========== LDAP configurations ==========
|
||||
|
||||
public function isLdapActive(): bool
|
||||
{
|
||||
return (bool) $this->find('ldap.activate');
|
||||
}
|
||||
|
||||
public function getLdapRoleParameters(): array
|
||||
{
|
||||
return (array) $this->find('ldap.role');
|
||||
}
|
||||
|
||||
public function getLdapUserParameters(): array
|
||||
{
|
||||
return (array) $this->find('ldap.user');
|
||||
}
|
||||
|
||||
public function getLdapConnectionParameters(): array
|
||||
{
|
||||
return (array) $this->find('ldap.connection');
|
||||
}
|
||||
|
||||
// ========== Calendar configurations ==========
|
||||
|
||||
public function getCalendarBusinessDays(): array
|
||||
{
|
||||
return (array) $this->find('calendar.businessHours.days');
|
||||
}
|
||||
|
||||
public function getCalendarBusinessTimeBegin(): string
|
||||
{
|
||||
return (string) $this->find('calendar.businessHours.begin');
|
||||
@@ -165,9 +265,9 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return $this->find('calendar.google.api_key');
|
||||
}
|
||||
|
||||
public function getCalendarGoogleSources(): ?array
|
||||
public function getCalendarGoogleSources(): array
|
||||
{
|
||||
return $this->find('calendar.google.sources');
|
||||
return $this->findArray('calendar.google.sources');
|
||||
}
|
||||
|
||||
public function getCalendarSlotDuration(): string
|
||||
@@ -272,18 +372,16 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return (bool) $this->find('timesheet.markdown_content');
|
||||
}
|
||||
|
||||
public function isTimesheetRequiresActivity(): bool
|
||||
{
|
||||
return (bool) $this->find('timesheet.rules.require_activity');
|
||||
}
|
||||
|
||||
public function getTimesheetActiveEntriesHardLimit(): int
|
||||
{
|
||||
return (int) $this->find('timesheet.active_entries.hard_limit');
|
||||
}
|
||||
|
||||
public function getTimesheetActiveEntriesSoftLimit(): int
|
||||
{
|
||||
@trigger_error('The configuration timesheet.active_entries.soft_limit is deprecated since 1.15', E_USER_DEPRECATED);
|
||||
|
||||
return $this->getTimesheetActiveEntriesHardLimit();
|
||||
}
|
||||
|
||||
public function getTimesheetDefaultRoundingDays(): string
|
||||
{
|
||||
return (string) $this->find('timesheet.rounding.default.days');
|
||||
@@ -309,32 +407,7 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return (int) $this->find('timesheet.rounding.default.duration');
|
||||
}
|
||||
|
||||
public function getTimesheetLockdownPeriodStart(): string
|
||||
{
|
||||
return (string) $this->find('timesheet.rules.lockdown_period_start');
|
||||
}
|
||||
|
||||
public function getTimesheetLockdownPeriodEnd(): string
|
||||
{
|
||||
return (string) $this->find('timesheet.rules.lockdown_period_end');
|
||||
}
|
||||
|
||||
public function getTimesheetLockdownGracePeriod(): string
|
||||
{
|
||||
return (string) $this->find('timesheet.rules.lockdown_grace_period');
|
||||
}
|
||||
|
||||
public function getTimesheetLockdownTimeZone(): ?string
|
||||
{
|
||||
return $this->find('timesheet.rules.lockdown_period_timezone');
|
||||
}
|
||||
|
||||
public function isTimesheetLockdownActive(): bool
|
||||
{
|
||||
return !empty($this->find('timesheet.rules.lockdown_period_start')) && !empty($this->find('timesheet.rules.lockdown_period_end'));
|
||||
}
|
||||
|
||||
private function getIncrement(string $key, int $fallback, int $min = 1): ?int
|
||||
private function getIncrement(string $key, int $fallback, int $min = 1): int
|
||||
{
|
||||
$config = $this->find($key);
|
||||
|
||||
@@ -344,27 +417,22 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
|
||||
$config = (int) $config;
|
||||
|
||||
return $config < $min ? null : $config;
|
||||
return max($config, $min);
|
||||
}
|
||||
|
||||
public function getTimesheetIncrementDuration(): ?int
|
||||
public function getTimesheetIncrementDuration(): int
|
||||
{
|
||||
return $this->getIncrement('timesheet.duration_increment', $this->getTimesheetDefaultRoundingDuration(), 1);
|
||||
return $this->getIncrement('timesheet.duration_increment', $this->getTimesheetDefaultRoundingDuration(), 0);
|
||||
}
|
||||
|
||||
public function getTimesheetIncrementBegin(): ?int
|
||||
public function getTimesheetIncrementMinutes(): int
|
||||
{
|
||||
return $this->getIncrement('timesheet.time_increment', $this->getTimesheetDefaultRoundingBegin(), 0);
|
||||
}
|
||||
|
||||
public function getTimesheetIncrementEnd(): ?int
|
||||
{
|
||||
return $this->getIncrement('timesheet.time_increment', $this->getTimesheetDefaultRoundingEnd(), 0);
|
||||
return $this->getIncrement('timesheet.time_increment', $this->getTimesheetDefaultRoundingDuration(), 0);
|
||||
}
|
||||
|
||||
public function getQuickEntriesRecentAmount(): int
|
||||
{
|
||||
return $this->getIncrement('quick_entry.recent_activities', 5, 0) ?? 5;
|
||||
return $this->getIncrement('quick_entry.recent_activities', 5, 5);
|
||||
}
|
||||
|
||||
// ========== Company configurations ==========
|
||||
@@ -382,14 +450,9 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
|
||||
// ========== Theme configurations ==========
|
||||
|
||||
public function isThemeColorsLimited(): bool
|
||||
public function isShowAbout(): bool
|
||||
{
|
||||
return (bool) $this->find('theme.colors_limited');
|
||||
}
|
||||
|
||||
public function isThemeRandomColors(): bool
|
||||
{
|
||||
return (bool) $this->find('theme.random_colors');
|
||||
return (bool) $this->find('theme.show_about');
|
||||
}
|
||||
|
||||
public function isThemeAllowAvatarUrls(): bool
|
||||
@@ -397,11 +460,6 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return (bool) $this->find('theme.avatar_url');
|
||||
}
|
||||
|
||||
public function getThemeAutocompleteCharacters(): int
|
||||
{
|
||||
return (int) $this->find('theme.autocomplete_chars');
|
||||
}
|
||||
|
||||
public function getThemeColorChoices(): ?string
|
||||
{
|
||||
$config = $this->find('theme.color_choices');
|
||||
@@ -409,19 +467,7 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return $config;
|
||||
}
|
||||
|
||||
return $this->default('theme.color_choices');
|
||||
}
|
||||
|
||||
// ========== Branding configurations ==========
|
||||
|
||||
public function getBrandingTitle(): ?string
|
||||
{
|
||||
return $this->find('theme.branding.title');
|
||||
}
|
||||
|
||||
public function isAllowTagCreation(): bool
|
||||
{
|
||||
return (bool) $this->find('theme.tags_create');
|
||||
return 'Silver|#c0c0c0';
|
||||
}
|
||||
|
||||
// ========== Projects ==========
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
/**
|
||||
* @internal might be deprecated in the future, use SystemConfiguration instead
|
||||
*/
|
||||
final class ThemeConfiguration implements \ArrayAccess
|
||||
{
|
||||
private $systemConfiguration;
|
||||
|
||||
public function __construct(SystemConfiguration $systemConfiguration)
|
||||
{
|
||||
$this->systemConfiguration = $systemConfiguration;
|
||||
}
|
||||
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return $this->systemConfiguration->has('theme.' . $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
#[\ReturnTypeWillChange]
|
||||
public function offsetGet($offset)
|
||||
{
|
||||
return $this->systemConfiguration->find('theme.' . $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $offset
|
||||
* @param mixed $value
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
throw new \BadMethodCallException('ThemeConfiguration does not support offsetSet()');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $offset
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
throw new \BadMethodCallException('ThemeConfiguration does not support offsetUnset()');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 1.15
|
||||
*/
|
||||
public function isAllowTagCreation(): bool
|
||||
{
|
||||
return (bool) $this->offsetGet('tags_create');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 1.15
|
||||
*/
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
$title = $this->offsetGet('branding.title');
|
||||
if (null === $title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $title;
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
/**
|
||||
* @deprecated since 1.13, use SystemConfiguration instead
|
||||
*/
|
||||
class TimesheetConfiguration implements SystemBundleConfiguration
|
||||
{
|
||||
private $configuration;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function find(string $key)
|
||||
{
|
||||
if (strpos($key, $this->getPrefix() . '.') === false) {
|
||||
$key = $this->getPrefix() . '.' . $key;
|
||||
}
|
||||
|
||||
return $this->configuration->find($key);
|
||||
}
|
||||
|
||||
public function getPrefix(): string
|
||||
{
|
||||
return 'timesheet';
|
||||
}
|
||||
|
||||
public function isAllowFutureTimes(): bool
|
||||
{
|
||||
return $this->configuration->isTimesheetAllowFutureTimes();
|
||||
}
|
||||
|
||||
public function isAllowOverlappingRecords(): bool
|
||||
{
|
||||
return $this->configuration->isTimesheetAllowOverlappingRecords();
|
||||
}
|
||||
|
||||
public function getTrackingMode(): string
|
||||
{
|
||||
return $this->configuration->getTimesheetTrackingMode();
|
||||
}
|
||||
|
||||
public function getDefaultBeginTime(): string
|
||||
{
|
||||
return $this->configuration->getTimesheetDefaultBeginTime();
|
||||
}
|
||||
|
||||
public function isMarkdownEnabled(): bool
|
||||
{
|
||||
return $this->configuration->isTimesheetMarkdownEnabled();
|
||||
}
|
||||
|
||||
public function getActiveEntriesHardLimit(): int
|
||||
{
|
||||
return $this->configuration->getTimesheetActiveEntriesHardLimit();
|
||||
}
|
||||
|
||||
public function getActiveEntriesSoftLimit(): int
|
||||
{
|
||||
return $this->configuration->getTimesheetActiveEntriesSoftLimit();
|
||||
}
|
||||
|
||||
public function getDefaultRoundingDays(): string
|
||||
{
|
||||
return $this->configuration->getTimesheetDefaultRoundingDays();
|
||||
}
|
||||
|
||||
public function getDefaultRoundingMode(): string
|
||||
{
|
||||
return $this->configuration->getTimesheetDefaultRoundingMode();
|
||||
}
|
||||
|
||||
public function getDefaultRoundingBegin(): int
|
||||
{
|
||||
return $this->configuration->getTimesheetDefaultRoundingBegin();
|
||||
}
|
||||
|
||||
public function getDefaultRoundingEnd(): int
|
||||
{
|
||||
return $this->configuration->getTimesheetDefaultRoundingEnd();
|
||||
}
|
||||
|
||||
public function getDefaultRoundingDuration(): int
|
||||
{
|
||||
return $this->configuration->getTimesheetDefaultRoundingDuration();
|
||||
}
|
||||
|
||||
public function getLockdownPeriodStart(): string
|
||||
{
|
||||
return $this->configuration->getTimesheetLockdownPeriodStart();
|
||||
}
|
||||
|
||||
public function getLockdownPeriodEnd(): string
|
||||
{
|
||||
return $this->configuration->getTimesheetLockdownPeriodEnd();
|
||||
}
|
||||
|
||||
public function getLockdownGracePeriod(): string
|
||||
{
|
||||
return $this->configuration->getTimesheetLockdownGracePeriod();
|
||||
}
|
||||
|
||||
public function isLockdownActive(): bool
|
||||
{
|
||||
return $this->configuration->isTimesheetLockdownActive();
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,17 @@ namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
class ConsoleApplication extends Application
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ConsoleApplication extends Application
|
||||
{
|
||||
public function getName()
|
||||
public function getName(): string
|
||||
{
|
||||
return Constants::SOFTWARE;
|
||||
}
|
||||
|
||||
public function getVersion()
|
||||
public function getVersion(): string
|
||||
{
|
||||
return Constants::VERSION;
|
||||
}
|
||||
@@ -28,7 +31,7 @@ class ConsoleApplication extends Application
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getLongVersion()
|
||||
public function getLongVersion(): string
|
||||
{
|
||||
return sprintf('%s <info>%s</info> (env: <comment>%s</>, debug: <comment>%s</>)', $this->getName(), $this->getVersion(), $this->getKernel()->getEnvironment(), $this->getKernel()->isDebug() ? 'true' : 'false');
|
||||
}
|
||||
|
||||
@@ -17,27 +17,23 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '1.30.1';
|
||||
public const VERSION = '2.0.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 13001;
|
||||
/**
|
||||
* The current release status, either "stable" or "dev"
|
||||
*/
|
||||
public const STATUS = 'stable';
|
||||
public const VERSION_ID = 20000;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
public const SOFTWARE = 'Kimai';
|
||||
/**
|
||||
* The release name, will only change for new major version
|
||||
*/
|
||||
public const NAME = 'Ayumi';
|
||||
/**
|
||||
* Used in multiple views
|
||||
*/
|
||||
public const GITHUB = 'https://github.com/kimai/kimai/';
|
||||
/**
|
||||
* The Github repository name
|
||||
*/
|
||||
public const GITHUB_REPO = 'kimai/kimai';
|
||||
/**
|
||||
* Homepage, used in multiple views
|
||||
*/
|
||||
|
||||
@@ -10,30 +10,18 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Constants;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/about")
|
||||
*/
|
||||
class AboutController extends AbstractController
|
||||
#[Route(path: '/about')]
|
||||
final class AboutController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $projectDirectory;
|
||||
|
||||
/**
|
||||
* @param string $projectDirectory
|
||||
*/
|
||||
public function __construct(string $projectDirectory)
|
||||
public function __construct(private string $projectDirectory)
|
||||
{
|
||||
$this->projectDirectory = $projectDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="", name="about", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '', name: 'about', methods: ['GET'])]
|
||||
public function license(): Response
|
||||
{
|
||||
$filename = $this->projectDirectory . '/LICENSE';
|
||||
@@ -47,10 +35,11 @@ class AboutController extends AbstractController
|
||||
|
||||
if (false === $license) {
|
||||
$license = 'Failed reading license file: ' . $filename . '. ' .
|
||||
'Check this instead: ' . Constants::GITHUB . 'blob/master/LICENSE';
|
||||
'Check this instead: ' . Constants::GITHUB . 'blob/main/LICENSE';
|
||||
}
|
||||
|
||||
return $this->render('about/license.html.twig', [
|
||||
'page_setup' => new PageSetup('about.title'),
|
||||
'license' => $license
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -9,86 +9,112 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\LanguageFormattings;
|
||||
use App\Entity\Bookmark;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookmarkRepository;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\LocaleFormats;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Contracts\Service\ServiceSubscriberInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* The abstract base controller.
|
||||
* @method null|User getUser()
|
||||
*/
|
||||
abstract class AbstractController extends BaseAbstractController implements ServiceSubscriberInterface
|
||||
{
|
||||
/**
|
||||
* @deprecated since 1.6, will be removed with 2.0
|
||||
*/
|
||||
public const ROLE_ADMIN = User::ROLE_ADMIN;
|
||||
protected function getUser(): User
|
||||
{
|
||||
$user = parent::getUser();
|
||||
if ($user === null) {
|
||||
throw $this->createAccessDeniedException('Missing user');
|
||||
}
|
||||
|
||||
protected function getTranslator(): TranslatorInterface
|
||||
if (!($user instanceof User)) {
|
||||
throw $this->createAccessDeniedException('Expected Kimai user, received unknown type');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function getTranslator(): TranslatorInterface
|
||||
{
|
||||
return $this->container->get('translator');
|
||||
}
|
||||
|
||||
public function createFormForGetRequest(string $type = FormType::class, $data = null, array $options = []): FormInterface
|
||||
protected function createSearchForm(string $type = FormType::class, $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->createFormForGetRequest($type, $data, $options);
|
||||
}
|
||||
|
||||
protected function createFormForGetRequest(string $type = FormType::class, $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->container
|
||||
->get('form.factory')
|
||||
->createNamed('', $type, $data, $options);
|
||||
->createNamed('', $type, $data, array_merge(['method' => 'GET'], $options));
|
||||
}
|
||||
|
||||
private function getLogger(): LoggerInterface
|
||||
protected function createFormWithName(string $name, string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->container->get('logger');
|
||||
return $this->container->get('form.factory')->createNamed($name, $type, $data, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a RedirectResponse to the given route with the given parameters.
|
||||
*
|
||||
* This needs to be a 201 code and NOT 302 (as usual for redirects) because 302 cannot be handled on
|
||||
* javascript side, as the fetch() API will auto-redirect these responses without access to the header.
|
||||
*/
|
||||
protected function redirectToRouteAfterCreate(string $route, array $parameters = []): RedirectResponse
|
||||
{
|
||||
$url = $this->generateUrl($route, $parameters);
|
||||
$response = new RedirectResponse($url, 201);
|
||||
$response->headers->set('x-modal-redirect', $url);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a "successful" flash message to the stack.
|
||||
*
|
||||
* @param string $translationKey
|
||||
* @param array $parameter
|
||||
*/
|
||||
protected function flashSuccess(string $translationKey, array $parameter = []): void
|
||||
protected function flashSuccess(string $translationKey): void
|
||||
{
|
||||
$this->addFlashTranslated('success', $translationKey, $parameter);
|
||||
$this->addFlashTranslated('success', $translationKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a "warning" flash message to the stack.
|
||||
*
|
||||
* @param string $translationKey
|
||||
* @param array $parameter
|
||||
*/
|
||||
protected function flashWarning(string $translationKey, array $parameter = []): void
|
||||
protected function flashWarning(string $translationKey): void
|
||||
{
|
||||
$this->addFlashTranslated('warning', $translationKey, $parameter);
|
||||
$this->addFlashTranslated('warning', $translationKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a "error" flash message to the stack.
|
||||
* Adds an "error" flash message to the stack.
|
||||
*
|
||||
* @param string $translationKey
|
||||
* @param array $parameter
|
||||
* @param array<string, string>|string $reason passing an array is deprecated
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function flashError(string $translationKey, array $parameter = []): void
|
||||
protected function flashError(string $translationKey, array|string $reason = ''): void
|
||||
{
|
||||
$this->addFlashTranslated('error', $translationKey, $parameter);
|
||||
if (\is_array($reason)) {
|
||||
@trigger_error('Calling "flashError" with an array $reason is deprecated and will be removed soon. Refactor and pass a string instead.', E_USER_DEPRECATED);
|
||||
$reason = \array_key_exists('%reason%', $reason) ? $reason['%reason%'] : '';
|
||||
}
|
||||
|
||||
$this->addFlashTranslated('error', $translationKey, ['%reason%' => $reason]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an exception flash message for failed update/create actions.
|
||||
*
|
||||
* @param \Exception $exception
|
||||
*/
|
||||
protected function flashUpdateException(\Exception $exception): void
|
||||
{
|
||||
@@ -97,8 +123,6 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
|
||||
/**
|
||||
* Adds an exception flash message for failed delete actions.
|
||||
*
|
||||
* @param \Exception $exception
|
||||
*/
|
||||
protected function flashDeleteException(\Exception $exception): void
|
||||
{
|
||||
@@ -106,21 +130,13 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a "error" flash message and logs the Exception.
|
||||
*
|
||||
* @param \Exception $exception
|
||||
* @param string $translationKey
|
||||
* @param array $parameter
|
||||
* Adds an "error" flash message and logs the Exception.
|
||||
*/
|
||||
protected function flashException(\Exception $exception, string $translationKey, array $parameter = []): void
|
||||
protected function flashException(\Exception $exception, string $translationKey): void
|
||||
{
|
||||
$this->logException($exception);
|
||||
|
||||
if (!\array_key_exists('%reason%', $parameter)) {
|
||||
$parameter['%reason%'] = $exception->getMessage();
|
||||
}
|
||||
|
||||
$this->addFlashTranslated('error', $translationKey, $parameter);
|
||||
$this->addFlashTranslated('error', $translationKey, ['%reason%' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,9 +144,11 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
*
|
||||
* @param string $type
|
||||
* @param string $message
|
||||
* @param array $parameter
|
||||
* @param array<string, string> $parameter
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function addFlashTranslated(string $type, string $message, array $parameter = []): void
|
||||
private function addFlashTranslated(string $type, string $message, array $parameter = []): void
|
||||
{
|
||||
if (!empty($parameter)) {
|
||||
foreach ($parameter as $key => $value) {
|
||||
@@ -146,17 +164,39 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
$this->addFlash($type, $message);
|
||||
}
|
||||
|
||||
protected function logException(\Exception $ex): void
|
||||
/**
|
||||
* Handles exception flash messages for failed update/create actions.
|
||||
*/
|
||||
protected function handleFormUpdateException(\Exception $exception, FormInterface $form): void
|
||||
{
|
||||
$this->getLogger()->critical($ex->getMessage());
|
||||
if (!($exception instanceof ValidationFailedException)) {
|
||||
$this->flashUpdateException($exception);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$msg = $this->getTranslator()->trans($exception->getMessage(), [], 'validators');
|
||||
if ($exception->getViolations()->count() > 0) {
|
||||
for ($i = 0; $i < $exception->getViolations()->count(); $i++) {
|
||||
$violation = $exception->getViolations()->get($i);
|
||||
$form->addError(new FormError($violation->getMessage()));
|
||||
}
|
||||
} else {
|
||||
$form->addError(new FormError($msg));
|
||||
}
|
||||
}
|
||||
|
||||
public static function getSubscribedServices()
|
||||
protected function logException(\Exception $ex): void
|
||||
{
|
||||
$this->container->get('logger')->critical($ex->getMessage());
|
||||
}
|
||||
|
||||
public static function getSubscribedServices(): array
|
||||
{
|
||||
return array_merge(parent::getSubscribedServices(), [
|
||||
'translator' => TranslatorInterface::class,
|
||||
'logger' => LoggerInterface::class,
|
||||
LanguageFormattings::class => LanguageFormattings::class,
|
||||
BookmarkRepository::class => BookmarkRepository::class,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -169,28 +209,30 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
return DateTimeFactory::createByUser($user);
|
||||
}
|
||||
|
||||
protected function getLocaleFormats(string $locale): LocaleFormats
|
||||
// ================================ SEARCH AND BOOKMARKS ================================
|
||||
|
||||
private function getBookmark(): BookmarkRepository
|
||||
{
|
||||
return new LocaleFormats($this->container->get(LanguageFormattings::class), $locale);
|
||||
return $this->container->get(BookmarkRepository::class);
|
||||
}
|
||||
|
||||
private function getLastSearch(BaseQuery $query): ?array
|
||||
private function getLastSearch(SessionInterface $session, BaseQuery $query): ?array
|
||||
{
|
||||
$name = 'search_' . $this->getSearchName($query);
|
||||
|
||||
if (!$this->get('session')->has($name)) {
|
||||
if (!$session->has($name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->get('session')->get($name);
|
||||
return $session->get($name);
|
||||
}
|
||||
|
||||
private function removeLastSearch(BaseQuery $query): void
|
||||
private function removeLastSearch(SessionInterface $session, BaseQuery $query): void
|
||||
{
|
||||
$name = 'search_' . $this->getSearchName($query);
|
||||
|
||||
if ($this->get('session')->has($name)) {
|
||||
$this->get('session')->remove($name);
|
||||
if ($session->has($name)) {
|
||||
$session->remove($name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,15 +241,6 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
return substr($query->getName(), 0, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @internal
|
||||
*/
|
||||
protected function ignorePersistedSearch(Request $request): void
|
||||
{
|
||||
$request->query->set('performSearch', true);
|
||||
}
|
||||
|
||||
protected function handleSearch(FormInterface $form, Request $request): bool
|
||||
{
|
||||
$data = $form->getData();
|
||||
@@ -230,21 +263,26 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
|
||||
if ($request->query->has('resetSearchFilter')) {
|
||||
$data->resetFilter();
|
||||
$this->removeLastSearch($data);
|
||||
$this->removeLastSearch($request->getSession(), $data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$submitData = $request->query->all();
|
||||
// allow to use forms with block-prefix
|
||||
$queryKey = null;
|
||||
if (!empty($formName = $form->getConfig()->getName()) && $request->request->has($formName)) {
|
||||
$submitData = $request->request->get($formName);
|
||||
// allow using forms with block-prefix
|
||||
$queryKey = $formName;
|
||||
}
|
||||
|
||||
if ($request->isMethod(Request::METHOD_POST)) {
|
||||
$submitData = $request->request->all($queryKey);
|
||||
} else {
|
||||
$submitData = $request->query->all($queryKey);
|
||||
}
|
||||
$searchName = $this->getSearchName($data);
|
||||
|
||||
/** @var BookmarkRepository $bookmarkRepo */
|
||||
$bookmarkRepo = $this->getDoctrine()->getRepository(Bookmark::class);
|
||||
$bookmarkRepo = $this->getBookmark();
|
||||
$bookmark = $bookmarkRepo->getSearchDefaultOptions($this->getUser(), $searchName);
|
||||
|
||||
if ($bookmark !== null) {
|
||||
@@ -260,12 +298,23 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
|
||||
// apply persisted search data ONLY if search form was not submitted manually
|
||||
if (!$request->query->has('performSearch')) {
|
||||
$sessionSearch = $this->getLastSearch($data);
|
||||
$sessionSearch = $this->getLastSearch($request->getSession(), $data);
|
||||
if ($sessionSearch !== null) {
|
||||
$submitData = array_merge($sessionSearch, $submitData);
|
||||
} elseif ($bookmark !== null && !$request->query->has('setDefaultQuery')) {
|
||||
$submitData = array_merge($bookmark->getContent(), $submitData);
|
||||
$data->flagAsBookmarkSearch();
|
||||
$bookContent = $bookmark->getContent();
|
||||
$isBookmarkSearch = true;
|
||||
foreach ($submitData as $key => $value) {
|
||||
if (!\array_key_exists($key, $bookContent) || $value !== $bookContent[$key]) {
|
||||
$isBookmarkSearch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($isBookmarkSearch) {
|
||||
$data->flagAsBookmarkSearch();
|
||||
}
|
||||
|
||||
$submitData = array_merge($bookContent, $submitData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +347,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
}
|
||||
|
||||
if ($request->query->has('performSearch')) {
|
||||
$this->get('session')->set('search_' . $searchName, $params);
|
||||
$request->getSession()->set('search_' . $searchName, $params);
|
||||
}
|
||||
|
||||
// filter stuff, that does not belong in a bookmark
|
||||
@@ -310,7 +359,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
}
|
||||
|
||||
if ($request->query->has('setDefaultQuery')) {
|
||||
$this->removeLastSearch($data);
|
||||
$this->removeLastSearch($request->getSession(), $data);
|
||||
if ($bookmark === null) {
|
||||
$bookmark = new Bookmark();
|
||||
$bookmark->setType(Bookmark::SEARCH_DEFAULT);
|
||||
|
||||
@@ -32,50 +32,29 @@ use App\Repository\ActivityRateRepository;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* Controller used to manage activities in the admin part of the site.
|
||||
*
|
||||
* @Route(path="/admin/activity")
|
||||
* @Security("is_granted('view_activity') or is_granted('view_teamlead_activity') or is_granted('view_team_activity')")
|
||||
* Controller used to manage activities.
|
||||
*/
|
||||
#[Route(path: '/admin/activity')]
|
||||
#[Security("is_granted('view_activity') or is_granted('view_teamlead_activity') or is_granted('view_team_activity')")]
|
||||
final class ActivityController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var ActivityRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var SystemConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var ActivityService
|
||||
*/
|
||||
private $activityService;
|
||||
|
||||
public function __construct(ActivityRepository $repository, SystemConfiguration $configuration, EventDispatcherInterface $dispatcher, ActivityService $activityService)
|
||||
public function __construct(private ActivityRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ActivityService $activityService)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->configuration = $configuration;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->activityService = $activityService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"})
|
||||
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_activity', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_activity_paginated', methods: ['GET'])]
|
||||
public function indexAction($page, Request $request)
|
||||
{
|
||||
$query = new ActivityQuery();
|
||||
@@ -88,12 +67,43 @@ final class ActivityController extends AbstractController
|
||||
}
|
||||
|
||||
$entries = $this->repository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
|
||||
$table = new DataTable('activity_admin', $query);
|
||||
$table->setPagination($entries);
|
||||
$table->setSearchForm($form);
|
||||
$table->setPaginationRoute('admin_activity_paginated');
|
||||
$table->setReloadEvents('kimai.activityUpdate kimai.activityDelete kimai.activityTeamUpdate');
|
||||
|
||||
$table->addColumn('name', ['class' => 'alwaysVisible']);
|
||||
$table->addColumn('project', ['class' => 'd-none']);
|
||||
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
|
||||
|
||||
foreach ($metaColumns as $metaColumn) {
|
||||
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('budget_money', 'activity')) {
|
||||
$table->addColumn('budget', ['class' => 'd-none text-end w-min', 'title' => 'budget']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('budget_time', 'activity')) {
|
||||
$table->addColumn('timeBudget', ['class' => 'd-none text-end w-min', 'title' => 'timeBudget']);
|
||||
}
|
||||
|
||||
$table->addColumn('billable', ['class' => 'd-none text-center w-min', 'orderBy' => false]);
|
||||
$table->addColumn('team', ['class' => 'text-center w-min', 'orderBy' => false]);
|
||||
$table->addColumn('visible', ['class' => 'd-none text-center w-min']);
|
||||
$table->addColumn('actions', ['class' => 'actions']);
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
$page->setDataTable($table);
|
||||
$page->setActionName('activities');
|
||||
|
||||
return $this->render('activity/index.html.twig', [
|
||||
'entries' => $entries,
|
||||
'query' => $query,
|
||||
'toolbarForm' => $form->createView(),
|
||||
'metaColumns' => $this->findMetaColumns($query),
|
||||
'page_setup' => $page,
|
||||
'dataTable' => $table,
|
||||
'metaColumns' => $metaColumns,
|
||||
'defaultCurrency' => $this->configuration->getCustomerDefaultCurrency(),
|
||||
'now' => $this->getDateTimeFactory()->createDateTime(),
|
||||
]);
|
||||
@@ -103,7 +113,7 @@ final class ActivityController extends AbstractController
|
||||
* @param ActivityQuery $query
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
protected function findMetaColumns(ActivityQuery $query): array
|
||||
private function findMetaColumns(ActivityQuery $query): array
|
||||
{
|
||||
$event = new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::ACTIVITY);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -111,10 +121,8 @@ final class ActivityController extends AbstractController
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/details", name="activity_details", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view', activity)")
|
||||
*/
|
||||
#[Route(path: '/{id}/details', name: 'activity_details', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('view', activity)")]
|
||||
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService)
|
||||
{
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
@@ -146,7 +154,13 @@ final class ActivityController extends AbstractController
|
||||
$this->dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
$page->setActionName('activity');
|
||||
$page->setActionView('activity_details');
|
||||
$page->setActionPayload(['activity' => $activity]);
|
||||
|
||||
return $this->render('activity/details.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'activity' => $activity,
|
||||
'stats' => $stats,
|
||||
'rates' => $rates,
|
||||
@@ -157,17 +171,27 @@ final class ActivityController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/rate", name="admin_activity_rate_add", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', activity)")
|
||||
*/
|
||||
public function addRateAction(Activity $activity, Request $request, ActivityRateRepository $repository)
|
||||
#[Route(path: '/{id}/rate/{rate}', name: 'admin_activity_rate_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', activity)")]
|
||||
public function editRateAction(Activity $activity, ActivityRate $rate, Request $request, ActivityRateRepository $repository): Response
|
||||
{
|
||||
return $this->rateFormAction($activity, $rate, $request, $repository, $this->generateUrl('admin_activity_rate_edit', ['id' => $activity->getId(), 'rate' => $rate->getId()]));
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/rate', name: 'admin_activity_rate_add', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', activity)")]
|
||||
public function addRateAction(Activity $activity, Request $request, ActivityRateRepository $repository): Response
|
||||
{
|
||||
$rate = new ActivityRate();
|
||||
$rate->setActivity($activity);
|
||||
|
||||
return $this->rateFormAction($activity, $rate, $request, $repository, $this->generateUrl('admin_activity_rate_add', ['id' => $activity->getId()]));
|
||||
}
|
||||
|
||||
private function rateFormAction(Activity $activity, ActivityRate $rate, Request $request, ActivityRateRepository $repository, string $formUrl): Response
|
||||
{
|
||||
$form = $this->createForm(ActivityRateForm::class, $rate, [
|
||||
'action' => $this->generateUrl('admin_activity_rate_add', ['id' => $activity->getId()]),
|
||||
'action' => $formUrl,
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
@@ -185,17 +209,27 @@ final class ActivityController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('activity/rates.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'activity' => $activity,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"})
|
||||
* @Route(path="/create/{project}", name="admin_activity_create_with_project", methods={"GET", "POST"})
|
||||
* @Security("is_granted('create_activity')")
|
||||
*/
|
||||
public function createAction(Request $request, ?Project $project = null)
|
||||
#[Route(path: '/create/{project}', name: 'admin_activity_create_with_project', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('create_activity')")]
|
||||
public function createWithProjectAction(Request $request, Project $project): Response
|
||||
{
|
||||
return $this->createActivity($request, $project);
|
||||
}
|
||||
|
||||
#[Route(path: '/create', name: 'admin_activity_create', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('create_activity')")]
|
||||
public function createAction(Request $request): Response
|
||||
{
|
||||
return $this->createActivity($request, null);
|
||||
}
|
||||
|
||||
private function createActivity(Request $request, ?Project $project = null): Response
|
||||
{
|
||||
$activity = $this->activityService->createNewActivity($project);
|
||||
|
||||
@@ -210,23 +244,22 @@ final class ActivityController extends AbstractController
|
||||
$this->activityService->saveNewActivity($activity);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_activity');
|
||||
return $this->redirectToRouteAfterCreate('activity_details', ['id' => $activity->getId()]);
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
$this->handleFormUpdateException($ex, $editForm);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('activity/edit.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'activity' => $activity,
|
||||
'form' => $editForm->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/permissions", name="admin_activity_permissions", methods={"GET", "POST"})
|
||||
* @Security("is_granted('permissions', activity)")
|
||||
*/
|
||||
public function teamPermissionsAction(Activity $activity, Request $request)
|
||||
#[Route(path: '/{id}/permissions', name: 'admin_activity_permissions', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('permissions', activity)")]
|
||||
public function teamPermissionsAction(Activity $activity, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(ActivityTeamPermissionForm::class, $activity, [
|
||||
'action' => $this->generateUrl('admin_activity_permissions', ['id' => $activity->getId()]),
|
||||
@@ -251,26 +284,24 @@ final class ActivityController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('activity/permissions.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'activity' => $activity,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/create_team", name="activity_team_create", methods={"GET"})
|
||||
* @Security("is_granted('create_team') and is_granted('permissions', activity)")
|
||||
*/
|
||||
public function createDefaultTeamAction(Activity $activity, TeamRepository $teamRepository)
|
||||
#[Route(path: '/{id}/create_team', name: 'activity_team_create', methods: ['GET'])]
|
||||
#[Security("is_granted('create_team') and is_granted('permissions', activity)")]
|
||||
public function createDefaultTeamAction(Activity $activity, TeamRepository $teamRepository): Response
|
||||
{
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]);
|
||||
if (null !== $defaultTeam) {
|
||||
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
|
||||
$this->flashError('action.update.error', 'Team already existing');
|
||||
|
||||
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
|
||||
}
|
||||
|
||||
$defaultTeam = new Team();
|
||||
$defaultTeam->setName($activity->getName());
|
||||
$defaultTeam = new Team($activity->getName());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addActivity($activity);
|
||||
|
||||
@@ -283,11 +314,9 @@ final class ActivityController extends AbstractController
|
||||
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/edit", name="admin_activity_edit", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', activity)")
|
||||
*/
|
||||
public function editAction(Activity $activity, Request $request)
|
||||
#[Route(path: '/{id}/edit', name: 'admin_activity_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', activity)")]
|
||||
public function editAction(Activity $activity, Request $request): Response
|
||||
{
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -307,16 +336,15 @@ final class ActivityController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('activity/edit.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'activity' => $activity,
|
||||
'form' => $editForm->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/delete", name="admin_activity_delete", methods={"GET", "POST"})
|
||||
* @Security("is_granted('delete', activity)")
|
||||
*/
|
||||
public function deleteAction(Activity $activity, Request $request, ActivityStatisticService $statisticService)
|
||||
#[Route(path: '/{id}/delete', name: 'admin_activity_delete', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('delete', activity)")]
|
||||
public function deleteAction(Activity $activity, Request $request, ActivityStatisticService $statisticService): Response
|
||||
{
|
||||
$stats = $statisticService->getActivityStatistics($activity);
|
||||
|
||||
@@ -352,20 +380,16 @@ final class ActivityController extends AbstractController
|
||||
return $this->redirectToRoute('admin_activity');
|
||||
}
|
||||
|
||||
return $this->render(
|
||||
'activity/delete.html.twig',
|
||||
[
|
||||
'activity' => $activity,
|
||||
'stats' => $stats,
|
||||
'form' => $deleteForm->createView(),
|
||||
]
|
||||
);
|
||||
return $this->render('activity/delete.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'activity' => $activity,
|
||||
'stats' => $stats,
|
||||
'form' => $deleteForm->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/export", name="activity_export", methods={"GET"})
|
||||
*/
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
|
||||
#[Route(path: '/export', name: 'activity_export', methods: ['GET'])]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
|
||||
{
|
||||
$query = new ActivityQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -390,25 +414,16 @@ final class ActivityController extends AbstractController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ActivityQuery $query
|
||||
* @return FormInterface
|
||||
*/
|
||||
protected function getToolbarForm(ActivityQuery $query)
|
||||
private function getToolbarForm(ActivityQuery $query): FormInterface
|
||||
{
|
||||
return $this->createForm(ActivityToolbarForm::class, $query, [
|
||||
return $this->createSearchForm(ActivityToolbarForm::class, $query, [
|
||||
'action' => $this->generateUrl('admin_activity', [
|
||||
'page' => $query->getPage(),
|
||||
]),
|
||||
'method' => 'GET',
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Activity $activity
|
||||
* @return FormInterface
|
||||
*/
|
||||
private function createEditForm(Activity $activity)
|
||||
private function createEditForm(Activity $activity): FormInterface
|
||||
{
|
||||
$currency = $this->configuration->getCustomerDefaultCurrency();
|
||||
$url = $this->generateUrl('admin_activity_create');
|
||||
@@ -428,4 +443,12 @@ final class ActivityController extends AbstractController
|
||||
'include_time' => $this->isGranted('time', $activity),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPageSetup(): PageSetup
|
||||
{
|
||||
$page = new PageSetup('activities');
|
||||
$page->setHelp('activity.html');
|
||||
|
||||
return $page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,23 +17,14 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* @Route(path="/saml")
|
||||
*/
|
||||
#[Route(path: '/saml')]
|
||||
final class SamlController extends AbstractController
|
||||
{
|
||||
private $authFactory;
|
||||
private $samlConfiguration;
|
||||
|
||||
public function __construct(SamlAuthFactory $authFactory, SamlConfigurationInterface $samlConfiguration)
|
||||
public function __construct(private SamlAuthFactory $authFactory, private SamlConfigurationInterface $samlConfiguration)
|
||||
{
|
||||
$this->authFactory = $authFactory;
|
||||
$this->samlConfiguration = $samlConfiguration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/login", name="saml_login")
|
||||
*/
|
||||
#[Route(path: '/login', name: 'saml_login')]
|
||||
public function loginAction(Request $request)
|
||||
{
|
||||
if (!$this->samlConfiguration->isActivated()) {
|
||||
@@ -47,7 +38,7 @@ final class SamlController extends AbstractController
|
||||
|
||||
if ($request->attributes->has($authErrorKey)) {
|
||||
$error = $request->attributes->get($authErrorKey);
|
||||
} elseif (null !== $session && $session->has($authErrorKey)) {
|
||||
} elseif ($session->has($authErrorKey)) {
|
||||
$error = $session->get($authErrorKey);
|
||||
$session->remove($authErrorKey);
|
||||
}
|
||||
@@ -62,9 +53,7 @@ final class SamlController extends AbstractController
|
||||
$this->authFactory->create()->login($session->get('_security.main.target_path'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/metadata", name="saml_metadata")
|
||||
*/
|
||||
#[Route(path: '/metadata', name: 'saml_metadata')]
|
||||
public function metadataAction()
|
||||
{
|
||||
if (!$this->samlConfiguration->isActivated()) {
|
||||
@@ -79,9 +68,7 @@ final class SamlController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/acs", name="saml_acs")
|
||||
*/
|
||||
#[Route(path: '/acs', name: 'saml_acs')]
|
||||
public function assertionConsumerServiceAction()
|
||||
{
|
||||
if (!$this->samlConfiguration->isActivated()) {
|
||||
@@ -91,9 +78,7 @@ final class SamlController extends AbstractController
|
||||
throw new \RuntimeException('You must configure the check path in your firewall.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/logout", name="saml_logout")
|
||||
*/
|
||||
#[Route(path: '/logout', name: 'saml_logout')]
|
||||
public function logoutAction()
|
||||
{
|
||||
if (!$this->samlConfiguration->isActivated()) {
|
||||
|
||||
140
src/Controller/BookmarkController.php
Normal file
140
src/Controller/BookmarkController.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?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\Bookmark;
|
||||
use App\Repository\BookmarkRepository;
|
||||
use App\Utils\ProfileManager;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Exception\RuntimeException;
|
||||
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
/**
|
||||
* This does not go into the API, because it is ONLY related to the Web UI.
|
||||
*/
|
||||
#[Route(path: '/bookmark')]
|
||||
final class BookmarkController extends AbstractController
|
||||
{
|
||||
public const DATATABLE_TOKEN = 'datatable_update';
|
||||
public const PARAM_TOKEN_NAME = 'datatable_token';
|
||||
public const PARAM_DATATABLE = 'datatable_name';
|
||||
public const PARAM_PROFILE = 'datatable_profile';
|
||||
|
||||
public function __construct(private BookmarkRepository $bookmarkRepository, private ProfileManager $profileManager)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route(path: '/datatable/profile', name: 'bookmark_profile', methods: ['POST'])]
|
||||
public function datatableProfile(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$request->request->has(self::PARAM_TOKEN_NAME) || !$request->request->has(self::PARAM_PROFILE)) {
|
||||
throw $this->createNotFoundException('Missing CSRF Token');
|
||||
}
|
||||
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF Token');
|
||||
}
|
||||
|
||||
$profile = $request->request->get(self::PARAM_PROFILE);
|
||||
if (!$this->profileManager->isValidProfile($profile)) {
|
||||
throw $this->createNotFoundException('Invalid profile given');
|
||||
}
|
||||
|
||||
$this->profileManager->setProfile($request->getSession(), $profile);
|
||||
$csrfTokenManager->refreshToken(self::DATATABLE_TOKEN);
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
#[Route(path: '/datatable/save', name: 'bookmark_save_datatable', methods: ['POST'])]
|
||||
public function datatableSave(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$request->request->has(self::PARAM_TOKEN_NAME) || !$request->request->has(self::PARAM_DATATABLE) || !$request->request->has(self::PARAM_PROFILE)) {
|
||||
throw $this->createNotFoundException('Missing data: csrf token, datatable name or profile');
|
||||
}
|
||||
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF Token');
|
||||
}
|
||||
|
||||
$profile = $request->request->get(self::PARAM_PROFILE);
|
||||
if (!$this->profileManager->isValidProfile($profile)) {
|
||||
throw $this->createNotFoundException('Invalid profile given');
|
||||
}
|
||||
|
||||
$datatableName = $request->request->get(self::PARAM_DATATABLE);
|
||||
$datatableName = $this->profileManager->getDatatableName($datatableName, $profile);
|
||||
|
||||
if (empty($datatableName) || mb_strlen($datatableName) > 50) {
|
||||
throw new RuntimeException('Invalid datatable name');
|
||||
}
|
||||
|
||||
$enabled = [];
|
||||
foreach ($request->request->all() as $name => $value) {
|
||||
if ($value !== 'on' || mb_strlen($name) > 30) {
|
||||
continue;
|
||||
}
|
||||
$enabled[$name] = true;
|
||||
}
|
||||
|
||||
if (\count($enabled) > 50) {
|
||||
throw new RuntimeException(sprintf('Too many columns provided, expected maximum 50, received %s.', \count($enabled)));
|
||||
}
|
||||
|
||||
$user = $this->getUser();
|
||||
|
||||
$bookmark = $this->bookmarkRepository->findBookmark($user, Bookmark::COLUMN_VISIBILITY, $datatableName);
|
||||
if ($bookmark === null) {
|
||||
$bookmark = new Bookmark();
|
||||
$bookmark->setUser($user);
|
||||
$bookmark->setType(Bookmark::COLUMN_VISIBILITY);
|
||||
$bookmark->setName($datatableName);
|
||||
}
|
||||
$bookmark->setContent($enabled);
|
||||
|
||||
$this->bookmarkRepository->saveBookmark($bookmark);
|
||||
$this->profileManager->setProfile($request->getSession(), $profile);
|
||||
$csrfTokenManager->refreshToken(self::DATATABLE_TOKEN);
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
#[Route(path: '/datatable/delete', name: 'bookmark_delete', methods: ['POST'])]
|
||||
public function datatableDelete(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$request->request->has(self::PARAM_TOKEN_NAME) || !$request->request->has(self::PARAM_DATATABLE) || !$request->request->has(self::PARAM_PROFILE)) {
|
||||
throw $this->createNotFoundException('Missing data: csrf token, datatable name or profile');
|
||||
}
|
||||
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken(self::DATATABLE_TOKEN, $request->request->get(self::PARAM_TOKEN_NAME)))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF Token');
|
||||
}
|
||||
|
||||
$profile = $request->request->get(self::PARAM_PROFILE);
|
||||
if (!$this->profileManager->isValidProfile($profile)) {
|
||||
throw $this->createNotFoundException('Invalid profile given');
|
||||
}
|
||||
|
||||
$datatableName = $request->request->get(self::PARAM_DATATABLE);
|
||||
$datatableName = $this->profileManager->getDatatableName($datatableName, $profile);
|
||||
|
||||
$bookmark = $this->bookmarkRepository->findBookmark($this->getUser(), Bookmark::COLUMN_VISIBILITY, $datatableName);
|
||||
if ($bookmark !== null) {
|
||||
$this->bookmarkRepository->deleteBookmark($bookmark);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken(self::DATATABLE_TOKEN);
|
||||
|
||||
return new Response();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Form\CalendarForm;
|
||||
use App\Timesheet\TrackingModeService;
|
||||
use App\Utils\PageSetup;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -21,27 +22,17 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* Controller used to display calendars.
|
||||
*
|
||||
* @Route(path="/calendar")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class CalendarController extends AbstractController
|
||||
#[Route(path: '/calendar')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
final class CalendarController extends AbstractController
|
||||
{
|
||||
private $calendarService;
|
||||
private $configuration;
|
||||
private $service;
|
||||
|
||||
public function __construct(CalendarService $calendarService, SystemConfiguration $configuration, TrackingModeService $service)
|
||||
public function __construct(private CalendarService $calendarService, private SystemConfiguration $configuration, private TrackingModeService $service)
|
||||
{
|
||||
$this->calendarService = $calendarService;
|
||||
$this->configuration = $configuration;
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", name="calendar", methods={"GET"})
|
||||
* @Route(path="/{profile}", name="calendar_user", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '/', name: 'calendar', methods: ['GET'])]
|
||||
#[Route(path: '/{profile}', name: 'calendar_user', methods: ['GET'])]
|
||||
public function userCalendar(Request $request): Response
|
||||
{
|
||||
$form = null;
|
||||
@@ -72,7 +63,13 @@ class CalendarController extends AbstractController
|
||||
|
||||
$mode = $this->service->getActiveMode();
|
||||
$factory = $this->getDateTimeFactory();
|
||||
$defaultStart = $factory->createDateTime($this->configuration->getTimesheetDefaultBeginTime());
|
||||
|
||||
// if now is default time, we do not pass it on, so it can be re-calculated for each new entry
|
||||
$defaultStart = null;
|
||||
if ($this->configuration->getTimesheetDefaultBeginTime() !== 'now') {
|
||||
$defaultStart = $factory->createDateTime($this->configuration->getTimesheetDefaultBeginTime());
|
||||
$defaultStart = $defaultStart->format('h:i:s');
|
||||
}
|
||||
|
||||
$config = $this->calendarService->getConfiguration();
|
||||
|
||||
@@ -87,14 +84,18 @@ class CalendarController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
$page = new PageSetup('calendar');
|
||||
$page->setHelp('calendar.html');
|
||||
|
||||
return $this->render('calendar/user.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'form' => $form,
|
||||
'user' => $profile,
|
||||
'config' => $config,
|
||||
'dragAndDrop' => $dragAndDrop,
|
||||
'google' => $this->calendarService->getGoogleSources($profile),
|
||||
'now' => $factory->createDateTime(),
|
||||
'defaultStartTime' => $defaultStart->format('h:i:s'),
|
||||
'defaultStartTime' => $defaultStart,
|
||||
'is_punch_mode' => $isPunchMode,
|
||||
'can_edit_begin' => $mode->canEditBegin(),
|
||||
'can_edit_end' => $mode->canEditBegin(),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Customer\CustomerService;
|
||||
use App\Customer\CustomerStatisticService;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerComment;
|
||||
@@ -34,44 +34,34 @@ use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\FileHelper;
|
||||
use App\Utils\PageSetup;
|
||||
use JeroenDesloovere\VCard\VCard;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Intl\Countries;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
/**
|
||||
* Controller used to manage customer in the admin part of the site.
|
||||
*
|
||||
* @Route(path="/admin/customer")
|
||||
* @Security("is_granted('view_customer') or is_granted('view_teamlead_customer') or is_granted('view_team_customer')")
|
||||
*/
|
||||
#[Route(path: '/admin/customer')]
|
||||
#[Security("is_granted('view_customer') or is_granted('view_teamlead_customer') or is_granted('view_team_customer')")]
|
||||
final class CustomerController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
|
||||
public function __construct(CustomerRepository $repository, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(private CustomerRepository $repository, private EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"})
|
||||
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_customer', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_customer_paginated', methods: ['GET'])]
|
||||
public function indexAction($page, Request $request)
|
||||
{
|
||||
$query = new CustomerQuery();
|
||||
@@ -84,12 +74,54 @@ final class CustomerController extends AbstractController
|
||||
}
|
||||
|
||||
$entries = $this->repository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
|
||||
$table = new DataTable('customer_admin', $query);
|
||||
$table->setPagination($entries);
|
||||
$table->setSearchForm($form);
|
||||
$table->setPaginationRoute('admin_customer_paginated');
|
||||
$table->setReloadEvents('kimai.customerUpdate kimai.customerDelete kimai.customerTeamUpdate');
|
||||
|
||||
$table->addColumn('name', ['class' => 'alwaysVisible']);
|
||||
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
|
||||
$table->addColumn('number', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('company', ['class' => 'd-none']);
|
||||
$table->addColumn('vat_id', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('contact', ['class' => 'd-none']);
|
||||
$table->addColumn('address', ['class' => 'd-none']);
|
||||
$table->addColumn('country', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('currency', ['class' => 'd-none w-min']);
|
||||
$table->addColumn('phone', ['class' => 'd-none']);
|
||||
$table->addColumn('fax', ['class' => 'd-none']);
|
||||
$table->addColumn('mobile', ['class' => 'd-none']);
|
||||
$table->addColumn('email', ['class' => 'd-none']);
|
||||
$table->addColumn('homepage', ['class' => 'd-none']);
|
||||
|
||||
foreach ($metaColumns as $metaColumn) {
|
||||
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('budget_money', 'customer')) {
|
||||
$table->addColumn('budget', ['class' => 'd-none text-end w-min', 'title' => 'budget']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('budget_time', 'customer')) {
|
||||
$table->addColumn('timeBudget', ['class' => 'd-none text-end w-min', 'title' => 'timeBudget']);
|
||||
}
|
||||
|
||||
$table->addColumn('billable', ['class' => 'd-none text-center w-min', 'orderBy' => false]);
|
||||
$table->addColumn('team', ['class' => 'text-center w-min', 'orderBy' => false]);
|
||||
$table->addColumn('visible', ['class' => 'd-none text-center w-min']);
|
||||
$table->addColumn('actions', ['class' => 'actions']);
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
$page->setDataTable($table);
|
||||
$page->setActionName('customers');
|
||||
|
||||
return $this->render('customer/index.html.twig', [
|
||||
'entries' => $entries,
|
||||
'query' => $query,
|
||||
'toolbarForm' => $form->createView(),
|
||||
'metaColumns' => $this->findMetaColumns($query),
|
||||
'page_setup' => $page,
|
||||
'dataTable' => $table,
|
||||
'metaColumns' => $metaColumns,
|
||||
'now' => $this->getDateTimeFactory()->createDateTime(),
|
||||
]);
|
||||
}
|
||||
@@ -106,29 +138,17 @@ final class CustomerController extends AbstractController
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
|
||||
* @Security("is_granted('create_customer')")
|
||||
*/
|
||||
public function createAction(Request $request, SystemConfiguration $configuration)
|
||||
#[Route(path: '/create', name: 'admin_customer_create', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('create_customer')")]
|
||||
public function createAction(Request $request, CustomerService $customerService)
|
||||
{
|
||||
$timezone = date_default_timezone_get();
|
||||
if (null !== $configuration->getCustomerDefaultTimezone()) {
|
||||
$timezone = $configuration->getCustomerDefaultTimezone();
|
||||
}
|
||||
$customer = $customerService->createNewCustomer('');
|
||||
|
||||
$customer = new Customer();
|
||||
$customer->setCountry($configuration->getCustomerDefaultCountry());
|
||||
$customer->setCurrency($configuration->getCustomerDefaultCurrency());
|
||||
$customer->setTimezone($timezone);
|
||||
|
||||
return $this->renderCustomerForm($customer, $request);
|
||||
return $this->renderCustomerForm($customer, $request, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"})
|
||||
* @Security("is_granted('permissions', customer)")
|
||||
*/
|
||||
#[Route(path: '/{id}/permissions', name: 'admin_customer_permissions', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('permissions', customer)")]
|
||||
public function teamPermissionsAction(Customer $customer, Request $request)
|
||||
{
|
||||
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
|
||||
@@ -154,15 +174,14 @@ final class CustomerController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('customer/permissions.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'customer' => $customer,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/comment_delete/{token}", name="customer_comment_delete", methods={"GET"})
|
||||
* @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
|
||||
*/
|
||||
#[Route(path: '/{id}/comment_delete/{token}', name: 'customer_comment_delete', methods: ['GET'])]
|
||||
#[Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")]
|
||||
public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
{
|
||||
$customerId = $comment->getCustomer()->getId();
|
||||
@@ -184,14 +203,12 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/comment_add", name="customer_comment_add", methods={"POST"})
|
||||
* @Security("is_granted('comments_create', customer)")
|
||||
*/
|
||||
#[Route(path: '/{id}/comment_add', name: 'customer_comment_add', methods: ['POST'])]
|
||||
#[Security("is_granted('comments', customer)")]
|
||||
public function addCommentAction(Customer $customer, Request $request)
|
||||
{
|
||||
$comment = new CustomerComment();
|
||||
$form = $this->getCommentForm($customer, $comment);
|
||||
$comment = new CustomerComment($customer);
|
||||
$form = $this->getCommentForm($comment);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
@@ -206,10 +223,8 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/comment_pin/{token}", name="customer_comment_pin", methods={"GET"})
|
||||
* @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
|
||||
*/
|
||||
#[Route(path: '/{id}/comment_pin/{token}', name: 'customer_comment_pin', methods: ['GET'])]
|
||||
#[Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")]
|
||||
public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
{
|
||||
$customerId = $comment->getCustomer()->getId();
|
||||
@@ -232,21 +247,18 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/create_team", name="customer_team_create", methods={"GET"})
|
||||
* @Security("is_granted('create_team') and is_granted('permissions', customer)")
|
||||
*/
|
||||
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
|
||||
#[Security("is_granted('create_team') and is_granted('permissions', customer)")]
|
||||
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository)
|
||||
{
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
|
||||
if (null !== $defaultTeam) {
|
||||
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
|
||||
$this->flashError('action.update.error', 'Team already existing');
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
$defaultTeam = new Team();
|
||||
$defaultTeam->setName($customer->getName());
|
||||
$defaultTeam = new Team($customer->getName());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addCustomer($customer);
|
||||
|
||||
@@ -259,10 +271,8 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/projects/{page}", defaults={"page": 1}, name="customer_projects", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view', customer)")
|
||||
*/
|
||||
#[Route(path: '/{id}/projects/{page}', defaults: ['page' => 1], name: 'customer_projects', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('view', customer)")]
|
||||
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository)
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
@@ -274,7 +284,6 @@ final class CustomerController extends AbstractController
|
||||
$query->addOrderGroup('visible', ProjectQuery::ORDER_DESC);
|
||||
$query->addOrderGroup('name', ProjectQuery::ORDER_ASC);
|
||||
|
||||
/* @var $entries Pagerfanta */
|
||||
$entries = $projectRepository->getPagerfantaForQuery($query);
|
||||
|
||||
return $this->render('customer/embed_projects.html.twig', [
|
||||
@@ -285,10 +294,8 @@ final class CustomerController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/details", name="customer_details", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view', customer)")
|
||||
*/
|
||||
#[Route(path: '/{id}/details', name: 'customer_details', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('view', customer)")]
|
||||
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService)
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
@@ -321,10 +328,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
if ($this->isGranted('comments', $customer)) {
|
||||
$comments = $this->repository->getComments($customer);
|
||||
}
|
||||
|
||||
if ($this->isGranted('comments_create', $customer)) {
|
||||
$commentForm = $this->getCommentForm($customer, new CustomerComment())->createView();
|
||||
$commentForm = $this->getCommentForm(new CustomerComment($customer))->createView();
|
||||
}
|
||||
|
||||
if ($this->isGranted('permissions', $customer) || $this->isGranted('details', $customer) || $this->isGranted('view_team')) {
|
||||
@@ -336,7 +340,13 @@ final class CustomerController extends AbstractController
|
||||
$this->dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
$page->setActionName('customer');
|
||||
$page->setActionView('customer_details');
|
||||
$page->setActionPayload(['customer' => $customer]);
|
||||
|
||||
return $this->render('customer/details.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'customer' => $customer,
|
||||
'comments' => $comments,
|
||||
'commentForm' => $commentForm,
|
||||
@@ -351,17 +361,80 @@ final class CustomerController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/rate", name="admin_customer_rate_add", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', customer)")
|
||||
*/
|
||||
public function addRateAction(Customer $customer, Request $request, CustomerRateRepository $repository)
|
||||
#[Route(path: '/{id}/vcard', name: 'customer_vcard', methods: ['GET'])]
|
||||
#[Security("is_granted('view', customer)")]
|
||||
public function downloadVCard(Customer $customer): Response
|
||||
{
|
||||
$vcard = new VCard();
|
||||
|
||||
$contact = $customer->getContact() ?? $customer->getName();
|
||||
$contact = explode(' ', $contact);
|
||||
$lastname = array_pop($contact);
|
||||
$firstname = \count($contact) > 0 ? $contact[0] : $lastname;
|
||||
$note = $customer->getComment();
|
||||
if ($note !== null) {
|
||||
$note .= PHP_EOL;
|
||||
}
|
||||
|
||||
$vcard->addName($lastname, $firstname);
|
||||
$vcard->addNote($note . $customer->getAddress());
|
||||
$vcard->addAddress(null, null, null, null, null, null, Countries::getName($customer->getCountry()));
|
||||
|
||||
$vcard->addCompany($customer->getCompany() ?? $customer->getName());
|
||||
$vcard->addEmail($customer->getEmail());
|
||||
|
||||
$hasPref = false;
|
||||
|
||||
if ($customer->getPhone() !== null) {
|
||||
$hasPref = true;
|
||||
$vcard->addPhoneNumber($customer->getPhone(), 'PREF;WORK');
|
||||
}
|
||||
|
||||
if ($customer->getMobile() !== null) {
|
||||
$type = $hasPref ? 'CELL' : 'PREF;CELL';
|
||||
$vcard->addPhoneNumber($customer->getMobile(), $type);
|
||||
}
|
||||
|
||||
if ($customer->getFax() !== null) {
|
||||
$vcard->addPhoneNumber($customer->getFax(), 'FAX');
|
||||
}
|
||||
|
||||
if ($customer->getHomepage() !== null) {
|
||||
$vcard->addURL($customer->getHomepage(), 'WORK');
|
||||
}
|
||||
|
||||
$response = new Response($vcard->getOutput());
|
||||
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
FileHelper::convertToAsciiFilename($customer->getName()) . '.vcf'
|
||||
);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/rate/{rate}', name: 'admin_customer_rate_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', customer)")]
|
||||
public function editRateAction(Customer $customer, CustomerRate $rate, Request $request, CustomerRateRepository $repository): Response
|
||||
{
|
||||
return $this->rateFormAction($customer, $rate, $request, $repository, $this->generateUrl('admin_customer_rate_edit', ['id' => $customer->getId(), 'rate' => $rate->getId()]));
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/rate', name: 'admin_customer_rate_add', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', customer)")]
|
||||
public function addRateAction(Customer $customer, Request $request, CustomerRateRepository $repository): Response
|
||||
{
|
||||
$rate = new CustomerRate();
|
||||
$rate->setCustomer($customer);
|
||||
|
||||
return $this->rateFormAction($customer, $rate, $request, $repository, $this->generateUrl('admin_customer_rate_add', ['id' => $customer->getId()]));
|
||||
}
|
||||
|
||||
private function rateFormAction(Customer $customer, CustomerRate $rate, Request $request, CustomerRateRepository $repository, string $formUrl): Response
|
||||
{
|
||||
$form = $this->createForm(CustomerRateForm::class, $rate, [
|
||||
'action' => $this->generateUrl('admin_customer_rate_add', ['id' => $customer->getId()]),
|
||||
'action' => $formUrl,
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
@@ -379,24 +452,21 @@ final class CustomerController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('customer/rates.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'customer' => $customer,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', customer)")
|
||||
*/
|
||||
#[Route(path: '/{id}/edit', name: 'admin_customer_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', customer)")]
|
||||
public function editAction(Customer $customer, Request $request)
|
||||
{
|
||||
return $this->renderCustomerForm($customer, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"})
|
||||
* @Security("is_granted('delete', customer)")
|
||||
*/
|
||||
#[Route(path: '/{id}/delete', name: 'admin_customer_delete', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('delete', customer)")]
|
||||
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService)
|
||||
{
|
||||
$stats = $statisticService->getCustomerStatistics($customer);
|
||||
@@ -431,15 +501,14 @@ final class CustomerController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('customer/delete.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'customer' => $customer,
|
||||
'stats' => $stats,
|
||||
'form' => $deleteForm->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/export", name="customer_export", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '/export', name: 'customer_export', methods: ['GET'])]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
|
||||
{
|
||||
$query = new CustomerQuery();
|
||||
@@ -465,12 +534,7 @@ final class CustomerController extends AbstractController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Customer $customer
|
||||
* @param Request $request
|
||||
* @return RedirectResponse|Response
|
||||
*/
|
||||
private function renderCustomerForm(Customer $customer, Request $request)
|
||||
private function renderCustomerForm(Customer $customer, Request $request, bool $create = false): Response
|
||||
{
|
||||
$editForm = $this->createEditForm($customer);
|
||||
|
||||
@@ -481,13 +545,18 @@ final class CustomerController extends AbstractController
|
||||
$this->repository->saveCustomer($customer);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($create) {
|
||||
return $this->redirectToRouteAfterCreate('customer_details', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
$this->handleFormUpdateException($ex, $editForm);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('customer/edit.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'customer' => $customer,
|
||||
'form' => $editForm->createView()
|
||||
]);
|
||||
@@ -495,23 +564,21 @@ final class CustomerController extends AbstractController
|
||||
|
||||
private function getToolbarForm(CustomerQuery $query): FormInterface
|
||||
{
|
||||
return $this->createForm(CustomerToolbarForm::class, $query, [
|
||||
return $this->createSearchForm(CustomerToolbarForm::class, $query, [
|
||||
'action' => $this->generateUrl('admin_customer', [
|
||||
'page' => $query->getPage(),
|
||||
]),
|
||||
'method' => 'GET',
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
private function getCommentForm(Customer $customer, CustomerComment $comment): FormInterface
|
||||
private function getCommentForm(CustomerComment $comment): FormInterface
|
||||
{
|
||||
if (null === $comment->getId()) {
|
||||
$comment->setCustomer($customer);
|
||||
$comment->setCreatedBy($this->getUser());
|
||||
}
|
||||
|
||||
return $this->createForm(CustomerCommentForm::class, $comment, [
|
||||
'action' => $this->generateUrl('customer_comment_add', ['id' => $customer->getId()]),
|
||||
'action' => $this->generateUrl('customer_comment_add', ['id' => $comment->getCustomer()->getId()]),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
@@ -534,4 +601,12 @@ final class CustomerController extends AbstractController
|
||||
'include_time' => $this->isGranted('time', $customer),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPageSetup(): PageSetup
|
||||
{
|
||||
$page = new PageSetup('customers');
|
||||
$page->setHelp('customer.html');
|
||||
|
||||
return $page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,145 +9,292 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Bookmark;
|
||||
use App\Entity\User;
|
||||
use App\Event\DashboardEvent;
|
||||
use App\Widget\Type\AbstractContainer;
|
||||
use App\Widget\Type\AuthorizedWidget;
|
||||
use App\Widget\Type\CompoundRow;
|
||||
use App\Widget\Type\UserWidget;
|
||||
use App\Widget\WidgetContainerInterface;
|
||||
use App\Widget\WidgetException;
|
||||
use App\Repository\BookmarkRepository;
|
||||
use App\Utils\PageSetup;
|
||||
use App\Widget\WidgetInterface;
|
||||
use App\Widget\WidgetService;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* Dashboard controller for the admin area.
|
||||
*
|
||||
* @Route(path="/dashboard")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class DashboardController extends AbstractController
|
||||
#[Route(path: '/dashboard')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
final class DashboardController extends AbstractController
|
||||
{
|
||||
public const BOOKMARK_TYPE = 'dashboard';
|
||||
public const BOOKMARK_NAME = 'default';
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
* @var WidgetInterface[]|null
|
||||
*/
|
||||
private $eventDispatcher;
|
||||
/**
|
||||
* @var WidgetService
|
||||
*/
|
||||
private $widgets;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $dashboard;
|
||||
private ?array $widgets = null;
|
||||
|
||||
/**
|
||||
* @param EventDispatcherInterface $dispatcher
|
||||
* @param WidgetService $service
|
||||
* @param array $dashboard
|
||||
*/
|
||||
public function __construct(EventDispatcherInterface $dispatcher, WidgetService $service, array $dashboard)
|
||||
public function __construct(private EventDispatcherInterface $eventDispatcher, private WidgetService $service, private BookmarkRepository $repository)
|
||||
{
|
||||
$this->eventDispatcher = $dispatcher;
|
||||
$this->widgets = $service;
|
||||
$this->dashboard = $dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", defaults={}, name="dashboard", methods={"GET"})
|
||||
* @param User $user
|
||||
* @return array<WidgetInterface>
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function indexAction()
|
||||
private function getAllAvailableWidgets(User $user): array
|
||||
{
|
||||
$user = $this->getUser();
|
||||
if ($this->widgets === null) {
|
||||
$all = [];
|
||||
foreach ($this->service->getAllWidgets() as $widget) {
|
||||
$widget->setUser($user);
|
||||
|
||||
$event = new DashboardEvent($user);
|
||||
foreach ($this->dashboard as $widgetRow) {
|
||||
if (empty($widgetRow['widgets'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== $widgetRow['permission'] && !$this->isGranted($widgetRow['permission'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($widgetRow['type'])) {
|
||||
$widgetRow['type'] = CompoundRow::class;
|
||||
}
|
||||
|
||||
if (!class_exists($widgetRow['type'])) {
|
||||
throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetRow['type']));
|
||||
}
|
||||
|
||||
$row = new $widgetRow['type']();
|
||||
if (!($row instanceof AbstractContainer)) {
|
||||
throw new WidgetException(
|
||||
sprintf(
|
||||
'Expected widget type to be an instanceof "%s", but found "%s"',
|
||||
AbstractContainer::class,
|
||||
$widgetRow['type']
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$row->setTitle($widgetRow['title'] ?? '');
|
||||
$row->setOrder($widgetRow['order']);
|
||||
|
||||
foreach ($widgetRow['widgets'] as $widgetName) {
|
||||
if (!$this->widgets->hasWidget($widgetName)) {
|
||||
throw new \Exception(sprintf('Unknown widget "%s"', $widgetName));
|
||||
}
|
||||
|
||||
$widget = $this->widgets->getWidget($widgetName);
|
||||
|
||||
$add = true;
|
||||
if ($widget instanceof AuthorizedWidget) {
|
||||
$tmp = false;
|
||||
foreach ($widget->getPermissions() as $perm) {
|
||||
$permissions = $widget->getPermissions();
|
||||
if (\count($permissions) > 0) {
|
||||
$add = false;
|
||||
foreach ($permissions as $perm) {
|
||||
if ($this->isGranted($perm)) {
|
||||
$tmp = true;
|
||||
$add = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$add = $tmp;
|
||||
}
|
||||
|
||||
if ($widget instanceof UserWidget) {
|
||||
$widget->setUser($user);
|
||||
}
|
||||
|
||||
if ($add) {
|
||||
$row->addWidget($widget);
|
||||
if (!$add) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$all[] = $widget;
|
||||
}
|
||||
$this->widgets = $all;
|
||||
}
|
||||
|
||||
$event->addSection($row);
|
||||
return $this->widgets;
|
||||
}
|
||||
|
||||
private function getBookmark(User $user): ?Bookmark
|
||||
{
|
||||
return $this->repository->findBookmark($user, self::BOOKMARK_TYPE, self::BOOKMARK_NAME);
|
||||
}
|
||||
|
||||
private function getDefaultConfig(): array
|
||||
{
|
||||
$event = new DashboardEvent($this->getUser());
|
||||
|
||||
// default widgets
|
||||
$dashboard = [
|
||||
'PaginatedWorkingTimeChart',
|
||||
//'UserAmountToday',
|
||||
//'UserAmountWeek',
|
||||
//'UserAmountMonth',
|
||||
//'UserAmountYear',
|
||||
//'UserTeams',
|
||||
//'UserTeamProjects',
|
||||
'DurationToday',
|
||||
'DurationWeek',
|
||||
'DurationMonth',
|
||||
'DurationYear',
|
||||
//'ActiveUsersToday',
|
||||
//'ActiveUsersWeek',
|
||||
//'ActiveUsersMonth',
|
||||
//'ActiveUsersYear',
|
||||
//'AmountToday',
|
||||
//'AmountWeek',
|
||||
//'AmountMonth',
|
||||
//'AmountYear',
|
||||
//'TotalsUser',
|
||||
//'TotalsCustomer',
|
||||
//'TotalsProject',
|
||||
//'TotalsActivity',
|
||||
];
|
||||
|
||||
foreach ($dashboard as $widgetName) {
|
||||
$event->addWidget($widgetName);
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
$sections = $event->getSections();
|
||||
$clearedSections = [];
|
||||
/** @var WidgetContainerInterface $section */
|
||||
foreach ($sections as $key => $section) {
|
||||
if (!empty($section->getWidgets())) {
|
||||
$clearedSections[] = $section;
|
||||
return $event->getWidgets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of widgets names and options for a user.
|
||||
*
|
||||
* @param User $user
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function getUserConfig(User $user): array
|
||||
{
|
||||
$bookmark = $this->getBookmark($user);
|
||||
if ($bookmark !== null) {
|
||||
return $bookmark->getContent();
|
||||
}
|
||||
|
||||
$widgets = [];
|
||||
|
||||
foreach ($this->getDefaultConfig() as $name) {
|
||||
$widgets[] = ['id' => $name, 'options' => []];
|
||||
}
|
||||
|
||||
return $widgets;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<WidgetInterface> $widgets
|
||||
* @param User $user
|
||||
* @return array<WidgetInterface>
|
||||
*/
|
||||
private function filterWidgets(array $widgets, User $user): array
|
||||
{
|
||||
$filteredWidgets = [];
|
||||
|
||||
foreach ($this->getUserConfig($user) as $setting) {
|
||||
$id = $setting['id'];
|
||||
$options = $setting['options'];
|
||||
foreach ($widgets as $widget) {
|
||||
if ($widget->getId() === $id) {
|
||||
$tmpWidget = clone $widget;
|
||||
foreach ($options as $key => $value) {
|
||||
$tmpWidget->setOption($key, $value);
|
||||
}
|
||||
$filteredWidgets[] = $tmpWidget;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uasort(
|
||||
$clearedSections,
|
||||
function (WidgetContainerInterface $a, WidgetContainerInterface $b) {
|
||||
if ($a->getOrder() == $b->getOrder()) {
|
||||
return 0;
|
||||
}
|
||||
return $filteredWidgets;
|
||||
}
|
||||
|
||||
return ($a->getOrder() < $b->getOrder()) ? -1 : 1;
|
||||
}
|
||||
);
|
||||
#[Route(path: '/', defaults: [], name: 'dashboard', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$available = $this->getAllAvailableWidgets($user);
|
||||
$widgets = $this->filterWidgets($available, $user);
|
||||
|
||||
$page = new PageSetup('dashboard.title');
|
||||
$page->setHelp('dashboard.html');
|
||||
$page->setActionName('dashboard');
|
||||
$page->setActionPayload(['widgets' => $widgets, 'available' => $available]);
|
||||
|
||||
return $this->render('dashboard/index.html.twig', [
|
||||
'widgets' => $clearedSections
|
||||
'page_setup' => $page,
|
||||
'widgets' => $widgets,
|
||||
'available' => $available,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/reset/', defaults: [], name: 'dashboard_reset', methods: ['GET', 'POST'])]
|
||||
public function reset(): RedirectResponse
|
||||
{
|
||||
$bookmark = $this->getBookmark($this->getUser());
|
||||
if ($bookmark !== null) {
|
||||
$this->repository->deleteBookmark($bookmark);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('dashboard');
|
||||
}
|
||||
|
||||
#[Route(path: '/add-widget/{widget}', defaults: [], name: 'dashboard_add', methods: ['GET'])]
|
||||
public function add(string $widget): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
$widgets = $this->getUserConfig($user);
|
||||
|
||||
// prevent to add the same widget multiple times
|
||||
foreach ($widgets as $id => $setting) {
|
||||
if ($setting['id'] === $widget) {
|
||||
return $this->redirectToRoute('dashboard_edit');
|
||||
}
|
||||
}
|
||||
|
||||
$widgets[] = ['id' => $widget, 'options' => []];
|
||||
|
||||
$this->saveBookmark($user, $widgets);
|
||||
|
||||
return $this->redirectToRoute('dashboard_edit');
|
||||
}
|
||||
|
||||
private function saveBookmark(User $user, array $widgets): void
|
||||
{
|
||||
$bookmark = $this->getBookmark($user);
|
||||
if ($bookmark === null) {
|
||||
$bookmark = new Bookmark();
|
||||
$bookmark->setUser($user);
|
||||
$bookmark->setType(self::BOOKMARK_TYPE);
|
||||
$bookmark->setName(self::BOOKMARK_NAME);
|
||||
}
|
||||
$bookmark->setContent($widgets);
|
||||
|
||||
$this->repository->saveBookmark($bookmark);
|
||||
}
|
||||
|
||||
#[Route(path: '/edit/', defaults: [], name: 'dashboard_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
|
||||
$available = $this->getAllAvailableWidgets($user);
|
||||
$widgets = $this->filterWidgets($available, $user);
|
||||
|
||||
$choices = [];
|
||||
|
||||
foreach ($available as $widget) {
|
||||
if (empty($widget->getTitle())) {
|
||||
continue;
|
||||
}
|
||||
$choices[$widget->getId()] = $widget->getId();
|
||||
}
|
||||
|
||||
$form = $this->createFormBuilder(null, [])
|
||||
->add('widgets', ChoiceType::class, ['choices' => $choices, 'multiple' => true])
|
||||
->setAction($this->generateUrl('dashboard_edit'))
|
||||
->setMethod('POST')
|
||||
->getForm();
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$userWidgets = $this->getUserConfig($user);
|
||||
$saveWidgets = [];
|
||||
foreach ($form->getData()['widgets'] as $widgetId) {
|
||||
$options = [];
|
||||
foreach ($userWidgets as $setting) {
|
||||
if ($setting['id'] === $widgetId) {
|
||||
$options = $setting['options'];
|
||||
}
|
||||
}
|
||||
$saveWidgets[] = ['id' => $widgetId, 'options' => $options];
|
||||
}
|
||||
|
||||
$this->saveBookmark($user, $saveWidgets);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('dashboard');
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
}
|
||||
}
|
||||
|
||||
$page = new PageSetup('dashboard.title');
|
||||
$page->setHelp('dashboard.html');
|
||||
$page->setActionName('dashboard');
|
||||
$page->setActionView('edit');
|
||||
$page->setActionPayload(['widgets' => $widgets, 'available' => $available]);
|
||||
|
||||
return $this->render('dashboard/grid.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'widgets' => $widgets,
|
||||
'available' => $available,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,26 +10,25 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Utils\FileHelper;
|
||||
use App\Utils\PageSetup;
|
||||
use App\Utils\ReleaseVersion;
|
||||
use Composer\InstalledVersions;
|
||||
use PackageVersions\Versions;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* @Route(path="/doctor")
|
||||
* @Security("is_granted('system_information')")
|
||||
*/
|
||||
class DoctorController extends AbstractController
|
||||
#[Route(path: '/doctor')]
|
||||
#[Security("is_granted('system_information')")]
|
||||
final class DoctorController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* PHP extensions which Kimai needs for runtime.
|
||||
* Some are not a hard requiremenet, but some functions might not work as expected.
|
||||
* Required PHP extensions for Kimai.
|
||||
*/
|
||||
public const REQUIRED_EXTENSIONS = [
|
||||
'gd',
|
||||
'intl',
|
||||
'json',
|
||||
'mbstring',
|
||||
@@ -47,21 +46,12 @@ class DoctorController extends AbstractController
|
||||
'var/log/',
|
||||
];
|
||||
|
||||
private $projectDirectory;
|
||||
private $environment;
|
||||
private $fileHelper;
|
||||
|
||||
public function __construct(string $projectDirectory, string $kernelEnvironment, FileHelper $fileHelper)
|
||||
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private FileHelper $fileHelper, private CacheInterface $cache)
|
||||
{
|
||||
$this->projectDirectory = $projectDirectory;
|
||||
$this->environment = $kernelEnvironment;
|
||||
$this->fileHelper = $fileHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/flush-log/{token}", name="doctor_flush_log", methods={"GET"})
|
||||
* @Security("is_granted('system_configuration')")
|
||||
*/
|
||||
#[Route(path: '/flush-log/{token}', name: 'doctor_flush_log', methods: ['GET'])]
|
||||
#[Security("is_granted('system_configuration')")]
|
||||
public function deleteLogfileAction(string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('doctor.flush_log', $token))) {
|
||||
@@ -76,10 +66,10 @@ class DoctorController extends AbstractController
|
||||
|
||||
if (file_exists($logfile)) {
|
||||
if (!is_writable($logfile)) {
|
||||
$this->flashError('action.delete.error', ['%reason%' => 'Logfile cannot be written']);
|
||||
$this->flashError('action.delete.error', 'Logfile cannot be written');
|
||||
} else {
|
||||
if (false === file_put_contents($logfile, '')) {
|
||||
$this->flashError('action.delete.error', ['%reason%' => 'Failed writing to logfile']);
|
||||
$this->flashError('action.delete.error', 'Failed writing to logfile');
|
||||
} else {
|
||||
$this->flashSuccess('action.delete.success');
|
||||
}
|
||||
@@ -89,32 +79,34 @@ class DoctorController extends AbstractController
|
||||
return $this->redirectToRoute('doctor');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="", name="doctor", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '', name: 'doctor', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$logLines = 100;
|
||||
|
||||
$canDeleteLogfile = $this->isGranted('system_configuration') && is_writable($this->getLogFilename());
|
||||
$page = new PageSetup('Doctor');
|
||||
$page->setHelp('doctor.html');
|
||||
|
||||
return $this->render('doctor/index.html.twig', array_merge(
|
||||
[
|
||||
'modules' => get_loaded_extensions(),
|
||||
'environment' => $this->environment,
|
||||
'info' => $this->getPhpInfo(),
|
||||
'settings' => $this->getIniSettings(),
|
||||
'extensions' => $this->getLoadedExtensions(),
|
||||
'directories' => $this->getFilePermissions(),
|
||||
'log_delete' => $canDeleteLogfile,
|
||||
'logs' => $this->getLog(),
|
||||
'logLines' => $logLines,
|
||||
'logSize' => $this->getLogSize(),
|
||||
'composer' => $this->getComposerPackages(),
|
||||
]
|
||||
));
|
||||
return $this->render('doctor/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'modules' => get_loaded_extensions(),
|
||||
'environment' => $this->kernelEnvironment,
|
||||
'info' => $this->getPhpInfo(),
|
||||
'settings' => $this->getIniSettings(),
|
||||
'extensions' => $this->getLoadedExtensions(),
|
||||
'directories' => $this->getFilePermissions(),
|
||||
'log_delete' => $canDeleteLogfile,
|
||||
'logs' => $this->getLog(),
|
||||
'logLines' => $logLines,
|
||||
'logSize' => $this->getLogSize(),
|
||||
'composer' => $this->getComposerPackages(),
|
||||
'release' => $this->getNextUpdateVersion()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getComposerPackages(): array
|
||||
{
|
||||
$versions = [];
|
||||
@@ -124,35 +116,30 @@ class DoctorController extends AbstractController
|
||||
foreach (InstalledVersions::getInstalledPackages() as $package) {
|
||||
$versions[$package] = InstalledVersions::getPrettyVersion($package);
|
||||
}
|
||||
} else {
|
||||
@trigger_error('Please upgrade your Composer to 2.x', E_USER_DEPRECATED);
|
||||
|
||||
// @deprecated since 1.14, will be removed with 2.0
|
||||
$rootPackage = Versions::rootPackageName();
|
||||
foreach (Versions::VERSIONS as $name => $version) {
|
||||
$versions[$name] = explode('@', $version)[0];
|
||||
}
|
||||
// remove kimai from the package list
|
||||
$versions = array_filter($versions, function ($version, $name) use ($rootPackage): bool {
|
||||
if ($name === $rootPackage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($version === null || $version === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, ARRAY_FILTER_USE_BOTH);
|
||||
|
||||
ksort($versions);
|
||||
}
|
||||
|
||||
// remove kimai from the package list
|
||||
$versions = array_filter($versions, function ($version, $name) use ($rootPackage) {
|
||||
if ($name === $rootPackage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($version === null || $version === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, ARRAY_FILTER_USE_BOTH);
|
||||
|
||||
ksort($versions);
|
||||
|
||||
return $versions;
|
||||
}
|
||||
|
||||
private function getLoadedExtensions()
|
||||
/**
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
private function getLoadedExtensions(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
@@ -175,7 +162,7 @@ class DoctorController extends AbstractController
|
||||
|
||||
private function getLogFilename(): string
|
||||
{
|
||||
$logfileName = 'var/log/' . $this->environment . '.log';
|
||||
$logfileName = 'var/log/' . $this->kernelEnvironment . '.log';
|
||||
|
||||
return $this->projectDirectory . '/' . $logfileName;
|
||||
}
|
||||
@@ -218,7 +205,7 @@ class DoctorController extends AbstractController
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getFilePermissions()
|
||||
private function getFilePermissions(): array
|
||||
{
|
||||
$testPaths = [];
|
||||
$baseDir = $this->projectDirectory . DIRECTORY_SEPARATOR;
|
||||
@@ -241,7 +228,7 @@ class DoctorController extends AbstractController
|
||||
foreach ($testPaths as $fullUri) {
|
||||
$fullUri = rtrim($fullUri, DIRECTORY_SEPARATOR);
|
||||
$tmp = str_replace($baseDir, '', $fullUri) . DIRECTORY_SEPARATOR;
|
||||
if ($fullUri !== false && is_readable($fullUri) && is_writable($fullUri)) {
|
||||
if (is_readable($fullUri) && is_writable($fullUri)) {
|
||||
$results[$tmp] = true;
|
||||
} else {
|
||||
$results[$tmp] = false;
|
||||
@@ -251,9 +238,13 @@ class DoctorController extends AbstractController
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function getIniSettings()
|
||||
private function getIniSettings(): array
|
||||
{
|
||||
$ini = [
|
||||
'memory_limit',
|
||||
'session.gc_maxlifetime',
|
||||
'max_execution_time',
|
||||
'date.timezone',
|
||||
'allow_url_fopen',
|
||||
'allow_url_include',
|
||||
'default_charset',
|
||||
@@ -262,8 +253,6 @@ class DoctorController extends AbstractController
|
||||
'error_log',
|
||||
'error_reporting',
|
||||
'log_errors',
|
||||
'max_execution_time',
|
||||
'memory_limit',
|
||||
'open_basedir',
|
||||
'post_max_size',
|
||||
'sys_temp_dir',
|
||||
@@ -274,7 +263,7 @@ class DoctorController extends AbstractController
|
||||
$settings = [];
|
||||
foreach ($ini as $name) {
|
||||
try {
|
||||
$settings[$name] = ini_get($name);
|
||||
$settings[$name] = \ini_get($name);
|
||||
} catch (\Exception $ex) {
|
||||
$settings[$name] = "Couldn't load ini setting: " . $ex->getMessage();
|
||||
}
|
||||
@@ -287,9 +276,9 @@ class DoctorController extends AbstractController
|
||||
* @author https://php.net/manual/en/function.phpinfo.php#117961
|
||||
* @return array
|
||||
*/
|
||||
private function getPhpInfo()
|
||||
private function getPhpInfo(): array
|
||||
{
|
||||
$plainText = function ($input) {
|
||||
$plainText = function ($input): string {
|
||||
return trim(html_entity_decode(strip_tags($input)));
|
||||
};
|
||||
|
||||
@@ -323,4 +312,24 @@ class DoctorController extends AbstractController
|
||||
|
||||
return $phpInfo;
|
||||
}
|
||||
|
||||
private function getNextUpdateVersion(): ?array
|
||||
{
|
||||
return $this->cache->get('kimai.update_release', function (ItemInterface $item) {
|
||||
// we cache the result, no matter if the call failed: at the end, this is "just"
|
||||
// an update note but an expensive call
|
||||
|
||||
$item->expiresAfter(86400); // one day
|
||||
|
||||
try {
|
||||
$version = new ReleaseVersion();
|
||||
|
||||
return $version->getLatestReleaseFromGithub(true);
|
||||
} catch (\Exception $ex) {
|
||||
// something failed, retry tomorrow
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\Base\DispositionInlineInterface;
|
||||
use App\Export\ExportItemInterface;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\TooManyItemsExportException;
|
||||
use App\Form\Toolbar\ExportToolbarForm;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use App\Utils\PageSetup;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -23,25 +24,16 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* Controller used to export timesheet data.
|
||||
*
|
||||
* @Route(path="/export")
|
||||
* @Security("is_granted('create_export')")
|
||||
*/
|
||||
class ExportController extends AbstractController
|
||||
#[Route(path: '/export')]
|
||||
#[Security("is_granted('create_export')")]
|
||||
final class ExportController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var ServiceExport
|
||||
*/
|
||||
private $export;
|
||||
|
||||
public function __construct(ServiceExport $export)
|
||||
public function __construct(private ServiceExport $export)
|
||||
{
|
||||
$this->export = $export;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", name="export", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '/', name: 'export', methods: ['GET'])]
|
||||
public function indexAction(Request $request): Response
|
||||
{
|
||||
$query = $this->getDefaultQuery();
|
||||
@@ -84,7 +76,11 @@ class ExportController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
$page = new PageSetup('export');
|
||||
$page->setHelp('export.html');
|
||||
|
||||
return $this->render('export/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'too_many' => $tooManyResults,
|
||||
'by_customer' => $byCustomer,
|
||||
'query' => $query,
|
||||
@@ -97,9 +93,7 @@ class ExportController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/data", name="export_data", methods={"POST"})
|
||||
*/
|
||||
#[Route(path: '/data', name: 'export_data', methods: ['POST'])]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$query = $this->getDefaultQuery();
|
||||
@@ -133,7 +127,7 @@ class ExportController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function getDefaultQuery(): ExportQuery
|
||||
private function getDefaultQuery(): ExportQuery
|
||||
{
|
||||
$begin = $this->getDateTimeFactory()->getStartOfMonth();
|
||||
$end = $this->getDateTimeFactory()->getEndOfMonth();
|
||||
@@ -148,10 +142,10 @@ class ExportController extends AbstractController
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @return ExportItemInterface[]
|
||||
* @return ExportableItem[]
|
||||
* @throws TooManyItemsExportException
|
||||
*/
|
||||
protected function getEntries(ExportQuery $query): array
|
||||
private function getEntries(ExportQuery $query): array
|
||||
{
|
||||
if (null !== $query->getBegin()) {
|
||||
$query->getBegin()->setTime(0, 0, 0);
|
||||
@@ -163,9 +157,9 @@ class ExportController extends AbstractController
|
||||
return $this->export->getExportItems($query);
|
||||
}
|
||||
|
||||
protected function getToolbarForm(ExportQuery $query, string $method): FormInterface
|
||||
private function getToolbarForm(ExportQuery $query, string $method): FormInterface
|
||||
{
|
||||
return $this->createForm(ExportToolbarForm::class, $query, [
|
||||
return $this->createSearchForm(ExportToolbarForm::class, $query, [
|
||||
'action' => $this->generateUrl('export', []),
|
||||
'include_user' => $this->isGranted('view_other_timesheet'),
|
||||
'include_export' => $this->isGranted('edit_export_other_timesheet'),
|
||||
|
||||
46
src/Controller/FavoriteController.php
Normal file
46
src/Controller/FavoriteController.php
Normal 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\Controller;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Timesheet\FavoriteRecordService;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
#[Route(path: '/favorite')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
final class FavoriteController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/timesheet/', name: 'favorites_timesheets', methods: ['GET'])]
|
||||
#[Security("is_granted('view_own_timesheet')")]
|
||||
public function favoriteAction(): Response
|
||||
{
|
||||
return $this->render('partials/recent-activities.html.twig');
|
||||
}
|
||||
|
||||
#[Route(path: '/timesheet/add/{id}', name: 'favorites_timesheets_add', methods: ['GET'])]
|
||||
#[Security("is_granted('view_own_timesheet')")]
|
||||
public function add(Timesheet $timesheet, FavoriteRecordService $favoriteRecordService): Response
|
||||
{
|
||||
$favoriteRecordService->addFavorite($timesheet);
|
||||
|
||||
return $this->render('partials/recent-activities.html.twig');
|
||||
}
|
||||
|
||||
#[Route(path: '/timesheet/remove/{id}', name: 'favorites_timesheets_remove', methods: ['GET'])]
|
||||
#[Security("is_granted('view_own_timesheet')")]
|
||||
public function remove(Timesheet $timesheet, FavoriteRecordService $favoriteRecordService): Response
|
||||
{
|
||||
$favoriteRecordService->removeFavorite($timesheet);
|
||||
|
||||
return $this->render('partials/recent-activities.html.twig');
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,8 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\User;
|
||||
use App\Form\Type\InitialViewType;
|
||||
use App\Utils\LanguageService;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -19,20 +18,19 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* Homepage controller is a redirect controller with user specific logic.
|
||||
*
|
||||
* @Route(path="/homepage")
|
||||
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
|
||||
*/
|
||||
class HomepageController extends AbstractController
|
||||
#[Route(path: '/homepage')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")]
|
||||
final class HomepageController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="", defaults={}, name="homepage", methods={"GET"})
|
||||
*/
|
||||
public function indexAction(Request $request, LanguageService $service): Response
|
||||
public const DEFAULT_ROUTE = 'timesheet';
|
||||
|
||||
#[Route(path: '', defaults: [], name: 'homepage', methods: ['GET'])]
|
||||
public function indexAction(Request $request, LocaleService $service): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$userRoute = $user->getPreferenceValue('login.initial_view', InitialViewType::DEFAULT_VIEW, false);
|
||||
$userRoute = $user->getPreferenceValue('login_initial_view', self::DEFAULT_ROUTE, false);
|
||||
$userLanguage = $user->getLanguage();
|
||||
$requestLanguage = $request->getLocale();
|
||||
|
||||
@@ -46,16 +44,16 @@ class HomepageController extends AbstractController
|
||||
|
||||
// if a user somehow managed to get a wrong locale into hos account (eg. an imported user from Kimai 1)
|
||||
// make sure that he will still see a beautiful page and not a 404
|
||||
if (!$service->isKnownLanguage($userLanguage)) {
|
||||
$userLanguage = $service->getDefaultLanguage();
|
||||
if (!$service->isKnownLocale($userLanguage)) {
|
||||
$userLanguage = $service->getDefaultLocale();
|
||||
}
|
||||
|
||||
$routes = [
|
||||
[$userRoute, $userLanguage],
|
||||
[$userRoute, $requestLanguage],
|
||||
[$userRoute, User::DEFAULT_LANGUAGE],
|
||||
[InitialViewType::DEFAULT_VIEW, $userLanguage],
|
||||
[InitialViewType::DEFAULT_VIEW, $requestLanguage],
|
||||
[self::DEFAULT_ROUTE, $userLanguage],
|
||||
[self::DEFAULT_ROUTE, $requestLanguage],
|
||||
];
|
||||
|
||||
foreach ($routes as $routeSettings) {
|
||||
@@ -69,6 +67,6 @@ class HomepageController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute(InitialViewType::DEFAULT_VIEW, ['_locale' => User::DEFAULT_LANGUAGE]);
|
||||
return $this->redirectToRoute(self::DEFAULT_ROUTE, ['_locale' => User::DEFAULT_LANGUAGE]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,10 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\InvoiceCreatedMultipleEvent;
|
||||
use App\Event\InvoiceDocumentsEvent;
|
||||
use App\Event\InvoiceMetaDefinitionEvent;
|
||||
use App\Event\InvoiceMetaDisplayEvent;
|
||||
@@ -26,16 +24,21 @@ use App\Form\InvoiceEditForm;
|
||||
use App\Form\InvoiceTemplateForm;
|
||||
use App\Form\Toolbar\InvoiceArchiveForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarSimpleForm;
|
||||
use App\Form\Type\DatePickerType;
|
||||
use App\Form\Type\InvoiceTemplateType;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Repository\Query\InvoiceArchiveQuery;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -47,30 +50,22 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* Controller used to create invoices and manage invoice templates.
|
||||
*
|
||||
* @Route(path="/invoice")
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
#[Route(path: '/invoice')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('view_invoice')")]
|
||||
final class InvoiceController extends AbstractController
|
||||
{
|
||||
private $service;
|
||||
private $templateRepository;
|
||||
private $invoiceRepository;
|
||||
private $dispatcher;
|
||||
|
||||
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
$this->service = $service;
|
||||
$this->templateRepository = $templateRepository;
|
||||
$this->invoiceRepository = $invoiceRepository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
public function __construct(
|
||||
private ServiceInvoice $service,
|
||||
private InvoiceTemplateRepository $templateRepository,
|
||||
private InvoiceRepository $invoiceRepository,
|
||||
private EventDispatcherInterface $dispatcher
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", name="invoice", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
public function indexAction(Request $request, SystemConfiguration $configuration, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
#[Route(path: '/', name: 'invoice', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('create_invoice')")]
|
||||
public function indexAction(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
if ($this->isGranted('manage_invoice_template')) {
|
||||
@@ -81,13 +76,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
$query = $this->getDefaultQuery();
|
||||
|
||||
$token = null;
|
||||
if ($request->query->has('token')) {
|
||||
$token = $request->query->get('token');
|
||||
$request->query->remove('token');
|
||||
}
|
||||
|
||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||
$form = $this->getToolbarForm($query);
|
||||
if ($this->handleSearch($form, $request)) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
@@ -96,53 +85,50 @@ final class InvoiceController extends AbstractController
|
||||
$total = 0;
|
||||
$searched = false;
|
||||
|
||||
if ($form->isValid() && $this->isGranted('create_invoice')) {
|
||||
if ($request->query->has('createInvoice')) {
|
||||
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('invoice.create');
|
||||
|
||||
try {
|
||||
return $this->renderInvoice($query, $request);
|
||||
} catch (Exception $ex) {
|
||||
$this->logException($ex);
|
||||
$this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($form->get('template')->getData() !== null) {
|
||||
try {
|
||||
$models = $this->service->createModels($query);
|
||||
$searched = true;
|
||||
} catch (Exception $ex) {
|
||||
$this->logException($ex);
|
||||
$this->flashError($ex->getMessage());
|
||||
}
|
||||
if ($form->isValid() && $query->getTemplate() !== null) {
|
||||
try {
|
||||
$models = $this->service->createModels($query);
|
||||
$searched = true;
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
}
|
||||
|
||||
$forms = [];
|
||||
|
||||
foreach ($models as $model) {
|
||||
$customer = $model->getCustomer();
|
||||
$customerTpl = $model->getTemplate();
|
||||
$total += \count($model->getCalculator()->getEntries());
|
||||
|
||||
$values = [
|
||||
'invoiceDate' => $query->getInvoiceDate(),
|
||||
'template' => $customerTpl
|
||||
];
|
||||
|
||||
$forms[] = $this->createFormWithName('customer_' . $customer->getId(), FormType::class, $values, [
|
||||
'csrf_protection' => false,
|
||||
])
|
||||
->add('template', InvoiceTemplateType::class)
|
||||
->add('invoiceDate', DatePickerType::class, [
|
||||
'required' => true,
|
||||
])
|
||||
->createView();
|
||||
}
|
||||
|
||||
return $this->render('invoice/index.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'models' => $models,
|
||||
'forms' => $forms,
|
||||
'form' => $form->createView(),
|
||||
'limit_preview' => ($total > 500),
|
||||
'searched' => $searched,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/preview/{customer}/{token}", name="invoice_preview", methods={"GET"})
|
||||
* @Security("is_granted('access', customer)")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
public function previewAction(Customer $customer, string $token, Request $request, SystemConfiguration $configuration): Response
|
||||
#[Route(path: '/preview/{customer}/{token}', name: 'invoice_preview', methods: ['GET'])]
|
||||
#[Security("is_granted('access', customer) and is_granted('create_invoice')")]
|
||||
public function previewAction(Customer $customer, string $token, Request $request): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
@@ -158,7 +144,8 @@ final class InvoiceController extends AbstractController
|
||||
// so the new token would not be loaded
|
||||
|
||||
$query = $this->getDefaultQuery();
|
||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||
$query->setAllowTemplateOverwrite(false);
|
||||
$form = $this->getToolbarForm($query);
|
||||
if ($this->handleSearch($form, $request)) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
@@ -168,10 +155,9 @@ final class InvoiceController extends AbstractController
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $this->service->createModel($query);
|
||||
|
||||
return $this->service->renderInvoiceWithModel($model, $this->dispatcher, true);
|
||||
return $this->service->renderInvoice($model, $this->dispatcher, true);
|
||||
} catch (Exception $ex) {
|
||||
$this->logException($ex);
|
||||
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
} else {
|
||||
$this->flashFormError($form);
|
||||
@@ -180,12 +166,9 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/save-invoice/{customer}/{template}/{token}", name="invoice_create", methods={"GET"})
|
||||
* @Security("is_granted('access', customer)")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, string $token, Request $request, SystemConfiguration $configuration, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
#[Route(path: '/save-invoice/{customer}/{token}', name: 'invoice_create', methods: ['GET'])]
|
||||
#[Security("is_granted('access', customer) and is_granted('create_invoice')")]
|
||||
public function createInvoiceAction(Customer $customer, string $token, Request $request, CustomerRepository $customerRepository): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
@@ -198,28 +181,40 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
$query = $this->getDefaultQuery();
|
||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||
$query->setAllowTemplateOverwrite(false);
|
||||
$form = $this->getToolbarForm($query);
|
||||
if ($this->handleSearch($form, $request)) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if ($form->isValid()) {
|
||||
$query->setTemplate($template);
|
||||
$query->setCustomers([$customer]);
|
||||
try {
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $this->service->createModel($query);
|
||||
|
||||
return $this->renderInvoice($query, $request);
|
||||
// save default template for customer if not yet set
|
||||
if ($customer->getInvoiceTemplate() === null) {
|
||||
$customer->setInvoiceTemplate($query->getTemplate());
|
||||
$customerRepository->saveCustomer($customer);
|
||||
}
|
||||
|
||||
$invoice = $this->service->createInvoice($model, $this->dispatcher);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
} else {
|
||||
$this->flashFormError($form);
|
||||
}
|
||||
|
||||
$this->flashFormError($form);
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/change-status/{id}/{status}/{token}", name="admin_invoice_status", methods={"GET", "POST"})
|
||||
* @Security("is_granted('access', invoice.getCustomer())")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
#[Route(path: '/change-status/{id}/{status}/{token}', name: 'admin_invoice_status', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('access', invoice.getCustomer()) and is_granted('create_invoice')")]
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
@@ -238,6 +233,7 @@ final class InvoiceController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
return $this->render('invoice/invoice_edit.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'invoice' => $invoice,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
@@ -253,11 +249,8 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/edit/{id}", name="admin_invoice_edit", methods={"GET", "POST"})
|
||||
* @Security("is_granted('access', invoice.getCustomer())")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
#[Route(path: '/edit/{id}', name: 'admin_invoice_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('access', invoice.getCustomer()) and is_granted('create_invoice')")]
|
||||
public function editAction(Invoice $invoice, Request $request): Response
|
||||
{
|
||||
$form = $this->createInvoiceEditForm($invoice);
|
||||
@@ -267,24 +260,22 @@ final class InvoiceController extends AbstractController
|
||||
try {
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
$this->flashSuccess('action.update.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
} catch (Exception $ex) {
|
||||
$this->handleFormUpdateException($ex, $form);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('invoice/invoice_edit.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'invoice' => $invoice,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/delete/{id}/{token}", name="admin_invoice_delete", methods={"GET"})
|
||||
* @Security("is_granted('access', invoice.getCustomer())")
|
||||
* @Security("is_granted('delete_invoice')")
|
||||
*/
|
||||
#[Route(path: '/delete/{id}/{token}', name: 'admin_invoice_delete', methods: ['GET'])]
|
||||
#[Security("is_granted('access', invoice.getCustomer()) and is_granted('delete_invoice')")]
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
@@ -305,11 +296,8 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
|
||||
* @Security("is_granted('access', invoice.getCustomer())")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
#[Route(path: '/download/{id}', name: 'admin_invoice_download', methods: ['GET'])]
|
||||
#[Security("is_granted('access', invoice.getCustomer()) and is_granted('view_invoice')")]
|
||||
public function downloadAction(Invoice $invoice): Response
|
||||
{
|
||||
$file = $this->service->getInvoiceFile($invoice);
|
||||
@@ -323,10 +311,8 @@ final class InvoiceController extends AbstractController
|
||||
return $this->file($file->getRealPath(), $file->getBasename());
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
#[Route(path: '/show/{page}', defaults: ['page' => 1], requirements: ['page' => '[1-9]\d*'], name: 'admin_invoice_list', methods: ['GET'])]
|
||||
#[Security("is_granted('view_invoice')")]
|
||||
public function showInvoicesAction(Request $request, int $page): Response
|
||||
{
|
||||
$invoice = null;
|
||||
@@ -344,21 +330,48 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
$invoices = $this->invoiceRepository->getPagerfantaForQuery($query);
|
||||
$entries = $this->invoiceRepository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
|
||||
$table = new DataTable('invoices', $query);
|
||||
$table->setPagination($entries);
|
||||
$table->setSearchForm($form);
|
||||
$table->setPaginationRoute('admin_invoice_list');
|
||||
$table->setReloadEvents('kimai.invoiceUpdate');
|
||||
|
||||
$table->addColumn('avatar', ['class' => 'text-nowrap w-avatar d-none d-md-table-cell', 'title' => false, 'orderBy' => false]);
|
||||
$table->addColumn('date', ['class' => 'd-none d-sm-table-cell text-nowrap w-min']);
|
||||
$table->addColumn('user', ['class' => 'd-none text-nowrap w-min', 'orderBy' => false]);
|
||||
$table->addColumn('customer', ['class' => 'alwaysVisible text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
|
||||
|
||||
foreach ($metaColumns as $metaColumn) {
|
||||
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false]);
|
||||
}
|
||||
|
||||
$table->addColumn('invoice_number', ['class' => 'd-none d-md-table-cell w-min', 'title' => 'invoice.number', 'orderBy' => false]);
|
||||
$table->addColumn('due_date', ['class' => 'd-none w-min', 'title' => 'invoice.due_days', 'orderBy' => false]);
|
||||
$table->addColumn('payment_date', ['class' => 'd-none w-min', 'title' => 'invoice.payment_date', 'orderBy' => false]);
|
||||
$table->addColumn('status', ['class' => 'd-none d-sm-table-cell w-min', 'orderBy' => false]);
|
||||
$table->addColumn('subtotal', ['class' => 'd-none text-end w-min', 'title' => 'invoice.subtotal', 'orderBy' => false]);
|
||||
$table->addColumn('tax', ['class' => 'd-none text-end w-min', 'title' => 'invoice.tax']);
|
||||
$table->addColumn('total_rate', ['class' => 'd-none d-md-table-cell text-end w-min']);
|
||||
$table->addColumn('actions', ['class' => 'actions']);
|
||||
|
||||
$page = $this->createPageSetup('all_invoices');
|
||||
$page->setDataTable($table);
|
||||
$page->setActionName('invoice_archive');
|
||||
|
||||
return $this->render('invoice/listing.html.twig', [
|
||||
'entries' => $invoices,
|
||||
'query' => $query,
|
||||
'toolbarForm' => $form->createView(),
|
||||
'page_setup' => $page,
|
||||
'dataTable' => $table,
|
||||
'download' => $invoice,
|
||||
'metaColumns' => $this->findMetaColumns($query),
|
||||
'metaColumns' => $metaColumns,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/export", name="invoice_export", methods={"GET"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
|
||||
#[Security("is_granted('view_invoice')")]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
|
||||
{
|
||||
$query = new InvoiceArchiveQuery();
|
||||
@@ -380,35 +393,52 @@ final class InvoiceController extends AbstractController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/template/{page}", requirements={"page": "[1-9]\d*"}, defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"})
|
||||
* @Security("is_granted('manage_invoice_template')")
|
||||
*/
|
||||
#[Route(path: '/template/{page}', requirements: ['page' => '[1-9]\d*'], defaults: ['page' => 1], name: 'admin_invoice_template', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('manage_invoice_template')")]
|
||||
public function listTemplateAction(int $page): Response
|
||||
{
|
||||
$query = new BaseQuery();
|
||||
$query->setPage($page);
|
||||
|
||||
$templates = $this->templateRepository->getPagerfantaForQuery($query);
|
||||
$entries = $this->templateRepository->getPagerfantaForQuery($query);
|
||||
|
||||
$table = new DataTable('invoice_template', $query);
|
||||
$table->setPagination($entries);
|
||||
$table->setPaginationRoute('admin_invoice_template');
|
||||
$table->setReloadEvents('kimai.invoiceTemplateUpdate');
|
||||
|
||||
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
|
||||
$table->addColumn('title', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('company', ['class' => 'd-none', 'orderBy' => false]);
|
||||
$table->addColumn('vat_id', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('tax_rate', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('due_days', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('address', ['class' => 'd-none', 'orderBy' => false]);
|
||||
$table->addColumn('contact', ['class' => 'd-none', 'orderBy' => false]);
|
||||
$table->addColumn('calculator', ['class' => 'd-none', 'orderBy' => false, 'title' => 'invoice_calculator', 'translation_domain' => 'invoice-calculator']);
|
||||
$table->addColumn('renderer', ['class' => 'd-none', 'orderBy' => false, 'title' => 'invoice_renderer', 'translation_domain' => 'invoice-renderer']);
|
||||
$table->addColumn('language', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
|
||||
$table->addColumn('actions', ['class' => 'actions', 'orderBy' => false]);
|
||||
|
||||
$page = $this->createPageSetup('admin_invoice_template.title');
|
||||
$page->setDataTable($table);
|
||||
$page->setActionName('invoice_templates');
|
||||
|
||||
return $this->render('invoice/templates.html.twig', [
|
||||
'entries' => $templates,
|
||||
'page_setup' => $page,
|
||||
'dataTable' => $table,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/template/{id}/edit", name="admin_invoice_template_edit", methods={"GET", "POST"})
|
||||
* @Security("is_granted('manage_invoice_template')")
|
||||
*/
|
||||
#[Route(path: '/template/{id}/edit', name: 'admin_invoice_template_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('manage_invoice_template')")]
|
||||
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
return $this->renderTemplateForm($template, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/document_upload", name="admin_invoice_document_upload", methods={"GET", "POST"})
|
||||
* @Security("is_granted('upload_invoice_template')")
|
||||
*/
|
||||
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('upload_invoice_template')")]
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository)
|
||||
{
|
||||
$dir = $documentRepository->getUploadDirectory();
|
||||
@@ -450,7 +480,7 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
if (!file_exists($invoiceDir)) {
|
||||
@mkdir($invoiceDir, 0777);
|
||||
@mkdir($invoiceDir, 0o777);
|
||||
}
|
||||
|
||||
if (!is_dir($invoiceDir)) {
|
||||
@@ -494,7 +524,10 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
$page = $this->createPageSetup('admin_invoice_template.title');
|
||||
|
||||
return $this->render('invoice/document_upload.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'error_replacer' => ['%max%' => $event->getMaximumAllowedDocuments(), '%dir%' => $dir],
|
||||
'upload_error' => $uploadError,
|
||||
'can_upload' => $canUpload,
|
||||
@@ -504,10 +537,8 @@ final class InvoiceController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/document/{id}/delete/{token}", name="invoice_document_delete", methods={"GET", "POST"})
|
||||
* @Security("is_granted('manage_invoice_template')")
|
||||
*/
|
||||
#[Route(path: '/document/{id}/delete/{token}', name: 'invoice_document_delete', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('manage_invoice_template')")]
|
||||
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
|
||||
{
|
||||
$document = $documentRepository->findByName($id);
|
||||
@@ -549,30 +580,38 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_document_upload');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"})
|
||||
* @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"})
|
||||
* @Security("is_granted('manage_invoice_template')")
|
||||
*/
|
||||
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
|
||||
#[Route(path: '/template/create/{id}', name: 'admin_invoice_template_copy', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('manage_invoice_template')")]
|
||||
public function copyTemplateAction(Request $request, InvoiceTemplate $copyFrom): Response
|
||||
{
|
||||
return $this->createTemplate($request, $copyFrom);
|
||||
}
|
||||
|
||||
#[Route(path: '/template/create', name: 'admin_invoice_template_create', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('manage_invoice_template')")]
|
||||
public function createTemplateAction(Request $request): Response
|
||||
{
|
||||
return $this->createTemplate($request, null);
|
||||
}
|
||||
|
||||
private function createTemplate(Request $request, ?InvoiceTemplate $copyFrom = null): Response
|
||||
{
|
||||
$template = new InvoiceTemplate();
|
||||
$template->setLanguage($request->getLocale());
|
||||
|
||||
if (null !== $copyFrom) {
|
||||
$template = clone $copyFrom;
|
||||
$template->setName('Copy of ' . $copyFrom->getName());
|
||||
$template->setName($copyFrom->getName() . ' (1)');
|
||||
}
|
||||
|
||||
return $this->renderTemplateForm($template, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/template/{id}/delete/{token}", name="admin_invoice_template_delete", methods={"GET", "POST"})
|
||||
* @Security("is_granted('manage_invoice_template')")
|
||||
*/
|
||||
public function deleteTemplate(InvoiceTemplate $template, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
#[Route(path: '/template/{id}/delete/{csrfToken}', name: 'admin_invoice_template_delete', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('manage_invoice_template')")]
|
||||
public function deleteTemplate(InvoiceTemplate $template, string $csrfToken, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $token))) {
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $csrfToken))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
@@ -599,6 +638,7 @@ final class InvoiceController extends AbstractController
|
||||
$query = new InvoiceQuery();
|
||||
$query->setBegin($begin);
|
||||
$query->setEnd($end);
|
||||
$query->setInvoiceDate($factory->createDateTime());
|
||||
// limit access to data from teams
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
@@ -610,32 +650,6 @@ final class InvoiceController extends AbstractController
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function renderInvoice(InvoiceQuery $query, Request $request)
|
||||
{
|
||||
// use the current request locale as fallback, if no translation was configured
|
||||
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
|
||||
$query->getTemplate()->setLanguage($request->getLocale());
|
||||
}
|
||||
|
||||
try {
|
||||
$invoices = $this->service->createInvoices($query, $this->dispatcher);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if (\count($invoices) === 1) {
|
||||
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
|
||||
} elseif (\count($invoices) > 1) {
|
||||
$this->dispatcher->dispatch(new InvoiceCreatedMultipleEvent($invoices));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
private function flashFormError(FormInterface $form): void
|
||||
{
|
||||
$err = '';
|
||||
@@ -643,7 +657,7 @@ final class InvoiceController extends AbstractController
|
||||
$err .= PHP_EOL . '[' . $error->getOrigin()->getName() . '] ' . $error->getMessage();
|
||||
}
|
||||
|
||||
$this->flashError('action.update.error', ['%reason%' => $err]);
|
||||
$this->flashError('action.update.error', $err);
|
||||
}
|
||||
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
|
||||
@@ -659,25 +673,24 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
$this->handleFormUpdateException($ex, $editForm);
|
||||
}
|
||||
}
|
||||
|
||||
$page = $this->createPageSetup('admin_invoice_template.title');
|
||||
|
||||
return $this->render('invoice/template_edit.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'template' => $template,
|
||||
'form' => $editForm->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
private function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface
|
||||
private function getToolbarForm(InvoiceQuery $query): FormInterface
|
||||
{
|
||||
$form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class;
|
||||
|
||||
return $this->createForm($form, $query, [
|
||||
return $this->createSearchForm(InvoiceToolbarForm::class, $query, [
|
||||
'action' => $this->generateUrl('invoice', []),
|
||||
'method' => 'GET',
|
||||
'include_user' => $this->isGranted('view_other_timesheet'),
|
||||
'include_export' => $this->isGranted('edit_export_other_timesheet'),
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
'attr' => [
|
||||
'id' => 'invoice-print-form'
|
||||
@@ -687,9 +700,8 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
private function getArchiveToolbarForm(InvoiceArchiveQuery $query): FormInterface
|
||||
{
|
||||
return $this->createForm(InvoiceArchiveForm::class, $query, [
|
||||
return $this->createSearchForm(InvoiceArchiveForm::class, $query, [
|
||||
'action' => $this->generateUrl('admin_invoice_list', []),
|
||||
'method' => 'GET',
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
'attr' => [
|
||||
'id' => 'invoice-archive-form'
|
||||
@@ -734,4 +746,12 @@ final class InvoiceController extends AbstractController
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPageSetup(string $title = 'invoices'): PageSetup
|
||||
{
|
||||
$page = new PageSetup($title);
|
||||
$page->setHelp('invoices.html');
|
||||
|
||||
return $page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use App\Repository\RoleRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\RolePermissionManager;
|
||||
use App\Security\RoleService;
|
||||
use App\Utils\PageSetup;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -31,38 +32,20 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
/**
|
||||
* Controller used to manage user roles and role permissions.
|
||||
*
|
||||
* @Route(path="/admin/permissions")
|
||||
* @Security("is_granted('role_permissions')")
|
||||
*/
|
||||
#[Route(path: '/admin/permissions')]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('role_permissions')")]
|
||||
final class PermissionController extends AbstractController
|
||||
{
|
||||
public const TOKEN_NAME = 'user_role_permissions';
|
||||
/**
|
||||
* @var RoleService
|
||||
*/
|
||||
private $roleService;
|
||||
/**
|
||||
* @var RolePermissionManager
|
||||
*/
|
||||
private $manager;
|
||||
/**
|
||||
* @var RoleRepository
|
||||
*/
|
||||
private $roleRepository;
|
||||
|
||||
public function __construct(RoleService $roleService, RolePermissionManager $manager, RoleRepository $roleRepository)
|
||||
public function __construct(private RolePermissionManager $manager, private RoleRepository $roleRepository)
|
||||
{
|
||||
$this->roleService = $roleService;
|
||||
$this->manager = $manager;
|
||||
$this->roleRepository = $roleRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="", name="admin_user_permissions", methods={"GET", "POST"})
|
||||
* @Security("is_granted('role_permissions')")
|
||||
*/
|
||||
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
#[Route(path: '', name: 'admin_user_permissions', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('role_permissions')")]
|
||||
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService)
|
||||
{
|
||||
$all = $this->roleRepository->findAll();
|
||||
$existing = [];
|
||||
@@ -74,8 +57,7 @@ final class PermissionController extends AbstractController
|
||||
$existing = array_map('strtoupper', $existing);
|
||||
|
||||
// automatically import all hard coded (default) roles into the database table
|
||||
foreach ($this->roleService->getAvailableNames() as $roleName) {
|
||||
$roleName = strtoupper($roleName);
|
||||
foreach ($roleService->getAvailableNames() as $roleName) {
|
||||
if (!\in_array($roleName, $existing)) {
|
||||
$role = new Role();
|
||||
$role->setName($roleName);
|
||||
@@ -152,6 +134,9 @@ final class PermissionController extends AbstractController
|
||||
foreach ($all as $role) {
|
||||
$roles[$role->getName()] = $role;
|
||||
}
|
||||
$default = $roles['ROLE_USER'];
|
||||
unset($roles['ROLE_USER']);
|
||||
$roles['ROLE_USER'] = $default;
|
||||
|
||||
$event = new PermissionsEvent();
|
||||
foreach ($permissionSorted as $title => $permissions) {
|
||||
@@ -160,20 +145,23 @@ final class PermissionController extends AbstractController
|
||||
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$page = new PageSetup('profile.roles');
|
||||
$page->setHelp('permissions.html');
|
||||
$page->setActionName('user_permissions');
|
||||
|
||||
return $this->render('permission/permissions.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'token' => $csrfTokenManager->refreshToken(self::TOKEN_NAME)->getValue(),
|
||||
'roles' => array_values($roles),
|
||||
'sorted' => $event->getPermissions(),
|
||||
'manager' => $this->manager,
|
||||
'system_roles' => $this->roleService->getSystemRoles(),
|
||||
'always_apply_superadmin' => RolePermissionManager::SUPER_ADMIN_PERMISSIONS,
|
||||
'system_roles' => $roleService->getSystemRoles(),
|
||||
'always_apply_superadmin' => array_keys(RolePermissionManager::SUPER_ADMIN_PERMISSIONS),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/roles/create", name="admin_user_roles", methods={"GET", "POST"})
|
||||
* @Security("is_granted('role_permissions')")
|
||||
*/
|
||||
#[Route(path: '/roles/create', name: 'admin_user_roles', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('role_permissions')")]
|
||||
public function createRole(Request $request): Response
|
||||
{
|
||||
$role = new Role();
|
||||
@@ -196,16 +184,18 @@ final class PermissionController extends AbstractController
|
||||
return $this->redirectToRoute('admin_user_permissions');
|
||||
}
|
||||
|
||||
$page = new PageSetup('profile.roles');
|
||||
$page->setHelp('permissions.html');
|
||||
|
||||
return $this->render('permission/edit_role.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'form' => $form->createView(),
|
||||
'role' => $role,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/roles/{id}/delete/{csrfToken}", name="admin_user_role_delete", methods={"GET", "POST"})
|
||||
* @Security("is_granted('role_permissions')")
|
||||
*/
|
||||
#[Route(path: '/roles/{id}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('role_permissions')")]
|
||||
public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
|
||||
@@ -234,10 +224,8 @@ final class PermissionController extends AbstractController
|
||||
return $this->redirectToRoute('admin_user_permissions');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/roles/{id}/{name}/{value}/{csrfToken}", name="admin_user_permission_save", methods={"POST"})
|
||||
* @Security("is_granted('role_permissions')")
|
||||
*/
|
||||
#[Route(path: '/roles/{id}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
|
||||
#[Security("is_granted('role_permissions')")]
|
||||
public function savePermission(Role $role, string $name, bool $value, string $csrfToken, RolePermissionRepository $rolePermissionRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
|
||||
@@ -248,8 +236,8 @@ final class PermissionController extends AbstractController
|
||||
throw $this->createNotFoundException('Unknown permission: ' . $name);
|
||||
}
|
||||
|
||||
if (false === $value && $role->getName() === User::ROLE_SUPER_ADMIN && \in_array($name, RolePermissionManager::SUPER_ADMIN_PERMISSIONS)) {
|
||||
throw $this->createAccessDeniedException(sprintf('Permission "%s" cannot be deactivated for role "%s"', $name, $role->getName()));
|
||||
if (false === $value && $role->getName() === User::ROLE_SUPER_ADMIN && \array_key_exists($name, RolePermissionManager::SUPER_ADMIN_PERMISSIONS)) {
|
||||
throw new BadRequestHttpException(sprintf('Permission "%s" cannot be deactivated for role "%s"', $name, $role->getName()));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -259,7 +247,7 @@ final class PermissionController extends AbstractController
|
||||
$permission->setRole($role);
|
||||
$permission->setPermission($name);
|
||||
}
|
||||
$permission->setAllowed((bool) $value);
|
||||
$permission->setAllowed($value);
|
||||
|
||||
$rolePermissionRepository->saveRolePermission($permission);
|
||||
|
||||
|
||||
@@ -10,42 +10,57 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Plugin\PluginManager;
|
||||
use App\Utils\PageSetup;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* @Route(path="/admin/plugins")
|
||||
* @Security("is_granted('plugins')")
|
||||
*/
|
||||
class PluginController extends AbstractController
|
||||
#[Route(path: '/admin/plugins')]
|
||||
#[Security("is_granted('plugins')")]
|
||||
final class PluginController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var PluginManager
|
||||
*/
|
||||
protected $plugins;
|
||||
|
||||
/**
|
||||
* @param PluginManager $manager
|
||||
*/
|
||||
public function __construct(PluginManager $manager)
|
||||
#[Route(path: '/', name: 'plugins', methods: ['GET'])]
|
||||
public function indexAction(PluginManager $manager, HttpClientInterface $client, CacheInterface $cache): Response
|
||||
{
|
||||
$this->plugins = $manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", name="plugins", methods={"GET"})
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
public function indexAction()
|
||||
{
|
||||
$plugins = $this->plugins->getPlugins();
|
||||
foreach ($this->plugins->getPlugins() as $plugin) {
|
||||
$this->plugins->loadMetadata($plugin);
|
||||
$installed = [];
|
||||
$plugins = $manager->getPlugins();
|
||||
foreach ($plugins as $plugin) {
|
||||
$manager->loadMetadata($plugin);
|
||||
$installed[] = $plugin->getId();
|
||||
}
|
||||
|
||||
$page = new PageSetup('menu.plugin');
|
||||
$page->setHelp('plugins.html');
|
||||
|
||||
return $this->render('plugin/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'plugins' => $plugins,
|
||||
'installed' => $installed,
|
||||
'extensions' => $this->getPluginInformation($client, $cache)
|
||||
]);
|
||||
}
|
||||
|
||||
private function getPluginInformation(HttpClientInterface $client, CacheInterface $cache): array
|
||||
{
|
||||
return $cache->get('kimai.marketplace_extensions', function (ItemInterface $item) use ($client) {
|
||||
$response = $client->request('GET', 'https://www.kimai.org/plugins.json');
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = json_decode($response->getContent(), true);
|
||||
|
||||
if ($json === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$item->expiresAfter(86400); // one day
|
||||
|
||||
return $response->toArray();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,26 @@ namespace App\Controller;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Event\PrepareUserEvent;
|
||||
use App\Form\Model\TotpActivation;
|
||||
use App\Form\UserApiTokenType;
|
||||
use App\Form\UserEditType;
|
||||
use App\Form\UserPasswordType;
|
||||
use App\Form\UserPreferencesForm;
|
||||
use App\Form\UserRolesType;
|
||||
use App\Form\UserTeamsType;
|
||||
use App\Form\UserTwoFactorType;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use App\User\UserService;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Encoding\Encoding;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
|
||||
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -33,28 +41,23 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* User profile controller
|
||||
*
|
||||
* @Route(path="/profile")
|
||||
* @Security("is_granted('view_own_profile') or is_granted('view_other_profile')")
|
||||
*/
|
||||
#[Route(path: '/profile')]
|
||||
#[Security("(is_granted('view_own_profile') or is_granted('view_other_profile'))")]
|
||||
final class ProfileController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/", name="my_profile", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '/', name: 'my_profile', methods: ['GET'])]
|
||||
public function profileAction(): Response
|
||||
{
|
||||
return $this->redirectToRoute('user_profile', ['username' => $this->getUser()->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile', ['username' => $this->getUser()->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{username}", name="user_profile", methods={"GET"})
|
||||
* @Security("is_granted('view', profile)")
|
||||
*/
|
||||
#[Route(path: '/{username}', name: 'user_profile', methods: ['GET'])]
|
||||
#[Security("is_granted('view', profile)")]
|
||||
public function indexAction(User $profile, TimesheetRepository $repository, TimesheetStatisticService $statisticService): Response
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$userStats = $repository->getUserStatistics($profile, false);
|
||||
$userStats = $repository->getUserStatistics($profile);
|
||||
$firstEntry = $statisticService->findFirstRecordDate($profile);
|
||||
|
||||
$begin = $firstEntry ?? $dateFactory->getStartOfMonth();
|
||||
@@ -75,10 +78,8 @@ final class ProfileController extends AbstractController
|
||||
return $this->render('user/stats.html.twig', $viewVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{username}/edit", name="user_profile_edit", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', profile)")
|
||||
*/
|
||||
#[Route(path: '/{username}/edit', name: 'user_profile_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', profile)")]
|
||||
public function editAction(User $profile, Request $request, UserRepository $userRepository): Response
|
||||
{
|
||||
$form = $this->createEditForm($profile);
|
||||
@@ -89,20 +90,18 @@ final class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
return $this->render('user/profile.html.twig', [
|
||||
'tab' => 'settings',
|
||||
'tab' => 'edit',
|
||||
'user' => $profile,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{username}/password", name="user_profile_password", methods={"GET", "POST"})
|
||||
* @Security("is_granted('password', profile)")
|
||||
*/
|
||||
#[Route(path: '/{username}/password', name: 'user_profile_password', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('password', profile)")]
|
||||
public function passwordAction(User $profile, Request $request, UserService $userService): Response
|
||||
{
|
||||
$form = $this->createPasswordForm($profile);
|
||||
@@ -113,7 +112,7 @@ final class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
return $this->render('user/form.html.twig', [
|
||||
@@ -123,10 +122,8 @@ final class ProfileController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{username}/api-token", name="user_profile_api_token", methods={"GET", "POST"})
|
||||
* @Security("is_granted('api-token', profile)")
|
||||
*/
|
||||
#[Route(path: '/{username}/api-token', name: 'user_profile_api_token', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('api-token', profile)")]
|
||||
public function apiTokenAction(User $profile, Request $request, UserService $userService): Response
|
||||
{
|
||||
$form = $this->createApiTokenForm($profile);
|
||||
@@ -137,7 +134,7 @@ final class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
return $this->render('user/api-token.html.twig', [
|
||||
@@ -147,10 +144,8 @@ final class ProfileController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{username}/roles", name="user_profile_roles", methods={"GET", "POST"})
|
||||
* @Security("is_granted('roles', profile)")
|
||||
*/
|
||||
#[Route(path: '/{username}/roles', name: 'user_profile_roles', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('roles', profile)")]
|
||||
public function rolesAction(User $profile, Request $request, UserRepository $userRepository): Response
|
||||
{
|
||||
$isSuperAdmin = $profile->isSuperAdmin();
|
||||
@@ -169,7 +164,7 @@ final class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
return $this->render('user/form.html.twig', [
|
||||
@@ -179,10 +174,8 @@ final class ProfileController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
|
||||
* @Security("is_granted('teams', profile)")
|
||||
*/
|
||||
#[Route(path: '/{username}/teams', name: 'user_profile_teams', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('teams', profile)")]
|
||||
public function teamsAction(User $profile, Request $request, UserRepository $userRepository, TeamRepository $teamRepository): Response
|
||||
{
|
||||
$originalMembers = new ArrayCollection();
|
||||
@@ -206,7 +199,7 @@ final class ProfileController extends AbstractController
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]);
|
||||
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
return $this->render('user/form.html.twig', [
|
||||
@@ -216,62 +209,32 @@ final class ProfileController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{username}/prefs", name="user_profile_preferences", methods={"GET", "POST"})
|
||||
* @Security("is_granted('preferences', profile)")
|
||||
*/
|
||||
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
|
||||
#[Route(path: '/{username}/prefs', name: 'user_profile_preferences', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('preferences', profile)")]
|
||||
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserRepository $userRepository): Response
|
||||
{
|
||||
// we need to prepare the user preferences, which is done via an EventSubscriber
|
||||
$event = new PrepareUserEvent($profile);
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$original = [];
|
||||
foreach ($profile->getPreferences() as $preference) {
|
||||
$original[$preference->getName()] = $preference;
|
||||
}
|
||||
|
||||
$form = $this->createPreferencesForm($profile);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted()) {
|
||||
if ($form->isValid()) {
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
$preferences = $profile->getPreferences();
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$userRepository->saveUser($profile);
|
||||
|
||||
// do not allow to add unknown preferences
|
||||
foreach ($preferences as $preference) {
|
||||
if (!isset($original[$preference->getName()])) {
|
||||
$preferences->removeElement($preference);
|
||||
}
|
||||
}
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
// but allow to delete already saved settings
|
||||
foreach ($original as $name => $preference) {
|
||||
if (false === $profile->getPreferences()->contains($preference)) {
|
||||
$entityManager->remove($preference);
|
||||
}
|
||||
}
|
||||
|
||||
$profile->setPreferences($preferences);
|
||||
$entityManager->persist($profile);
|
||||
$entityManager->flush();
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
// switch locale ONLY if updated profile is the current user
|
||||
$locale = $request->getLocale();
|
||||
if ($this->getUser()->getId() === $profile->getId()) {
|
||||
$locale = $profile->getPreferenceValue('language', $locale, false);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('user_profile_preferences', [
|
||||
'_locale' => $locale,
|
||||
'username' => $profile->getUsername()
|
||||
]);
|
||||
} else {
|
||||
$this->flashError('action.update.error', ['%reason%' => 'Validation failed']);
|
||||
// switch locale ONLY if updated profile is the current user
|
||||
$locale = $request->getLocale();
|
||||
if ($this->getUser()->getId() === $profile->getId()) {
|
||||
$locale = $profile->getPreferenceValue('language', $locale, false);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('user_profile_preferences', [
|
||||
'_locale' => $locale,
|
||||
'username' => $profile->getUserIdentifier()
|
||||
]);
|
||||
}
|
||||
|
||||
// prepare ordered preferences
|
||||
@@ -304,7 +267,7 @@ final class ProfileController extends AbstractController
|
||||
UserPreferencesForm::class,
|
||||
$user,
|
||||
[
|
||||
'action' => $this->generateUrl('user_profile_preferences', ['username' => $user->getUsername()]),
|
||||
'action' => $this->generateUrl('user_profile_preferences', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST'
|
||||
]
|
||||
);
|
||||
@@ -316,7 +279,7 @@ final class ProfileController extends AbstractController
|
||||
UserEditType::class,
|
||||
$user,
|
||||
[
|
||||
'action' => $this->generateUrl('user_profile_edit', ['username' => $user->getUsername()]),
|
||||
'action' => $this->generateUrl('user_profile_edit', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST',
|
||||
'include_active_flag' => ($user->getId() !== $this->getUser()->getId()),
|
||||
'include_preferences' => false,
|
||||
@@ -330,7 +293,7 @@ final class ProfileController extends AbstractController
|
||||
UserRolesType::class,
|
||||
$user,
|
||||
[
|
||||
'action' => $this->generateUrl('user_profile_roles', ['username' => $user->getUsername()]),
|
||||
'action' => $this->generateUrl('user_profile_roles', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST',
|
||||
]
|
||||
);
|
||||
@@ -342,7 +305,7 @@ final class ProfileController extends AbstractController
|
||||
UserTeamsType::class,
|
||||
$user,
|
||||
[
|
||||
'action' => $this->generateUrl('user_profile_teams', ['username' => $user->getUsername()]),
|
||||
'action' => $this->generateUrl('user_profile_teams', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST',
|
||||
]
|
||||
);
|
||||
@@ -354,7 +317,7 @@ final class ProfileController extends AbstractController
|
||||
UserPasswordType::class,
|
||||
$user,
|
||||
[
|
||||
'action' => $this->generateUrl('user_profile_password', ['username' => $user->getUsername()]),
|
||||
'action' => $this->generateUrl('user_profile_password', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST'
|
||||
]
|
||||
);
|
||||
@@ -366,9 +329,98 @@ final class ProfileController extends AbstractController
|
||||
UserApiTokenType::class,
|
||||
$user,
|
||||
[
|
||||
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUsername()]),
|
||||
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[Route(path: '/{username}/2fa', name: 'user_profile_2fa', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('2fa', profile)")]
|
||||
public function twoFactorAction(User $profile, Request $request, UserService $userService, TotpAuthenticatorInterface $totpAuthenticator): Response
|
||||
{
|
||||
if (!$profile->hasTotpSecret()) {
|
||||
$profile->setTotpSecret($totpAuthenticator->generateSecret());
|
||||
$userService->updateUser($profile);
|
||||
}
|
||||
|
||||
$data = new TotpActivation($profile);
|
||||
|
||||
$form = $this->createForm(UserTwoFactorType::class, $data, [
|
||||
'action' => $this->generateUrl('user_profile_2fa', ['username' => $profile->getUserIdentifier()]),
|
||||
'method' => 'POST'
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$profile->enableTotpAuthentication();
|
||||
$userService->updateUser($profile);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('user_profile_2fa', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
return $this->render('user/2fa.html.twig', [
|
||||
'tab' => '2fa',
|
||||
'user' => $profile,
|
||||
'form' => $form->createView(),
|
||||
'deactivate' => $this->getTwoFactorDeactivationForm($profile)->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getTwoFactorDeactivationForm(User $user): FormInterface
|
||||
{
|
||||
return $this->createFormBuilder(
|
||||
[],
|
||||
[
|
||||
'action' => $this->generateUrl('user_profile_2fa_deactivate', ['username' => $user->getUserIdentifier()]),
|
||||
'method' => 'POST'
|
||||
]
|
||||
)->getForm();
|
||||
}
|
||||
|
||||
#[Route(path: '/{username}/2fa_deactivate', name: 'user_profile_2fa_deactivate', methods: ['POST'])]
|
||||
#[Security("is_granted('IS_AUTHENTICATED_FULLY') and is_granted('2fa', profile)")]
|
||||
public function deactivateTwoFactorAction(User $profile, Request $request, UserService $userService, TotpAuthenticatorInterface $totpAuthenticator): Response
|
||||
{
|
||||
if ($profile->hasTotpSecret()) {
|
||||
$form = $this->getTwoFactorDeactivationForm($profile);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$profile->disableTotpAuthentication();
|
||||
$userService->updateUser($profile);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('user_profile_2fa', ['username' => $profile->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{username}/totp.png', name: 'user_profile_2fa_image', methods: ['GET'])]
|
||||
#[Security("is_granted('2fa', profile)")]
|
||||
public function displayTotpQrCode(User $profile, TotpAuthenticatorInterface $totpAuthenticator): Response
|
||||
{
|
||||
if (!$profile->hasTotpSecret()) {
|
||||
throw $this->createNotFoundException('User has no TOTP secret.');
|
||||
}
|
||||
|
||||
$qrCodeContent = $totpAuthenticator->getQRContent($profile);
|
||||
|
||||
$result = Builder::create()
|
||||
->writer(new PngWriter())
|
||||
->writerOptions([])
|
||||
->data($qrCodeContent)
|
||||
->encoding(new Encoding('UTF-8'))
|
||||
->errorCorrectionLevel(new ErrorCorrectionLevelHigh())
|
||||
->size(200)
|
||||
->margin(0)
|
||||
->roundBlockSizeMode(new RoundBlockSizeModeMargin())
|
||||
->build();
|
||||
|
||||
return new Response($result->getString(), 200, ['Content-Type' => 'image/png']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectComment;
|
||||
use App\Entity\ProjectRate;
|
||||
use App\Entity\Rate;
|
||||
use App\Entity\Team;
|
||||
use App\Event\ProjectDetailControllerEvent;
|
||||
use App\Event\ProjectMetaDefinitionEvent;
|
||||
@@ -39,52 +38,30 @@ use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Utils\Context;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
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\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
/**
|
||||
* Controller used to manage projects.
|
||||
*
|
||||
* @Route(path="/admin/project")
|
||||
* @Security("is_granted('view_project') or is_granted('view_teamlead_project') or is_granted('view_team_project')")
|
||||
*/
|
||||
#[Route(path: '/admin/project')]
|
||||
#[Security("is_granted('view_project') or is_granted('view_teamlead_project') or is_granted('view_team_project')")]
|
||||
final class ProjectController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var ProjectRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var SystemConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var ProjectService
|
||||
*/
|
||||
private $projectService;
|
||||
|
||||
public function __construct(ProjectRepository $repository, SystemConfiguration $configuration, EventDispatcherInterface $dispatcher, ProjectService $projectService)
|
||||
public function __construct(private ProjectRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ProjectService $projectService)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->configuration = $configuration;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->projectService = $projectService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"})
|
||||
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_project', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_project_paginated', methods: ['GET'])]
|
||||
public function indexAction($page, Request $request)
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
@@ -97,12 +74,47 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
$entries = $this->repository->getPagerfantaForQuery($query);
|
||||
$metaColumns = $this->findMetaColumns($query);
|
||||
|
||||
$table = new DataTable('project_admin', $query);
|
||||
$table->setPagination($entries);
|
||||
$table->setSearchForm($form);
|
||||
$table->setPaginationRoute('admin_project_paginated');
|
||||
$table->setReloadEvents('kimai.projectUpdate kimai.projectDelete kimai.projectTeamUpdate');
|
||||
|
||||
$table->addColumn('name', ['class' => 'alwaysVisible']);
|
||||
$table->addColumn('customer', ['class' => 'd-none']);
|
||||
$table->addColumn('comment', ['class' => 'd-none', 'title' => 'description']);
|
||||
$table->addColumn('orderNumber', ['class' => 'd-none']);
|
||||
$table->addColumn('orderDate', ['class' => 'd-none']);
|
||||
$table->addColumn('project_start', ['class' => 'd-none']);
|
||||
$table->addColumn('project_end', ['class' => 'd-none']);
|
||||
|
||||
foreach ($metaColumns as $metaColumn) {
|
||||
$table->addColumn('mf_' . $metaColumn->getName(), ['title' => $metaColumn->getLabel(), 'class' => 'd-none', 'orderBy' => false, 'data' => $metaColumn]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('budget_money', 'project')) {
|
||||
$table->addColumn('budget', ['class' => 'd-none text-end w-min', 'title' => 'budget']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('budget_time', 'project')) {
|
||||
$table->addColumn('timeBudget', ['class' => 'd-none text-end w-min', 'title' => 'timeBudget']);
|
||||
}
|
||||
|
||||
$table->addColumn('billable', ['class' => 'd-none text-center w-min', 'orderBy' => false]);
|
||||
$table->addColumn('team', ['class' => 'text-center w-min', 'orderBy' => false]);
|
||||
$table->addColumn('visible', ['class' => 'd-none text-center w-min']);
|
||||
$table->addColumn('actions', ['class' => 'actions']);
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
$page->setDataTable($table);
|
||||
$page->setActionName('projects');
|
||||
|
||||
return $this->render('project/index.html.twig', [
|
||||
'entries' => $entries,
|
||||
'query' => $query,
|
||||
'toolbarForm' => $form->createView(),
|
||||
'metaColumns' => $this->findMetaColumns($query),
|
||||
'page_setup' => $page,
|
||||
'dataTable' => $table,
|
||||
'metaColumns' => $metaColumns,
|
||||
'now' => $this->getDateTimeFactory()->createDateTime(),
|
||||
]);
|
||||
}
|
||||
@@ -111,7 +123,7 @@ final class ProjectController extends AbstractController
|
||||
* @param ProjectQuery $query
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
protected function findMetaColumns(ProjectQuery $query): array
|
||||
private function findMetaColumns(ProjectQuery $query): array
|
||||
{
|
||||
$event = new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::PROJECT);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -119,10 +131,8 @@ final class ProjectController extends AbstractController
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/permissions", name="admin_project_permissions", methods={"GET", "POST"})
|
||||
* @Security("is_granted('permissions', project)")
|
||||
*/
|
||||
#[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, [
|
||||
@@ -148,17 +158,27 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('project/permissions.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'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')")
|
||||
*/
|
||||
public function createAction(Request $request, ?Customer $customer = null)
|
||||
#[Route(path: '/create/{customer}', name: 'admin_project_create_with_customer', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('create_project')")]
|
||||
public function createWithCustomerAction(Request $request, Customer $customer)
|
||||
{
|
||||
return $this->createProject($request, $customer);
|
||||
}
|
||||
|
||||
#[Route(path: '/create', name: 'admin_project_create', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('create_project')")]
|
||||
public function createAction(Request $request)
|
||||
{
|
||||
return $this->createProject($request, null);
|
||||
}
|
||||
|
||||
private function createProject(Request $request, ?Customer $customer = null)
|
||||
{
|
||||
$project = $this->projectService->createNewProject($customer);
|
||||
|
||||
@@ -170,22 +190,21 @@ final class ProjectController extends AbstractController
|
||||
$this->projectService->saveNewProject($project, new Context($this->getUser()));
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
return $this->redirectToRouteAfterCreate('project_details', ['id' => $project->getId()]);
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
$this->handleFormUpdateException($ex, $editForm);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('project/edit.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'project' => $project,
|
||||
'form' => $editForm->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/comment_delete/{token}", name="project_comment_delete", methods={"GET"})
|
||||
* @Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")
|
||||
*/
|
||||
#[Route(path: '/{id}/comment_delete/{token}', name: 'project_comment_delete', methods: ['GET'])]
|
||||
#[Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")]
|
||||
public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
{
|
||||
$projectId = $comment->getProject()->getId();
|
||||
@@ -207,14 +226,12 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $projectId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/comment_add", name="project_comment_add", methods={"POST"})
|
||||
* @Security("is_granted('comments_create', project)")
|
||||
*/
|
||||
#[Route(path: '/{id}/comment_add', name: 'project_comment_add', methods: ['POST'])]
|
||||
#[Security("is_granted('comments', project)")]
|
||||
public function addCommentAction(Project $project, Request $request)
|
||||
{
|
||||
$comment = new ProjectComment();
|
||||
$form = $this->getCommentForm($project, $comment);
|
||||
$comment = new ProjectComment($project);
|
||||
$form = $this->getCommentForm($comment);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
@@ -229,10 +246,8 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/comment_pin/{token}", name="project_comment_pin", methods={"GET"})
|
||||
* @Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")
|
||||
*/
|
||||
#[Route(path: '/{id}/comment_pin/{token}', name: 'project_comment_pin', methods: ['GET'])]
|
||||
#[Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")]
|
||||
public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
{
|
||||
$projectId = $comment->getProject()->getId();
|
||||
@@ -255,21 +270,18 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $projectId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/create_team", name="project_team_create", methods={"GET"})
|
||||
* @Security("is_granted('create_team') and is_granted('edit', project)")
|
||||
*/
|
||||
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
|
||||
#[Security("is_granted('create_team') and is_granted('permissions', project)")]
|
||||
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository)
|
||||
{
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
|
||||
if (null !== $defaultTeam) {
|
||||
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
|
||||
$this->flashError('action.update.error', 'Team already existing');
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
$defaultTeam = new Team();
|
||||
$defaultTeam->setName($project->getName());
|
||||
$defaultTeam = new Team($project->getName());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addProject($project);
|
||||
|
||||
@@ -282,10 +294,8 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/activities/{page}", defaults={"page": 1}, name="project_activities", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view', project)")
|
||||
*/
|
||||
#[Route(path: '/{id}/activities/{page}', defaults: ['page' => 1], name: 'project_activities', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('view', project)")]
|
||||
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository)
|
||||
{
|
||||
$query = new ActivityQuery();
|
||||
@@ -298,7 +308,6 @@ final class ProjectController extends AbstractController
|
||||
$query->addOrderGroup('visible', ActivityQuery::ORDER_DESC);
|
||||
$query->addOrderGroup('name', ActivityQuery::ORDER_ASC);
|
||||
|
||||
/* @var $entries Pagerfanta */
|
||||
$entries = $activityRepository->getPagerfantaForQuery($query);
|
||||
|
||||
return $this->render('project/embed_activities.html.twig', [
|
||||
@@ -309,11 +318,9 @@ final class ProjectController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/details", name="project_details", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view', project)")
|
||||
*/
|
||||
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService)
|
||||
#[Route(path: '/{id}/details', name: 'project_details', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('view', project)")]
|
||||
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
{
|
||||
$event = new ProjectMetaDefinitionEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -340,10 +347,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
if ($this->isGranted('comments', $project)) {
|
||||
$comments = $this->repository->getComments($project);
|
||||
}
|
||||
|
||||
if ($this->isGranted('comments_create', $project)) {
|
||||
$commentForm = $this->getCommentForm($project, new ProjectComment())->createView();
|
||||
$commentForm = $this->getCommentForm(new ProjectComment($project))->createView();
|
||||
}
|
||||
|
||||
if ($this->isGranted('permissions', $project) || $this->isGranted('details', $project) || $this->isGranted('view_team')) {
|
||||
@@ -355,7 +359,13 @@ final class ProjectController extends AbstractController
|
||||
$this->dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
$page = $this->createPageSetup();
|
||||
$page->setActionName('project');
|
||||
$page->setActionView('project_details');
|
||||
$page->setActionPayload(['project' => $project, 'token' => $csrfTokenManager->getToken('project.duplicate')]);
|
||||
|
||||
return $this->render('project/details.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'project' => $project,
|
||||
'comments' => $comments,
|
||||
'commentForm' => $commentForm,
|
||||
@@ -369,17 +379,27 @@ final class ProjectController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/rate", name="admin_project_rate_add", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', project)")
|
||||
*/
|
||||
public function addRateAction(Project $project, Request $request, ProjectRateRepository $repository)
|
||||
#[Route(path: '/{id}/rate/{rate}', name: 'admin_project_rate_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', project)")]
|
||||
public function editRateAction(Project $project, ProjectRate $rate, Request $request, ProjectRateRepository $repository): Response
|
||||
{
|
||||
return $this->rateFormAction($project, $rate, $request, $repository, $this->generateUrl('admin_project_rate_edit', ['id' => $project->getId(), 'rate' => $rate->getId()]));
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/rate', name: 'admin_project_rate_add', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', project)")]
|
||||
public function addRateAction(Project $project, Request $request, ProjectRateRepository $repository): Response
|
||||
{
|
||||
$rate = new ProjectRate();
|
||||
$rate->setProject($project);
|
||||
|
||||
return $this->rateFormAction($project, $rate, $request, $repository, $this->generateUrl('admin_project_rate_add', ['id' => $project->getId()]));
|
||||
}
|
||||
|
||||
private function rateFormAction(Project $project, ProjectRate $rate, Request $request, ProjectRateRepository $repository, string $formUrl): Response
|
||||
{
|
||||
$form = $this->createForm(ProjectRateForm::class, $rate, [
|
||||
'action' => $this->generateUrl('admin_project_rate_add', ['id' => $project->getId()]),
|
||||
'action' => $formUrl,
|
||||
'method' => 'POST',
|
||||
]);
|
||||
|
||||
@@ -397,15 +417,14 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('project/rates.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'project' => $project,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', project)")
|
||||
*/
|
||||
#[Route(path: '/{id}/edit', name: 'admin_project_edit', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', project)")]
|
||||
public function editAction(Project $project, Request $request)
|
||||
{
|
||||
$editForm = $this->createEditForm($project);
|
||||
@@ -423,15 +442,14 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('project/edit.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'project' => $project,
|
||||
'form' => $editForm->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/duplicate/{token}", name="admin_project_duplicate", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', project)")
|
||||
*/
|
||||
#[Route(path: '/{id}/duplicate/{token}', name: 'admin_project_duplicate', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('edit', project)")]
|
||||
public function duplicateAction(Project $project, string $token, ProjectDuplicationService $projectDuplicationService, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.duplicate', $token))) {
|
||||
@@ -449,10 +467,8 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $newProject->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"})
|
||||
* @Security("is_granted('delete', project)")
|
||||
*/
|
||||
#[Route(path: '/{id}/delete', name: 'admin_project_delete', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('delete', project)")]
|
||||
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService)
|
||||
{
|
||||
$stats = $statisticService->getProjectStatistics($project);
|
||||
@@ -488,15 +504,14 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('project/delete.html.twig', [
|
||||
'page_setup' => $this->createPageSetup(),
|
||||
'project' => $project,
|
||||
'stats' => $stats,
|
||||
'form' => $deleteForm->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/export", name="project_export", methods={"GET"})
|
||||
*/
|
||||
#[Route(path: '/export', name: 'project_export', methods: ['GET'])]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
@@ -522,25 +537,23 @@ final class ProjectController extends AbstractController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
protected function getToolbarForm(ProjectQuery $query): FormInterface
|
||||
private function getToolbarForm(ProjectQuery $query): FormInterface
|
||||
{
|
||||
return $this->createForm(ProjectToolbarForm::class, $query, [
|
||||
return $this->createSearchForm(ProjectToolbarForm::class, $query, [
|
||||
'action' => $this->generateUrl('admin_project', [
|
||||
'page' => $query->getPage(),
|
||||
]),
|
||||
'method' => 'GET',
|
||||
]);
|
||||
}
|
||||
|
||||
private function getCommentForm(Project $project, ProjectComment $comment): FormInterface
|
||||
private function getCommentForm(ProjectComment $comment): FormInterface
|
||||
{
|
||||
if (null === $comment->getId()) {
|
||||
$comment->setProject($project);
|
||||
$comment->setCreatedBy($this->getUser());
|
||||
}
|
||||
|
||||
return $this->createForm(ProjectCommentForm::class, $comment, [
|
||||
'action' => $this->generateUrl('project_comment_add', ['id' => $project->getId()]),
|
||||
'action' => $this->generateUrl('project_comment_add', ['id' => $comment->getProject()->getId()]),
|
||||
'method' => 'POST',
|
||||
]);
|
||||
}
|
||||
@@ -565,7 +578,14 @@ final class ProjectController extends AbstractController
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
'include_budget' => $this->isGranted('budget', $project),
|
||||
'include_time' => $this->isGranted('time', $project),
|
||||
'time_increment' => 15,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPageSetup(): PageSetup
|
||||
{
|
||||
$page = new PageSetup('projects');
|
||||
$page->setHelp('project.html');
|
||||
|
||||
return $page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,32 +16,23 @@ use App\Model\QuickEntryWeek;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\TimesheetService;
|
||||
use App\Utils\PageSetup;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* Controller used to enter times in weekly form.
|
||||
*
|
||||
* @Route(path="/quick_entry")
|
||||
* @Security("is_granted('quick-entry')")
|
||||
*/
|
||||
class QuickEntryController extends AbstractController
|
||||
#[Route(path: '/quick_entry')]
|
||||
#[Security("is_granted('quick-entry')")]
|
||||
final class QuickEntryController extends AbstractController
|
||||
{
|
||||
private $configuration;
|
||||
private $timesheetService;
|
||||
private $repository;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration, TimesheetService $timesheetService, TimesheetRepository $repository)
|
||||
public function __construct(private SystemConfiguration $configuration, private TimesheetService $timesheetService, private TimesheetRepository $repository)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
$this->timesheetService = $timesheetService;
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{begin}", name="quick_entry", methods={"GET", "POST"})
|
||||
*/
|
||||
#[Route(path: '/{begin}', name: 'quick_entry', methods: ['GET', 'POST'])]
|
||||
public function quickEntry(Request $request, ?string $begin = null)
|
||||
{
|
||||
$factory = $this->getDateTimeFactory();
|
||||
@@ -67,7 +58,7 @@ class QuickEntryController extends AbstractController
|
||||
$query->setBegin($startWeek);
|
||||
$query->setEnd($endWeek);
|
||||
$query->setName('quickEntryForm');
|
||||
$query->setUser($this->getUser());
|
||||
$query->setUser($user);
|
||||
|
||||
$result = $this->repository->getTimesheetResult($query);
|
||||
|
||||
@@ -104,7 +95,7 @@ class QuickEntryController extends AbstractController
|
||||
$startFrom = clone $startWeek;
|
||||
$startFrom->modify(sprintf('-%s weeks', $takeOverWeeks));
|
||||
}
|
||||
$timesheets = $this->repository->getRecentActivities($this->getUser(), $startFrom, $amount);
|
||||
$timesheets = $this->repository->getRecentActivities($user, $startFrom, $amount);
|
||||
foreach ($timesheets as $timesheet) {
|
||||
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
|
||||
if (\array_key_exists($id, $rows)) {
|
||||
@@ -112,7 +103,7 @@ class QuickEntryController extends AbstractController
|
||||
}
|
||||
// there is an edge case possible with a project that starts and ends between the start and end date
|
||||
// user could still select it from the dropdown, but it is better to hide a row than displaying already ended projects
|
||||
if (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek)) {
|
||||
if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) {
|
||||
continue;
|
||||
}
|
||||
$rows[$id] = [
|
||||
@@ -225,12 +216,15 @@ class QuickEntryController extends AbstractController
|
||||
return $this->redirectToRoute('quick_entry', ['begin' => $begin->format('Y-m-d')]);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.update.error');
|
||||
$this->logException($ex);
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
}
|
||||
|
||||
$page = new PageSetup('quick_entry.title');
|
||||
$page->setHelp('weekly-times.html');
|
||||
|
||||
return $this->render('quick-entry/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'days' => $week,
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
|
||||
@@ -21,15 +21,8 @@ use DateTime;
|
||||
|
||||
abstract class AbstractUserReportController extends AbstractController
|
||||
{
|
||||
protected $statisticService;
|
||||
private $projectRepository;
|
||||
private $activityRepository;
|
||||
|
||||
public function __construct(TimesheetStatisticService $statisticService, ProjectRepository $projectRepository, ActivityRepository $activityRepository)
|
||||
public function __construct(protected TimesheetStatisticService $statisticService, private ProjectRepository $projectRepository, private ActivityRepository $activityRepository)
|
||||
{
|
||||
$this->statisticService = $statisticService;
|
||||
$this->projectRepository = $projectRepository;
|
||||
$this->activityRepository = $activityRepository;
|
||||
}
|
||||
|
||||
protected function canSelectUser(): bool
|
||||
|
||||
@@ -14,38 +14,32 @@ use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjects;
|
||||
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjectsForm;
|
||||
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjectsRepository;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/customer/monthly_projects")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
#[Route(path: '/reporting/customer/monthly_projects')]
|
||||
#[Security("is_granted('report:customer') and is_granted('report:other')")]
|
||||
final class CustomerMonthlyProjectsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/view", name="report_customer_monthly_projects", methods={"GET","POST"})
|
||||
*/
|
||||
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
#[Route(path: '/view', name: 'report_customer_monthly_projects', methods: ['GET', 'POST'])]
|
||||
public function report(Request $request, CustomerMonthlyProjectsRepository $repository, UserRepository $userRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
'reporting/customer/monthly_projects.html.twig',
|
||||
$this->getData($request, $statisticService, $userRepository)
|
||||
$this->getData($request, $repository, $userRepository)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/export", name="report_customer_monthly_projects_export", methods={"GET","POST"})
|
||||
*/
|
||||
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
#[Route(path: '/export', name: 'report_customer_monthly_projects_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request, CustomerMonthlyProjectsRepository $repository, UserRepository $userRepository): Response
|
||||
{
|
||||
$data = $this->getData($request, $statisticService, $userRepository);
|
||||
$data = $this->getData($request, $repository, $userRepository);
|
||||
|
||||
$content = $this->render('reporting/customer/monthly_projects_export.html.twig', $data)->getContent();
|
||||
|
||||
@@ -57,12 +51,13 @@ final class CustomerMonthlyProjectsController extends AbstractController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
|
||||
private function getData(Request $request, CustomerMonthlyProjectsRepository $repository, UserRepository $userRepository): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setSystemAccount(false);
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $userRepository->getUsersForQuery($query);
|
||||
|
||||
@@ -94,7 +89,7 @@ final class CustomerMonthlyProjectsController extends AbstractController
|
||||
$next = clone $start;
|
||||
$next->modify('+1 month');
|
||||
|
||||
$stats = $statisticService->getGroupedByCustomerProjectActivityUser($start, $end, $allUsers);
|
||||
$stats = $repository->getGroupedByCustomerProjectActivityUser($start, $end, $allUsers, $values->getCustomer());
|
||||
|
||||
return [
|
||||
'dataType' => $values->getSumType(),
|
||||
|
||||
@@ -20,20 +20,18 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final class ProjectDateRangeController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/reporting/project_daterange", name="report_project_daterange", methods={"GET","POST"})
|
||||
* @Security("is_granted('view_reporting') and is_granted('budget_any', 'project')")
|
||||
*/
|
||||
#[Route(path: '/reporting/project_daterange', name: 'report_project_daterange', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('report:project') and is_granted('budget_any', 'project')")]
|
||||
public function __invoke(Request $request, ProjectStatisticService $service)
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ProjectDaterangeQuery($dateFactory->getStartOfMonth(), $user);
|
||||
$form = $this->createForm(ProjectDateRangeForm::class, $query, [
|
||||
$form = $this->createFormForGetRequest(ProjectDateRangeForm::class, $query, [
|
||||
'timezone' => $user->getTimezone()
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$dateRange = new DateRange(true);
|
||||
$dateRange->setBegin($query->getMonth());
|
||||
@@ -52,6 +50,7 @@ final class ProjectDateRangeController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('reporting/project_daterange.html.twig', [
|
||||
'report_title' => 'report_project_daterange',
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
'queryEnd' => $dateRange->getEnd(),
|
||||
|
||||
@@ -10,40 +10,50 @@
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Entity\Project;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Reporting\ProjectDetails\ProjectDetailsForm;
|
||||
use App\Reporting\ProjectDetails\ProjectDetailsQuery;
|
||||
use App\Utils\PageSetup;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final class ProjectDetailsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/reporting/project_details", name="report_project_details", methods={"GET"})
|
||||
* @Security("is_granted('view_reporting') and is_granted('details', 'project')")
|
||||
*/
|
||||
#[Route(path: '/reporting/project_details', name: 'report_project_details', methods: ['GET'])]
|
||||
#[Security("is_granted('report:project') and is_granted('details', 'project')")]
|
||||
public function __invoke(Request $request, ProjectStatisticService $service)
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ProjectDetailsQuery($dateFactory->createDateTime(), $user);
|
||||
$form = $this->createForm(ProjectDetailsForm::class, $query);
|
||||
$form = $this->createFormForGetRequest(ProjectDetailsForm::class, $query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$projectView = null;
|
||||
$projectDetails = null;
|
||||
$project = $query->getProject();
|
||||
|
||||
if ($query->getProject() !== null && $this->isGranted('details', $query->getProject())) {
|
||||
$projectViews = $service->getProjectView($user, [$query->getProject()], $query->getToday());
|
||||
if ($project !== null && $this->isGranted('details', $project)) {
|
||||
$projectViews = $service->getProjectView($user, [$project], $query->getToday());
|
||||
$projectView = $projectViews[0];
|
||||
$projectDetails = $service->getProjectsDetails($query);
|
||||
}
|
||||
|
||||
$page = new PageSetup('projects');
|
||||
$page->setHelp('project.html');
|
||||
|
||||
if ($project !== null) {
|
||||
$page->setActionName('project');
|
||||
$page->setActionView('project_details_report');
|
||||
$page->setActionPayload(['project' => $project]);
|
||||
}
|
||||
|
||||
return $this->render('reporting/project_details.html.twig', [
|
||||
'project' => $query->getProject(),
|
||||
'page_setup' => $page,
|
||||
'report_title' => 'report_project_details',
|
||||
'project' => $project,
|
||||
'project_view' => $projectView,
|
||||
'project_details' => $projectDetails,
|
||||
'form' => $form->createView(),
|
||||
|
||||
@@ -19,10 +19,8 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final class ProjectInactiveController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/reporting/project_inactive", name="report_project_inactive", methods={"GET","POST"})
|
||||
* @Security("is_granted('view_reporting') and is_granted('budget_any', 'project')")
|
||||
*/
|
||||
#[Route(path: '/reporting/project_inactive', name: 'report_project_inactive', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('report:project') and is_granted('budget_any', 'project')")]
|
||||
public function __invoke(Request $request, ProjectStatisticService $service)
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
@@ -30,7 +28,7 @@ final class ProjectInactiveController extends AbstractController
|
||||
$now = $dateFactory->createDateTime();
|
||||
|
||||
$query = new ProjectInactiveQuery($dateFactory->createDateTime('-1 year'), $user);
|
||||
$form = $this->createForm(ProjectInactiveForm::class, $query, [
|
||||
$form = $this->createFormForGetRequest(ProjectInactiveForm::class, $query, [
|
||||
'timezone' => $user->getTimezone()
|
||||
]);
|
||||
$form->submit($request->query->all(), false);
|
||||
@@ -47,10 +45,10 @@ final class ProjectInactiveController extends AbstractController
|
||||
$byCustomer[$customer->getId()]['projects'][] = $entry;
|
||||
}
|
||||
|
||||
return $this->render('reporting/project_view.html.twig', [
|
||||
return $this->render('reporting/project_inactive.html.twig', [
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
'title' => 'report_inactive_project',
|
||||
'report_title' => 'report_inactive_project',
|
||||
'tableName' => 'inactive_project_reporting',
|
||||
'now' => $now,
|
||||
'skipColumns' => ['today', 'week', 'month', 'projectStart', 'projectEnd', 'comment'],
|
||||
|
||||
@@ -19,17 +19,15 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final class ProjectViewController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/reporting/project_view", name="report_project_view", methods={"GET","POST"})
|
||||
* @Security("is_granted('view_reporting') and is_granted('budget_any', 'project')")
|
||||
*/
|
||||
#[Route(path: '/reporting/project_view', name: 'report_project_view', methods: ['GET', 'POST'])]
|
||||
#[Security("is_granted('report:project') and is_granted('budget_any', 'project')")]
|
||||
public function __invoke(Request $request, ProjectStatisticService $service)
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ProjectViewQuery($dateFactory->createDateTime(), $user);
|
||||
$form = $this->createForm(ProjectViewForm::class, $query);
|
||||
$form = $this->createFormForGetRequest(ProjectViewForm::class, $query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$projects = $service->findProjectsForView($query);
|
||||
@@ -47,7 +45,7 @@ final class ProjectViewController extends AbstractController
|
||||
return $this->render('reporting/project_view.html.twig', [
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
'title' => 'report_project_view',
|
||||
'report_title' => 'report_project_view',
|
||||
'tableName' => 'project_view_reporting',
|
||||
'now' => $dateFactory->createDateTime(),
|
||||
]);
|
||||
|
||||
@@ -13,8 +13,8 @@ use App\Controller\AbstractController;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\MonthlyUserList;
|
||||
use App\Reporting\MonthlyUserListForm;
|
||||
use App\Reporting\MonthlyUserList\MonthlyUserList;
|
||||
use App\Reporting\MonthlyUserList\MonthlyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
@@ -24,15 +24,11 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/users")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
#[Route(path: '/reporting/users')]
|
||||
#[Security("is_granted('report:other')")]
|
||||
final class ReportUsersMonthController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/month", name="report_monthly_users", methods={"GET","POST"})
|
||||
*/
|
||||
#[Route(path: '/month', name: 'report_monthly_users', methods: ['GET', 'POST'])]
|
||||
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
@@ -41,9 +37,7 @@ final class ReportUsersMonthController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/month_export", name="report_monthly_users_export", methods={"GET","POST"})
|
||||
*/
|
||||
#[Route(path: '/month_export', name: 'report_monthly_users_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
$data = $this->getData($request, $statisticService, $userRepository);
|
||||
@@ -66,7 +60,7 @@ final class ReportUsersMonthController extends AbstractController
|
||||
$values = new MonthlyUserList();
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createForm(MonthlyUserListForm::class, $values, [
|
||||
$form = $this->createFormForGetRequest(MonthlyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
@@ -74,6 +68,7 @@ final class ReportUsersMonthController extends AbstractController
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setSystemAccount(false);
|
||||
$query->setCurrentUser($currentUser);
|
||||
|
||||
if ($form->isSubmitted()) {
|
||||
|
||||
@@ -13,8 +13,8 @@ use App\Controller\AbstractController;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\WeeklyUserList;
|
||||
use App\Reporting\WeeklyUserListForm;
|
||||
use App\Reporting\WeeklyUserList\WeeklyUserList;
|
||||
use App\Reporting\WeeklyUserList\WeeklyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
@@ -24,15 +24,11 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/users")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
#[Route(path: '/reporting/users')]
|
||||
#[Security("is_granted('report:other')")]
|
||||
final class ReportUsersWeekController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/week", name="report_weekly_users", methods={"GET","POST"})
|
||||
*/
|
||||
#[Route(path: '/week', name: 'report_weekly_users', methods: ['GET', 'POST'])]
|
||||
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
@@ -41,9 +37,7 @@ final class ReportUsersWeekController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/week_export", name="report_weekly_users_export", methods={"GET","POST"})
|
||||
*/
|
||||
#[Route(path: '/week_export', name: 'report_weekly_users_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
$data = $this->getData($request, $statisticService, $userRepository);
|
||||
@@ -66,7 +60,7 @@ final class ReportUsersWeekController extends AbstractController
|
||||
$values = new WeeklyUserList();
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
|
||||
$form = $this->createForm(WeeklyUserListForm::class, $values, [
|
||||
$form = $this->createFormForGetRequest(WeeklyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
@@ -74,6 +68,7 @@ final class ReportUsersWeekController extends AbstractController
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setSystemAccount(false);
|
||||
$query->setCurrentUser($currentUser);
|
||||
|
||||
if ($form->isSubmitted()) {
|
||||
|
||||
@@ -14,8 +14,8 @@ use App\Controller\AbstractController;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\MonthlyStatistic;
|
||||
use App\Reporting\YearlyUserList;
|
||||
use App\Reporting\YearlyUserListForm;
|
||||
use App\Reporting\YearlyUserList\YearlyUserList;
|
||||
use App\Reporting\YearlyUserList\YearlyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
@@ -26,19 +26,16 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/users")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
#[Route(path: '/reporting/users')]
|
||||
#[Security("is_granted('report:other')")]
|
||||
final class ReportUsersYearController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/year", name="report_yearly_users", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
#[Route(path: '/year', name: 'report_yearly_users', methods: ['GET', 'POST'])]
|
||||
public function report(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
@@ -48,12 +45,11 @@ final class ReportUsersYearController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/year_export", name="report_yearly_users_export", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
#[Route(path: '/year_export', name: 'report_yearly_users_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
$data = $this->getData($request, $systemConfiguration, $statisticService, $userRepository);
|
||||
@@ -82,7 +78,7 @@ final class ReportUsersYearController extends AbstractController
|
||||
$values = new YearlyUserList();
|
||||
$values->setDate(clone $defaultDate);
|
||||
|
||||
$form = $this->createForm(YearlyUserListForm::class, $values, [
|
||||
$form = $this->createFormForGetRequest(YearlyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
@@ -90,6 +86,7 @@ final class ReportUsersYearController extends AbstractController
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setSystemAccount(false);
|
||||
$query->setCurrentUser($currentUser);
|
||||
|
||||
if ($form->isSubmitted()) {
|
||||
@@ -126,7 +123,7 @@ final class ReportUsersYearController extends AbstractController
|
||||
}
|
||||
|
||||
return [
|
||||
'query' => $values,
|
||||
'subReportDate' => $values->getDate(),
|
||||
'period_attribute' => 'months',
|
||||
'dataType' => $values->getSumType(),
|
||||
'report_title' => 'report_yearly_users',
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\MonthByUser;
|
||||
use App\Reporting\MonthByUserForm;
|
||||
use App\Reporting\MonthByUser\MonthByUser;
|
||||
use App\Reporting\MonthByUser\MonthByUserForm;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -20,19 +19,16 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/user")
|
||||
* @Security("is_granted('view_reporting')")
|
||||
*/
|
||||
#[Route(path: '/reporting/user')]
|
||||
#[Security("is_granted('report:user')")]
|
||||
final class UserMonthController extends AbstractUserReportController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/month", name="report_user_month", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
#[Route(path: '/month', name: 'report_user_month', methods: ['GET', 'POST'])]
|
||||
public function monthByUser(Request $request): Response
|
||||
{
|
||||
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
|
||||
@@ -48,7 +44,7 @@ final class UserMonthController extends AbstractUserReportController
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createForm(MonthByUserForm::class, $values, [
|
||||
$form = $this->createFormForGetRequest(MonthByUserForm::class, $values, [
|
||||
'include_user' => $canChangeUser,
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\WeekByUser;
|
||||
use App\Reporting\WeekByUserForm;
|
||||
use App\Reporting\WeekByUser\WeekByUser;
|
||||
use App\Reporting\WeekByUser\WeekByUserForm;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -20,19 +19,16 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/user")
|
||||
* @Security("is_granted('view_reporting')")
|
||||
*/
|
||||
#[Route(path: '/reporting/user')]
|
||||
#[Security("is_granted('report:user')")]
|
||||
final class UserWeekController extends AbstractUserReportController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/week", name="report_user_week", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
#[Route(path: '/week', name: 'report_user_week', methods: ['GET', 'POST'])]
|
||||
public function weekByUser(Request $request): Response
|
||||
{
|
||||
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
|
||||
@@ -48,7 +44,7 @@ final class UserWeekController extends AbstractUserReportController
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
|
||||
$form = $this->createForm(WeekByUserForm::class, $values, [
|
||||
$form = $this->createFormForGetRequest(WeekByUserForm::class, $values, [
|
||||
'include_user' => $canChangeUser,
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user