convert timesheets to UTC with support for user timezone (#372)

This commit is contained in:
Kevin Papst
2019-02-08 18:24:33 +01:00
committed by GitHub
parent 8dcc18dde3
commit d00596d297
58 changed files with 1637 additions and 207 deletions

View File

@@ -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 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. 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) ## [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, The configuration `kimai.theme.active_warning` was deprecated and should be replaced in your local.yaml,

View File

@@ -5,10 +5,17 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
/** global: jQuery */
/** global: moment */
if (typeof jQuery === 'undefined') { if (typeof jQuery === 'undefined') {
throw new Error('Kimai requires jQuery'); throw new Error('Kimai requires jQuery');
} }
if (typeof moment === 'undefined') {
throw new Error('Kimai requires moment.js');
}
/* kimai /* kimai
* *
* @type Object * @type Object

View File

@@ -17,6 +17,9 @@ doctrine:
# With Symfony 3.3, remove the `resolve:` prefix # With Symfony 3.3, remove the `resolve:` prefix
url: '%env(resolve:DATABASE_URL)%' url: '%env(resolve:DATABASE_URL)%'
types:
datetime: App\Doctrine\UTCDateTimeType
orm: orm:
auto_generate_proxy_classes: '%kernel.debug%' auto_generate_proxy_classes: '%kernel.debug%'
naming_strategy: doctrine.orm.naming_strategy.underscore naming_strategy: doctrine.orm.naming_strategy.underscore

View File

@@ -47,6 +47,11 @@ kimai:
soft_limit: 1 soft_limit: 1
hard_limit: 3 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 management
#invoice: #invoice:

View File

@@ -105,6 +105,10 @@ services:
tags: tags:
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType } - { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
App\Validator\Constraints\TimesheetValidator:
arguments:
$ruleset: "%kimai.timesheet.rules%"
# ================================================================================ # ================================================================================
# THEME # THEME
# ================================================================================ # ================================================================================

File diff suppressed because one or more lines are too long

View File

@@ -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/app.css": "/build/app.css?caadc4d6101e9889a4fa28e77f1d9362",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd", "build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/images/blue.png": "/build/images/blue.png?96f8a905", "build/images/blue.png": "/build/images/blue.png?96f8a905",

View 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);
}
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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;
}
}

View 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('');
}
}

View File

@@ -19,6 +19,8 @@ use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\DBAL\Configuration; use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Types\DateTimeType;
use Doctrine\DBAL\Types\Type;
use Symfony\Bridge\Doctrine\RegistryInterface; use Symfony\Bridge\Doctrine\RegistryInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@@ -132,6 +134,10 @@ class KimaiImporterCommand extends Command
*/ */
protected function execute(InputInterface $input, OutputInterface $output) 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()); $this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection());
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);

View File

