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);
|
||||
|
||||
Reference in New Issue
Block a user