added drag and drop for new records via calendar (#1962)

This commit is contained in:
Kevin Papst
2020-09-17 01:13:48 +02:00
committed by GitHub
parent 14b3de4300
commit 9ef32e75c5
82 changed files with 2808 additions and 385 deletions

View File

@@ -0,0 +1,66 @@
<?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\Serializer;
use App\Validator\ValidationFailedException;
use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\Visitor\SerializationVisitorInterface;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ValidationFailedExceptionErrorHandler implements SubscribingHandlerInterface
{
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
}
public static function getSubscribingMethods()
{
return [[
'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
'type' => ValidationFailedException::class,
'format' => 'json',
'method' => 'serializeExceptionToJson',
]];
}
public function serializeExceptionToJson(SerializationVisitorInterface $visitor, ValidationFailedException $exception, array $type)
{
$errors = [];
/** @var ConstraintViolationInterface $error */
foreach (iterator_to_array($exception->getViolations()) as $error) {
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error);
}
return [
'code' => '400',
'message' => $this->translator->trans($exception->getMessage(), [], 'validators'),
'errors' => [
'children' => $errors
],
];
}
private function getErrorMessage(ConstraintViolationInterface $error): string
{
if (null !== $error->getPlural()) {
return $this->translator->trans($error->getMessageTemplate(), ['%count%' => $error->getPlural()] + $error->getParameters(), 'validators');
}
return $this->translator->trans($error->getMessageTemplate(), $error->getParameters(), 'validators');
}
}

View File