@@ -20,16 +20,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/ */
class RunCodeSnifferCommand extends Command class RunCodeSnifferCommand extends Command
{ {
/**
* @var BashExecutor
*/
protected $executor;
/** /**
* @var string * @var string
*/ */
protected $rootDir = ''; protected $rootDir = '';
/** /**
* @param BashExecutor $executor
* @param string $projectDirectory * @param string $projectDirectory
*/ */
public function __construct($projectDirectory) public function __construct(BashExecutor $executor, string $projectDirectory)
{ {
$this->executor = $executor;
$this->rootDir = realpath($projectDirectory); $this->rootDir = realpath($projectDirectory);
parent::__construct(); parent::__construct();
} }
@@ -42,7 +48,7 @@ class RunCodeSnifferCommand extends Command
$this $this
->setName('kimai:phpcs') ->setName('kimai:phpcs')
->setDescription('Run PHP_CodeSniffer to check for the projects coding style') ->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, '') ->addOption('checkstyle', null, InputOption::VALUE_OPTIONAL, '')
; ;
} }
@@ -55,7 +61,6 @@ class RunCodeSnifferCommand extends Command
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$filename = null; $filename = null;
ob_start();
$args = []; $args = [];
if (!$input->getOption('fix')) { if (!$input->getOption('fix')) {
@@ -78,13 +83,11 @@ class RunCodeSnifferCommand extends Command
} }
} }
$exitCode = 0; $result = $this->executor->execute('/vendor/bin/php-cs-fixer fix ' . implode(' ', $args));
passthru($this->rootDir . '/vendor/bin/php-cs-fixer fix ' . implode(' ', $args), $exitCode);
$result = ob_get_clean();
$io->write($result); $io->write($result->getResult());
if ($exitCode > 0) { if ($result->getExitCode() > 0) {
$io->error( $io->error(
'Found problems while checking your code styles' . 'Found problems while checking your code styles' .
(!empty($filename) ? '. Saved checkstyle data to: ' . $filename : '') (!empty($filename) ? '. Saved checkstyle data to: ' . $filename : '')

View File

@@ -9,8 +9,6 @@
namespace App\Command; namespace App\Command;
use Symfony\Component\Console\Command\Command;
/** /**
* Command used to run all integration tests. * Command used to run all integration tests.
*/ */
@@ -29,11 +27,10 @@ class RunIntegrationTestsCommand extends RunUnitTestsCommand
} }
/** /**
* @param $directory
* @return string * @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';
} }
} }

View File

@@ -19,17 +19,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/ */
class RunUnitTestsCommand extends Command class RunUnitTestsCommand extends Command
{ {
/**
* @var BashExecutor
*/
protected $executor;
/** /**
* @var string * @var string
*/ */
protected $rootDir; protected $rootDir;
/** /**
* RunCodeSnifferCommand constructor. * @param BashExecutor $executor
* @param string $projectDirectory * @param string $projectDirectory
*/ */
public function __construct($projectDirectory) public function __construct(BashExecutor $executor, string $projectDirectory)
{ {
$this->executor = $executor;
$this->rootDir = realpath($projectDirectory); $this->rootDir = realpath($projectDirectory);
parent::__construct(); parent::__construct();
} }
@@ -53,36 +58,24 @@ class RunUnitTestsCommand extends Command
{ {
$io = new SymfonyStyle($input, $output); $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 * @return string
*/ */
protected function createPhpunitCmdLine($directory) protected function createPhpunitCmdLine()
{ {
return $this->rootDir . '/bin/phpunit --exclude-group integration ' . $directory; return '/bin/phpunit --exclude-group integration ' . $this->rootDir . '/tests';
}
/**
* @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);
}
} }
} }

View File

@@ -114,6 +114,7 @@ class AppExtension extends Extension implements PrependExtensionInterface
*/ */
protected function createTimesheetParameter(array $config, ContainerBuilder $container) 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.rates', $config['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['rounding']); $container->setParameter('kimai.timesheet.rounding', $config['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['duration_only']); $container->setParameter('kimai.timesheet.duration_only', $config['duration_only']);

View File

@@ -113,28 +113,35 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->arrayNode('active_entries') ->arrayNode('active_entries')
->addDefaultsIfNotSet() ->addDefaultsIfNotSet()
->children() ->children()
->integerNode('soft_limit') ->integerNode('soft_limit')
->defaultValue(1) ->defaultValue(1)
->validate() ->validate()
->ifTrue(function ($value) { ->ifTrue(function ($value) {
return $value <= 0; return $value <= 0;
}) })
->thenInvalid('The soft_limit must be at least 1') ->thenInvalid('The soft_limit must be at least 1')
->end()
->end() ->end()
->integerNode('hard_limit') ->end()
->defaultValue(1) ->integerNode('hard_limit')
->validate() ->defaultValue(1)
->ifTrue(function ($value) { ->validate()
return $value <= 0; ->ifTrue(function ($value) {
}) return $value <= 0;
->thenInvalid('The hard_limit must be at least 1') })
->end() ->thenInvalid('The hard_limit must be at least 1')
->end() ->end()
->end() ->end()
->end() ->end()
->end() ->end()
->arrayNode('rules')
->addDefaultsIfNotSet()
->children()
->booleanNode('allow_future_times')
->defaultTrue()
->end()
->end()
->end()
->end() ->end()
; ;

View 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;
}
}

View File

@@ -11,7 +11,6 @@ namespace App\Entity;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/** /**
* Timesheet entity. * Timesheet entity.
@@ -25,7 +24,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* ) * )
* @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository") * @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository")
* @ORM\HasLifecycleCallbacks() * @ORM\HasLifecycleCallbacks()
* @Assert\Callback("validate") * @App\Validator\Constraints\Timesheet
*/ */
class Timesheet class Timesheet
{ {
@@ -53,6 +52,18 @@ class Timesheet
*/ */
private $end; private $end;
/**
* @var string
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
*/
private $timezone;
/**
* @var bool
*/
private $localized = false;
/** /**
* @var int * @var int
* *
@@ -137,11 +148,34 @@ class Timesheet
return $this->id; 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 * @return \DateTime
*/ */
public function getBegin() public function getBegin()
{ {
$this->localizeDates();
return $this->begin; return $this->begin;
} }
@@ -149,9 +183,10 @@ class Timesheet
* @param \DateTime $begin * @param \DateTime $begin
* @return Timesheet * @return Timesheet
*/ */
public function setBegin($begin) public function setBegin(\DateTime $begin)
{ {
$this->begin = $begin; $this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName();
return $this; return $this;
} }
@@ -161,6 +196,8 @@ class Timesheet
*/ */
public function getEnd() public function getEnd()
{ {
$this->localizeDates();
return $this->end; return $this->end;
} }
@@ -168,21 +205,21 @@ class Timesheet
* @param \DateTime $end * @param \DateTime $end
* @return Timesheet * @return Timesheet
*/ */
public function setEnd($end) public function setEnd(?\DateTime $end)
{ {
$this->end = $end; $this->end = $end;
if (null === $end) { if (null === $end) {
$this->duration = 0; $this->duration = 0;
$this->rate = 0; $this->rate = 0;
} else {
$this->timezone = $end->getTimezone()->getName();
} }
return $this; return $this;
} }
/** /**
* Set duration
*
* @param int $duration * @param int $duration
* @return Timesheet * @return Timesheet
*/ */
@@ -194,8 +231,7 @@ class Timesheet
} }
/** /**
* Get duration * Do not rely on the results of this method for running records.
* Do not rely on the results of this method for active records.
* *
* @return int * @return int
*/ */
@@ -205,8 +241,6 @@ class Timesheet
} }
/** /**
* Set user
*
* @param User $user * @param User $user
* @return Timesheet * @return Timesheet
*/ */
@@ -218,8 +252,6 @@ class Timesheet
} }
/** /**
* Get user
*
* @return User * @return User
*/ */
public function getUser() public function getUser()
@@ -228,8 +260,6 @@ class Timesheet
} }
/** /**
* Set activity
*
* @param Activity $activity * @param Activity $activity
* @return Timesheet * @return Timesheet
*/ */
@@ -241,8 +271,6 @@ class Timesheet
} }
/** /**
* Get Activity
*
* @return Activity * @return Activity
*/ */
public function getActivity() public function getActivity()
@@ -373,74 +401,24 @@ class Timesheet
} }
/** /**
* @param ExecutionContextInterface $context * @return string
* @param $payload
*/ */
public function validate(ExecutionContextInterface $context, $payload) public function getTimezone(): string
{ {
if (null === ($activity = $this->getActivity())) { return $this->timezone;
$context->buildViolation('A timesheet must have an activity.') }
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
if (null === ($project = $this->getProject())) { /**
$context->buildViolation('A timesheet must have a project.') * BE WARNED: this method should NOT be used programmatically, there is very likely no reason for it!
->atPath('project') *
->setTranslationDomain('validators') * @deprecated since it was introduced, only meant for the initial migration. Will be removed with 1.0.
->addViolation(); * @param string $timezone
} * @return Timesheet
*/
public function setTimezone(string $timezone)
{
$this->timezone = $timezone;
if (null !== $activity && null !== $project) { return $this;
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();
}
}
} }
} }

