timesheet lockdown with grace period (#1644)
This commit is contained in:
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.';
|
||||
|
||||
19
src/Validator/Constraints/TimesheetConstraint.php
Normal file
19
src/Validator/Constraints/TimesheetConstraint.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?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\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* Extend this class if you want to add dynamic timesheet validation (eg. via a bundle).
|
||||
*/
|
||||
abstract class TimesheetConstraint extends Constraint
|
||||
{
|
||||
}
|
||||
26
src/Validator/Constraints/TimesheetFutureTimes.php
Normal file
26
src/Validator/Constraints/TimesheetFutureTimes.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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\Constraints;
|
||||
|
||||
final class TimesheetFutureTimes extends TimesheetConstraint
|
||||
{
|
||||
public const BEGIN_IN_FUTURE_ERROR = 'kimai-timesheet-future-times-01';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::BEGIN_IN_FUTURE_ERROR => '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;
|
||||
}
|
||||
}
|
||||
58
src/Validator/Constraints/TimesheetFutureTimesValidator.php
Normal file
58
src/Validator/Constraints/TimesheetFutureTimesValidator.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?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\Constraints;
|
||||
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetFutureTimesValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @var TimesheetConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
|
||||
public function __construct(TimesheetConfiguration $configuration)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/Validator/Constraints/TimesheetLockdown.php
Normal file
30
src/Validator/Constraints/TimesheetLockdown.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?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\Constraints;
|
||||
|
||||
final class TimesheetLockdown extends TimesheetConstraint
|
||||
{
|
||||
public const PERIOD_LOCKED = 'kimai-timesheet-lockdown-01';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::PERIOD_LOCKED => '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;
|
||||
}
|
||||
}
|
||||
127
src/Validator/Constraints/TimesheetLockdownValidator.php
Normal file
127
src/Validator/Constraints/TimesheetLockdownValidator.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?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\Constraints;
|
||||
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetLockdownValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @var AuthorizationCheckerInterface
|
||||
*/
|
||||
private $auth;
|
||||
/**
|
||||
* @var TimesheetConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
|
||||
public function __construct(AuthorizationCheckerInterface $auth, TimesheetConfiguration $configuration)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
26
src/Validator/Constraints/TimesheetOverlapping.php
Normal file
26
src/Validator/Constraints/TimesheetOverlapping.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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\Constraints;
|
||||
|
||||
final class TimesheetOverlapping extends TimesheetConstraint
|
||||
{
|
||||
public const RECORD_OVERLAPPING = 'kimai-timesheet-overlapping-01';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::RECORD_OVERLAPPING => '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;
|
||||
}
|
||||
}
|
||||
64
src/Validator/Constraints/TimesheetOverlappingValidator.php
Normal file
64
src/Validator/Constraints/TimesheetOverlappingValidator.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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\Constraints;
|
||||
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetOverlappingValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @var TimesheetConfiguration
|
||||
*/
|
||||
private $configuration;
|
||||
/**
|
||||
* @var TimesheetRepository
|
||||
*/
|
||||
private $repository;
|
||||
|
||||
public function __construct(TimesheetConfiguration $configuration, TimesheetRepository $repository)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
26
src/Validator/Constraints/TimesheetRestart.php
Normal file
26
src/Validator/Constraints/TimesheetRestart.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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\Constraints;
|
||||
|
||||
final class TimesheetRestart extends TimesheetConstraint
|
||||
{
|
||||
public const START_DISALLOWED = 'kimai-timesheet-restart-01';
|
||||
|
||||
protected static $errorNames = [
|
||||
self::START_DISALLOWED => '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;
|
||||
}
|
||||
}
|
||||
82
src/Validator/Constraints/TimesheetRestartValidator.php
Normal file
82
src/Validator/Constraints/TimesheetRestartValidator.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?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\Constraints;
|
||||
|
||||
use App\Entity\Timesheet as TimesheetEntity;
|
||||
use App\Timesheet\TrackingModeService;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class TimesheetRestartValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @var TrackingModeService
|
||||
*/
|
||||
private $trackingModeService;
|
||||
/**
|
||||
* @var AuthorizationCheckerInterface
|
||||
*/
|
||||
private $auth;
|
||||
|
||||
public function __construct(TrackingModeService $service, AuthorizationCheckerInterface $auth)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Configuration\ConfigLoaderInterface;
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Validator\Constraints\TimesheetFutureTimes;
|
||||
use App\Validator\Constraints\TimesheetFutureTimesValidator;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetFutureTimesValidator
|
||||
*/
|
||||
class TimesheetFutureTimesValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return $this->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());
|
||||
}
|
||||
}
|
||||
224
tests/Validator/Constraints/TimesheetLockdownValidatorTest.php
Normal file
224
tests/Validator/Constraints/TimesheetLockdownValidatorTest.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Configuration\ConfigLoaderInterface;
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Validator\Constraints\TimesheetLockdown;
|
||||
use App\Validator\Constraints\TimesheetLockdownValidator;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetLockdownValidator
|
||||
*/
|
||||
class TimesheetLockdownValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return $this->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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Configuration\ConfigLoaderInterface;
|
||||
use App\Configuration\TimesheetConfiguration;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Validator\Constraints\TimesheetOverlapping;
|
||||
use App\Validator\Constraints\TimesheetOverlappingValidator;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetOverlappingValidator
|
||||
*/
|
||||
class TimesheetOverlappingValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return $this->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());
|
||||
}
|
||||
}
|
||||
100
tests/Validator/Constraints/TimesheetRestartValidatorTest.php
Normal file
100
tests/Validator/Constraints/TimesheetRestartValidatorTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Validator\Constraints;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Tests\Mocks\TrackingModeServiceFactory;
|
||||
use App\Validator\Constraints\TimesheetOverlapping;
|
||||
use App\Validator\Constraints\TimesheetRestart;
|
||||
use App\Validator\Constraints\TimesheetRestartValidator;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetRestartValidator
|
||||
*/
|
||||
class TimesheetRestartValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
return $this->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'];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,18 @@
|
||||
<source>label.timesheet.rules.allow_overlapping_records</source>
|
||||
<target>Erlaube überlappende Zeiteinträge</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.timesheet.rules.lockdown_period_start">
|
||||
<source>label.timesheet.rules.lockdown_period_start</source>
|
||||
<target>Start des gesperrten Zeitraums (relatives PHP Datumsformat)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.timesheet.rules.lockdown_period_end">
|
||||
<source>label.timesheet.rules.lockdown_period_end</source>
|
||||
<target>Ende des gesperrten Zeitraums (relatives PHP Datumsformat)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.timesheet.rules.lockdown_grace_period">
|
||||
<source>label.timesheet.rules.lockdown_grace_period</source>
|
||||
<target>Übergangsfrist für gesperrten Zeitraum (relatives PHP Datumsformat ab Enddatum)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.timesheet.active_entries.hard_limit">
|
||||
<source>label.timesheet.active_entries.hard_limit</source>
|
||||
<target>Erlaubte Anzahl an gleichzeitig laufenden Zeiteinträgen</target>
|
||||
|
||||
@@ -70,6 +70,18 @@
|
||||
<source>label.timesheet.rules.allow_overlapping_records</source>
|
||||
<target>Allow overlapping time entries</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.timesheet.rules.lockdown_period_start">
|
||||
<source>label.timesheet.rules.lockdown_period_start</source>
|
||||
<target>Lockdown period start (PHP relative date to now)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.timesheet.rules.lockdown_period_end">
|
||||
<source>label.timesheet.rules.lockdown_period_end</source>
|
||||
<target>Lockdown period end (PHP relative date to now)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.timesheet.rules.lockdown_grace_period">
|
||||
<source>label.timesheet.rules.lockdown_grace_period</source>
|
||||
<target>Lockdown grace period end (PHP relative date to lockdown period end)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.timesheet.active_entries.hard_limit">
|
||||
<source>label.timesheet.active_entries.hard_limit</source>
|
||||
<target>Permitted number of simultaneously running time entries</target>
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
<source>This invoice document cannot be used, please rename the file and upload it again.</source>
|
||||
<target>Dieses Rechnungsdokument kann nicht verwendet werden, bitte benennen Sie die Datei um und laden Sie diese erneut hoch.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="This period is locked, please choose a later date.">
|
||||
<source>This period is locked, please choose a later date.</source>
|
||||
<target>Dieser Zeitraum ist gesperrt, bitte wählen Sie ein späteres Datum.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="You already have an entry for this time.">
|
||||
<source>You already have an entry for this time.</source>
|
||||
<target>Es existiert bereits ein Eintrag für diesen Zeitpunkt.</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
<source>This invoice document cannot be used, please rename the file and upload it again.</source>
|
||||
<target>This invoice document cannot be used, please rename the file and upload it again.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="This period is locked, please choose a later date.">
|
||||
<source>This period is locked, please choose a later date.</source>
|
||||
<target>This period is locked, please choose a later date.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="You already have an entry for this time.">
|
||||
<source>You already have an entry for this time.</source>
|
||||
<target>You already have an entry for this time.</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
Reference in New Issue
Block a user