@@ -18,7 +18,6 @@ use App\Form\API\TimesheetApiEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\RoundingService;
use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Timesheet\TrackingModeService;
@@ -39,7 +38,6 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Validator\Constraints;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @RouteResource("Timesheet")
@@ -50,6 +48,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
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'];
public const GROUPS_FORM = ['Default', 'Entity', 'Timesheet', 'Not_Expanded'];
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Timesheet', 'Not_Expanded'];
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'Timesheet', 'Expanded'];
@@ -78,10 +77,6 @@ class TimesheetController extends BaseApiController
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var RoundingService
*/
private $roundingService;
/**
* @var TimesheetService
*/
@@ -94,7 +89,6 @@ class TimesheetController extends BaseApiController
TagRepository $tagRepository,
TrackingModeService $trackingModeService,
EventDispatcherInterface $dispatcher,
RoundingService $roundingService,
TimesheetService $service
) {
$this->viewHandler = $viewHandler;
@@ -103,7 +97,6 @@ class TimesheetController extends BaseApiController
$this->tagRepository = $tagRepository;
$this->trackingModeService = $trackingModeService;
$this->dispatcher = $dispatcher;
$this->roundingService = $roundingService;
$this->service = $service;
}
@@ -330,12 +323,14 @@ class TimesheetController extends BaseApiController
* @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")
*/
public function postAction(Request $request): Response
public function postAction(Request $request, ParamFetcherInterface $paramFetcher): Response
{
/** @var User $user */
$user = $this->getUser();
@@ -367,7 +362,12 @@ class TimesheetController extends BaseApiController
}
$view = new View($timesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
if ('true' === $paramFetcher->get('full')) {
$view->getContext()->setGroups(self::GROUPS_ENTITY_FULL);
} else {
$view->getContext()->setGroups(self::GROUPS_ENTITY);
}
return $this->viewHandler->handle($view);
}
@@ -626,11 +626,12 @@ class TimesheetController extends BaseApiController
* )
*
* @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, rates, description, meta (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")
*/
public function restartAction(int $id, ParamFetcherInterface $paramFetcher, ValidatorInterface $validator): Response
public function restartAction(int $id, ParamFetcherInterface $paramFetcher): Response
{
$timesheet = $this->repository->find($id);
@@ -647,12 +648,19 @@ class TimesheetController extends BaseApiController
$copyTimesheet = $this->service->createNewTimesheet($user);
$factory = $this->getDateTimeFactory();
$begin = $factory->createDateTime();
if (null !== ($beginTmp = $paramFetcher->get('begin'))) {
$begin = $factory->createDateTime($beginTmp);
}
$copyTimesheet
->setBegin($this->getDateTimeFactory()->createDateTime())
->setBegin($begin)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())
;
$this->roundingService->roundBegin($copyTimesheet);
$this->service->prepareNewTimesheet($copyTimesheet);
if (null !== ($copy = $paramFetcher->get('copy'))) {
if (\in_array($copy, ['rates', 'all'])) {
@@ -678,13 +686,7 @@ class TimesheetController extends BaseApiController
}
}
$errors = $validator->validate($copyTimesheet);
if (\count($errors) > 0) {
throw new BadRequestHttpException($errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage());
}
$this->service->saveNewTimesheet($copyTimesheet);
$this->service->restartTimesheet($copyTimesheet, $timesheet);
$view = new View($copyTimesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -0,0 +1,41 @@
<?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\Calendar;
interface DragAndDropEntry
{
/**
* Data to be passed to the API call.
*
* @return array<string, mixed>
*/
public function getData(): array;
/**
* Returns the title for this entry.
*
* @return string
*/
public function getTitle(): string;
/**
* Returns the color for this entry.
*
* @return string
*/
public function getColor(): string;
/**
* The block to use for rendering the entry.
*
* @return string|null
*/
public function getBlockName(): ?string;
}

View File

@@ -0,0 +1,50 @@
<?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\Calendar;
interface DragAndDropSource
{
/**
* @return string
*/
public function getTitle(): string;
/**
* @return string
*/
public function getRoute(): string;
/**
* @return array<string, string>
*/
public function getRouteParams(): array;
/**
* @return array<string, string>
*/
public function getRouteReplacer(): array;
/**
* @return string
*/
public function getMethod(): string;
/**
* @return DragAndDropEntry[]
*/
public function getEntries(): array;
/**
* If you want to customize the item rendering, you have to return a path to your include here.
*
* @return string|null
*/
public function getBlockInclude(): ?string;
}

View File

@@ -9,39 +9,36 @@
namespace App\Calendar;
class Google
final class Google
{
/**
* @var Source[]
* @var GoogleSource[]
*/
protected $sources = [];
private $sources;
/**
* @var string
*/
protected $apiKey = null;
private $apiKey;
/**
* @param string $apiKey
* @param Source[] $sources
* @param GoogleSource[] $sources
*/
public function __construct($apiKey, $sources = [])
public function __construct(string $apiKey, array $sources = [])
{
$this->apiKey = $apiKey;
$this->sources = $sources;
}
/**
* @return Source[]
* @return GoogleSource[]
*/
public function getSources()
public function getSources(): array
{
return $this->sources;
}
/**
* @return string
*/
public function getApiKey()
public function getApiKey(): string
{
return $this->apiKey;
}

View File

@@ -0,0 +1,48 @@
<?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\Calendar;
final class GoogleSource
{
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $uri;
/**
* @var string|null
*/
private $color;
public function __construct(string $id, string $uri, ?string $color = null)
{
$this->id = $id;
$this->uri = $uri;
$this->color = $color;
}
public function getId(): string
{
return $this->id;
}
public function getUri(): string
{
return $this->uri;
}
public function getColor(): ?string
{
return $this->color;
}
}

View File

@@ -0,0 +1,70 @@
<?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\Calendar;
class RecentActivitiesSource implements DragAndDropSource
{
/**
* @var DragAndDropEntry[]
*/
private $entries;
/**
* @param DragAndDropEntry[] $entries
*/
public function __construct(array $entries)
{
$this->entries = $entries;
}
public function getTitle(): string
{
return 'recent.activities';
}
public function getRoute(): string
{
return 'post_timesheet';
}
public function getMethod(): string
{
return 'POST';
}
/**
* @return array<string, string>
*/
public function getRouteParams(): array
{
return ['full' => 'true'];
}
/**
* @return array<string, string>
*/
public function getRouteReplacer(): array
{
return [];
}
/**
* @return DragAndDropEntry[]
*/
public function getEntries(): array
{
return $this->entries;
}
public function getBlockInclude(): ?string
{
return 'calendar/drag-drop.html.twig';
}
}

View File

@@ -1,83 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Calendar;
class Source
{
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $uri;
/**
* @var string
*/
protected $color;
/**
* @return string
*/
public function getId(): ?string
{
return $this->id;
}
/**
* @param string $id
* @return Source
*/
public function setId(string $id)
{
$this->id = $id;
return $this;
}
/**
* @return string
*/
public function getUri(): ?string
{
return $this->uri;
}
/**
* @param string $uri
* @return Source
*/
public function setUri(string $uri)
{
$this->uri = $uri;
return $this;
}
/**
* @return string
*/
public function getColor(): ?string
{
return $this->color;
}
/**
* @param string $color
* @return Source
*/
public function setColor(string $color)
{
$this->color = $color;
return $this;
}
}

View File

@@ -0,0 +1,75 @@
<?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\Calendar;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
final class TimesheetEntry implements DragAndDropEntry
{
/**
* @var Timesheet
*/
private $timesheet;
/**
* @var string
*/
private $color;
public function __construct(Timesheet $timesheet, string $color)
{
$this->timesheet = $timesheet;
$this->color = $color;
}
public function getData(): array
{
return [
'description' => $this->timesheet->getDescription(),
'activity' => $this->timesheet->getActivity() !== null ? $this->timesheet->getActivity()->getId() : null,
'project' => $this->timesheet->getProject() !== null ? $this->timesheet->getProject()->getId() : null,
'tags' => implode(',', $this->timesheet->getTagsAsArray()),
];
}
public function getTitle(): string
{
if ($this->timesheet->getActivity() !== null && $this->timesheet->getActivity()->getName() !== null) {
return $this->timesheet->getActivity()->getName();
}
if (null !== $this->timesheet->getProject() && $this->timesheet->getProject()->getName() !== null) {
return $this->timesheet->getProject()->getName();
}
return $this->timesheet->getDescription() ?? '';
}
public function getColor(): string
{
return $this->color;
}
public function getBlockName(): ?string
{
return 'dd_timesheet';
}
public function getActivity(): ?Activity
{
return $this->timesheet->getActivity();
}
public function getProject(): ?Project
{
return $this->timesheet->getProject();
}
}

View File

@@ -9,6 +9,9 @@
namespace App\Configuration;
/**
* @deprecated since 1.11 - use SystemConfiguration instead
*/
class CalendarConfiguration implements SystemBundleConfiguration
{
use StringAccessibleConfigTrait;

View File

@@ -9,6 +9,9 @@
namespace App\Configuration;
/**
* @internal will be deprecated soon, use SystemConfiguration instead
*/
class FormConfiguration implements SystemBundleConfiguration
{
use StringAccessibleConfigTrait;

View File

@@ -22,4 +22,64 @@ class SystemConfiguration implements SystemBundleConfiguration
{
return $repository->getConfiguration();
}
public function getTimesheetDefaultBeginTime(): string
{
return (string) $this->find('timesheet.default_begin');
}
public function getCalendarBusinessDays(): array
{
return (array) $this->find('calendar.businessHours.days');
}
public function getCalendarBusinessTimeBegin(): string
{
return (string) $this->find('calendar.businessHours.begin');
}
public function getCalendarBusinessTimeEnd(): string
{
return (string) $this->find('calendar.businessHours.end');
}
public function getCalendarTimeframeBegin(): string
{
return (string) $this->find('calendar.visibleHours.begin');
}
public function getCalendarTimeframeEnd(): string
{
return (string) $this->find('calendar.visibleHours.end');
}
public function getCalendarDayLimit(): int
{
return (int) $this->find('calendar.day_limit');
}
public function isCalendarShowWeekNumbers(): bool
{
return (bool) $this->find('calendar.week_numbers');
}
public function isCalendarShowWeekends(): bool
{
return (bool) $this->find('calendar.weekends');
}
public function getCalendarGoogleApiKey(): ?string
{
return $this->find('calendar.google.api_key');
}
public function getCalendarGoogleSources(): ?array
{
return $this->find('calendar.google.sources');
}
public function getCalendarSlotDuration(): string
{
return (string) $this->find('calendar.slot_duration');
}
}

View File

@@ -9,6 +9,9 @@
namespace App\Configuration;
/**
* @internal will be deprecated soon, use SystemConfiguration instead
*/
class ThemeConfiguration implements SystemBundleConfiguration, \ArrayAccess
{
use StringAccessibleConfigTrait;

View File

@@ -9,6 +9,9 @@
namespace App\Configuration;
/**
* @internal will be deprecated soon, use SystemConfiguration instead
*/
class TimesheetConfiguration implements SystemBundleConfiguration
{
use StringAccessibleConfigTrait;

View File

@@ -9,12 +9,20 @@
namespace App\Controller;
use App\Calendar\DragAndDropSource;
use App\Calendar\Google;
use App\Calendar\Source;
use App\Configuration\CalendarConfiguration;
use App\Calendar\GoogleSource;
use App\Calendar\RecentActivitiesSource;
use App\Calendar\TimesheetEntry;
use App\Configuration\SystemConfiguration;
use App\Event\CalendarDragAndDropSourceEvent;
use App\Event\CalendarGoogleSourceEvent;
use App\Repository\TimesheetRepository;
use App\Timesheet\TrackingModeService;
use App\Utils\Color;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Controller used to display calendars.
@@ -24,37 +32,109 @@ use Symfony\Component\Routing\Annotation\Route;
*/
class CalendarController extends AbstractController
{
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(EventDispatcherInterface $dispatcher)
{
$this->dispatcher = $dispatcher;
}
/**
* @Route(path="/", name="calendar", methods={"GET"})
*/
public function userCalendar(CalendarConfiguration $configuration, TrackingModeService $service)
public function userCalendar(SystemConfiguration $configuration, TrackingModeService $service, TimesheetRepository $repository)
{
$mode = $service->getActiveMode();
$factory = $this->getDateTimeFactory();
$defaultStart = $factory->createDateTime($configuration->getTimesheetDefaultBeginTime());
$config = [
'dayLimit' => $configuration->getCalendarDayLimit(),
'showWeekNumbers' => $configuration->isCalendarShowWeekNumbers(),
'showWeekends' => $configuration->isCalendarShowWeekends(),
'businessDays' => $configuration->getCalendarBusinessDays(),
'businessTimeBegin' => $configuration->getCalendarBusinessTimeBegin(),
'businessTimeEnd' => $configuration->getCalendarBusinessTimeEnd(),
'slotDuration' => $configuration->getCalendarSlotDuration(),
'timeframeBegin' => $configuration->getCalendarTimeframeBegin(),
'timeframeEnd' => $configuration->getCalendarTimeframeEnd(),
];
$isPunchMode = !$mode->canEditDuration() && !$mode->canEditBegin() && !$mode->canEditEnd();
$dragAndDrop = [];
if ($mode->canEditBegin()) {
$dragAndDrop = $this->getDragAndDropResources($repository);
}
return $this->render('calendar/user.html.twig', [
'config' => $configuration,
'config' => $config,
'dragAndDrop' => $dragAndDrop,
'google' => $this->getGoogleSources($configuration),
'now' => $this->getDateTimeFactory()->createDateTime(),
'is_punch_mode' => !$mode->canEditDuration() && !$mode->canEditBegin() && !$mode->canEditEnd()
'now' => $factory->createDateTime(),
'defaultStartTime' => $defaultStart->format('h:i:s'),
'is_punch_mode' => $isPunchMode,
'can_edit_begin' => $mode->canEditBegin(),
'can_edit_end' => $mode->canEditBegin(),
'can_edit_duration' => $mode->canEditDuration(),
]);
}
/**
* @return Google
* @return DragAndDropSource[]
*/
protected function getGoogleSources(CalendarConfiguration $configuration)
private function getDragAndDropResources(TimesheetRepository $repository): array
{
$apiKey = $configuration->getGoogleApiKey() ?? null;
$sources = [];
foreach ($configuration->getGoogleSources() as $name => $config) {
$source = new Source();
$source
->setColor($config['color'])
->setUri($config['id'])
->setId($name)
;
try {
$data = $repository->getRecentActivities(
$this->getUser(),
$this->getDateTimeFactory()->createDateTime('-1 year'),
10
);
$entries = [];
$colorHelper = new Color();
foreach ($data as $timesheet) {
$entries[] = new TimesheetEntry($timesheet, $colorHelper->getTimesheetColor($timesheet));
}
$sources[] = new RecentActivitiesSource($entries);
} catch (\Exception $ex) {
$this->logException($ex);
}
$event = new CalendarDragAndDropSourceEvent($this->getUser());
$this->dispatcher->dispatch($event);
foreach ($event->getSources() as $source) {
$sources[] = $source;
}
return $sources;
}
private function getGoogleSources(SystemConfiguration $configuration): ?Google
{
$apiKey = $configuration->getCalendarGoogleApiKey();
if ($apiKey === null) {
return null;
}
$sources = [];
foreach ($configuration->getCalendarGoogleSources() as $name => $config) {
$sources[] = new GoogleSource($name, $config['id'], $config['color']);
}
$event = new CalendarGoogleSourceEvent($this->getUser());
$this->dispatcher->dispatch($event);
foreach ($event->getSources() as $source) {
$sources[] = $source;
}

View File

@@ -9,6 +9,7 @@
namespace App\DependencyInjection;
use App\Constants;
use App\Entity\Customer;
use App\Entity\User;
use App\Repository\InvoiceDocumentRepository;
@@ -386,12 +387,18 @@ class Configuration implements ConfigurationInterface
->arrayNode('chart')
->addDefaultsIfNotSet()
->children()
->scalarNode('background_color')->defaultValue('rgba(0,115,183,0.7)')->end()
->scalarNode('background_color')->defaultValue('#3c8dbc')->end() // rgba(0,115,183,0.7) = #0073b7 = Constants::DEFAULT_COLOR
->scalarNode('border_color')->defaultValue('#3b8bba')->end()
->scalarNode('grid_color')->defaultValue('rgba(0,0,0,.05)')->end()
->scalarNode('height')->defaultValue('200')->end()
->end()
->end()
->arrayNode('calendar')
->addDefaultsIfNotSet()
->children()
->scalarNode('background_color')->defaultValue(Constants::DEFAULT_COLOR)->end()
->end()
->end()
->arrayNode('branding')
->addDefaultsIfNotSet()
->children()

View File

@@ -44,6 +44,11 @@ trait ColorTrait
return $this->color;
}
public function hasColor(): bool
{
return null !== $this->color && $this->color !== Constants::DEFAULT_COLOR;
}
/**
* @param string $color
* @return self

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Calendar\DragAndDropSource;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
final class CalendarDragAndDropSourceEvent extends Event
{
/**
* @var User
*/
private $user;
/**
* @var DragAndDropSource[]
*/
private $sources = [];
public function __construct(User $user)
{
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
public function addSource(DragAndDropSource $source): CalendarDragAndDropSourceEvent
{
$this->sources[] = $source;
return $this;
}
/**
* @return DragAndDropSource[]
*/
public function getSources(): array
{
return $this->sources;
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Calendar\GoogleSource;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
final class CalendarGoogleSourceEvent extends Event
{
/**
* @var User
*/
private $user;
/**
* @var GoogleSource[]
*/
private $sources = [];
public function __construct(User $user)
{
$this->user = $user;
}
public function getUser(): User
{
return $this->user;
}
public function addSource(GoogleSource $source): CalendarGoogleSourceEvent
{
$this->sources[] = $source;
return $this;
}
/**
* @return GoogleSource[]
*/
public function getSources(): array
{
return $this->sources;
}
}

View 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\Event;
use App\Entity\Timesheet;
final class TimesheetRestartPostEvent extends AbstractTimesheetEvent
{
/**
* @var Timesheet
*/
private $original;
public function __construct(Timesheet $new, Timesheet $original)
{
parent::__construct($new);
$this->original = $original;
}
public function getOriginalTimesheet(): Timesheet
{
return $this->original;
}
}

View 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\Event;
use App\Entity\Timesheet;
final class TimesheetRestartPreEvent extends AbstractTimesheetEvent
{
/**
* @var Timesheet
*/
private $original;
public function __construct(Timesheet $new, Timesheet $original)
{
parent::__construct($new);
$this->original = $original;
}
public function getOriginalTimesheet(): Timesheet
{
return $this->original;
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\API\BaseApiController;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class APIDateTimeType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'documentation' => [
'type' => 'string',
'format' => 'date-time',
'example' => (new \DateTime())->format(BaseApiController::DATE_FORMAT_PHP),
],
'widget' => 'single_text',
'html5' => true,
'model_timezone' => date_default_timezone_get(),
'view_timezone' => date_default_timezone_get(),
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return DateTimeType::class;
}
}

View File

@@ -32,9 +32,6 @@ class DateTimePickerType extends AbstractType
*/
protected $dateTime;
/**
* @param LocaleSettings $localeSettings
*/
public function __construct(LocaleSettings $localeSettings, UserDateTimeFactory $dateTime)
{
$this->localeSettings = $localeSettings;

View File

@@ -23,10 +23,14 @@ use App\Repository\Loader\TimesheetLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery;
use DateInterval;
use DateTime;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Exception;
use InvalidArgumentException;
use Pagerfanta\Pagerfanta;
use PDO;
/**
* @extends \Doctrine\ORM\EntityRepository<Timesheet>
@@ -74,7 +78,7 @@ class TimesheetRepository extends EntityRepository
/**
* @param Timesheet[] $timesheets
* @throws \Exception
* @throws Exception
*/
public function deleteMultiple(iterable $timesheets): void
{
@@ -87,12 +91,15 @@ class TimesheetRepository extends EntityRepository
}
$em->flush();
$em->commit();
} catch (\Exception $ex) {
} catch (Exception $ex) {
$em->rollback();
throw $ex;
}
}
/**
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
*/
public function add(Timesheet $timesheet, int $maxRunningEntries)
{
$em = $this->getEntityManager();
@@ -106,7 +113,7 @@ class TimesheetRepository extends EntityRepository
$em->persist($timesheet);
$em->flush();
$em->commit();
} catch (\Exception $ex) {
} catch (Exception $ex) {
$em->rollback();
throw $ex;
}
@@ -126,7 +133,7 @@ class TimesheetRepository extends EntityRepository
/**
* @param Timesheet[] $timesheets
* @throws \Exception
* @throws Exception
*/
public function saveMultiple(array $timesheets): void
{
@@ -139,7 +146,7 @@ class TimesheetRepository extends EntityRepository
}
$em->flush();
$em->commit();
} catch (\Exception $ex) {
} catch (Exception $ex) {
$em->rollback();
throw $ex;
}
@@ -152,6 +159,7 @@ class TimesheetRepository extends EntityRepository
* @throws RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
*/
public function stopRecording(Timesheet $entry, bool $flush = true)
{
@@ -161,7 +169,7 @@ class TimesheetRepository extends EntityRepository
// seems to be necessary so Doctrine will recognize a changed timestamp
$begin = clone $entry->getBegin();
$end = new \DateTime('now', $begin->getTimezone());
$end = new DateTime('now', $begin->getTimezone());
$entry->setBegin($begin);
$entry->setEnd($end);
@@ -208,7 +216,7 @@ class TimesheetRepository extends EntityRepository
$what = 'COUNT(t.id)';
break;
default:
throw new \InvalidArgumentException('Invalid query type: ' . $type);
throw new InvalidArgumentException('Invalid query type: ' . $type);
}
return $this->queryTimeRange($what, $begin, $end, $user);
@@ -401,7 +409,7 @@ class TimesheetRepository extends EntityRepository
$results = [];
/** @var Timesheet $result */
foreach ($timesheets as $result) {
/** @var \DateTime $beginTmp */
/** @var DateTime $beginTmp */
$beginTmp = $result->getBegin();
/** @var DateTime $endTmp */
$endTmp = $result->getEnd();
@@ -412,7 +420,7 @@ class TimesheetRepository extends EntityRepository
if ($dateKey !== $dateKeyEnd) {
$newDateBegin = clone $beginTmp;
$newDateBegin->add(new \DateInterval('P1D'));
$newDateBegin->add(new DateInterval('P1D'));
// overlapping records should always start at midnight
$newDateBegin->setTime(0, 0, 0);
} else {
@@ -490,7 +498,7 @@ class TimesheetRepository extends EntityRepository
* @param DateTime $begin
* @param DateTime $end
* @return Day[]
* @throws \Exception
* @throws Exception
*/
public function getDailyStats(?User $user, DateTime $begin, DateTime $end): array
{
@@ -557,6 +565,7 @@ class TimesheetRepository extends EntityRepository
* @throws RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @deprecated since 1.11 use TimesheetService::stopTimesheet() instead
*/
public function stopActiveEntries(User $user, int $hardLimit, bool $flush = true)
{
@@ -573,7 +582,7 @@ class TimesheetRepository extends EntityRepository
foreach ($activeEntries as $activeEntry) {
if ($i > $limit) {
if ($hardLimit > 1) {
throw new \Exception('timesheet.start.exceeded_limit');
throw new Exception('timesheet.start.exceeded_limit');
}
$this->stopRecording($activeEntry, $flush);
@@ -773,15 +782,15 @@ class TimesheetRepository extends EntityRepository
}
if ($query->isExported()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', true, \PDO::PARAM_BOOL);
$qb->andWhere('t.exported = :exported')->setParameter('exported', true, PDO::PARAM_BOOL);
} elseif ($query->isNotExported()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', false, \PDO::PARAM_BOOL);
$qb->andWhere('t.exported = :exported')->setParameter('exported', false, PDO::PARAM_BOOL);
}
if ($query->isBillable()) {
$qb->andWhere('t.billable = :billable')->setParameter('billable', true, \PDO::PARAM_BOOL);
$qb->andWhere('t.billable = :billable')->setParameter('billable', true, PDO::PARAM_BOOL);
} elseif ($query->isNotBillable()) {
$qb->andWhere('t.billable = :billable')->setParameter('billable', false, \PDO::PARAM_BOOL);
$qb->andWhere('t.billable = :billable')->setParameter('billable', false, PDO::PARAM_BOOL);
}
if (null !== $query->getModifiedAfter()) {
@@ -848,7 +857,7 @@ class TimesheetRepository extends EntityRepository
* @return array|mixed
* @throws \Doctrine\ORM\Query\QueryException
*/
public function getRecentActivities(User $user = null, \DateTime $startFrom = null, $limit = 10)
public function getRecentActivities(User $user = null, DateTime $startFrom = null, $limit = 10)
{
$qb = $this->getEntityManager()->createQueryBuilder();
@@ -865,7 +874,7 @@ class TimesheetRepository extends EntityRepository
->groupBy('a.id', 'p.id')
->orderBy('maxid', 'DESC')
->setMaxResults($limit)
->setParameter('visible', true, \PDO::PARAM_BOOL)
->setParameter('visible', true, PDO::PARAM_BOOL)
;
if (null !== $user) {
@@ -909,7 +918,7 @@ class TimesheetRepository extends EntityRepository
->update(Timesheet::class, 't')
->set('t.exported', ':exported')
->where($qb->expr()->in('t.id', ':ids'))
->setParameter('exported', true, \PDO::PARAM_BOOL)
->setParameter('exported', true, PDO::PARAM_BOOL)
->setParameter('ids', $timesheets)
->getQuery()
->execute();
@@ -1008,21 +1017,35 @@ class TimesheetRepository extends EntityRepository
$or->add($qb->expr()->between(':end', 't.begin', 't.end'));
$or->add($qb->expr()->between('t.begin', ':begin', ':end'));
$or->add($qb->expr()->between('t.end', ':begin', ':end'));
$qb->setParameter('end', $timesheet->getEnd());
$end = clone $timesheet->getEnd();
$end->sub(new DateInterval('PT1S'));
$qb->setParameter('end', $end);
}
// one second is added, because people normally either use the calendar / times which are rounded to the full minute
// for an existing entry like 12:45-13:00 it is impossible to add a new one from 13:00-13:15 as the between() query find the first one
// by adding one second the between() select will not match any longer
$begin = clone $timesheet->getBegin();
$begin->add(new DateInterval('PT1S'));
$qb->select($qb->expr()->count('t.id'))
->from(Timesheet::class, 't')
->andWhere($qb->expr()->eq('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere($or)
->setParameter('begin', $timesheet->getBegin())
->setParameter('user', $timesheet->getUser())
->setParameter('begin', $begin)
->setParameter('user', $timesheet->getUser()->getId())
;
// if we edit an existing entry, make sure we do not find "the same entry" when only updating eg. the description
if ($timesheet->getId() !== null) {
$qb->andWhere($qb->expr()->neq('t.id', $timesheet->getId()));
}
try {
$result = (int) $qb->getQuery()->getSingleScalarResult();
} catch (\Exception $ex) {
} catch (Exception $ex) {
return true;
}

View File

@@ -17,6 +17,8 @@ use App\Event\TimesheetCreatePreEvent;
use App\Event\TimesheetDeleteMultiplePreEvent;
use App\Event\TimesheetDeletePreEvent;
use App\Event\TimesheetMetaDefinitionEvent;
use App\Event\TimesheetRestartPostEvent;
use App\Event\TimesheetRestartPreEvent;
use App\Event\TimesheetStopPostEvent;
use App\Event\TimesheetStopPreEvent;
use App\Event\TimesheetUpdateMultiplePostEvent;
@@ -24,10 +26,14 @@ use App\Event\TimesheetUpdateMultiplePreEvent;
use App\Event\TimesheetUpdatePostEvent;
use App\Event\TimesheetUpdatePreEvent;
use App\Repository\TimesheetRepository;
use App\Validator\ValidationException;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class TimesheetService
{
@@ -51,19 +57,25 @@ final class TimesheetService
* @var AuthorizationCheckerInterface
*/
private $auth;
/**
* @var ValidatorInterface
*/
private $validator;
public function __construct(
TimesheetConfiguration $configuration,
TimesheetRepository $repository,
TrackingModeService $service,
EventDispatcherInterface $dispatcher,
AuthorizationCheckerInterface $security
AuthorizationCheckerInterface $security,
ValidatorInterface $validator
) {
$this->configuration = $configuration;
$this->repository = $repository;
$this->trackingModeService = $service;
$this->dispatcher = $dispatcher;
$this->auth = $security;
$this->validator = $validator;
}
/**
@@ -88,7 +100,7 @@ final class TimesheetService
public function prepareNewTimesheet(Timesheet $timesheet, ?Request $request = null): Timesheet
{
if (null !== $timesheet->getId()) {
throw new \InvalidArgumentException('Cannot prepare timesheet, already persisted');
throw new InvalidArgumentException('Cannot prepare timesheet, already persisted');
}
$event = new TimesheetMetaDefinitionEvent($timesheet);
@@ -100,25 +112,73 @@ final class TimesheetService
return $timesheet;
}
/**
* @param Timesheet $timesheet
* @param Timesheet $copyFrom
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
* @throws InvalidArgumentException for already persisted timesheets
* @throws AccessDeniedHttpException if user is not allowed to start timesheet
*/
public function restartTimesheet(Timesheet $timesheet, Timesheet $copyFrom): Timesheet
{
$this->dispatcher->dispatch(new TimesheetRestartPreEvent($timesheet, $copyFrom));
$this->saveNewTimesheet($timesheet);
$this->dispatcher->dispatch(new TimesheetRestartPostEvent($timesheet, $copyFrom));
return $timesheet;
}
/**
* @param Timesheet $timesheet
* @return Timesheet
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
* @throws InvalidArgumentException for already persisted timesheets
* @throws AccessDeniedHttpException if user is not allowed to start timesheet
*/
public function saveNewTimesheet(Timesheet $timesheet): Timesheet
{
if (null !== $timesheet->getId()) {
throw new \InvalidArgumentException('Cannot create timesheet, already persisted');
throw new InvalidArgumentException('Cannot create timesheet, already persisted');
}
if (null === $timesheet->getEnd() && !$this->auth->isGranted('start', $timesheet)) {
throw new AccessDeniedHttpException('You are not allowed to start this timesheet record');
}
$this->validateTimesheet($timesheet);
try {
$this->stopActiveEntries($timesheet);
} catch (ValidationFailedException $vex) {
// could happen for timesheets that were started in the future (end before begin)
throw new ValidationFailedException($vex->getViolations(), 'Cannot stop running timesheet');
}
$this->dispatcher->dispatch(new TimesheetCreatePreEvent($timesheet));
$this->repository->add($timesheet, $this->configuration->getActiveEntriesHardLimit());
$this->repository->save($timesheet);
$this->dispatcher->dispatch(new TimesheetCreatePostEvent($timesheet));
return $timesheet;
}
/**
* Does NOT validate the given timesheet!
*
* @param Timesheet $timesheet
* @return Timesheet
* @throws \Exception
*/
public function updateTimesheet(Timesheet $timesheet): Timesheet
{
// there is at least one edge case which leads to a problem:
// if you do not allow overlapping entries, you cannot restart a timesheet by removing the
// end date if another timesheet is running, because the check for existing timesheets will always trigger
/*
if ($timesheet->getEnd() === null) {
$this->stopActiveEntries($timesheet);
}
*/
$this->dispatcher->dispatch(new TimesheetUpdatePreEvent($timesheet));
$this->repository->save($timesheet);
$this->dispatcher->dispatch(new TimesheetUpdatePostEvent($timesheet));
@@ -126,6 +186,13 @@ final class TimesheetService
return $timesheet;
}
/**
* Does NOT validate the given timesheet!
*
* @param array $timesheets
* @return array
* @throws \Exception
*/
public function updateMultipleTimesheets(array $timesheets): array
{
$this->dispatcher->dispatch(new TimesheetUpdateMultiplePreEvent($timesheets));
@@ -135,10 +202,30 @@ final class TimesheetService
return $timesheets;
}
/**
* Validates the given timesheet, especially important for not setting a wrong end date.
* But also to check that all required data is set.
*
* @param Timesheet $timesheet
* @throws ValidationException for already stopped timesheets
* @throws ValidationFailedException
*/
public function stopTimesheet(Timesheet $timesheet): void
{
if (null !== $timesheet->getEnd()) {
throw new ValidationException('Timesheet entry already stopped');
}
$begin = clone $timesheet->getBegin();
$now = new \DateTime('now', $begin->getTimezone());
$timesheet->setBegin($begin);
$timesheet->setEnd($now);
$this->validateTimesheet($timesheet);
$this->dispatcher->dispatch(new TimesheetStopPreEvent($timesheet));
$this->repository->stopRecording($timesheet);
$this->repository->save($timesheet);
$this->dispatcher->dispatch(new TimesheetStopPostEvent($timesheet));
}
@@ -153,4 +240,56 @@ final class TimesheetService
$this->dispatcher->dispatch(new TimesheetDeleteMultiplePreEvent($timesheets));
$this->repository->deleteMultiple($timesheets);
}
/**
* @param Timesheet $timesheet
* @param string[] $groups
* @throws ValidationFailedException
*/
private function validateTimesheet(Timesheet $timesheet, array $groups = []): void
{
$errors = $this->validator->validate($timesheet, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors, 'Validation Failed');
}
}
/**
* Stops all active records for the current user, besides the given $timesheet.
*
* @param Timesheet $timesheet
* @return int
* @throws ValidationException
* @throws ValidationFailedException
*/
private function stopActiveEntries(Timesheet $timesheet): int
{
$user = $timesheet->getUser();
$hardLimit = $this->configuration->getActiveEntriesHardLimit();
$activeEntries = $this->repository->getActiveEntries($user);
$counter = 0;
// reduce limit by one:
// this method is only called when a new entry is started
// -> all entries, including the new one must not exceed the $limit
$limit = $hardLimit - 1;
if (\count($activeEntries) > $limit) {
$i = 1;
foreach ($activeEntries as $activeEntry) {
if ($i > $limit && $timesheet->getId() !== $activeEntry->getId()) {
if ($hardLimit > 1) {
throw new ValidationException('timesheet.start.exceeded_limit');
}
$this->stopTimesheet($activeEntry);
$counter++;
}
$i++;
}
}
return $counter;
}
}

View File

@@ -10,10 +10,8 @@
namespace App\Twig;
use App\Constants;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\EntityWithMetaFields;
use App\Entity\Project;
use App\Utils\Color;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -32,6 +30,7 @@ class Extensions extends AbstractExtension
new TwigFilter('docu_link', [$this, 'documentationLink']),
new TwigFilter('multiline_indent', [$this, 'multilineIndent']),
new TwigFilter('color', [$this, 'color']),
new TwigFilter('font_contrast', [$this, 'calculateFontContrastColor']),
];
}
@@ -60,32 +59,20 @@ class Extensions extends AbstractExtension
return ++$key;
}
public function color(EntityWithMetaFields $entity): ?string
/**
* Returns null instead of the default color if $defaultColor is not set to true.
*
* @param EntityWithMetaFields $entity
* @return string|null
*/
public function color(EntityWithMetaFields $entity, bool $defaultColor = false): ?string
{
if ($entity instanceof Activity) {
if (!empty($entity->getColor())) {
return $entity->getColor();
}
return (new Color())->getColor($entity, $defaultColor);
}
if (null !== $entity->getProject()) {
$entity = $entity->getProject();
}
}
if ($entity instanceof Project) {
if (!empty($entity->getColor())) {
return $entity->getColor();
}
$entity = $entity->getCustomer();
}
if ($entity instanceof Customer) {
if (!empty($entity->getColor())) {
return $entity->getColor();
}
}
return null;
public function calculateFontContrastColor(string $color): string
{
return (new Color())->getFontContrastColor($color);
}
/**

98
src/Utils/Color.php Normal file
View File

@@ -0,0 +1,98 @@
<?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\Utils;
use App\Constants;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\EntityWithMetaFields;
use App\Entity\Project;
use App\Entity\Timesheet;
final class Color
{
public function getTimesheetColor(Timesheet $timesheet): string
{
$activity = $timesheet->getActivity();
if (null !== $activity && $activity->hasColor()) {
return $activity->getColor();
}
$project = $timesheet->getProject();
if (null !== $project) {
if ($project->hasColor()) {
return $project->getColor();
}
$customer = $project->getCustomer();
if ($customer->hasColor()) {
return $customer->getColor();
}
}
return Constants::DEFAULT_COLOR;
}
public function getColor(EntityWithMetaFields $entity, bool $defaultColor = false): ?string
{
if ($entity instanceof Timesheet) {
$color = $this->getTimesheetColor($entity);
if ($color === Constants::DEFAULT_COLOR && !$defaultColor) {
$color = null;
}
return $color;
}
if ($entity instanceof Activity) {
if ($entity->hasColor()) {
return $entity->getColor();
}
if (null !== $entity->getProject()) {
$entity = $entity->getProject();
}
}
if ($entity instanceof Project) {
if ($entity->hasColor()) {
return $entity->getColor();
}
$entity = $entity->getCustomer();
}
if ($entity instanceof Customer) {
if ($entity->hasColor()) {
return $entity->getColor();
}
}
return $defaultColor ? Constants::DEFAULT_COLOR : null;
}
public function getFontContrastColor(string $color): string
{
if ($color[0] !== '#') {
throw new \InvalidArgumentException('Invalid color code given, only #hexadecimal is supported.');
}
$color = substr($color, 1);
if (\strlen($color) === 3) {
$color = $color[0] . $color[0] . $color[1] . $color[1] . $color[2] . $color[2];
}
$r = hexdec(substr($color, 0, 2));
$g = hexdec(substr($color, 2, 2));
$b = hexdec(substr($color, 4, 2));
$yiq = (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
return ($yiq >= 128) ? '#000000' : '#ffffff';
}
}

View File

@@ -45,9 +45,11 @@ final class TimesheetFutureTimesValidator extends ConstraintValidator
return;
}
$now = new \DateTime('now', $timesheet->getBegin()->getTimezone());
// allow configured default rounding time + 1 minute - see #1295
$allowedDiff = ($this->configuration->getDefaultRoundingBegin() * 60) + 60;
if ((time() + $allowedDiff) < $timesheet->getBegin()->getTimestamp()) {
if (($now->getTimestamp() + $allowedDiff) < $timesheet->getBegin()->getTimestamp()) {
$this->context->buildViolation('The begin date cannot be in the future.')
->atPath('begin')
->setTranslationDomain('validators')

View File

@@ -47,6 +47,14 @@ final class TimesheetOverlappingValidator extends ConstraintValidator
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
}
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
// this case is handled in TimesheetValidator and should not raise a second validation
if ($begin !== null && $end !== null && $begin > $end) {
return;
}
if ($this->configuration->isAllowOverlappingRecords()) {
return;
}

View File

@@ -72,7 +72,10 @@ final class TimesheetValidator extends ConstraintValidator
*/
protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context)
{
if (null === $timesheet->getBegin()) {
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
if (null === $begin) {
$context->buildViolation('You must submit a begin date.')
->atPath('begin')
->setTranslationDomain('validators')
@@ -82,7 +85,7 @@ final class TimesheetValidator extends ConstraintValidator
return;
}
if (null !== $timesheet->getBegin() && null !== $timesheet->getEnd() && $timesheet->getEnd()->getTimestamp() < $timesheet->getBegin()->getTimestamp()) {
if (null !== $end && $begin > $end) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')

View File

@@ -0,0 +1,21 @@
<?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\Validator;
class ValidationException extends \RuntimeException
{
public function __construct(string $message = null)
{
if ($message === null) {
$message = 'Validation failed';
}
parent::__construct($message, 400);
}
}

View File

@@ -0,0 +1,35 @@
<?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\Validator;
use Symfony\Component\Validator\ConstraintViolationListInterface;
class ValidationFailedException extends \RuntimeException
{
/**
* @var ConstraintViolationListInterface
*/
private $violations;
public function __construct(ConstraintViolationListInterface $violations, ?string $message = null)
{
if ($message === null) {
$message = 'Validation failed';
}
parent::__construct($message, 400);
$this->violations = $violations;
}
public function getViolations(): ConstraintViolationListInterface
{
return $this->violations;
}
}