View 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);
}
}

View File

@@ -21,6 +21,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType; 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\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\Range; use Symfony\Component\Validator\Constraints\Range;
@@ -91,6 +92,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setEnabled($enableHourlyRate) ->setEnabled($enableHourlyRate)
->addConstraint(new Range(['min' => 0])), ->addConstraint(new Range(['min' => 0])),
(new UserPreference())
->setName('timezone')
->setValue(date_default_timezone_get())
->setType(TimezoneType::class),
(new UserPreference()) (new UserPreference())
->setName('language') ->setName('language')
->setValue('en') ->setValue('en')

View File

@@ -54,7 +54,7 @@ abstract class AbstractSpreadsheetRenderer
*/ */
protected function getFormattedDateTime(\DateTime $date) protected function getFormattedDateTime(\DateTime $date)
{ {
return $this->dateExtension->dateShort($date) . ' ' . date('H:i', $date->getTimestamp()); return $this->dateExtension->dateShort($date) . ' ' . $this->dateExtension->time($date);
} }
/** /**

View File

@@ -103,6 +103,7 @@ class TimesheetEditForm extends AbstractType
} else { } else {
$builder->add('end', DateTimePickerType::class, [ $builder->add('end', DateTimePickerType::class, [
'label' => 'label.end', 'label' => 'label.end',
'required' => false,
]); ]);
} }
@@ -129,26 +130,22 @@ class TimesheetEditForm extends AbstractType
$projectOptions['group_by'] = null; $projectOptions['group_by'] = null;
} }
if ($this->projects->countProject(true) > 1) { if ($this->projects->countProject(true) <= 1) {
$projectOptions['placeholder'] = null;
} else {
$projectOptions['group_by'] = null; $projectOptions['group_by'] = null;
} }
$builder $builder
->add( ->add('project', ProjectType::class, array_merge($projectOptions, [
'project', 'placeholder' => '',
ProjectType::class, 'activity_enabled' => true,
array_merge($projectOptions, [ // documentation is for NelmioApiDocBundle
'activity_enabled' => true, 'documentation' => [
// documentation is for NelmioApiDocBundle 'type' => 'integer',
'documentation' => [ 'description' => 'Project ID',
'type' => 'integer', ],
'description' => 'Project ID', 'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
], return $repo->builderForEntityType($project, $customer);
'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, [ $event->getForm()->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true, 'activity_enabled' => true,
'group_by' => null, 'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($data, $project) { 'query_builder' => function (ProjectRepository $repo) use ($data, $project) {
@@ -174,7 +172,7 @@ class TimesheetEditForm extends AbstractType
$builder $builder
->add('activity', ActivityType::class, [ ->add('activity', ActivityType::class, [
// documentation is for NelmioApiDocBundle // documentation is for NelmioApiDocBundle
'placeholder' => null, 'placeholder' => '',
'documentation' => [ 'documentation' => [
'type' => 'integer', 'type' => 'integer',
'description' => 'Activity ID', 'description' => 'Activity ID',
@@ -195,7 +193,7 @@ class TimesheetEditForm extends AbstractType
} }
$event->getForm()->add('activity', ActivityType::class, [ $event->getForm()->add('activity', ActivityType::class, [
'placeholder' => null, 'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) { 'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
return $repo->builderForEntityType($activity, $data['project']); return $repo->builderForEntityType($activity, $data['project']);
}, },

View File

@@ -53,6 +53,15 @@ abstract class AbstractRenderer
return $this->dateExtension->dateShort($date); return $this->dateExtension->dateShort($date);
} }
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedTime(\DateTime $date)
{
return $this->dateExtension->time($date);
}
/** /**
* @param $amount * @param $amount
* @param $currency * @param $currency

View File

@@ -49,6 +49,12 @@ trait RendererTrait
*/ */
abstract protected function getFormattedDateTime(\DateTime $date); abstract protected function getFormattedDateTime(\DateTime $date);
/**
* @param \DateTime $date
* @return mixed
*/
abstract protected function getFormattedTime(\DateTime $date);
/** /**
* @param $amount * @param $amount
* @return mixed * @return mixed
@@ -172,10 +178,10 @@ trait RendererTrait
'entry.duration' => $timesheet->getDuration(), 'entry.duration' => $timesheet->getDuration(),
'entry.duration_minutes' => number_format($timesheet->getDuration() / 60), 'entry.duration_minutes' => number_format($timesheet->getDuration() / 60),
'entry.begin' => $this->getFormattedDateTime($begin), '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.begin_timestamp' => $begin->getTimestamp(),
'entry.end' => $this->getFormattedDateTime($end), '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.end_timestamp' => $end->getTimestamp(),
'entry.date' => $this->getFormattedDateTime($begin), 'entry.date' => $this->getFormattedDateTime($begin),
'entry.user_id' => $user->getId(), 'entry.user_id' => $user->getId(),

View 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');
}
}
}

View File

@@ -29,6 +29,18 @@ class TimesheetRepository extends AbstractRepository
public const STATS_QUERY_ACTIVE = 'active'; public const STATS_QUERY_ACTIVE = 'active';
public const STATS_QUERY_MONTHLY = 'monthly'; 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 * @param Timesheet $entry
* @return bool * @return bool

View File

@@ -40,6 +40,7 @@ class DateExtensions extends \Twig_Extension
new TwigFilter('month_name', [$this, 'monthName']), new TwigFilter('month_name', [$this, 'monthName']),
new TwigFilter('date_short', [$this, 'dateShort']), new TwigFilter('date_short', [$this, 'dateShort']),
new TwigFilter('date_time', [$this, 'dateTime']), 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); return date_format($date, $format);
} }
/**
* @param DateTime $date
* @return string
*/
public function time(DateTime $date)
{
return date_format($date, 'H:i');
}
/** /**
* @param \DateTime $date * @param \DateTime $date
* @return string * @return string

View File

@@ -86,7 +86,7 @@ class Extensions extends \Twig_Extension
'user' => 'fas fa-user', 'user' => 'fas fa-user',
'visibility' => 'far fa-eye', 'visibility' => 'far fa-eye',
'settings' => 'fas fa-wrench', 'settings' => 'fas fa-wrench',
'export' => 'fas fa-database', 'export' => 'fas fa-file-export',
'pdf' => 'fas fa-file-pdf', 'pdf' => 'fas fa-file-pdf',
'csv' => 'fas fa-table', 'csv' => 'fas fa-table',
'ods' => 'fas fa-table', 'ods' => 'fas fa-table',

View File

@@ -15,7 +15,7 @@ namespace App\Utils;
class Markdown class Markdown
{ {
/** /**
* @var MarkdownParser * @var ParsedownExtension
*/ */
private $parser; private $parser;

