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

@@ -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\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);

View File

@@ -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 : '')

View File

@@ -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';
}
}

View File

@@ -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';
}
}

View File

@@ -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']);

View File

@@ -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()
;

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

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\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')

View File

@@ -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);
}
/**

View File

@@ -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']);
},

View File

@@ -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

View File

@@ -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(),

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_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

View File

@@ -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

View File

@@ -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',

View File

@@ -15,7 +15,7 @@ namespace App\Utils;
class Markdown
{
/**
* @var MarkdownParser
* @var ParsedownExtension
*/
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)
{
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');
}
}

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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