convert timesheets to UTC with support for user timezone (#372)
This commit is contained in:
@@ -18,6 +18,14 @@ bin/console doctrine:migrations:migrate
|
||||
There might be version specific tasks that need to be executed before or after these steps, please see below
|
||||
if your updated version is mentioned below.
|
||||
|
||||
## [0.8](https://github.com/kevinpapst/kimai2/releases/tag/0.8) (unreleased)
|
||||
|
||||
There was a change introduced regarding the storage of the date-time objects in timesheet records.
|
||||
Please read this [Pull request](https://github.com/kevinpapst/kimai2/pull/372) carefully before you follow the instructions
|
||||
to convert the timezones in your existing time records with `bin/console kimai:convert-timezone --help`.
|
||||
|
||||
If you don't do that, you will end up with wrong times in your database. Be especially careful, when you previously imported data from Kimai v1.
|
||||
|
||||
## [0.7](https://github.com/kevinpapst/kimai2/releases/tag/0.7) (2019-01-28)
|
||||
|
||||
The configuration `kimai.theme.active_warning` was deprecated and should be replaced in your local.yaml,
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/** global: jQuery */
|
||||
/** global: moment */
|
||||
|
||||
if (typeof jQuery === 'undefined') {
|
||||
throw new Error('Kimai requires jQuery');
|
||||
}
|
||||
|
||||
if (typeof moment === 'undefined') {
|
||||
throw new Error('Kimai requires moment.js');
|
||||
}
|
||||
|
||||
/* kimai
|
||||
*
|
||||
* @type Object
|
||||
|
||||
@@ -17,6 +17,9 @@ doctrine:
|
||||
|
||||
# With Symfony 3.3, remove the `resolve:` prefix
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
types:
|
||||
datetime: App\Doctrine\UTCDateTimeType
|
||||
orm:
|
||||
auto_generate_proxy_classes: '%kernel.debug%'
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||
|
||||
@@ -47,6 +47,11 @@ kimai:
|
||||
soft_limit: 1
|
||||
hard_limit: 3
|
||||
|
||||
# Rules that define the Timesheet validation and behaviour
|
||||
rules:
|
||||
# whether records in the future can be created
|
||||
allow_future_times: true
|
||||
|
||||
# --------------------------------------------------------------------------------
|
||||
# Invoice management
|
||||
#invoice:
|
||||
|
||||
@@ -105,6 +105,10 @@ services:
|
||||
tags:
|
||||
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
|
||||
|
||||
App\Validator\Constraints\TimesheetValidator:
|
||||
arguments:
|
||||
$ruleset: "%kimai.timesheet.rules%"
|
||||
|
||||
# ================================================================================
|
||||
# THEME
|
||||
# ================================================================================
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"build/app.js": "/build/app.js?bf44c766db95ef7d121c",
|
||||
"build/app.js": "/build/app.js?19fe460d7c7c857b408b",
|
||||
"build/app.css": "/build/app.css?caadc4d6101e9889a4fa28e77f1d9362",
|
||||
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
|
||||
"build/images/blue.png": "/build/images/blue.png?96f8a905",
|
||||
|
||||
43
src/Command/BashExecutor.php
Normal file
43
src/Command/BashExecutor.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
class BashExecutor
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $rootDir;
|
||||
|
||||
/**
|
||||
* @param string $projectDirectory
|
||||
*/
|
||||
public function __construct(string $projectDirectory)
|
||||
{
|
||||
$this->rootDir = realpath($projectDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $command
|
||||
* @return BashResult
|
||||
*/
|
||||
public function execute(string $command)
|
||||
{
|
||||
$exitCode = 0;
|
||||
|
||||
$command = rtrim($this->rootDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($command, DIRECTORY_SEPARATOR);
|
||||
|
||||
ob_start();
|
||||
passthru($command, $exitCode);
|
||||
$result = ob_get_clean();
|
||||
|
||||
return new BashResult($exitCode, $result);
|
||||
}
|
||||
}
|
||||
70
src/Command/BashResult.php
Normal file
70
src/Command/BashResult.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
class BashResult
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $exitCode;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $result;
|
||||
|
||||
/**
|
||||
* @param string $exitCode
|
||||
* @param string $result
|
||||
*/
|
||||
public function __construct($exitCode, $result)
|
||||
{
|
||||
$this->exitCode = $exitCode;
|
||||
$this->result = $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getExitCode(): string
|
||||
{
|
||||
return $this->exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $exitCode
|
||||
* @return BashResult
|
||||
*/
|
||||
public function setExitCode(string $exitCode)
|
||||
{
|
||||
$this->exitCode = $exitCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getResult(): string
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $result
|
||||
* @return BashResult
|
||||
*/
|
||||
public function setResult(string $result)
|
||||
{
|
||||
$this->result = $result;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
147
src/Command/ConvertTimezoneCommand.php
Normal file
147
src/Command/ConvertTimezoneCommand.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use Doctrine\DBAL\Types\DateTimeType;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* This command was added with v0.8.
|
||||
* Kimai saved the DateTime before with the local timezone, which can cause big problems when used in different environments.
|
||||
* You should convert all timesheet records that were saved with Kimai 2 directly, but NOT the ones migrated from Kimai v1.
|
||||
*
|
||||
* Please read https://github.com/kevinpapst/kimai2/pull/372 to find out more!
|
||||
*/
|
||||
class ConvertTimezoneCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var TimesheetRepository
|
||||
*/
|
||||
protected $repository;
|
||||
|
||||
/**
|
||||
* @param TimesheetRepository $repository
|
||||
*/
|
||||
public function __construct(TimesheetRepository $repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:convert-timezone')
|
||||
->setDescription('Convert timesheet dates between timezones, only made for updates from 0.7 to 0.8')
|
||||
->setHelp('This command should only be required if you imported data from Kimai v1')
|
||||
->addOption('first-id', 'f', InputArgument::OPTIONAL, 'The database ID which should be converted first')
|
||||
->addOption('last-id', 'l', InputArgument::OPTIONAL, 'The database ID which should be converted last')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|string $start
|
||||
* @param null|string $end
|
||||
* @return Timesheet[]
|
||||
*/
|
||||
protected function getTimesheets($start = null, $end = null)
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('t');
|
||||
|
||||
$qb->select('t');
|
||||
if (!empty($start)) {
|
||||
$qb->andWhere($qb->expr()->gte('t.id', $start));
|
||||
}
|
||||
if (!empty($end)) {
|
||||
$qb->andWhere($qb->expr()->lte('t.id', $end));
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $timesheet
|
||||
* @param \DateTimeZone $timeZone
|
||||
* @return Timesheet
|
||||
*/
|
||||
protected function convertTimesheet(Timesheet $timesheet, \DateTimeZone $timeZone): Timesheet
|
||||
{
|
||||
$oldTimezone = $timesheet->getTimezone();
|
||||
|
||||
if (null !== $timesheet->getBegin()) {
|
||||
$beginDate = clone $timesheet->getBegin();
|
||||
$beginDate->setTimezone($timeZone);
|
||||
$timesheet->setBegin($beginDate);
|
||||
}
|
||||
|
||||
if (null !== $timesheet->getEnd()) {
|
||||
$endDate = clone $timesheet->getEnd();
|
||||
$endDate->setTimezone($timeZone);
|
||||
$timesheet->setEnd($endDate);
|
||||
}
|
||||
|
||||
$timesheet->setTimezone($oldTimezone);
|
||||
|
||||
return $timesheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
Type::overrideType(Type::DATETIME, DateTimeType::class);
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$start = $input->getOption('first-id');
|
||||
$end = $input->getOption('last-id');
|
||||
|
||||
$result = $this->getTimesheets($start, $end);
|
||||
$amount = count($result);
|
||||
|
||||
$answer = $io->ask(sprintf('This will update %s timesheet records, continue (y/n) ? ', $amount));
|
||||
|
||||
if ('y' !== $answer) {
|
||||
$io->text('Aborting.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$utc = new \DateTimeZone('UTC');
|
||||
$i = 0;
|
||||
|
||||
/** @var Timesheet $timesheet */
|
||||
foreach ($result as $timesheet) {
|
||||
$timesheet = $this->convertTimesheet($timesheet, $utc);
|
||||
$this->repository->save($timesheet);
|
||||
|
||||
if (++$i % 80 === 0) {
|
||||
$io->writeln('. (' . $i . '/' . $amount . ')');
|
||||
} else {
|
||||
$io->write('.');
|
||||
}
|
||||
}
|
||||
|
||||
$io->writeln('. (' . $i . '/' . $amount . ')');
|
||||
$io->writeln('');
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ use Doctrine\Common\Persistence\ObjectManager;
|
||||
use Doctrine\DBAL\Configuration;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Doctrine\DBAL\Types\DateTimeType;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -132,6 +134,10 @@ class KimaiImporterCommand extends Command
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
// do not convert the times, Kimai 1 stored them already in UTC
|
||||
Type::overrideType(Type::DATETIME, DateTimeType::class);
|
||||
|
||||
// don't calculate rates ... this was done in Kimai 1
|
||||
$this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection());
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -20,16 +20,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*/
|
||||
class RunCodeSnifferCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var BashExecutor
|
||||
*/
|
||||
protected $executor;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $rootDir = '';
|
||||
|
||||
/**
|
||||
* @param BashExecutor $executor
|
||||
* @param string $projectDirectory
|
||||
*/
|
||||
public function __construct($projectDirectory)
|
||||
public function __construct(BashExecutor $executor, string $projectDirectory)
|
||||
{
|
||||
$this->executor = $executor;
|
||||
$this->rootDir = realpath($projectDirectory);
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -42,7 +48,7 @@ class RunCodeSnifferCommand extends Command
|
||||
$this
|
||||
->setName('kimai:phpcs')
|
||||
->setDescription('Run PHP_CodeSniffer to check for the projects coding style')
|
||||
->addOption('fix', null, InputOption::VALUE_NONE, 'Fix all found problems (risky: modifies your files)')
|
||||
->addOption('fix', null, InputOption::VALUE_NONE, 'Fix all found problems')
|
||||
->addOption('checkstyle', null, InputOption::VALUE_OPTIONAL, '')
|
||||
;
|
||||
}
|
||||
@@ -55,7 +61,6 @@ class RunCodeSnifferCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$filename = null;
|
||||
ob_start();
|
||||
|
||||
$args = [];
|
||||
if (!$input->getOption('fix')) {
|
||||
@@ -78,13 +83,11 @@ class RunCodeSnifferCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$exitCode = 0;
|
||||
passthru($this->rootDir . '/vendor/bin/php-cs-fixer fix ' . implode(' ', $args), $exitCode);
|
||||
$result = ob_get_clean();
|
||||
$result = $this->executor->execute('/vendor/bin/php-cs-fixer fix ' . implode(' ', $args));
|
||||
|
||||
$io->write($result);
|
||||
$io->write($result->getResult());
|
||||
|
||||
if ($exitCode > 0) {
|
||||
if ($result->getExitCode() > 0) {
|
||||
$io->error(
|
||||
'Found problems while checking your code styles' .
|
||||
(!empty($filename) ? '. Saved checkstyle data to: ' . $filename : '')
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
/**
|
||||
* Command used to run all integration tests.
|
||||
*/
|
||||
@@ -29,11 +27,10 @@ class RunIntegrationTestsCommand extends RunUnitTestsCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $directory
|
||||
* @return string
|
||||
*/
|
||||
protected function createPhpunitCmdLine($directory)
|
||||
protected function createPhpunitCmdLine()
|
||||
{
|
||||
return $this->rootDir . '/bin/phpunit --group integration ' . $directory;
|
||||
return '/bin/phpunit --group integration ' . $this->rootDir . '/tests';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,17 +19,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*/
|
||||
class RunUnitTestsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var BashExecutor
|
||||
*/
|
||||
protected $executor;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $rootDir;
|
||||
|
||||
/**
|
||||
* RunCodeSnifferCommand constructor.
|
||||
* @param BashExecutor $executor
|
||||
* @param string $projectDirectory
|
||||
*/
|
||||
public function __construct($projectDirectory)
|
||||
public function __construct(BashExecutor $executor, string $projectDirectory)
|
||||
{
|
||||
$this->executor = $executor;
|
||||
$this->rootDir = realpath($projectDirectory);
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -53,36 +58,24 @@ class RunUnitTestsCommand extends Command
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$this->executeTests($io, '/tests');
|
||||
$result = $this->executor->execute($this->createPhpunitCmdLine());
|
||||
|
||||
$io->write($result->getResult());
|
||||
|
||||
if ($result->getExitCode() > 0) {
|
||||
$io->error('Found problems while running tests');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$io->success('All tests were successful');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $directory
|
||||
* @return string
|
||||
*/
|
||||
protected function createPhpunitCmdLine($directory)
|
||||
protected function createPhpunitCmdLine()
|
||||
{
|
||||
return $this->rootDir . '/bin/phpunit --exclude-group integration ' . $directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $directory
|
||||
*/
|
||||
protected function executeTests(SymfonyStyle $io, $directory)
|
||||
{
|
||||
$directory = $this->rootDir . $directory;
|
||||
|
||||
$exitCode = 0;
|
||||
ob_start();
|
||||
passthru($this->createPhpunitCmdLine($directory), $exitCode);
|
||||
$result = ob_get_clean();
|
||||
|
||||
$io->write($result);
|
||||
|
||||
if ($exitCode > 0) {
|
||||
$io->error('Found problems while running tests at: ' . $directory);
|
||||
} else {
|
||||
$io->success('All tests performed good at: ' . $directory);
|
||||
}
|
||||
return '/bin/phpunit --exclude-group integration ' . $this->rootDir . '/tests';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ class AppExtension extends Extension implements PrependExtensionInterface
|
||||
*/
|
||||
protected function createTimesheetParameter(array $config, ContainerBuilder $container)
|
||||
{
|
||||
$container->setParameter('kimai.timesheet.rules', $config['rules']);
|
||||
$container->setParameter('kimai.timesheet.rates', $config['rates']);
|
||||
$container->setParameter('kimai.timesheet.rounding', $config['rounding']);
|
||||
$container->setParameter('kimai.timesheet.duration_only', $config['duration_only']);
|
||||
|
||||
@@ -113,28 +113,35 @@ class Configuration implements ConfigurationInterface
|
||||
->end()
|
||||
->arrayNode('active_entries')
|
||||
->addDefaultsIfNotSet()
|
||||
->children()
|
||||
->integerNode('soft_limit')
|
||||
->defaultValue(1)
|
||||
->validate()
|
||||
->ifTrue(function ($value) {
|
||||
return $value <= 0;
|
||||
})
|
||||
->thenInvalid('The soft_limit must be at least 1')
|
||||
->end()
|
||||
->children()
|
||||
->integerNode('soft_limit')
|
||||
->defaultValue(1)
|
||||
->validate()
|
||||
->ifTrue(function ($value) {
|
||||
return $value <= 0;
|
||||
})
|
||||
->thenInvalid('The soft_limit must be at least 1')
|
||||
->end()
|
||||
->integerNode('hard_limit')
|
||||
->defaultValue(1)
|
||||
->validate()
|
||||
->ifTrue(function ($value) {
|
||||
return $value <= 0;
|
||||
})
|
||||
->thenInvalid('The hard_limit must be at least 1')
|
||||
->end()
|
||||
->end()
|
||||
->integerNode('hard_limit')
|
||||
->defaultValue(1)
|
||||
->validate()
|
||||
->ifTrue(function ($value) {
|
||||
return $value <= 0;
|
||||
})
|
||||
->thenInvalid('The hard_limit must be at least 1')
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->arrayNode('rules')
|
||||
->addDefaultsIfNotSet()
|
||||
->children()
|
||||
->booleanNode('allow_future_times')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
|
||||
74
src/Doctrine/UTCDateTimeType.php
Normal file
74
src/Doctrine/UTCDateTimeType.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?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\Doctrine;
|
||||
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Types\ConversionException;
|
||||
use Doctrine\DBAL\Types\DateTimeType;
|
||||
|
||||
class UTCDateTimeType extends DateTimeType
|
||||
{
|
||||
/**
|
||||
* @var \DateTimeZone
|
||||
*/
|
||||
private static $utc;
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param AbstractPlatform $platform
|
||||
* @return mixed|string
|
||||
* @throws ConversionException
|
||||
*/
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform)
|
||||
{
|
||||
if ($value instanceof \DateTime) {
|
||||
$value->setTimezone(self::getUtc());
|
||||
}
|
||||
|
||||
return parent::convertToDatabaseValue($value, $platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeZone
|
||||
*/
|
||||
public static function getUtc()
|
||||
{
|
||||
return self::$utc ? self::$utc : self::$utc = new \DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param AbstractPlatform $platform
|
||||
* @return bool|\DateTime|false|mixed
|
||||
* @throws ConversionException
|
||||
*/
|
||||
public function convertToPHPValue($value, AbstractPlatform $platform)
|
||||
{
|
||||
if (null === $value || $value instanceof \DateTime) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$converted = \DateTime::createFromFormat(
|
||||
$platform->getDateTimeFormatString(),
|
||||
$value,
|
||||
self::getUtc()
|
||||
);
|
||||
|
||||
if (!$converted) {
|
||||
throw ConversionException::conversionFailedFormat(
|
||||
$value,
|
||||
$this->getName(),
|
||||
$platform->getDateTimeFormatString()
|
||||
);
|
||||
}
|
||||
|
||||
return $converted;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Timesheet entity.
|
||||
@@ -25,7 +24,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* )
|
||||
* @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository")
|
||||
* @ORM\HasLifecycleCallbacks()
|
||||
* @Assert\Callback("validate")
|
||||
* @App\Validator\Constraints\Timesheet
|
||||
*/
|
||||
class Timesheet
|
||||
{
|
||||
@@ -53,6 +52,18 @@ class Timesheet
|
||||
*/
|
||||
private $end;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
|
||||
*/
|
||||
private $timezone;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $localized = false;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
@@ -137,11 +148,34 @@ class Timesheet
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure begin and end date have the correct timezone.
|
||||
* This will be called once for each item after being loaded from the database.
|
||||
*/
|
||||
protected function localizeDates()
|
||||
{
|
||||
if ($this->localized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $this->begin) {
|
||||
$this->begin->setTimeZone(new \DateTimeZone($this->timezone));
|
||||
}
|
||||
|
||||
if (null !== $this->end) {
|
||||
$this->end->setTimeZone(new \DateTimeZone($this->timezone));
|
||||
}
|
||||
|
||||
$this->localized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getBegin()
|
||||
{
|
||||
$this->localizeDates();
|
||||
|
||||
return $this->begin;
|
||||
}
|
||||
|
||||
@@ -149,9 +183,10 @@ class Timesheet
|
||||
* @param \DateTime $begin
|
||||
* @return Timesheet
|
||||
*/
|
||||
public function setBegin($begin)
|
||||
public function setBegin(\DateTime $begin)
|
||||
{
|
||||
$this->begin = $begin;
|
||||
$this->timezone = $begin->getTimezone()->getName();
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -161,6 +196,8 @@ class Timesheet
|
||||
*/
|
||||
public function getEnd()
|
||||
{
|
||||
$this->localizeDates();
|
||||
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
@@ -168,21 +205,21 @@ class Timesheet
|
||||
* @param \DateTime $end
|
||||
* @return Timesheet
|
||||
*/
|
||||
public function setEnd($end)
|
||||
public function setEnd(?\DateTime $end)
|
||||
{
|
||||
$this->end = $end;
|
||||
|
||||
if (null === $end) {
|
||||
$this->duration = 0;
|
||||
$this->rate = 0;
|
||||
} else {
|
||||
$this->timezone = $end->getTimezone()->getName();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set duration
|
||||
*
|
||||
* @param int $duration
|
||||
* @return Timesheet
|
||||
*/
|
||||
@@ -194,8 +231,7 @@ class Timesheet
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duration
|
||||
* Do not rely on the results of this method for active records.
|
||||
* Do not rely on the results of this method for running records.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
@@ -205,8 +241,6 @@ class Timesheet
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user
|
||||
*
|
||||
* @param User $user
|
||||
* @return Timesheet
|
||||
*/
|
||||
@@ -218,8 +252,6 @@ class Timesheet
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user
|
||||
*
|
||||
* @return User
|
||||
*/
|
||||
public function getUser()
|
||||
@@ -228,8 +260,6 @@ class Timesheet
|
||||
}
|
||||
|
||||
/**
|
||||
* Set activity
|
||||
*
|
||||
* @param Activity $activity
|
||||
* @return Timesheet
|
||||
*/
|
||||
@@ -241,8 +271,6 @@ class Timesheet
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Activity
|
||||
*
|
||||
* @return Activity
|
||||
*/
|
||||
public function getActivity()
|
||||
@@ -373,74 +401,24 @@ class Timesheet
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExecutionContextInterface $context
|
||||
* @param $payload
|
||||
* @return string
|
||||
*/
|
||||
public function validate(ExecutionContextInterface $context, $payload)
|
||||
public function getTimezone(): string
|
||||
{
|
||||
if (null === ($activity = $this->getActivity())) {
|
||||
$context->buildViolation('A timesheet must have an activity.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
}
|
||||
return $this->timezone;
|
||||
}
|
||||
|
||||
if (null === ($project = $this->getProject())) {
|
||||
$context->buildViolation('A timesheet must have a project.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
}
|
||||
/**
|
||||
* BE WARNED: this method should NOT be used programmatically, there is very likely no reason for it!
|
||||
*
|
||||
* @deprecated since it was introduced, only meant for the initial migration. Will be removed with 1.0.
|
||||
* @param string $timezone
|
||||
* @return Timesheet
|
||||
*/
|
||||
public function setTimezone(string $timezone)
|
||||
{
|
||||
$this->timezone = $timezone;
|
||||
|
||||
if (null !== $activity && null !== $project) {
|
||||
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
|
||||
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->getEnd() && $activity->getVisible() === false) {
|
||||
$context->buildViolation('Cannot start a disabled activity.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->getEnd() && $project->getVisible() === false) {
|
||||
$context->buildViolation('Cannot start a disabled project.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->getEnd() && $project->getCustomer()->getVisible() === false) {
|
||||
$context->buildViolation('Cannot start a disabled customer.')
|
||||
->atPath('customer')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $this->getBegin()) {
|
||||
$context->buildViolation('You must submit a begin date.')
|
||||
->atPath('begin')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
} else {
|
||||
if (null !== $this->getBegin() && null !== $this->getEnd() && $this->getEnd()->getTimestamp() < $this->getBegin()->getTimestamp()) {
|
||||
$context->buildViolation('End date must not be earlier then start date.')
|
||||
->atPath('end')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (time() < $this->getBegin()->getTimestamp()) {
|
||||
$context->buildViolation('The begin date cannot be in the future.')
|
||||
->atPath('begin')
|
||||
->setTranslationDomain('validators')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
58
src/EventSubscriber/TimezoneSubscriber.php
Normal file
58
src/EventSubscriber/TimezoneSubscriber.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\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use KevinPapst\AdminLTEBundle\Event\ShowUserEvent;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
|
||||
class TimezoneSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/**
|
||||
* @var TokenStorageInterface
|
||||
*/
|
||||
protected $storage;
|
||||
|
||||
/**
|
||||
* @param TokenStorageInterface $tokenStorage
|
||||
*/
|
||||
public function __construct(TokenStorageInterface $tokenStorage)
|
||||
{
|
||||
$this->storage = $tokenStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['setTimezone', 100],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShowUserEvent $event
|
||||
*/
|
||||
public function setTimezone(GetResponseEvent $event)
|
||||
{
|
||||
if (null === $this->storage->getToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* @var $user User */
|
||||
$user = $this->storage->getToken()->getUser();
|
||||
$timezone = $user->getPreferenceValue('timezone', date_default_timezone_get());
|
||||
date_default_timezone_set($timezone);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Validator\Constraints\Range;
|
||||
@@ -91,6 +92,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
|
||||
->setEnabled($enableHourlyRate)
|
||||
->addConstraint(new Range(['min' => 0])),
|
||||
|
||||
(new UserPreference())
|
||||
->setName('timezone')
|
||||
->setValue(date_default_timezone_get())
|
||||
->setType(TimezoneType::class),
|
||||
|
||||
(new UserPreference())
|
||||
->setName('language')
|
||||
->setValue('en')
|
||||
|
||||
@@ -54,7 +54,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
*/
|
||||
protected function getFormattedDateTime(\DateTime $date)
|
||||
{
|
||||
return $this->dateExtension->dateShort($date) . ' ' . date('H:i', $date->getTimestamp());
|
||||
return $this->dateExtension->dateShort($date) . ' ' . $this->dateExtension->time($date);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,6 +103,7 @@ class TimesheetEditForm extends AbstractType
|
||||
} else {
|
||||
$builder->add('end', DateTimePickerType::class, [
|
||||
'label' => 'label.end',
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -129,26 +130,22 @@ class TimesheetEditForm extends AbstractType
|
||||
$projectOptions['group_by'] = null;
|
||||
}
|
||||
|
||||
if ($this->projects->countProject(true) > 1) {
|
||||
$projectOptions['placeholder'] = null;
|
||||
} else {
|
||||
if ($this->projects->countProject(true) <= 1) {
|
||||
$projectOptions['group_by'] = null;
|
||||
}
|
||||
|
||||
$builder
|
||||
->add(
|
||||
'project',
|
||||
ProjectType::class,
|
||||
array_merge($projectOptions, [
|
||||
'activity_enabled' => true,
|
||||
// documentation is for NelmioApiDocBundle
|
||||
'documentation' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Project ID',
|
||||
],
|
||||
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
|
||||
return $repo->builderForEntityType($project, $customer);
|
||||
},
|
||||
->add('project', ProjectType::class, array_merge($projectOptions, [
|
||||
'placeholder' => '',
|
||||
'activity_enabled' => true,
|
||||
// documentation is for NelmioApiDocBundle
|
||||
'documentation' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Project ID',
|
||||
],
|
||||
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
|
||||
return $repo->builderForEntityType($project, $customer);
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
@@ -162,6 +159,7 @@ class TimesheetEditForm extends AbstractType
|
||||
}
|
||||
|
||||
$event->getForm()->add('project', ProjectType::class, [
|
||||
'placeholder' => '',
|
||||
'activity_enabled' => true,
|
||||
'group_by' => null,
|
||||
'query_builder' => function (ProjectRepository $repo) use ($data, $project) {
|
||||
@@ -174,7 +172,7 @@ class TimesheetEditForm extends AbstractType
|
||||
$builder
|
||||
->add('activity', ActivityType::class, [
|
||||
// documentation is for NelmioApiDocBundle
|
||||
'placeholder' => null,
|
||||
'placeholder' => '',
|
||||
'documentation' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Activity ID',
|
||||
@@ -195,7 +193,7 @@ class TimesheetEditForm extends AbstractType
|
||||
}
|
||||
|
||||
$event->getForm()->add('activity', ActivityType::class, [
|
||||
'placeholder' => null,
|
||||
'placeholder' => '',
|
||||
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
|
||||
return $repo->builderForEntityType($activity, $data['project']);
|
||||
},
|
||||
|
||||
@@ -53,6 +53,15 @@ abstract class AbstractRenderer
|
||||
return $this->dateExtension->dateShort($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $date
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getFormattedTime(\DateTime $date)
|
||||
{
|
||||
return $this->dateExtension->time($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $amount
|
||||
* @param $currency
|
||||
|
||||
@@ -49,6 +49,12 @@ trait RendererTrait
|
||||
*/
|
||||
abstract protected function getFormattedDateTime(\DateTime $date);
|
||||
|
||||
/**
|
||||
* @param \DateTime $date
|
||||
* @return mixed
|
||||
*/
|
||||
abstract protected function getFormattedTime(\DateTime $date);
|
||||
|
||||
/**
|
||||
* @param $amount
|
||||
* @return mixed
|
||||
@@ -172,10 +178,10 @@ trait RendererTrait
|
||||
'entry.duration' => $timesheet->getDuration(),
|
||||
'entry.duration_minutes' => number_format($timesheet->getDuration() / 60),
|
||||
'entry.begin' => $this->getFormattedDateTime($begin),
|
||||
'entry.begin_time' => date('H:i', $begin->getTimestamp()),
|
||||
'entry.begin_time' => $this->getFormattedTime($begin),
|
||||
'entry.begin_timestamp' => $begin->getTimestamp(),
|
||||
'entry.end' => $this->getFormattedDateTime($end),
|
||||
'entry.end_time' => date('H:i', $end->getTimestamp()),
|
||||
'entry.end_time' => $this->getFormattedTime($end),
|
||||
'entry.end_timestamp' => $end->getTimestamp(),
|
||||
'entry.date' => $this->getFormattedDateTime($begin),
|
||||
'entry.user_id' => $user->getId(),
|
||||
|
||||
71
src/Migrations/Version20190201150324.php
Normal file
71
src/Migrations/Version20190201150324.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* Adds the timezone column to the timesheet table
|
||||
* See https://github.com/kevinpapst/kimai2/pull/372 for further information.
|
||||
*/
|
||||
final class Version20190201150324 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$platform = $this->getPlatform();
|
||||
|
||||
if (!in_array($platform, ['sqlite', 'mysql'])) {
|
||||
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
|
||||
}
|
||||
|
||||
$timesheet = $this->getTableName('timesheet');
|
||||
$timezone = date_default_timezone_get();
|
||||
|
||||
if ($platform === 'sqlite') {
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD COLUMN timezone VARCHAR(64) DEFAULT NULL');
|
||||
} else {
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD timezone VARCHAR(64) NOT NULL');
|
||||
}
|
||||
|
||||
$this->addSql('UPDATE ' . $timesheet . ' SET timezone = "' . $timezone . '"');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$platform = $this->getPlatform();
|
||||
|
||||
if (!in_array($platform, ['sqlite', 'mysql'])) {
|
||||
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
|
||||
}
|
||||
|
||||
$timesheet = $this->getTableName('timesheet');
|
||||
|
||||
if ($platform === 'sqlite') {
|
||||
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
|
||||
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
|
||||
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate, exported FROM ' . $timesheet);
|
||||
$this->addSql('DROP TABLE ' . $timesheet);
|
||||
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL --(DC2Type:datetime)
|
||||
, end_time DATETIME DEFAULT NULL --(DC2Type:datetime)
|
||||
, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, exported BOOLEAN NOT NULL DEFAULT false)');
|
||||
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate, exported) SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate, exported FROM __temp__' . $timesheet);
|
||||
$this->addSql('DROP TABLE __temp__' . $timesheet);
|
||||
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
|
||||
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
|
||||
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
|
||||
} else {
|
||||
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP timezone');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,18 @@ class TimesheetRepository extends AbstractRepository
|
||||
public const STATS_QUERY_ACTIVE = 'active';
|
||||
public const STATS_QUERY_MONTHLY = 'monthly';
|
||||
|
||||
/**
|
||||
* @param Timesheet $timesheet
|
||||
* @throws \Doctrine\ORM\ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
*/
|
||||
public function save(Timesheet $timesheet)
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->persist($timesheet);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $entry
|
||||
* @return bool
|
||||
|
||||
@@ -40,6 +40,7 @@ class DateExtensions extends \Twig_Extension
|
||||
new TwigFilter('month_name', [$this, 'monthName']),
|
||||
new TwigFilter('date_short', [$this, 'dateShort']),
|
||||
new TwigFilter('date_time', [$this, 'dateTime']),
|
||||
new TwigFilter('time', [$this, 'time']),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -65,6 +66,15 @@ class DateExtensions extends \Twig_Extension
|
||||
return date_format($date, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DateTime $date
|
||||
* @return string
|
||||
*/
|
||||
public function time(DateTime $date)
|
||||
{
|
||||
return date_format($date, 'H:i');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $date
|
||||
* @return string
|
||||
|
||||
@@ -86,7 +86,7 @@ class Extensions extends \Twig_Extension
|
||||
'user' => 'fas fa-user',
|
||||
'visibility' => 'far fa-eye',
|
||||
'settings' => 'fas fa-wrench',
|
||||
'export' => 'fas fa-database',
|
||||
'export' => 'fas fa-file-export',
|
||||
'pdf' => 'fas fa-file-pdf',
|
||||
'csv' => 'fas fa-table',
|
||||
'ods' => 'fas fa-table',
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace App\Utils;
|
||||
class Markdown
|
||||
{
|
||||
/**
|
||||
* @var MarkdownParser
|
||||
* @var ParsedownExtension
|
||||
*/
|
||||
private $parser;
|
||||
|
||||
|
||||
49
src/Validator/Constraints/Timesheet.php
Normal file
49
src/Validator/Constraints/Timesheet.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?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 Doctrine\Common\Annotations\Annotation\Target;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @Annotation
|
||||
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
|
||||
*/
|
||||
class Timesheet extends Constraint
|
||||
{
|
||||
public const MISSING_BEGIN_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d81';
|
||||
public const END_BEFORE_BEGIN_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d82';
|
||||
public const BEGIN_IN_FUTURE_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d83';
|
||||
public const MISSING_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d84';
|
||||
public const MISSING_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d85';
|
||||
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d86';
|
||||
public const DISABLED_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d87';
|
||||
public const DISABLED_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d88';
|
||||
public const DISABLED_CUSTOMER_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d89';
|
||||
|
||||
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.',
|
||||
];
|
||||
|
||||
public $message = 'This timesheet has invalid settings.';
|
||||
|
||||
public function getTargets()
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
157
src/Validator/Constraints/TimesheetValidator.php
Normal file
157
src/Validator/Constraints/TimesheetValidator.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?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;
|
||||
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
|
||||
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
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $rules = [];
|
||||
|
||||
/**
|
||||
* @param array $ruleset
|
||||
*/
|
||||
public function __construct(array $ruleset)
|
||||
{
|
||||
$this->rules = $ruleset;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @param null $default
|
||||
* @return mixed|null
|
||||
*/
|
||||
protected function getRule(string $key, $default = null)
|
||||
{
|
||||
if (!isset($this->rules[$key])) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->rules[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $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 Timesheet)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateBeginAndEnd($value, $this->context);
|
||||
$this->validateActivityAndProject($value, $this->context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $timesheet
|
||||
* @param ExecutionContextInterface $context
|
||||
*/
|
||||
protected function validateBeginAndEnd(Timesheet $timesheet, ExecutionContextInterface $context)
|
||||
{
|
||||
if (null === $timesheet->getBegin()) {
|
||||
$context->buildViolation('You must submit a begin date.')
|
||||
->atPath('begin')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::MISSING_BEGIN_ERROR)
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $timesheet->getBegin() && null !== $timesheet->getEnd() && $timesheet->getEnd()->getTimestamp() < $timesheet->getBegin()->getTimestamp()) {
|
||||
$context->buildViolation('End date must not be earlier then start date.')
|
||||
->atPath('end')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::END_BEFORE_BEGIN_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (false === $this->getRule('allow_future_times', true) && time() < $timesheet->getBegin()->getTimestamp()) {
|
||||
$context->buildViolation('The begin date cannot be in the future.')
|
||||
->atPath('begin')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Timesheet $timesheet
|
||||
* @param ExecutionContextInterface $context
|
||||
*/
|
||||
protected function validateActivityAndProject(Timesheet $timesheet, ExecutionContextInterface $context)
|
||||
{
|
||||
if (null === ($activity = $timesheet->getActivity())) {
|
||||
$context->buildViolation('A timesheet must have an activity.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === ($project = $timesheet->getProject())) {
|
||||
$context->buildViolation('A timesheet must have a project.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $activity || null === $project) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
|
||||
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::ACTIVITY_PROJECT_MISMATCH_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $timesheet->getEnd() && $activity->getVisible() === false) {
|
||||
$context->buildViolation('Cannot start a disabled activity.')
|
||||
->atPath('activity')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::DISABLED_ACTIVITY_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $timesheet->getEnd() && $project->getVisible() === false) {
|
||||
$context->buildViolation('Cannot start a disabled project.')
|
||||
->atPath('project')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::DISABLED_PROJECT_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (null === $timesheet->getEnd() && $project->getCustomer()->getVisible() === false) {
|
||||
$context->buildViolation('Cannot start a disabled customer.')
|
||||
->atPath('customer')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class ActivityVoter extends AbstractVoter
|
||||
*/
|
||||
protected function supports($attribute, $subject)
|
||||
{
|
||||
if (!$subject instanceof Activity) {
|
||||
if (!($subject instanceof Activity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,6 @@ class ActivityVoter extends AbstractVoter
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subject instanceof Activity) {
|
||||
return $this->hasRolePermission($user, $attribute . '_activity');
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->hasRolePermission($user, $attribute . '_activity');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class CustomerVoter extends AbstractVoter
|
||||
*/
|
||||
protected function supports($attribute, $subject)
|
||||
{
|
||||
if (!$subject instanceof Customer) {
|
||||
if (!($subject instanceof Customer)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,6 @@ class CustomerVoter extends AbstractVoter
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subject instanceof Customer) {
|
||||
return $this->hasRolePermission($user, $attribute . '_customer');
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->hasRolePermission($user, $attribute . '_customer');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class InvoiceTemplateVoter extends AbstractVoter
|
||||
*/
|
||||
protected function supports($attribute, $subject)
|
||||
{
|
||||
if (!$subject instanceof InvoiceTemplate) {
|
||||
if (!($subject instanceof InvoiceTemplate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,6 @@ class InvoiceTemplateVoter extends AbstractVoter
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subject instanceof InvoiceTemplate) {
|
||||
return $this->hasRolePermission($user, $attribute . '_invoice_template');
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->hasRolePermission($user, $attribute . '_invoice_template');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class ProjectVoter extends AbstractVoter
|
||||
*/
|
||||
protected function supports($attribute, $subject)
|
||||
{
|
||||
if (!$subject instanceof Project) {
|
||||
if (!($subject instanceof Project)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,6 @@ class ProjectVoter extends AbstractVoter
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subject instanceof Project) {
|
||||
return $this->hasRolePermission($user, $attribute . '_project');
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->hasRolePermission($user, $attribute . '_project');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Voter;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
@@ -49,7 +48,7 @@ class TimesheetVoter extends AbstractVoter
|
||||
*/
|
||||
protected function supports($attribute, $subject)
|
||||
{
|
||||
if (!$subject instanceof Timesheet) {
|
||||
if (!($subject instanceof Timesheet)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -62,7 +61,7 @@ class TimesheetVoter extends AbstractVoter
|
||||
|
||||
/**
|
||||
* @param string $attribute
|
||||
* @param Timesheet|Activity $subject
|
||||
* @param Timesheet $subject
|
||||
* @param TokenInterface $token
|
||||
* @return bool
|
||||
*/
|
||||
@@ -74,10 +73,6 @@ class TimesheetVoter extends AbstractVoter
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!($subject instanceof Timesheet)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permission = '';
|
||||
|
||||
switch ($attribute) {
|
||||
|
||||
@@ -44,11 +44,11 @@ class UserVoter extends AbstractVoter
|
||||
*/
|
||||
protected function supports($attribute, $subject)
|
||||
{
|
||||
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
|
||||
if (!($subject instanceof User)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!($subject instanceof User)) {
|
||||
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,12 +52,12 @@
|
||||
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
|
||||
|
||||
{% if not duration_only %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|date("H:i") }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|time }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if entry.end %}
|
||||
{% if not duration_only %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|time }}</td>
|
||||
{% endif %}
|
||||
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
|
||||
{% else %}
|
||||
|
||||
@@ -56,6 +56,13 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if is_granted('view_export') %}
|
||||
<li class="visible-xs-inline-block">
|
||||
<a href="{{ path('export') }}" class="ddt-large">
|
||||
<i class="{{ 'export'|icon }} fa-2x"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% block navbar_extensions %}{% endblock %}
|
||||
{{ render(controller('App\\Controller\\TimesheetController::activeEntriesAction')) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -123,8 +123,8 @@
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{ entry.begin|date_short }}</td>
|
||||
<td class="text-nowrap">{{ entry.begin|date("H:i") }}</td>
|
||||
<td class="text-nowrap">{{ entry.end|date("H:i") }}</td>
|
||||
<td class="text-nowrap">{{ entry.begin|time }}</td>
|
||||
<td class="text-nowrap">{{ entry.end|time }}</td>
|
||||
<td>{{ widgets.username(entry.user) }}</td>
|
||||
<td>{{ entry.project.customer.name }}</td>
|
||||
<td>{{ entry.project.name }}</td>
|
||||
|
||||
@@ -56,12 +56,12 @@
|
||||
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
|
||||
|
||||
{% if not duration_only %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|date("H:i") }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'starttime') }}">{{ entry.begin|time }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if entry.end %}
|
||||
{% if not duration_only %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|time }}</td>
|
||||
{% endif %}
|
||||
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
|
||||
{% if is_granted('view_rate', entry) %}
|
||||
|
||||
97
tests/Command/RunCodeSnifferCommandTest.php
Normal file
97
tests/Command/RunCodeSnifferCommandTest.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?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\Command;
|
||||
|
||||
use App\Command\BashResult;
|
||||
use App\Command\RunCodeSnifferCommand;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\Command\RunCodeSnifferCommand
|
||||
* @group integration
|
||||
*/
|
||||
class RunCodeSnifferCommandTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* @var Application
|
||||
*/
|
||||
protected $application;
|
||||
/**
|
||||
* @var TestBashExecutor
|
||||
*/
|
||||
protected $executor;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $directory;
|
||||
|
||||
protected function setUp()
|
||||
{
|
||||
$kernel = self::bootKernel();
|
||||
$this->application = new Application($kernel);
|
||||
$this->directory = realpath(__DIR__ . '/../../');
|
||||
$this->executor = new TestBashExecutor($this->directory);
|
||||
|
||||
$this->application->add(new RunCodeSnifferCommand($this->executor, $this->directory));
|
||||
}
|
||||
|
||||
public function testSuccessCommandNoOptions()
|
||||
{
|
||||
$command = $this->assertSuccessCommand([]);
|
||||
$this->assertStringStartsWith('/vendor/bin/php-cs-fixer fix --dry-run --verbose --show-progress=none --format=txt', $command);
|
||||
}
|
||||
|
||||
public function testSuccessCommandFix()
|
||||
{
|
||||
$command = $this->assertSuccessCommand(['--fix' => true]);
|
||||
$this->assertStringStartsWith('/vendor/bin/php-cs-fixer fix', $command);
|
||||
}
|
||||
|
||||
public function testSuccessCommandCheckstyle()
|
||||
{
|
||||
$command = $this->assertSuccessCommand(['--checkstyle' => 'phpcs.txt']);
|
||||
$this->assertStringStartsWith('/vendor/bin/php-cs-fixer fix --dry-run --verbose --show-progress=none > ', $command);
|
||||
$this->assertStringEndsWith('phpcs.txt', $command);
|
||||
}
|
||||
|
||||
protected function assertSuccessCommand(array $options)
|
||||
{
|
||||
$result = new BashResult(0, 'FooBar');
|
||||
$this->executor->setResult($result);
|
||||
|
||||
$command = $this->application->find('kimai:phpcs');
|
||||
$commandTester = new CommandTester($command);
|
||||
$inputs = array_merge(['command' => $command->getName()], $options);
|
||||
$commandTester->execute($inputs);
|
||||
|
||||
$output = $commandTester->getDisplay();
|
||||
$this->assertContains('FooBar', $output);
|
||||
$this->assertContains('[OK] All source files have proper code styles', $output);
|
||||
|
||||
return $this->executor->getCommand();
|
||||
}
|
||||
|
||||
public function testFailureCommand()
|
||||
{
|
||||
$result = new BashResult(1, 'BarFoo');
|
||||
$this->executor->setResult($result);
|
||||
|
||||
$command = $this->application->find('kimai:phpcs');
|
||||
$commandTester = new CommandTester($command);
|
||||
$inputs = array_merge(['command' => $command->getName()], ['--fix' => true]);
|
||||
$commandTester->execute($inputs);
|
||||
|
||||
$output = $commandTester->getDisplay();
|
||||
$this->assertContains('BarFoo', $output);
|
||||
$this->assertContains('[ERROR] Found problems while checking your code styles', $output);
|
||||
}
|
||||
}
|
||||
81
tests/Command/RunIntegrationTestsCommandTest.php
Normal file
81
tests/Command/RunIntegrationTestsCommandTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?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\Command;
|
||||
|
||||
use App\Command\BashResult;
|
||||
use App\Command\RunIntegrationTestsCommand;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\Command\RunIntegrationTestsCommand
|
||||
* @group integration
|
||||
*/
|
||||
class RunIntegrationTestsCommandTest extends RunUnitTestsCommandTest
|
||||
{
|
||||
/**
|
||||
* @var Application
|
||||
*/
|
||||
protected $application;
|
||||
/**
|
||||
* @var TestBashExecutor
|
||||
*/
|
||||
protected $executor;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $directory;
|
||||
|
||||
protected function setUp()
|
||||
{
|
||||
$kernel = self::bootKernel();
|
||||
$this->application = new Application($kernel);
|
||||
$this->directory = realpath(__DIR__ . '/../../');
|
||||
$this->executor = new TestBashExecutor($this->directory);
|
||||
|
||||
$this->application->add(new RunIntegrationTestsCommand($this->executor, $this->directory));
|
||||
}
|
||||
|
||||
public function testSuccessCommand()
|
||||
{
|
||||
$result = new BashResult(0, 'FooBar');
|
||||
$this->executor->setResult($result);
|
||||
|
||||
$command = $this->application->find('kimai:test-integration');
|
||||
$commandTester = new CommandTester($command);
|
||||
$inputs = array_merge(['command' => $command->getName()], []);
|
||||
$commandTester->execute($inputs);
|
||||
|
||||
$output = $commandTester->getDisplay();
|
||||
$this->assertContains('FooBar', $output);
|
||||
$this->assertContains('[OK] All tests were successful', $output);
|
||||
|
||||
$this->assertStringStartsWith('/bin/phpunit --group integration', $this->executor->getCommand());
|
||||
$this->assertContains($this->directory, $this->executor->getCommand());
|
||||
}
|
||||
|
||||
public function testFailureCommand()
|
||||
{
|
||||
$result = new BashResult(1, 'BarFoo');
|
||||
$this->executor->setResult($result);
|
||||
|
||||
$command = $this->application->find('kimai:test-integration');
|
||||
$commandTester = new CommandTester($command);
|
||||
$inputs = array_merge(['command' => $command->getName()], []);
|
||||
$commandTester->execute($inputs);
|
||||
|
||||
$output = $commandTester->getDisplay();
|
||||
$this->assertContains('BarFoo', $output);
|
||||
$this->assertContains('[ERROR] Found problems while running tests', $output);
|
||||
|
||||
$this->assertStringStartsWith('/bin/phpunit --group integration', $this->executor->getCommand());
|
||||
$this->assertContains($this->directory, $this->executor->getCommand());
|
||||
}
|
||||
}
|
||||
82
tests/Command/RunUnitTestsCommandTest.php
Normal file
82
tests/Command/RunUnitTestsCommandTest.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\Tests\Command;
|
||||
|
||||
use App\Command\BashResult;
|
||||
use App\Command\RunUnitTestsCommand;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \App\Command\RunUnitTestsCommand
|
||||
* @group integration
|
||||
*/
|
||||
class RunUnitTestsCommandTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* @var Application
|
||||
*/
|
||||
protected $application;
|
||||
/**
|
||||
* @var TestBashExecutor
|
||||
*/
|
||||
protected $executor;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $directory;
|
||||
|
||||
protected function setUp()
|
||||
{
|
||||
$kernel = self::bootKernel();
|
||||
$this->application = new Application($kernel);
|
||||
$this->directory = realpath(__DIR__ . '/../../');
|
||||
$this->executor = new TestBashExecutor($this->directory);
|
||||
|
||||
$this->application->add(new RunUnitTestsCommand($this->executor, $this->directory));
|
||||
}
|
||||
|
||||
public function testSuccessCommand()
|
||||
{
|
||||
$result = new BashResult(0, 'FooBar');
|
||||
$this->executor->setResult($result);
|
||||
|
||||
$command = $this->application->find('kimai:test-unit');
|
||||
$commandTester = new CommandTester($command);
|
||||
$inputs = array_merge(['command' => $command->getName()], []);
|
||||
$commandTester->execute($inputs);
|
||||
|
||||
$output = $commandTester->getDisplay();
|
||||
$this->assertContains('FooBar', $output);
|
||||
$this->assertContains('[OK] All tests were successful', $output);
|
||||
|
||||
$this->assertStringStartsWith('/bin/phpunit --exclude-group integration', $this->executor->getCommand());
|
||||
$this->assertContains($this->directory, $this->executor->getCommand());
|
||||
}
|
||||
|
||||
public function testFailureCommand()
|
||||
{
|
||||
$result = new BashResult(1, 'BarFoo');
|
||||
$this->executor->setResult($result);
|
||||
|
||||
$command = $this->application->find('kimai:test-unit');
|
||||
$commandTester = new CommandTester($command);
|
||||
$inputs = array_merge(['command' => $command->getName()], []);
|
||||
$commandTester->execute($inputs);
|
||||
|
||||
$output = $commandTester->getDisplay();
|
||||
$this->assertContains('BarFoo', $output);
|
||||
$this->assertContains('[ERROR] Found problems while running tests', $output);
|
||||
|
||||
$this->assertStringStartsWith('/bin/phpunit --exclude-group integration', $this->executor->getCommand());
|
||||
$this->assertContains($this->directory, $this->executor->getCommand());
|
||||
}
|
||||
}
|
||||
55
tests/Command/TestBashExecutor.php
Normal file
55
tests/Command/TestBashExecutor.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\BashExecutor;
|
||||
use App\Command\BashResult;
|
||||
|
||||
class TestBashExecutor extends BashExecutor
|
||||
{
|
||||
/**
|
||||
* @var BashResult
|
||||
*/
|
||||
protected $result;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $command;
|
||||
|
||||
/**
|
||||
* @param BashResult $result
|
||||
* @return $this
|
||||
*/
|
||||
public function setResult(BashResult $result)
|
||||
{
|
||||
$this->result = $result;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getCommand(): string
|
||||
{
|
||||
return $this->command;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $command
|
||||
* @return BashResult
|
||||
*/
|
||||
public function execute(string $command)
|
||||
{
|
||||
$this->command = $command;
|
||||
|
||||
return $this->result;
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,7 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
'user_preferences_form' => [
|
||||
'preferences' => [
|
||||
['name' => UserPreference::HOURLY_RATE, 'value' => 37.5],
|
||||
// ['name' => 'timezone', 'value' => 'America/Creston'],
|
||||
['name' => 'timezone', 'value' => 'America/Creston'],
|
||||
['name' => 'language', 'value' => 'ar'],
|
||||
['name' => UserPreference::SKIN, 'value' => 'blue'],
|
||||
['name' => 'theme.fixed_layout', 'value' => false],
|
||||
@@ -280,7 +280,7 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$user = $this->getUserByName($em, $username);
|
||||
|
||||
$this->assertEquals($hourlyRate, $user->getPreferenceValue(UserPreference::HOURLY_RATE));
|
||||
//$this->assertEquals('', $user->getPreferenceValue('America/Creston'));
|
||||
$this->assertEquals('', $user->getPreferenceValue('America/Creston'));
|
||||
$this->assertEquals('ar', $user->getPreferenceValue('language'));
|
||||
$this->assertEquals('blue', $user->getPreferenceValue(UserPreference::SKIN));
|
||||
$this->assertEquals(false, $user->getPreferenceValue('theme.fixed_layout'));
|
||||
|
||||
@@ -116,6 +116,11 @@ class TimesheetControllerTest extends ControllerBaseTest
|
||||
$client->submit($form, [
|
||||
'timesheet_edit_form' => [
|
||||
'description' => 'Testing is fun!',
|
||||
// begin is always pre-filled with the current datetime
|
||||
// 'begin' => null,
|
||||
// end must be allowed to be null, to start a record
|
||||
// there was a bug with end begin required, so we manually set this field to be empty
|
||||
'end' => null,
|
||||
'project' => 1,
|
||||
'activity' => 1,
|
||||
]
|
||||
|
||||
105
tests/Doctrine/UTCDateTimeTypeTest.php
Normal file
105
tests/Doctrine/UTCDateTimeTypeTest.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?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\Doctrine;
|
||||
|
||||
use App\Doctrine\UTCDateTimeType;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Doctrine\UTCDateTimeType
|
||||
*/
|
||||
class UTCDateTimeTypeTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* @var AbstractPlatform
|
||||
*/
|
||||
private $platform;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp()
|
||||
{
|
||||
$kernel = self::bootKernel();
|
||||
|
||||
$registry = $kernel->getContainer()->get('doctrine');
|
||||
/** @var \Doctrine\DBAL\Connection $connection */
|
||||
$connection = $registry->getConnection();
|
||||
$this->platform = $connection->getDatabasePlatform();
|
||||
}
|
||||
|
||||
public function testGetUtc()
|
||||
{
|
||||
Type::overrideType(Type::DATETIME, UTCDateTimeType::class);
|
||||
/** @var UTCDateTimeType $type */
|
||||
$type = Type::getType(Type::DATETIME);
|
||||
|
||||
$this->assertInstanceOf(UTCDateTimeType::class, $type);
|
||||
$utc = $type::getUtc();
|
||||
$this->assertSame($utc, $type::getUtc());
|
||||
$this->assertEquals('UTC', $type::getUtc()->getName());
|
||||
}
|
||||
|
||||
public function testConvertToDatabaseValue()
|
||||
{
|
||||
Type::overrideType(Type::DATETIME, UTCDateTimeType::class);
|
||||
/** @var UTCDateTimeType $type */
|
||||
$type = Type::getType(Type::DATETIME);
|
||||
|
||||
$result = $type->convertToDatabaseValue(null, $this->platform);
|
||||
$this->assertNull($result);
|
||||
|
||||
$berlinTz = new \DateTimeZone('Europe/Berlin');
|
||||
$date = new \DateTime('2019-01-17 13:30:00');
|
||||
$date->setTimezone($berlinTz);
|
||||
|
||||
$this->assertEquals('Europe/Berlin', $date->getTimezone()->getName());
|
||||
|
||||
$expected = clone $date;
|
||||
$expected->setTimezone($type::getUtc());
|
||||
$bla = $expected->format($this->platform->getDateTimeFormatString());
|
||||
|
||||
/** @var \DateTime $result */
|
||||
$result = $type->convertToDatabaseValue($date, $this->platform);
|
||||
|
||||
$this->assertEquals($bla, $result);
|
||||
}
|
||||
|
||||
public function testConvertToPHPValue()
|
||||
{
|
||||
Type::overrideType(Type::DATETIME, UTCDateTimeType::class);
|
||||
/** @var UTCDateTimeType $type */
|
||||
$type = Type::getType(Type::DATETIME);
|
||||
|
||||
$result = $type->convertToPHPValue(null, $this->platform);
|
||||
$this->assertNull($result);
|
||||
|
||||
$result = $type->convertToPHPValue('2019-01-17 13:30:00', $this->platform);
|
||||
$this->assertInstanceOf(\DateTime::class, $result);
|
||||
$this->assertEquals('UTC', $result->getTimezone()->getName());
|
||||
|
||||
$result = $result->format($this->platform->getDateTimeFormatString());
|
||||
$this->assertEquals('2019-01-17 13:30:00', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Doctrine\DBAL\Types\ConversionException
|
||||
*/
|
||||
public function testConvertToPHPValueWithInvalidValue()
|
||||
{
|
||||
Type::overrideType(Type::DATETIME, UTCDateTimeType::class);
|
||||
/** @var UTCDateTimeType $type */
|
||||
$type = Type::getType(Type::DATETIME);
|
||||
|
||||
$type->convertToPHPValue('201xx01-17 13:30:00', $this->platform);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ abstract class AbstractCalculatorTest extends TestCase
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setDescription('timesheet description')
|
||||
->setBegin(new \DateTime())
|
||||
->setDuration(3600)
|
||||
->setRate(293.27)
|
||||
->setUser(new User())
|
||||
|
||||
@@ -44,6 +44,15 @@ class DebugRenderer implements RendererInterface
|
||||
return $date->format('d.m.Y');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $date
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getFormattedTime(\DateTime $date)
|
||||
{
|
||||
return $date->format('H:i');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $amount
|
||||
* @return mixed
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
@@ -93,4 +95,28 @@ class TimesheetRepositoryTest extends AbstractRepositoryTest
|
||||
$this->assertTrue($result);
|
||||
$this->assertInstanceOf(\DateTime::class, $timesheet->getEnd());
|
||||
}
|
||||
|
||||
public function testSave()
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$activityRepository = $em->getRepository(Activity::class);
|
||||
$activity = $activityRepository->find(1);
|
||||
$projectRepository = $em->getRepository(Project::class);
|
||||
$project = $projectRepository->find(1);
|
||||
|
||||
$user = $this->getUserByRole($em, User::ROLE_USER);
|
||||
$repository = $em->getRepository(Timesheet::class);
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setBegin(new \DateTime())
|
||||
->setEnd(new \DateTime())
|
||||
->setDescription('foo')
|
||||
->setUser($user)
|
||||
->setActivity($activity)
|
||||
->setProject($project);
|
||||
|
||||
$this->assertNull($timesheet->getId());
|
||||
$repository->save($timesheet);
|
||||
$this->assertEquals(1, $timesheet->getId());
|
||||
}
|
||||
}
|
||||
|
||||
57
tests/Security/UserCheckerTest.php
Normal file
57
tests/Security/UserCheckerTest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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\Model;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Security\UserChecker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Security\UserChecker
|
||||
*/
|
||||
class UserCheckerTest extends TestCase
|
||||
{
|
||||
public function testCheckPreAuth()
|
||||
{
|
||||
$sut = new UserChecker();
|
||||
$user = new User();
|
||||
|
||||
try {
|
||||
$sut->checkPreAuth($user);
|
||||
} catch (\Exception $ex) {
|
||||
$this->fail('UserChecker should not throw exception in checkPreAuth()');
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Symfony\Component\Security\Core\Exception\LockedException
|
||||
*/
|
||||
public function testDisabledCannotLogin()
|
||||
{
|
||||
$sut = new UserChecker();
|
||||
$user = new User();
|
||||
$user->setEnabled(false);
|
||||
|
||||
$sut->checkPostAuth($user);
|
||||
}
|
||||
|
||||
public function testCheckPostAuth()
|
||||
{
|
||||
$sut = new UserChecker();
|
||||
|
||||
$mock = $this->getMockBuilder(UserInterface::class)->setMethods(['isEnabled'])->getMockForAbstractClass();
|
||||
$mock->expects($this->never())->method('isEnabled')->willReturn(false);
|
||||
|
||||
$sut->checkPostAuth($mock);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -163,10 +163,13 @@ class RateCalculatorTest extends TestCase
|
||||
*/
|
||||
public function testCalculateWithRules($rules, $expectedFactor)
|
||||
{
|
||||
$seconds = 41837;
|
||||
$seconds = 31837;
|
||||
|
||||
$end = new \DateTime();
|
||||
$end->setTimezone(new \DateTimeZone('UTC'));
|
||||
$end->setTime(12, 0, 0);
|
||||
$start = clone $end;
|
||||
$start->setTimezone(new \DateTimeZone('UTC'));
|
||||
$start->setTimestamp($end->getTimestamp() - $seconds);
|
||||
|
||||
$record = new Timesheet();
|
||||
@@ -191,6 +194,7 @@ class RateCalculatorTest extends TestCase
|
||||
public function getRuleDefinitions()
|
||||
{
|
||||
$start = new \DateTime();
|
||||
$start->setTimezone(new \DateTimeZone('UTC'));
|
||||
$start->setTime(12, 0, 0);
|
||||
$day = $start->format('l');
|
||||
|
||||
@@ -219,7 +223,7 @@ class RateCalculatorTest extends TestCase
|
||||
'factor' => 2.0
|
||||
],
|
||||
'weekdays' => [
|
||||
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
|
||||
'days' => ['MonDay', 'tUEsdAy', 'WEdnesday', 'THursday', 'friDay', 'SATURday', 'sunDAY'],
|
||||
'factor' => 1.5
|
||||
],
|
||||
],
|
||||
|
||||
@@ -40,7 +40,7 @@ class DateExtensionsTest extends TestCase
|
||||
|
||||
public function testGetFilters()
|
||||
{
|
||||
$filters = ['month_name', 'date_short', 'date_time'];
|
||||
$filters = ['month_name', 'date_short', 'date_time', 'time'];
|
||||
$sut = $this->getSut('de', []);
|
||||
$twigFilters = $sut->getFilters();
|
||||
$this->assertCount(count($filters), $twigFilters);
|
||||
@@ -119,4 +119,13 @@ class DateExtensionsTest extends TestCase
|
||||
[new \DateTime('2016-12-23'), 'month.12'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testTime()
|
||||
{
|
||||
$time = new \DateTime('2016-06-23');
|
||||
$time->setTime(17, 53, 23);
|
||||
|
||||
$sut = $this->getSut('en', []);
|
||||
$this->assertEquals('17:53', $sut->time($time));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,36 @@ class MarkdownTest extends TestCase
|
||||
$sut = new Markdown();
|
||||
$this->assertEquals('<p><em>test</em></p>', $sut->toHtml('*test*'));
|
||||
$this->assertEquals('<h1 id="foobar">foobar</h1>', $sut->toHtml('# foobar'));
|
||||
$html = <<<'EOT'
|
||||
<p>foo bar</p>
|
||||
<ul>
|
||||
<li>sdfasdfasdf</li>
|
||||
<li>asdfasdfasdf</li>
|
||||
</ul>
|
||||
<h1 id="test">test</h1>
|
||||
<p>asdfasdfa</p>
|
||||
<pre><code>ssdfsdf</code></pre>
|
||||
<p>sdfsdf <a href="#test-1">asdfasdf</a> asdfasdf</p>
|
||||
<h1 id="test-1">test</h1>
|
||||
<p>aasdfasdf</p>
|
||||
EOT;
|
||||
|
||||
$markdown = <<<EOT
|
||||
foo bar
|
||||
|
||||
- sdfasdfasdf
|
||||
- asdfasdfasdf
|
||||
|
||||
# test
|
||||
asdfasdfa
|
||||
|
||||
ssdfsdf
|
||||
|
||||
sdfsdf [asdfasdf](#test-1) asdfasdf
|
||||
|
||||
# test
|
||||
aasdfasdf
|
||||
EOT;
|
||||
$this->assertEquals($html, $sut->toHtml($markdown));
|
||||
}
|
||||
}
|
||||
|
||||
159
tests/Validator/Constraints/TimesheetValidatorTest.php
Normal file
159
tests/Validator/Constraints/TimesheetValidatorTest.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?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\Validator\Constraints\Timesheet as TimesheetConstraint;
|
||||
use App\Validator\Constraints\TimesheetValidator;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Validator\Constraints\TimesheetValidator
|
||||
*/
|
||||
class TimesheetValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
protected function createValidator()
|
||||
{
|
||||
$options = [
|
||||
'allow_future_times' => false
|
||||
];
|
||||
|
||||
return new TimesheetValidator($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Symfony\Component\Validator\Exception\UnexpectedTypeException
|
||||
*/
|
||||
public function testConstraintIsInvalid()
|
||||
{
|
||||
$this->validator->validate('foo', new NotBlank());
|
||||
}
|
||||
|
||||
public function testEmptyTimesheet()
|
||||
{
|
||||
$timesheet = new Timesheet();
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('You must submit a begin date.')
|
||||
->atPath('property.path.begin')
|
||||
->setCode(TimesheetConstraint::MISSING_BEGIN_ERROR)
|
||||
->buildNextViolation('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 testFutureBegin()
|
||||
{
|
||||
$begin = new \DateTime('+10 hour');
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin($begin);
|
||||
|
||||
$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.')
|
||||
->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 testEndBeforeBegin()
|
||||
{
|
||||
$end = new \DateTime('-10 hour');
|
||||
$begin = new \DateTime('-1 hour');
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setBegin($begin);
|
||||
$timesheet->setEnd($end);
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('End date must not be earlier then start date.')
|
||||
->atPath('property.path.end')
|
||||
->setCode(TimesheetConstraint::END_BEFORE_BEGIN_ERROR)
|
||||
->buildNextViolation('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 testProjectMismatch()
|
||||
{
|
||||
$end = new \DateTime('-1 hour');
|
||||
$begin = new \DateTime('-10 hour');
|
||||
$activity = new Activity();
|
||||
$project1 = new Project();
|
||||
$project2 = new Project();
|
||||
$activity->setProject($project1);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setBegin($begin)
|
||||
->setEnd($end)
|
||||
->setActivity($activity)
|
||||
->setProject($project2)
|
||||
;
|
||||
|
||||
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
|
||||
|
||||
$this->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetConstraint::ACTIVITY_PROJECT_MISMATCH_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testDisabledValuesDuringStart()
|
||||
{
|
||||
$begin = new \DateTime('-10 hour');
|
||||
$customer = new Customer();
|
||||
$customer->setVisible(false);
|
||||
$activity = new Activity();
|
||||
$activity->setVisible(false);
|
||||
$project = new Project();
|
||||
$project->setVisible(false);
|
||||
$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('Cannot start a disabled activity.')
|
||||
->atPath('property.path.activity')
|
||||
->setCode(TimesheetConstraint::DISABLED_ACTIVITY_ERROR)
|
||||
->buildNextViolation('Cannot start a disabled project.')
|
||||
->atPath('property.path.project')
|
||||
->setCode(TimesheetConstraint::DISABLED_PROJECT_ERROR)
|
||||
->buildNextViolation('Cannot start a disabled customer.')
|
||||
->atPath('property.path.customer')
|
||||
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
|
||||
->assertRaised();
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ class UserVoterTest extends AbstractVoterTest
|
||||
yield [$user, $user4, 'edit', $result];
|
||||
yield [$user, $user4, 'delete', $result];
|
||||
yield [$user, $user4, 'hourly-rate', $result];
|
||||
yield [$user, $user4, 'hourly-rate', $result];
|
||||
}
|
||||
|
||||
$result = VoterInterface::ACCESS_ABSTAIN;
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user