View 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;
}
}

View 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();
}
}
}

View File

@@ -38,7 +38,7 @@ class ActivityVoter extends AbstractVoter
*/ */
protected function supports($attribute, $subject) protected function supports($attribute, $subject)
{ {
if (!$subject instanceof Activity) { if (!($subject instanceof Activity)) {
return false; return false;
} }
@@ -63,10 +63,6 @@ class ActivityVoter extends AbstractVoter
return false; return false;
} }
if ($subject instanceof Activity) { return $this->hasRolePermission($user, $attribute . '_activity');
return $this->hasRolePermission($user, $attribute . '_activity');
}
return false;
} }
} }

View File

@@ -38,7 +38,7 @@ class CustomerVoter extends AbstractVoter
*/ */
protected function supports($attribute, $subject) protected function supports($attribute, $subject)
{ {
if (!$subject instanceof Customer) { if (!($subject instanceof Customer)) {
return false; return false;
} }
@@ -63,10 +63,6 @@ class CustomerVoter extends AbstractVoter
return false; return false;
} }
if ($subject instanceof Customer) { return $this->hasRolePermission($user, $attribute . '_customer');
return $this->hasRolePermission($user, $attribute . '_customer');
}
return false;
} }
} }

View File

@@ -38,7 +38,7 @@ class InvoiceTemplateVoter extends AbstractVoter
*/ */
protected function supports($attribute, $subject) protected function supports($attribute, $subject)
{ {
if (!$subject instanceof InvoiceTemplate) { if (!($subject instanceof InvoiceTemplate)) {
return false; return false;
} }
@@ -63,10 +63,6 @@ class InvoiceTemplateVoter extends AbstractVoter
return false; return false;
} }
if ($subject instanceof InvoiceTemplate) { return $this->hasRolePermission($user, $attribute . '_invoice_template');
return $this->hasRolePermission($user, $attribute . '_invoice_template');
}
return false;
} }
} }

