diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml
index 062c386a..793d4cb4 100644
--- a/config/packages/kimai.yaml
+++ b/config/packages/kimai.yaml
@@ -107,7 +107,8 @@ kimai:
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team']
- # some single default definitions for roles
+ LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
+ # some single default definitions for roles
SINGLE_USER: ['view_team_member','budget_team_project']
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member']
@@ -115,8 +116,8 @@ kimai:
# link above sets to one complete set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
- ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
- ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
+ ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@SINGLE_ADMIN']
+ ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@SINGLE_SUPER_ADMIN']
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps:
ROLE_USER: ['ROLE_USER']
diff --git a/config/services.yaml b/config/services.yaml
index 6a65951a..4e80ec3c 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -74,6 +74,9 @@ services:
App\Plugin\PluginManager:
arguments: [!tagged kimai.plugin]
+ App\Validator\Constraints\TimesheetValidator:
+ arguments: [!tagged timesheet.validator]
+
App\Widget\WidgetService:
arguments:
$renderer: !tagged widget.renderer
diff --git a/src/API/BaseApiController.php b/src/API/BaseApiController.php
index e9d69218..f1369c37 100644
--- a/src/API/BaseApiController.php
+++ b/src/API/BaseApiController.php
@@ -21,5 +21,5 @@ use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
abstract class BaseApiController extends AbstractController
{
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
- public const DATE_FORMAT_PHP = 'Y-m-d\TH:m:s';
+ public const DATE_FORMAT_PHP = 'Y-m-d\TH:i:s';
}
diff --git a/src/Configuration/TimesheetConfiguration.php b/src/Configuration/TimesheetConfiguration.php
index 3f261c46..6b51269e 100644
--- a/src/Configuration/TimesheetConfiguration.php
+++ b/src/Configuration/TimesheetConfiguration.php
@@ -77,4 +77,24 @@ class TimesheetConfiguration implements SystemBundleConfiguration
{
return (int) $this->find('rounding.default.duration');
}
+
+ public function getLockdownPeriodStart(): string
+ {
+ return (string) $this->find('rules.lockdown_period_start');
+ }
+
+ public function getLockdownPeriodEnd(): string
+ {
+ return (string) $this->find('rules.lockdown_period_end');
+ }
+
+ public function getLockdownGracePeriod(): string
+ {
+ return (string) $this->find('rules.lockdown_grace_period');
+ }
+
+ public function isLockdownActive(): bool
+ {
+ return !empty($this->find('rules.lockdown_period_start')) && !empty($this->find('rules.lockdown_period_end'));
+ }
}
diff --git a/src/Controller/SystemConfigurationController.php b/src/Controller/SystemConfigurationController.php
index 4f415858..f39ba17f 100644
--- a/src/Controller/SystemConfigurationController.php
+++ b/src/Controller/SystemConfigurationController.php
@@ -232,6 +232,24 @@ final class SystemConfigurationController extends AbstractController
->setName('timesheet.rules.allow_overlapping_records')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
+ (new Configuration())
+ ->setName('timesheet.rules.lockdown_period_start')
+ ->setType(TextType::class)
+ ->setRequired(false)
+ ->setConstraints([new DateTimeFormat()])
+ ->setTranslationDomain('system-configuration'),
+ (new Configuration())
+ ->setName('timesheet.rules.lockdown_period_end')
+ ->setType(TextType::class)
+ ->setRequired(false)
+ ->setConstraints([new DateTimeFormat()])
+ ->setTranslationDomain('system-configuration'),
+ (new Configuration())
+ ->setName('timesheet.rules.lockdown_grace_period')
+ ->setType(TextType::class)
+ ->setRequired(false)
+ ->setConstraints([new DateTimeFormat()])
+ ->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.active_entries.hard_limit')
->setType(IntegerType::class)
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index 2081829b..0a28fa78 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -198,6 +198,15 @@ class Configuration implements ConfigurationInterface
->booleanNode('allow_overlapping_records')
->defaultTrue()
->end()
+ ->scalarNode('lockdown_period_start')
+ ->defaultNull()
+ ->end()
+ ->scalarNode('lockdown_period_end')
+ ->defaultNull()
+ ->end()
+ ->scalarNode('lockdown_grace_period')
+ ->defaultNull()
+ ->end()
->end()
->end()
->end()
diff --git a/src/Kernel.php b/src/Kernel.php
index fd48242f..8651b23e 100644
--- a/src/Kernel.php
+++ b/src/Kernel.php
@@ -27,6 +27,7 @@ use App\Saml\Security\SamlFactory;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use App\Timesheet\Rounding\RoundingInterface;
use App\Timesheet\TrackingMode\TrackingModeInterface;
+use App\Validator\Constraints\TimesheetConstraint;
use App\Widget\WidgetInterface;
use App\Widget\WidgetRendererInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
@@ -54,6 +55,7 @@ class Kernel extends BaseKernel
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
public const TAG_INVOICE_REPOSITORY = 'invoice.repository';
public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator';
+ public const TAG_TIMESHEET_VALIDATOR = 'timesheet.validator';
public const TAG_TIMESHEET_EXPORTER = 'timesheet.exporter';
public const TAG_TIMESHEET_TRACKING_MODE = 'timesheet.tracking_mode';
public const TAG_TIMESHEET_ROUNDING_MODE = 'timesheet.rounding_mode';
@@ -82,6 +84,7 @@ class Kernel extends BaseKernel
$container->registerForAutoconfiguration(TimesheetExportInterface::class)->addTag(self::TAG_TIMESHEET_EXPORTER);
$container->registerForAutoconfiguration(TrackingModeInterface::class)->addTag(self::TAG_TIMESHEET_TRACKING_MODE);
$container->registerForAutoconfiguration(RoundingInterface::class)->addTag(self::TAG_TIMESHEET_ROUNDING_MODE);
+ $container->registerForAutoconfiguration(TimesheetConstraint::class)->addTag(self::TAG_TIMESHEET_VALIDATOR);
/** @var SecurityExtension $extension */
$extension = $container->getExtension('security');
diff --git a/src/Validator/Constraints/Timesheet.php b/src/Validator/Constraints/Timesheet.php
index 4cabab17..bbe3ec38 100644
--- a/src/Validator/Constraints/Timesheet.php
+++ b/src/Validator/Constraints/Timesheet.php
@@ -20,32 +20,26 @@ class Timesheet extends Constraint
{
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
- public const BEGIN_IN_FUTURE_ERROR = 'kimai-timesheet-83';
public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
- public const START_DISALLOWED = 'kimai-timesheet-90';
public const PROJECT_NOT_STARTED = 'kimai-timesheet-91';
public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92';
- public const RECORD_OVERLAPPING = 'kimai-timesheet-93';
protected static $errorNames = [
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
- self::BEGIN_IN_FUTURE_ERROR => 'The begin date cannot be in the future.',
self::MISSING_ACTIVITY_ERROR => 'A timesheet must have an activity.',
self::MISSING_PROJECT_ERROR => 'A timesheet must have a project.',
self::ACTIVITY_PROJECT_MISMATCH_ERROR => 'Project mismatch, project specific activity and timesheet project are different.',
self::DISABLED_ACTIVITY_ERROR => 'Cannot start a disabled activity.',
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
- self::START_DISALLOWED => 'You are not allowed to start this timesheet record.',
self::PROJECT_NOT_STARTED => 'The project has not started at that time.',
self::PROJECT_ALREADY_ENDED => 'The project is finished at that time.',
- self::RECORD_OVERLAPPING => 'You already have an entry for this time.',
];
public $message = 'This timesheet has invalid settings.';
diff --git a/src/Validator/Constraints/TimesheetConstraint.php b/src/Validator/Constraints/TimesheetConstraint.php
new file mode 100644
index 00000000..e8fb6e72
--- /dev/null
+++ b/src/Validator/Constraints/TimesheetConstraint.php
@@ -0,0 +1,19 @@
+ 'The begin date cannot be in the future.',
+ ];
+
+ public $message = 'The begin date cannot be in the future.';
+
+ public function getTargets()
+ {
+ return self::CLASS_CONSTRAINT;
+ }
+}
diff --git a/src/Validator/Constraints/TimesheetFutureTimesValidator.php b/src/Validator/Constraints/TimesheetFutureTimesValidator.php
new file mode 100644
index 00000000..c95f5688
--- /dev/null
+++ b/src/Validator/Constraints/TimesheetFutureTimesValidator.php
@@ -0,0 +1,58 @@
+configuration = $configuration;
+ }
+
+ /**
+ * @param TimesheetEntity $timesheet
+ * @param Constraint $constraint
+ */
+ public function validate($timesheet, Constraint $constraint)
+ {
+ if (!($constraint instanceof TimesheetFutureTimes)) {
+ throw new UnexpectedTypeException($constraint, TimesheetFutureTimes::class);
+ }
+
+ if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
+ throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
+ }
+
+ if ($this->configuration->isAllowFutureTimes()) {
+ return;
+ }
+
+ // allow configured default rounding time + 1 minute - see #1295
+ $allowedDiff = ($this->configuration->getDefaultRoundingBegin() * 60) + 60;
+ if ((time() + $allowedDiff) < $timesheet->getBegin()->getTimestamp()) {
+ $this->context->buildViolation('The begin date cannot be in the future.')
+ ->atPath('begin')
+ ->setTranslationDomain('validators')
+ ->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
+ ->addViolation();
+ }
+ }
+}
diff --git a/src/Validator/Constraints/TimesheetLockdown.php b/src/Validator/Constraints/TimesheetLockdown.php
new file mode 100644
index 00000000..48e78399
--- /dev/null
+++ b/src/Validator/Constraints/TimesheetLockdown.php
@@ -0,0 +1,30 @@
+ 'This period is locked, please choose a later date.',
+ ];
+
+ public $message = 'This period is locked, please choose a later date.';
+ /**
+ * @var \DateTime|string|null
+ */
+ public $now;
+
+ public function getTargets()
+ {
+ return self::CLASS_CONSTRAINT;
+ }
+}
diff --git a/src/Validator/Constraints/TimesheetLockdownValidator.php b/src/Validator/Constraints/TimesheetLockdownValidator.php
new file mode 100644
index 00000000..7b6e07a8
--- /dev/null
+++ b/src/Validator/Constraints/TimesheetLockdownValidator.php
@@ -0,0 +1,127 @@
+auth = $auth;
+ $this->configuration = $configuration;
+ }
+
+ /**
+ * @param TimesheetEntity $timesheet
+ * @param Constraint $constraint
+ */
+ public function validate($timesheet, Constraint $constraint)
+ {
+ if (!($constraint instanceof TimesheetLockdown)) {
+ throw new UnexpectedTypeException($constraint, TimesheetLockdown::class);
+ }
+
+ if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
+ throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
+ }
+
+ $timesheetStart = $timesheet->getBegin();
+
+ if (null === $timesheetStart) {
+ return;
+ }
+
+ if (!$this->configuration->isLockdownActive()) {
+ return;
+ }
+
+ $lockedStart = $this->configuration->getLockdownPeriodStart();
+ $lockedEnd = $this->configuration->getLockdownPeriodEnd();
+
+ $gracePeriod = $this->configuration->getLockdownGracePeriod();
+ if (!empty($gracePeriod)) {
+ $gracePeriod = $gracePeriod . ' ';
+ }
+
+ try {
+ $lockdownStart = new \DateTime($lockedStart, $timesheetStart->getTimezone());
+ $lockdownEnd = new \DateTime($lockedEnd, $timesheetStart->getTimezone());
+ $lockdownGrace = new \DateTime($gracePeriod . $lockdownEnd->format('Y-m-d'), $timesheetStart->getTimezone());
+ } catch (\Exception $ex) {
+ // should not happen, but ... if parsing of datetimes fails: skip validation
+ return;
+ }
+
+ // misconfiguration detected, skip validation
+ if ($lockdownEnd < $lockdownStart) {
+ return;
+ }
+
+ // validate only entries added before the end of lockdown period
+ if ($timesheetStart > $lockdownEnd) {
+ return;
+ }
+
+ // lockdown never takes effect for users with special permission
+ if ($this->auth->isGranted('lockdown_override_timesheet')) {
+ return;
+ }
+
+ if (!empty($constraint->now)) {
+ if ($constraint->now instanceof \DateTime) {
+ $now = $constraint->now;
+ } elseif (\is_string($constraint->now)) {
+ try {
+ $now = new \DateTime($constraint->now, $timesheetStart->getTimezone());
+ } catch (\Exception $ex) {
+ }
+ }
+ }
+
+ if (empty($now)) {
+ $now = new \DateTime('now', $timesheetStart->getTimezone());
+ }
+
+ // further validate entries inside of the most recent lockdown
+ if ($timesheetStart > $lockdownStart && $timesheetStart < $lockdownEnd) {
+ // if grace period is still in effect, validation succeeds
+ if ($now < $lockdownGrace) {
+ return;
+ }
+
+ // if user has special role, validation succeeds
+ if ($this->auth->isGranted('lockdown_grace_timesheet')) {
+ return;
+ }
+ }
+
+ // raise a violation for all entries before the start of lockdown period
+ $this->context->buildViolation('This period is locked, please choose a later date.')
+ ->atPath('begin')
+ ->setTranslationDomain('validators')
+ ->setCode(TimesheetLockdown::PERIOD_LOCKED)
+ ->addViolation();
+ }
+}
diff --git a/src/Validator/Constraints/TimesheetOverlapping.php b/src/Validator/Constraints/TimesheetOverlapping.php
new file mode 100644
index 00000000..1b1e44f0
--- /dev/null
+++ b/src/Validator/Constraints/TimesheetOverlapping.php
@@ -0,0 +1,26 @@
+ 'You already have an entry for this time.',
+ ];
+
+ public $message = 'You already have an entry for this time.';
+
+ public function getTargets()
+ {
+ return self::CLASS_CONSTRAINT;
+ }
+}
diff --git a/src/Validator/Constraints/TimesheetOverlappingValidator.php b/src/Validator/Constraints/TimesheetOverlappingValidator.php
new file mode 100644
index 00000000..4bfcb500
--- /dev/null
+++ b/src/Validator/Constraints/TimesheetOverlappingValidator.php
@@ -0,0 +1,64 @@
+configuration = $configuration;
+ $this->repository = $repository;
+ }
+
+ /**
+ * @param TimesheetEntity $timesheet
+ * @param Constraint $constraint
+ */
+ public function validate($timesheet, Constraint $constraint)
+ {
+ if (!($constraint instanceof TimesheetOverlapping)) {
+ throw new UnexpectedTypeException($constraint, TimesheetOverlapping::class);
+ }
+
+ if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
+ throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
+ }
+
+ if ($this->configuration->isAllowOverlappingRecords()) {
+ return;
+ }
+
+ if (!$this->repository->hasRecordForTime($timesheet)) {
+ return;
+ }
+
+ $this->context->buildViolation('You already have an entry for this time.')
+ ->atPath('begin')
+ ->setTranslationDomain('validators')
+ ->setCode(TimesheetOverlapping::RECORD_OVERLAPPING)
+ ->addViolation();
+ }
+}
diff --git a/src/Validator/Constraints/TimesheetRestart.php b/src/Validator/Constraints/TimesheetRestart.php
new file mode 100644
index 00000000..95c7e3ec
--- /dev/null
+++ b/src/Validator/Constraints/TimesheetRestart.php
@@ -0,0 +1,26 @@
+ 'You are not allowed to start this timesheet record.',
+ ];
+
+ public $message = 'You are not allowed to start this timesheet record.';
+
+ public function getTargets()
+ {
+ return self::CLASS_CONSTRAINT;
+ }
+}
diff --git a/src/Validator/Constraints/TimesheetRestartValidator.php b/src/Validator/Constraints/TimesheetRestartValidator.php
new file mode 100644
index 00000000..1a36b864
--- /dev/null
+++ b/src/Validator/Constraints/TimesheetRestartValidator.php
@@ -0,0 +1,82 @@
+trackingModeService = $service;
+ $this->auth = $auth;
+ }
+
+ /**
+ * @param TimesheetEntity $timesheet
+ * @param Constraint $constraint
+ */
+ public function validate($timesheet, Constraint $constraint)
+ {
+ if (!($constraint instanceof TimesheetRestart)) {
+ throw new UnexpectedTypeException($constraint, TimesheetRestart::class);
+ }
+
+ if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
+ throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
+ }
+
+ // special case that would otherwise need to be validated in several controllers:
+ // an entry is edited and the end date is removed (or duration deleted) would restart the record,
+ // which might be disallowed for the current user
+ if (null !== $timesheet->getEnd()) {
+ return;
+ }
+
+ if ($this->context->getViolations()->count() > 0) {
+ return;
+ }
+
+ if ($this->auth->isGranted('start', $timesheet)) {
+ return;
+ }
+
+ $mode = $this->trackingModeService->getActiveMode();
+ $path = 'start';
+
+ if ($mode->canEditEnd()) {
+ $path = 'end';
+ } elseif ($mode->canEditDuration()) {
+ $path = 'duration';
+ }
+
+ $this->context->buildViolation('You are not allowed to start this timesheet record.')
+ ->atPath($path)
+ ->setTranslationDomain('validators')
+ ->setCode(TimesheetRestart::START_DISALLOWED)
+ ->addViolation();
+
+ return;
+ }
+}
diff --git a/src/Validator/Constraints/TimesheetValidator.php b/src/Validator/Constraints/TimesheetValidator.php
index 4feca36e..4e5e4382 100644
--- a/src/Validator/Constraints/TimesheetValidator.php
+++ b/src/Validator/Constraints/TimesheetValidator.php
@@ -9,84 +9,52 @@
namespace App\Validator\Constraints;
-use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet as TimesheetEntity;
-use App\Repository\TimesheetRepository;
-use App\Timesheet\TrackingModeService;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
-use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
-class TimesheetValidator extends ConstraintValidator
+final class TimesheetValidator extends ConstraintValidator
{
/**
- * @var AuthorizationCheckerInterface
+ * @var TimesheetConstraint[]
*/
- protected $auth;
- /**
- * @var TimesheetConfiguration
- */
- protected $configuration;
- /**
- * @var TrackingModeService
- */
- protected $trackingModeService;
- /**
- * @var TimesheetRepository
- */
- private $repository;
+ private $constraints;
- public function __construct(AuthorizationCheckerInterface $auth, TimesheetConfiguration $configuration, TrackingModeService $service, TimesheetRepository $repository)
+ /**
+ * @param TimesheetConstraint[] $constraints
+ */
+ public function __construct(iterable $constraints)
{
- $this->auth = $auth;
- $this->configuration = $configuration;
- $this->trackingModeService = $service;
- $this->repository = $repository;
- }
-
- /**
- * @param TimesheetEntity|mixed $value
- * @param Constraint $constraint
- */
- public function validate($value, Constraint $constraint)
- {
- if (!($constraint instanceof TimesheetConstraint)) {
- throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Timesheet');
- }
-
- if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
- return;
- }
-
- $this->validateBeginAndEnd($value, $this->context);
- $this->validateActivityAndProject($value, $this->context);
- $this->validatePermissions($value, $this->context);
- $this->validateActiveLimit($value, $this->context);
- $this->validateOverlapping($value, $this->context);
+ $this->constraints = $constraints;
}
/**
* @param TimesheetEntity $timesheet
- * @param ExecutionContextInterface $context
+ * @param Constraint $constraint
*/
- protected function validateOverlapping(TimesheetEntity $timesheet, ExecutionContextInterface $context)
+ public function validate($timesheet, Constraint $constraint)
{
- if ($this->configuration->isAllowOverlappingRecords()) {
- return;
+ if (!($constraint instanceof TimesheetConstraint)) {
+ throw new UnexpectedTypeException($constraint, Timesheet::class);
}
- if (!$this->repository->hasRecordForTime($timesheet)) {
- return;
+ if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
+ throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
}
- $context->buildViolation('You already have an entry for this time.')
- ->atPath('begin')
- ->setTranslationDomain('validators')
- ->setCode(TimesheetConstraint::RECORD_OVERLAPPING)
- ->addViolation();
+ $this->validateBeginAndEnd($timesheet, $this->context);
+ $this->validateActivityAndProject($timesheet, $this->context);
+ $this->validateActiveLimit($timesheet, $this->context);
+
+ foreach ($this->constraints as $constraint) {
+ $this->context
+ ->getValidator()
+ ->inContext($this->context)
+ ->validate($timesheet, $constraint, [Constraint::DEFAULT_GROUP]);
+ }
}
/**
@@ -98,35 +66,6 @@ class TimesheetValidator extends ConstraintValidator
// TODO check active entries against hard_limit
}
- /**
- * @param TimesheetEntity $timesheet
- * @param ExecutionContextInterface $context
- */
- protected function validatePermissions(TimesheetEntity $timesheet, ExecutionContextInterface $context)
- {
- // special case that would otherwise need to be validated in several controllers:
- // an entry is edited and the end date is removed (or duration deleted) would restart the record,
- // which might be disallowed for the current user
- if ($context->getViolations()->count() == 0 && null === $timesheet->getEnd()) {
- $mode = $this->trackingModeService->getActiveMode();
- $path = 'start';
- if ($mode->canEditEnd()) {
- $path = 'end';
- } elseif ($mode->canEditDuration()) {
- $path = 'duration';
- }
- if (!$this->auth->isGranted('start', $timesheet)) {
- $context->buildViolation('You are not allowed to start this timesheet record.')
- ->atPath($path)
- ->setTranslationDomain('validators')
- ->setCode(TimesheetConstraint::START_DISALLOWED)
- ->addViolation();
-
- return;
- }
- }
- }
-
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
@@ -150,18 +89,6 @@ class TimesheetValidator extends ConstraintValidator
->setCode(TimesheetConstraint::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
-
- if (false === $this->configuration->isAllowFutureTimes()) {
- // allow configured default rounding time + 1 minute - see #1295
- $allowedDiff = ($this->configuration->getDefaultRoundingBegin() * 60) + 60;
- if ((time() + $allowedDiff) < $timesheet->getBegin()->getTimestamp()) {
- $context->buildViolation('The begin date cannot be in the future.')
- ->atPath('begin')
- ->setTranslationDomain('validators')
- ->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR)
- ->addViolation();
- }
- }
}
/**
diff --git a/tests/Configuration/TimesheetConfigurationTest.php b/tests/Configuration/TimesheetConfigurationTest.php
index 4bef9362..63f5c483 100644
--- a/tests/Configuration/TimesheetConfigurationTest.php
+++ b/tests/Configuration/TimesheetConfigurationTest.php
@@ -36,6 +36,9 @@ class TimesheetConfigurationTest extends TestCase
return [
'rules' => [
'allow_future_times' => false,
+ 'lockdown_period_start' => null,
+ 'lockdown_period_end' => null,
+ 'lockdown_grace_period' => null,
],
'mode' => 'duration_only',
'markdown_content' => false,
@@ -51,6 +54,9 @@ class TimesheetConfigurationTest extends TestCase
{
return [
(new Configuration())->setName('timesheet.rules.allow_future_times')->setValue('1'),
+ (new Configuration())->setName('timesheet.rules.lockdown_period_start')->setValue('first day of last month'),
+ (new Configuration())->setName('timesheet.rules.lockdown_period_end')->setValue('last day of last month'),
+ (new Configuration())->setName('timesheet.rules.lockdown_grace_period')->setValue('+5 days'),
(new Configuration())->setName('timesheet.mode')->setValue('default'),
(new Configuration())->setName('timesheet.markdown_content')->setValue('1'),
(new Configuration())->setName('timesheet.default_begin')->setValue('07:00'),
@@ -70,10 +76,14 @@ class TimesheetConfigurationTest extends TestCase
$sut = $this->getSut($this->getDefaultSettings(), []);
$this->assertEquals(99, $sut->getActiveEntriesHardLimit());
$this->assertEquals(15, $sut->getActiveEntriesSoftLimit());
- $this->assertEquals(false, $sut->isAllowFutureTimes());
- $this->assertEquals(false, $sut->isMarkdownEnabled());
+ $this->assertFalse($sut->isAllowFutureTimes());
+ $this->assertFalse($sut->isMarkdownEnabled());
$this->assertEquals('duration_only', $sut->getTrackingMode());
$this->assertEquals('now', $sut->getDefaultBeginTime());
+ $this->assertFalse($sut->isLockdownActive());
+ $this->assertEquals('', $sut->getLockdownPeriodStart());
+ $this->assertEquals('', $sut->getLockdownPeriodEnd());
+ $this->assertEquals('', $sut->getLockdownGracePeriod());
}
public function testDefaultWithLoader()
@@ -85,6 +95,10 @@ class TimesheetConfigurationTest extends TestCase
$this->assertEquals(true, $sut->isMarkdownEnabled());
$this->assertEquals('default', $sut->getTrackingMode());
$this->assertEquals('07:00', $sut->getDefaultBeginTime());
+ $this->assertTrue($sut->isLockdownActive());
+ $this->assertEquals('first day of last month', $sut->getLockdownPeriodStart());
+ $this->assertEquals('last day of last month', $sut->getLockdownPeriodEnd());
+ $this->assertEquals('+5 days', $sut->getLockdownGracePeriod());
}
public function testDefaultWithMixedConfigs()
diff --git a/tests/Controller/PermissionControllerTest.php b/tests/Controller/PermissionControllerTest.php
index 62333960..71407efe 100644
--- a/tests/Controller/PermissionControllerTest.php
+++ b/tests/Controller/PermissionControllerTest.php
@@ -33,7 +33,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
- $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 109);
+ $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 111);
$this->assertPageActions($client, [
'back' => $this->createUrl('/admin/user/'),
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
diff --git a/tests/Controller/SystemConfigurationControllerTest.php b/tests/Controller/SystemConfigurationControllerTest.php
index ac4df806..cd1180a8 100644
--- a/tests/Controller/SystemConfigurationControllerTest.php
+++ b/tests/Controller/SystemConfigurationControllerTest.php
@@ -101,6 +101,9 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
['name' => 'timesheet.active_entries.default_begin', 'value' => '23:59'],
['name' => 'timesheet.rules.allow_future_times', 'value' => false],
['name' => 'timesheet.rules.allow_overlapping_records', 'value' => false],
+ ['name' => 'timesheet.rules.lockdown_period_start', 'value' => null],
+ ['name' => 'timesheet.rules.lockdown_period_end', 'value' => null],
+ ['name' => 'timesheet.rules.lockdown_grace_period', 'value' => null],
['name' => 'timesheet.active_entries.hard_limit', 'value' => 99],
['name' => 'timesheet.active_entries.soft_limit', 'value' => 77],
]
@@ -133,6 +136,9 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
['name' => 'timesheet.active_entries.default_begin', 'value' => '23:59'],
['name' => 'timesheet.rules.allow_future_times', 'value' => 1],
['name' => 'timesheet.rules.allow_overlapping_records', 'value' => 1],
+ ['name' => 'timesheet.rules.lockdown_period_start', 'value' => 'first day of last month'],
+ ['name' => 'timesheet.rules.lockdown_period_end', 'value' => 'first day of last month'],
+ ['name' => 'timesheet.rules.lockdown_grace_period', 'value' => '+10 days'],
['name' => 'timesheet.active_entries.hard_limit', 'value' => -1],
['name' => 'timesheet.active_entries.soft_limit', 'value' => -1],
]
@@ -140,8 +146,8 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
],
[
'#system_configuration_form_timesheet_configuration_0_value', // mode
- '#system_configuration_form_timesheet_configuration_4_value', // hard_limit
- '#system_configuration_form_timesheet_configuration_5_value', // soft_limit
+ '#system_configuration_form_timesheet_configuration_7_value', // hard_limit
+ '#system_configuration_form_timesheet_configuration_8_value', // soft_limit
],
true
);
diff --git a/tests/DependencyInjection/AppExtensionTest.php b/tests/DependencyInjection/AppExtensionTest.php
index fb2c99f8..d9d777cc 100644
--- a/tests/DependencyInjection/AppExtensionTest.php
+++ b/tests/DependencyInjection/AppExtensionTest.php
@@ -200,6 +200,9 @@ class AppExtensionTest extends TestCase
'rules' => [
'allow_future_times' => true,
'allow_overlapping_records' => true,
+ 'lockdown_period_start' => null,
+ 'lockdown_period_end' => null,
+ 'lockdown_grace_period' => null,
],
'default_begin' => 'now',
],
diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php
index 7bdee3ff..05e64da8 100644
--- a/tests/DependencyInjection/ConfigurationTest.php
+++ b/tests/DependencyInjection/ConfigurationTest.php
@@ -279,6 +279,9 @@ class ConfigurationTest extends TestCase
'rules' => [
'allow_future_times' => true,
'allow_overlapping_records' => true,
+ 'lockdown_period_start' => null,
+ 'lockdown_period_end' => null,
+ 'lockdown_grace_period' => null,
],
],
'user' => [
diff --git a/tests/Validator/Constraints/TimesheetFutureTimesValidatorTest.php b/tests/Validator/Constraints/TimesheetFutureTimesValidatorTest.php
new file mode 100644
index 00000000..03ad180a
--- /dev/null
+++ b/tests/Validator/Constraints/TimesheetFutureTimesValidatorTest.php
@@ -0,0 +1,88 @@
+createMyValidator(false);
+ }
+
+ protected function createMyValidator(bool $allowFutureTimes = false)
+ {
+ $loader = $this->createMock(ConfigLoaderInterface::class);
+ $config = new TimesheetConfiguration($loader, [
+ 'rules' => [
+ 'allow_future_times' => $allowFutureTimes,
+ ],
+ 'rounding' => [
+ 'default' => [
+ 'begin' => 1
+ ]
+ ]
+ ]);
+
+ return new TimesheetFutureTimesValidator($config);
+ }
+
+ public function testConstraintIsInvalid()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new Timesheet(), new NotBlank());
+ }
+
+ public function testInvalidValueThrowsException()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new NotBlank(), new TimesheetFutureTimes(['message' => 'myMessage']));
+ }
+
+ public function testFutureBeginIsDisallowed()
+ {
+ $begin = new \DateTime('+10 hour');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+
+ $this->validator->validate($timesheet, new TimesheetFutureTimes(['message' => 'myMessage']));
+
+ $this->buildViolation('The begin date cannot be in the future.')
+ ->atPath('property.path.begin')
+ ->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
+ ->assertRaised();
+ }
+
+ public function testFutureBeginIsAllowed()
+ {
+ $this->validator = $this->createMyValidator(true);
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime('+10 hour');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+
+ $this->validator->validate($timesheet, new TimesheetFutureTimes(['message' => 'myMessage']));
+ self::assertEmpty($this->context->getViolations());
+ }
+}
diff --git a/tests/Validator/Constraints/TimesheetLockdownValidatorTest.php b/tests/Validator/Constraints/TimesheetLockdownValidatorTest.php
new file mode 100644
index 00000000..1ddd4af8
--- /dev/null
+++ b/tests/Validator/Constraints/TimesheetLockdownValidatorTest.php
@@ -0,0 +1,224 @@
+createMyValidator(false, false, null, null, null);
+ }
+
+ protected function createMyValidator(bool $allowOverwriteFull, bool $allowOverwriteGrace, ?string $start, ?string $end, ?string $grace)
+ {
+ $auth = $this->createMock(AuthorizationCheckerInterface::class);
+ $auth->method('isGranted')->willReturnCallback(
+ function ($attributes, $subject = null) use ($allowOverwriteFull, $allowOverwriteGrace) {
+ switch ($attributes) {
+ case 'lockdown_override_timesheet':
+ return $allowOverwriteFull;
+ case 'lockdown_grace_timesheet':
+ return $allowOverwriteGrace;
+ }
+
+ return false;
+ }
+ );
+
+ $loader = $this->createMock(ConfigLoaderInterface::class);
+ $config = new TimesheetConfiguration($loader, [
+ 'rules' => [
+ 'lockdown_period_start' => $start,
+ 'lockdown_period_end' => $end,
+ 'lockdown_grace_period' => $grace,
+ ],
+ ]);
+
+ return new TimesheetLockdownValidator($auth, $config);
+ }
+
+ public function testConstraintIsInvalid()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new Timesheet(), new NotBlank());
+ }
+
+ public function testInvalidValueThrowsException()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new NotBlank(), new TimesheetLockdown(['message' => 'myMessage']));
+ }
+
+ public function testValidatorWithoutNowConstraint()
+ {
+ $this->validator = $this->createMyValidator(false, false, 'first day of last month', 'last day of last month', '+10 days');
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime('first day of last month');
+ $begin->modify('-5 days');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+
+ $constraint = new TimesheetLockdown(['message' => 'myMessage']);
+
+ $this->validator->validate($timesheet, $constraint);
+
+ $this->buildViolation('This period is locked, please choose a later date.')
+ ->atPath('property.path.begin')
+ ->setCode(TimesheetLockdown::PERIOD_LOCKED)
+ ->assertRaised();
+ }
+
+ public function testValidatorWithEmptyTimesheet()
+ {
+ $this->validator = $this->createMyValidator(false, false, 'first day of last month', 'last day of last month', '+10 days');
+ $this->validator->initialize($this->context);
+
+ $constraint = new TimesheetLockdown(['message' => 'myMessage']);
+
+ $this->validator->validate(new Timesheet(), $constraint);
+ self::assertEmpty($this->context->getViolations());
+ }
+
+ public function testValidatorWithoutNowStringConstraint()
+ {
+ $this->validator = $this->createMyValidator(false, false, 'first day of last month', 'last day of last month', '+10 days');
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime('first day of last month');
+ $begin->modify('+5 days');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+
+ $constraint = new TimesheetLockdown(['message' => 'myMessage', 'now' => 'first day of this month']);
+
+ $this->validator->validate($timesheet, $constraint);
+ self::assertEmpty($this->context->getViolations());
+ }
+
+ public function testValidatorWithEndBeforeStartPeriod()
+ {
+ $this->validator = $this->createMyValidator(false, false, 'first day of this month', 'last day of last month', '+10 days');
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime('first day of last month');
+ $begin->modify('+5 days');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+
+ $constraint = new TimesheetLockdown(['message' => 'myMessage', 'now' => 'first day of this month']);
+
+ $this->validator->validate($timesheet, $constraint);
+ self::assertEmpty($this->context->getViolations());
+ }
+
+ /**
+ * @dataProvider getTestData
+ */
+ public function testLockdown(bool $allowOverwriteFull, bool $allowOverwriteGrace, string $beginModifier, string $nowModifier, bool $isViolation)
+ {
+ $this->validator = $this->createMyValidator($allowOverwriteFull, $allowOverwriteGrace, 'first day of last month', 'last day of last month', '+10 days');
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime('first day of last month');
+ $begin->modify($beginModifier);
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+
+ $now = new \DateTime('first day of this month');
+ $now->modify($nowModifier);
+
+ $constraint = new TimesheetLockdown(['message' => 'myMessage', 'now' => $now]);
+
+ $this->validator->validate($timesheet, $constraint);
+
+ if ($isViolation) {
+ $this->buildViolation('This period is locked, please choose a later date.')
+ ->atPath('property.path.begin')
+ ->setCode(TimesheetLockdown::PERIOD_LOCKED)
+ ->assertRaised();
+ } else {
+ self::assertEmpty($this->context->getViolations());
+ }
+ }
+
+ public function getTestData()
+ {
+ // changing before last dockdown period is not allowed
+ yield [false, false, '-5 days', '+5 days', true];
+ // changing before last dockdown period is not allowed with grace permission
+ yield [false, true, '-5 days', '+5 days', true];
+ // changing before last dockdown period is allowed with full permission
+ yield [true, true, '-5 days', '+5 days', false];
+ yield [true, false, '-5 days', '+5 days', false];
+ // changing a value in the last lockdown period is allowed during grace period
+ yield [false, false, '+5 days', '+5 days', false];
+ // changing outside grace period is not allowed
+ yield [false, false, '+5 days', '+11 days', true];
+ // changing outside grace period is allowed with grace and full permission
+ yield [false, true, '+5 days', '+11 days', false];
+ yield [true, false, '+5 days', '+11 days', false];
+ yield [true, true, '+5 days', '+11 days', false];
+ }
+
+ /**
+ * @dataProvider getConfigTestData
+ */
+ public function testLockdownConfig(bool $allowOverwriteFull, bool $allowOverwriteGrace, ?string $lockdownBegin, ?string $lockdownEnd, ?string $grace, bool $isViolation)
+ {
+ $this->validator = $this->createMyValidator($allowOverwriteFull, $allowOverwriteGrace, $lockdownBegin, $lockdownEnd, $grace);
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime('first day of last month');
+ $begin->modify('+5 days');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+
+ $now = new \DateTime('first day of this month');
+
+ $constraint = new TimesheetLockdown(['message' => 'myMessage', 'now' => $now]);
+
+ $this->validator->validate($timesheet, $constraint);
+
+ if ($isViolation) {
+ $this->buildViolation('This period is locked, please choose a later date.')
+ ->atPath('property.path.begin')
+ ->setCode(TimesheetLockdown::PERIOD_LOCKED)
+ ->assertRaised();
+ } else {
+ self::assertEmpty($this->context->getViolations());
+ }
+ }
+
+ public function getConfigTestData()
+ {
+ yield [false, false, null, null, null, false];
+ yield [false, false, '+5 days', null, null, false];
+ yield [false, false, null, '+5 days', null, false];
+
+ yield [false, true, 'öööö', '+11 days', null, false];
+ yield [false, true, '+5 days', '+5 of !!!!', null, false];
+ }
+}
diff --git a/tests/Validator/Constraints/TimesheetOverlappingValidatorTest.php b/tests/Validator/Constraints/TimesheetOverlappingValidatorTest.php
new file mode 100644
index 00000000..1daa5656
--- /dev/null
+++ b/tests/Validator/Constraints/TimesheetOverlappingValidatorTest.php
@@ -0,0 +1,120 @@
+createMyValidator(false, true);
+ }
+
+ protected function createMyValidator(bool $allowOverlappingRecords = false, bool $hasRecords = true)
+ {
+ $loader = $this->createMock(ConfigLoaderInterface::class);
+ $config = new TimesheetConfiguration($loader, [
+ 'rules' => [
+ 'allow_overlapping_records' => $allowOverlappingRecords,
+ ],
+ ]);
+ $repository = $this->createMock(TimesheetRepository::class);
+ $repository->method('hasRecordForTime')->willReturn($hasRecords);
+
+ return new TimesheetOverlappingValidator($config, $repository);
+ }
+
+ public function testConstraintIsInvalid()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new Timesheet(), new NotBlank());
+ }
+
+ public function testInvalidValueThrowsException()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new NotBlank(), new TimesheetOverlapping(['message' => 'myMessage']));
+ }
+
+ public function testOverlappingDisallowedWithRecords()
+ {
+ $begin = new \DateTime();
+ $end = new \DateTime('+10 hour');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+ $timesheet->setEnd($end);
+
+ $this->validator->validate($timesheet, new TimesheetOverlapping(['message' => 'myMessage']));
+
+ $this->buildViolation('You already have an entry for this time.')
+ ->atPath('property.path.begin')
+ ->setCode(TimesheetOverlapping::RECORD_OVERLAPPING)
+ ->assertRaised();
+ }
+
+ public function testOverlappingDisallowedWithoutRecords()
+ {
+ $this->validator = $this->createMyValidator(false, false);
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime();
+ $end = new \DateTime('+10 hour');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+ $timesheet->setEnd($end);
+
+ $this->validator->validate($timesheet, new TimesheetOverlapping(['message' => 'myMessage']));
+ self::assertEmpty($this->context->getViolations());
+ }
+
+ public function testOverlappingAllowedWithRecords()
+ {
+ $this->validator = $this->createMyValidator(true, true);
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime();
+ $end = new \DateTime('+10 hour');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+ $timesheet->setEnd($end);
+
+ $this->validator->validate($timesheet, new TimesheetOverlapping(['message' => 'myMessage']));
+ self::assertEmpty($this->context->getViolations());
+ }
+
+ public function testOverlappingAllowedWithoutRecords()
+ {
+ $this->validator = $this->createMyValidator(true, false);
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime();
+ $end = new \DateTime('+10 hour');
+ $timesheet = new Timesheet();
+ $timesheet->setBegin($begin);
+ $timesheet->setEnd($end);
+
+ $this->validator->validate($timesheet, new TimesheetOverlapping(['message' => 'myMessage']));
+ self::assertEmpty($this->context->getViolations());
+ }
+}
diff --git a/tests/Validator/Constraints/TimesheetRestartValidatorTest.php b/tests/Validator/Constraints/TimesheetRestartValidatorTest.php
new file mode 100644
index 00000000..25a1d918
--- /dev/null
+++ b/tests/Validator/Constraints/TimesheetRestartValidatorTest.php
@@ -0,0 +1,100 @@
+createMyValidator(false, 'default');
+ }
+
+ protected function createMyValidator(bool $allowed, string $trackingMode)
+ {
+ $auth = $this->createMock(AuthorizationCheckerInterface::class);
+ $auth->method('isGranted')->willReturn($allowed);
+
+ $service = (new TrackingModeServiceFactory($this))->create($trackingMode);
+
+ return new TimesheetRestartValidator($service, $auth);
+ }
+
+ public function testConstraintIsInvalid()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new Timesheet(), new NotBlank());
+ }
+
+ public function testInvalidValueThrowsException()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new NotBlank(), new TimesheetOverlapping(['message' => 'myMessage']));
+ }
+
+ /**
+ * @dataProvider getTestData
+ */
+ public function testRestartDisallowed(bool $allowed, ?string $property, string $trackingMode)
+ {
+ $this->validator = $this->createMyValidator($allowed, $trackingMode);
+ $this->validator->initialize($this->context);
+
+ $begin = new \DateTime('-10 hour');
+ $customer = new Customer();
+ $activity = new Activity();
+ $project = new Project();
+ $project->setCustomer($customer);
+ $activity->setProject($project);
+
+ $timesheet = new Timesheet();
+ $timesheet
+ ->setBegin($begin)
+ ->setActivity($activity)
+ ->setProject($project)
+ ;
+
+ $this->validator->validate($timesheet, new TimesheetRestart(['message' => 'myMessage']));
+
+ if (null !== $property) {
+ $this->buildViolation('You are not allowed to start this timesheet record.')
+ ->atPath('property.path.' . $property)
+ ->setCode(TimesheetRestart::START_DISALLOWED)
+ ->assertRaised();
+ } else {
+ self::assertEmpty($this->context->getViolations());
+ }
+ }
+
+ public function getTestData()
+ {
+ yield [false, 'end', 'default'];
+ yield [true, null, 'default'];
+ yield [false, 'duration', 'duration_only'];
+ yield [false, 'start', 'punch'];
+ }
+}
diff --git a/tests/Validator/Constraints/TimesheetValidatorTest.php b/tests/Validator/Constraints/TimesheetValidatorTest.php
index 016f54cb..98dc4d87 100644
--- a/tests/Validator/Constraints/TimesheetValidatorTest.php
+++ b/tests/Validator/Constraints/TimesheetValidatorTest.php
@@ -18,6 +18,10 @@ use App\Entity\Timesheet;
use App\Repository\TimesheetRepository;
use App\Tests\Mocks\TrackingModeServiceFactory;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
+use App\Validator\Constraints\TimesheetFutureTimesValidator;
+use App\Validator\Constraints\TimesheetLockdownValidator;
+use App\Validator\Constraints\TimesheetOverlappingValidator;
+use App\Validator\Constraints\TimesheetRestartValidator;
use App\Validator\Constraints\TimesheetValidator;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -30,10 +34,15 @@ use Symfony\Component\Validator\Test\ConstraintViolationAssertion;
*/
class TimesheetValidatorTest extends ConstraintValidatorTestCase
{
- protected function createValidator($isGranted = true)
+ protected function createValidator()
{
- $authMock = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
- $authMock->method('isGranted')->willReturn($isGranted);
+ return $this->createMyValidator();
+ }
+
+ protected function createMyValidator(bool $isGranted = true)
+ {
+ $auth = $this->createMock(AuthorizationCheckerInterface::class);
+ $auth->method('isGranted')->willReturn($isGranted);
$loader = $this->createMock(ConfigLoaderInterface::class);
$config = new TimesheetConfiguration($loader, [
@@ -50,14 +59,28 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
$service = (new TrackingModeServiceFactory($this))->create('default');
$repository = $this->createMock(TimesheetRepository::class);
- return new TimesheetValidator($authMock, $config, $service, $repository);
+ $constraints = [
+ new TimesheetFutureTimesValidator($config),
+ new TimesheetLockdownValidator($auth, $config),
+ new TimesheetOverlappingValidator($config, $repository),
+ new TimesheetRestartValidator($service, $auth),
+ ];
+
+ return new TimesheetValidator($constraints);
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
- $this->validator->validate('foo', new NotBlank());
+ $this->validator->validate(new Timesheet(), new NotBlank());
+ }
+
+ public function testInvalidValueThrowsException()
+ {
+ $this->expectException(UnexpectedTypeException::class);
+
+ $this->validator->validate(new NotBlank(), new TimesheetConstraint(['message' => 'myMessage']));
}
public function testEmptyTimesheet()
@@ -85,42 +108,20 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
- $this->buildViolation('The begin date cannot be in the future.')
- ->atPath('property.path.begin')
- ->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR)
- ->buildNextViolation('A timesheet must have an activity.')
+ $this
+ ->buildViolation('A timesheet must have an activity.')
->atPath('property.path.activity')
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
->buildNextViolation('A timesheet must have a project.')
->atPath('property.path.project')
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
- ->assertRaised();
- }
-
- public function testRestartDisallowed()
- {
- $this->validator = $this->createValidator(false);
- $this->validator->initialize($this->context);
-
- $begin = new \DateTime('-10 hour');
- $customer = new Customer();
- $activity = new Activity();
- $project = new Project();
- $project->setCustomer($customer);
- $activity->setProject($project);
-
- $timesheet = new Timesheet();
- $timesheet
- ->setBegin($begin)
- ->setActivity($activity)
- ->setProject($project)
- ;
-
- $this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
-
- $this->buildViolation('You are not allowed to start this timesheet record.')
- ->atPath('property.path.end')
- ->setCode(TimesheetConstraint::START_DISALLOWED)
+ // The test context is not able to handle calls to validate() - see ConstraintValidatorTestCase::createContext()
+ // therefor sub-constraints will not be executed :-(
+ /*
+ ->buildNextViolation('The begin date cannot be in the future.')
+ ->atPath('property.path.begin')
+ ->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
+ */
->assertRaised();
}
diff --git a/translations/system-configuration.de.xlf b/translations/system-configuration.de.xlf
index d8afe6cf..dcf0cf12 100644
--- a/translations/system-configuration.de.xlf
+++ b/translations/system-configuration.de.xlf
@@ -70,6 +70,18 @@
label.timesheet.rules.allow_overlapping_records
Erlaube überlappende Zeiteinträge
+
+ label.timesheet.rules.lockdown_period_start
+ Start des gesperrten Zeitraums (relatives PHP Datumsformat)
+
+
+ label.timesheet.rules.lockdown_period_end
+ Ende des gesperrten Zeitraums (relatives PHP Datumsformat)
+
+
+ label.timesheet.rules.lockdown_grace_period
+ Übergangsfrist für gesperrten Zeitraum (relatives PHP Datumsformat ab Enddatum)
+
label.timesheet.active_entries.hard_limit
Erlaubte Anzahl an gleichzeitig laufenden Zeiteinträgen
diff --git a/translations/system-configuration.en.xlf b/translations/system-configuration.en.xlf
index 177040b6..373e4934 100644
--- a/translations/system-configuration.en.xlf
+++ b/translations/system-configuration.en.xlf
@@ -70,6 +70,18 @@
label.timesheet.rules.allow_overlapping_records
Allow overlapping time entries
+
+ label.timesheet.rules.lockdown_period_start
+ Lockdown period start (PHP relative date to now)
+
+
+ label.timesheet.rules.lockdown_period_end
+ Lockdown period end (PHP relative date to now)
+
+
+ label.timesheet.rules.lockdown_grace_period
+ Lockdown grace period end (PHP relative date to lockdown period end)
+
label.timesheet.active_entries.hard_limit
Permitted number of simultaneously running time entries
diff --git a/translations/validators.de.xlf b/translations/validators.de.xlf
index 75d45600..0c1cdafd 100644
--- a/translations/validators.de.xlf
+++ b/translations/validators.de.xlf
@@ -22,6 +22,14 @@
This invoice document cannot be used, please rename the file and upload it again.
Dieses Rechnungsdokument kann nicht verwendet werden, bitte benennen Sie die Datei um und laden Sie diese erneut hoch.
+
+ This period is locked, please choose a later date.
+ Dieser Zeitraum ist gesperrt, bitte wählen Sie ein späteres Datum.
+
+
+ You already have an entry for this time.
+ Es existiert bereits ein Eintrag für diesen Zeitpunkt.
+