View File

@@ -38,7 +38,7 @@ class ProjectVoter extends AbstractVoter
*/ */
protected function supports($attribute, $subject) protected function supports($attribute, $subject)
{ {
if (!$subject instanceof Project) { if (!($subject instanceof Project)) {
return false; return false;
} }
@@ -63,10 +63,6 @@ class ProjectVoter extends AbstractVoter
return false; return false;
} }
if ($subject instanceof Project) { return $this->hasRolePermission($user, $attribute . '_project');
return $this->hasRolePermission($user, $attribute . '_project');
}
return false;
} }
} }

View File

@@ -9,7 +9,6 @@
namespace App\Voter; namespace App\Voter;
use App\Entity\Activity;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -49,7 +48,7 @@ class TimesheetVoter extends AbstractVoter
*/ */
protected function supports($attribute, $subject) protected function supports($attribute, $subject)
{ {
if (!$subject instanceof Timesheet) { if (!($subject instanceof Timesheet)) {
return false; return false;
} }
@@ -62,7 +61,7 @@ class TimesheetVoter extends AbstractVoter
/** /**
* @param string $attribute * @param string $attribute
* @param Timesheet|Activity $subject * @param Timesheet $subject
* @param TokenInterface $token * @param TokenInterface $token
* @return bool * @return bool
*/ */
@@ -74,10 +73,6 @@ class TimesheetVoter extends AbstractVoter
return false; return false;
} }
if (!($subject instanceof Timesheet)) {
return false;
}
$permission = ''; $permission = '';
switch ($attribute) { switch ($attribute) {

View File

@@ -44,11 +44,11 @@ class UserVoter extends AbstractVoter
*/ */
protected function supports($attribute, $subject) protected function supports($attribute, $subject)
{ {
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) { if (!($subject instanceof User)) {
return false; return false;
} }
if (!($subject instanceof User)) { if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false; return false;
} }

View File

@@ -52,12 +52,12 @@
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
{% if not duration_only %} {% 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 %} {% endif %}
{% if entry.end %} {% if entry.end %}
{% if not duration_only %} {% 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 %} {% endif %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
{% else %} {% else %}

View File

@@ -56,6 +56,13 @@
</a> </a>
</li> </li>
{% endif %} {% 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 %} {% block navbar_extensions %}{% endblock %}
{{ render(controller('App\\Controller\\TimesheetController::activeEntriesAction')) }} {{ render(controller('App\\Controller\\TimesheetController::activeEntriesAction')) }}
{% endif %} {% endif %}

View File

@@ -123,8 +123,8 @@
{% endif %} {% endif %}
<tr> <tr>
<td class="text-nowrap">{{ entry.begin|date_short }}</td> <td class="text-nowrap">{{ entry.begin|date_short }}</td>
<td class="text-nowrap">{{ entry.begin|date("H:i") }}</td> <td class="text-nowrap">{{ entry.begin|time }}</td>
<td class="text-nowrap">{{ entry.end|date("H:i") }}</td> <td class="text-nowrap">{{ entry.end|time }}</td>
<td>{{ widgets.username(entry.user) }}</td> <td>{{ widgets.username(entry.user) }}</td>
<td>{{ entry.project.customer.name }}</td> <td>{{ entry.project.customer.name }}</td>
<td>{{ entry.project.name }}</td> <td>{{ entry.project.name }}</td>

View File

@@ -56,12 +56,12 @@
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
{% if not duration_only %} {% 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 %} {% endif %}
{% if entry.end %} {% if entry.end %}
{% if not duration_only %} {% 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 %} {% endif %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
{% if is_granted('view_rate', entry) %} {% if is_granted('view_rate', entry) %}

View 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);
}
}

View 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());
}
}

View 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());
}
}

View 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;
}
}

View File

@@ -256,7 +256,7 @@ class ProfileControllerTest extends ControllerBaseTest
'user_preferences_form' => [ 'user_preferences_form' => [
'preferences' => [ 'preferences' => [
['name' => UserPreference::HOURLY_RATE, 'value' => 37.5], ['name' => UserPreference::HOURLY_RATE, 'value' => 37.5],
// ['name' => 'timezone', 'value' => 'America/Creston'], ['name' => 'timezone', 'value' => 'America/Creston'],
['name' => 'language', 'value' => 'ar'], ['name' => 'language', 'value' => 'ar'],
['name' => UserPreference::SKIN, 'value' => 'blue'], ['name' => UserPreference::SKIN, 'value' => 'blue'],
['name' => 'theme.fixed_layout', 'value' => false], ['name' => 'theme.fixed_layout', 'value' => false],
@@ -280,7 +280,7 @@ class ProfileControllerTest extends ControllerBaseTest
$user = $this->getUserByName($em, $username); $user = $this->getUserByName($em, $username);
$this->assertEquals($hourlyRate, $user->getPreferenceValue(UserPreference::HOURLY_RATE)); $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('ar', $user->getPreferenceValue('language'));
$this->assertEquals('blue', $user->getPreferenceValue(UserPreference::SKIN)); $this->assertEquals('blue', $user->getPreferenceValue(UserPreference::SKIN));
$this->assertEquals(false, $user->getPreferenceValue('theme.fixed_layout')); $this->assertEquals(false, $user->getPreferenceValue('theme.fixed_layout'));

View File

@@ -116,6 +116,11 @@ class TimesheetControllerTest extends ControllerBaseTest
$client->submit($form, [ $client->submit($form, [
'timesheet_edit_form' => [ 'timesheet_edit_form' => [
'description' => 'Testing is fun!', '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, 'project' => 1,
'activity' => 1, 'activity' => 1,
] ]

View 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);
}
}

View File

@@ -68,6 +68,7 @@ abstract class AbstractCalculatorTest extends TestCase
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet $timesheet
->setDescription('timesheet description') ->setDescription('timesheet description')
->setBegin(new \DateTime())
->setDuration(3600) ->setDuration(3600)
->setRate(293.27) ->setRate(293.27)
->setUser(new User()) ->setUser(new User())

View File

@@ -44,6 +44,15 @@ class DebugRenderer implements RendererInterface
return $date->format('d.m.Y'); return $date->format('d.m.Y');
} }
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedTime(\DateTime $date)
{
return $date->format('H:i');
}
/** /**
* @param $amount * @param $amount
* @return mixed * @return mixed

View File

@@ -9,6 +9,8 @@
namespace App\Tests\Repository; namespace App\Tests\Repository;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Repository\Query\BaseQuery; use App\Repository\Query\BaseQuery;
@@ -93,4 +95,28 @@ class TimesheetRepositoryTest extends AbstractRepositoryTest
$this->assertTrue($result); $this->assertTrue($result);
$this->assertInstanceOf(\DateTime::class, $timesheet->getEnd()); $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());
}
} }

View 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);
}
}

View File

@@ -163,10 +163,13 @@ class RateCalculatorTest extends TestCase
*/ */
public function testCalculateWithRules($rules, $expectedFactor) public function testCalculateWithRules($rules, $expectedFactor)
{ {
$seconds = 41837; $seconds = 31837;
$end = new \DateTime(); $end = new \DateTime();
$end->setTimezone(new \DateTimeZone('UTC'));
$end->setTime(12, 0, 0);
$start = clone $end; $start = clone $end;
$start->setTimezone(new \DateTimeZone('UTC'));
$start->setTimestamp($end->getTimestamp() - $seconds); $start->setTimestamp($end->getTimestamp() - $seconds);
$record = new Timesheet(); $record = new Timesheet();
@@ -191,6 +194,7 @@ class RateCalculatorTest extends TestCase
public function getRuleDefinitions() public function getRuleDefinitions()
{ {
$start = new \DateTime(); $start = new \DateTime();
$start->setTimezone(new \DateTimeZone('UTC'));
$start->setTime(12, 0, 0); $start->setTime(12, 0, 0);
$day = $start->format('l'); $day = $start->format('l');
@@ -219,7 +223,7 @@ class RateCalculatorTest extends TestCase
'factor' => 2.0 'factor' => 2.0
], ],
'weekdays' => [ 'weekdays' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], 'days' => ['MonDay', 'tUEsdAy', 'WEdnesday', 'THursday', 'friDay', 'SATURday', 'sunDAY'],
'factor' => 1.5 'factor' => 1.5
], ],
], ],

View File

@@ -40,7 +40,7 @@ class DateExtensionsTest extends TestCase
public function testGetFilters() public function testGetFilters()
{ {
$filters = ['month_name', 'date_short', 'date_time']; $filters = ['month_name', 'date_short', 'date_time', 'time'];
$sut = $this->getSut('de', []); $sut = $this->getSut('de', []);
$twigFilters = $sut->getFilters(); $twigFilters = $sut->getFilters();
$this->assertCount(count($filters), $twigFilters); $this->assertCount(count($filters), $twigFilters);
@@ -119,4 +119,13 @@ class DateExtensionsTest extends TestCase
[new \DateTime('2016-12-23'), 'month.12'], [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));
}
} }

View File

@@ -22,5 +22,36 @@ class MarkdownTest extends TestCase
$sut = new Markdown(); $sut = new Markdown();
$this->assertEquals('<p><em>test</em></p>', $sut->toHtml('*test*')); $this->assertEquals('<p><em>test</em></p>', $sut->toHtml('*test*'));
$this->assertEquals('<h1 id="foobar">foobar</h1>', $sut->toHtml('# foobar')); $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));
} }
} }

View 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();
}
}

View File

@@ -72,6 +72,7 @@ class UserVoterTest extends AbstractVoterTest
yield [$user, $user4, 'edit', $result]; yield [$user, $user4, 'edit', $result];
yield [$user, $user4, 'delete', $result]; yield [$user, $user4, 'delete', $result];
yield [$user, $user4, 'hourly-rate', $result]; yield [$user, $user4, 'hourly-rate', $result];
yield [$user, $user4, 'hourly-rate', $result];
} }
$result = VoterInterface::ACCESS_ABSTAIN; $result = VoterInterface::ACCESS_ABSTAIN;

Binary file not shown.