Preparations for coming features (#1818)

* removed unused convert_tz code
* added timestampable listener to support automatic updates of change columns
* added modified_at, billable and category timesheet fields
* new timesheet API query parameter modified_after
This commit is contained in:
Kevin Papst
2020-07-13 23:35:06 +02:00
committed by GitHub
parent f2dd2331a5
commit f57b314466
18 changed files with 379 additions and 150 deletions

View File

@@ -145,6 +145,7 @@ class TimesheetController extends BaseApiController
* @Rest\QueryParam(name="active", requirements="0|1", strict=true, nullable=true, description="Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)")
* @Rest\QueryParam(name="full", requirements="true", strict=true, nullable=true, description="Allows to fetch fully serialized objects including subresources. Allowed values: true (default: false)")
* @Rest\QueryParam(name="term", description="Free search term")
* @Rest\QueryParam(name="modified_after", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.")
*
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
*
@@ -255,6 +256,10 @@ class TimesheetController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
if (!empty($modifiedAfter = $paramFetcher->get('modified_after'))) {
$query->setModifiedAfter($this->dateTime->createDateTime($modifiedAfter));
}
/** @var Pagerfanta $data */
$data = $this->repository->getPagerfantaForQuery($query);
$data = (array) $data->getCurrentPageResults();

View File

@@ -1,57 +0,0 @@
<?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\Query\Sqlite;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
/**
* This is just a fake, as SQLITE does not support this by now.
*/
class ConvertTz extends FunctionNode
{
protected $dateExpression;
protected $fromTz;
protected $toTz;
/**
* {@inheritdoc}
*/
public function getSql(SqlWalker $sqlWalker)
{
$fieldName = $sqlWalker->walkArithmeticExpression($this->dateExpression);
$fromTz = $sqlWalker->walkStringPrimary($this->fromTz);
$toTz = $sqlWalker->walkStringPrimary($this->toTz);
return sprintf('%s', $fieldName);
}
/**
* {@inheritdoc}
*/
public function parse(Parser $parser)
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$this->dateExpression = $parser->ArithmeticExpression();
$parser->match(Lexer::T_COMMA);
$this->fromTz = $parser->StringPrimary();
$parser->match(Lexer::T_COMMA);
$this->toTz = $parser->StringPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
}
}

View File

@@ -102,6 +102,7 @@ class Invoice
* @var string
*
* @ORM\Column(name="currency", type="string", length=3, nullable=false)
* @Assert\NotNull()
* @Assert\Length(max=3)
*/
private $currency;
@@ -110,6 +111,7 @@ class Invoice
* @var int
*
* @ORM\Column(name="due_days", type="integer", length=3, nullable=false)
* @Assert\NotNull()
* @Assert\Range(min = 0, max = 999)
*/
private $dueDays = 30;
@@ -118,6 +120,7 @@ class Invoice
* @var float
*
* @ORM\Column(name="vat", type="float", nullable=false)
* @Assert\NotNull()
* @Assert\Range(min = 0.0, max = 99.99)
*/
private $vat = 0.00;
@@ -126,13 +129,16 @@ class Invoice
* @var string
*
* @ORM\Column(name="status", type="string", length=20, nullable=false)
* @Assert\NotNull()
*/
private $status = self::STATUS_NEW;
/**
* @var string
*
* @ORM\Column(name="invoice_filename", type="string", length=100, nullable=false)
* @ORM\Column(name="invoice_filename", type="string", length=150, nullable=false)
* @Assert\NotNull()
* @Assert\Length(min=1, max=150)
*/
private $invoiceFilename;

View File

@@ -15,6 +15,7 @@ use DateTimeZone;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -31,18 +32,29 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository")
* @ORM\HasLifecycleCallbacks()
* @App\Validator\Constraints\Timesheet
*
* columns={"user"} => IDX_4F60C6B18D93D649 => count results for user timesheets
* columns={"activity_id"} => IDX_4F60C6B181C06096 => ???
* columns={"user","start_time"} => IDX_4F60C6B18D93D649502DF587 => recent activities, user timesheet with date filzer
* columns={"start_time"} => IDX_4F60C6B1502DF587 => team timesheets with timerange filter only
* columns={"start_time","end_time"} => IDX_4F60C6B1502DF58741561401 => ???
* columns={"start_time","end_time","user"} => IDX_4F60C6B1502DF587415614018D93D649 => ???
*/
class Timesheet implements EntityWithMetaFields, ExportItemInterface
{
public const TYPE_TIMESHEET = 'timesheet';
public const CATEGORY_WORK = 'work';
/**
* Category: Normal work-time (default category)
*/
public const WORK = 'work';
/**
* Category: Holiday
*/
public const HOLIDAY = 'holiday';
/**
* Category: Sickness
*/
public const SICKNESS = 'sickness';
/**
* Category: Parental leave
*/
public const PARENTAL = 'parental';
/**
* Category: Overtime reduction
*/
public const OVERTIME = 'overtime';
/**
* @var int|null
@@ -161,6 +173,30 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
*/
private $exported = false;
/**
* @var bool
*
* @ORM\Column(name="billable", type="boolean", nullable=false, options={"default": true})
* @Assert\NotNull()
*/
private $billable = true;
/**
* @var string
*
* @ORM\Column(name="category", type="string", length=10, nullable=false, options={"default": "work"})
* @Assert\NotNull()
*/
private $category = self::WORK;
/**
* @var DateTime|null
*
* @Gedmo\Timestampable
* @ORM\Column(name="modified_at", type="datetime", nullable=true)
*/
private $modifiedAt;
/**
* @var Tag[]|ArrayCollection
*
@@ -185,9 +221,6 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
*/
private $meta;
/**
* Default constructor, initializes collections
*/
public function __construct()
{
$this->tags = new ArrayCollection();
@@ -472,16 +505,44 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
return $this;
}
/**
* This method returns ALWAYS: "timesheet"
*
* @return string
*/
public function getType(): string
{
// this will be improved in a future version
return self::TYPE_TIMESHEET;
return 'timesheet';
}
public function getCategory(): string
{
// this will be improved in a future version
return self::CATEGORY_WORK;
return $this->category;
}
public function setCategory(string $category): Timesheet
{
$allowed = [self::WORK, self::HOLIDAY, self::SICKNESS, self::PARENTAL, self::OVERTIME];
if (!\in_array($category, $allowed)) {
throw new \InvalidArgumentException(sprintf('Invalid timesheet category "%s" given, expected one of: %s', $category, implode(', ', $allowed)));
}
$this->category = $category;
return $this;
}
public function isBillable(): bool
{
return $this->billable;
}
public function setBillable(bool $billable): Timesheet
{
$this->billable = $billable;
return $this;
}
public function getFixedRate(): ?float
@@ -508,6 +569,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
return $this;
}
public function getModifiedAt(): ?DateTime
{
return $this->modifiedAt;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/

View File

@@ -52,9 +52,11 @@ class UserEnvironmentSubscriber implements EventSubscriberInterface
$user = $this->storage->getToken()->getUser();
// the locale depends on the request, not on the user configuration
\Locale::setDefault($event->getRequest()->getLocale());
if ($user instanceof User) {
date_default_timezone_set($user->getTimezone());
\Locale::setDefault($user->getLocale());
$user->initCanSeeAllData($this->auth->isGranted('view_all_data'));
}
}

View File

@@ -0,0 +1,63 @@
<?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;
/**
* @version 1.10
*/
final class Version20200705152310 extends AbstractMigration
{
public function getDescription(): string
{
return 'Updated invoice and added timesheet columns';
}
public function up(Schema $schema): void
{
$invoices = $schema->getTable('kimai2_invoices');
$invoices->getColumn('invoice_filename')->setLength(150);
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->addColumn('billable', 'boolean', ['notnull' => false, 'default' => true]);
$timesheet->addColumn('category', 'string', ['length' => 10, 'notnull' => true, 'default' => 'work']);
$timesheet->addColumn('modified_at', 'datetime', ['notnull' => false]);
}
public function down(Schema $schema): void
{
$invoices = $schema->getTable('kimai2_invoices');
$invoices->getColumn('invoice_filename')->setLength(100);
$timesheet = $schema->getTable('kimai2_timesheet');
$timesheet->dropColumn('billable');
$timesheet->dropColumn('category');
$timesheet->dropColumn('modified_at');
}
protected function isSupportingForeignKeys(): bool
{
return false;
}
public function isTransactional(): bool
{
if ($this->isPlatformSqlite()) {
// does fail if we use transactions, as tables are re-created and foreign keys would fail
return false;
}
return true;
}
}

View File

@@ -22,6 +22,12 @@ class InvoiceQuery extends TimesheetQuery
*/
private $markAsExported = false;
public function __construct()
{
parent::__construct();
$this->setBillable(InvoiceQuery::STATE_BILLABLE);
}
public function getTemplate(): ?InvoiceTemplate
{
return $this->template;

View File

@@ -24,6 +24,8 @@ class TimesheetQuery extends ActivityQuery
public const STATE_STOPPED = 3;
public const STATE_EXPORTED = 4;
public const STATE_NOT_EXPORTED = 5;
public const STATE_BILLABLE = 6;
public const STATE_NOT_BILLABLE = 7;
public const TIMESHEET_ORDER_ALLOWED = ['begin', 'end', 'duration', 'rate', 'customer', 'project', 'activity', 'description'];
@@ -43,6 +45,14 @@ class TimesheetQuery extends ActivityQuery
* @var int
*/
protected $exported = self::STATE_ALL;
/**
* @var int
*/
private $billable = self::STATE_ALL;
/**
* @var \DateTime|null
*/
private $modifiedAfter;
/**
* @var DateRange
*/
@@ -115,7 +125,7 @@ class TimesheetQuery extends ActivityQuery
/**
* @return Activity|int|null
* @deprecated since 1.9 - use getProjects() instead - will be removed with 2.0
* @deprecated since 1.9 - use getActivities() instead - will be removed with 2.0
*/
public function getActivity()
{
@@ -151,7 +161,7 @@ class TimesheetQuery extends ActivityQuery
* @param Activity|int $activity
* @return $this
*/
public function addActivity($activity)
public function addActivity($activity): TimesheetQuery
{
$this->activities[] = $activity;
@@ -162,7 +172,7 @@ class TimesheetQuery extends ActivityQuery
* @param Activity[]|int[] $activities
* @return $this
*/
public function setActivities(array $activities)
public function setActivities(array $activities): TimesheetQuery
{
$this->activities = $activities;
@@ -174,19 +184,22 @@ class TimesheetQuery extends ActivityQuery
return !empty($this->activities);
}
/**
* @return int
*/
public function getState()
public function getState(): int
{
return $this->state;
}
/**
* @param int $state
* @return TimesheetQuery
*/
public function setState($state)
public function isRunning(): bool
{
return $this->state === self::STATE_RUNNING;
}
public function isStopped(): bool
{
return $this->state === self::STATE_STOPPED;
}
public function setState(int $state): TimesheetQuery
{
$state = (int) $state;
if (\in_array($state, [self::STATE_ALL, self::STATE_RUNNING, self::STATE_STOPPED], true)) {
@@ -196,19 +209,22 @@ class TimesheetQuery extends ActivityQuery
return $this;
}
/**
* @return int
*/
public function getExported()
public function getExported(): int
{
return $this->exported;
}
/**
* @param int $exported
* @return TimesheetQuery
*/
public function setExported($exported)
public function isExported(): bool
{
return $this->exported === self::STATE_EXPORTED;
}
public function isNotExported(): bool
{
return $this->exported === self::STATE_NOT_EXPORTED;
}
public function setExported(int $exported): TimesheetQuery
{
$exported = (int) $exported;
if (\in_array($exported, [self::STATE_ALL, self::STATE_EXPORTED, self::STATE_NOT_EXPORTED], true)) {
@@ -223,11 +239,7 @@ class TimesheetQuery extends ActivityQuery
return $this->dateRange->getBegin();
}
/**
* @param \DateTime $begin
* @return TimesheetQuery
*/
public function setBegin($begin)
public function setBegin(\DateTime $begin): TimesheetQuery
{
$this->dateRange->setBegin($begin);
@@ -239,40 +251,26 @@ class TimesheetQuery extends ActivityQuery
return $this->dateRange->getEnd();
}
/**
* @param \DateTime $end
* @return TimesheetQuery
*/
public function setEnd($end)
public function setEnd(\DateTime $end): TimesheetQuery
{
$this->dateRange->setEnd($end);
return $this;
}
/**
* @return DateRange
*/
public function getDateRange(): DateRange
{
return $this->dateRange;
}
/**
* @param DateRange $dateRange
* @return TimesheetQuery
*/
public function setDateRange(DateRange $dateRange)
public function setDateRange(DateRange $dateRange): TimesheetQuery
{
$this->dateRange = $dateRange;
return $this;
}
/**
* @return iterable
*/
public function getTags($allowUnknown = false)
public function getTags(bool $allowUnknown = false): iterable
{
if (empty($this->tags)) {
return [];
@@ -290,14 +288,46 @@ class TimesheetQuery extends ActivityQuery
return $result;
}
/**
* @param iterable $tags
* @return $this
*/
public function setTags(iterable $tags)
public function setTags(iterable $tags): TimesheetQuery
{
$this->tags = $tags;
return $this;
}
public function getBillable(): int
{
return $this->billable;
}
public function isBillable(): bool
{
return $this->billable === self::STATE_BILLABLE;
}
public function isNotBillable(): bool
{
return $this->billable === self::STATE_NOT_BILLABLE;
}
public function setBillable(int $billable): TimesheetQuery
{
if (\in_array($billable, [self::STATE_ALL, self::STATE_BILLABLE, self::STATE_NOT_BILLABLE], true)) {
$this->billable = $billable;
}
return $this;
}
public function getModifiedAfter(): ?\DateTime
{
return $this->modifiedAfter;
}
public function setModifiedAfter(\DateTime $modifiedAfter): TimesheetQuery
{
$this->modifiedAfter = $modifiedAfter;
return $this;
}
}

View File

@@ -753,9 +753,9 @@ class TimesheetRepository extends EntityRepository
->setParameter('begin', $query->getBegin());
}
if (TimesheetQuery::STATE_RUNNING == $query->getState()) {
if ($query->isRunning()) {
$qb->andWhere($qb->expr()->isNull('t.end'));
} elseif (TimesheetQuery::STATE_STOPPED == $query->getState()) {
} elseif ($query->isStopped()) {
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
@@ -764,12 +764,23 @@ class TimesheetRepository extends EntityRepository
->setParameter('end', $query->getEnd());
}
if ($query->getExported() === TimesheetQuery::STATE_EXPORTED) {
if ($query->isExported()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', true, \PDO::PARAM_BOOL);
} elseif ($query->getExported() === TimesheetQuery::STATE_NOT_EXPORTED) {
} elseif ($query->isNotExported()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', false, \PDO::PARAM_BOOL);
}
if ($query->isBillable()) {
$qb->andWhere('t.billable = :billable')->setParameter('billable', true, \PDO::PARAM_BOOL);
} elseif ($query->isNotBillable()) {
$qb->andWhere('t.billable = :billable')->setParameter('billable', false, \PDO::PARAM_BOOL);
}
if (null !== $query->getModifiedAfter()) {
$qb->andWhere($qb->expr()->gte($this->getDatetimeFieldSql('t.modifiedAt'), ':modified_at'))
->setParameter('modified_at', $query->getModifiedAfter());
}
if ($query->hasActivities()) {
$qb->andWhere($qb->expr()->in('t.activity', ':activity'))
->setParameter('activity', $query->getActivities());

View File

@@ -16,7 +16,6 @@ use App\Utils\Duration;
use App\Utils\LocaleFormats;
use App\Utils\LocaleHelper;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Intl\Languages;
use Symfony\Component\Intl\Locales;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
@@ -163,6 +162,8 @@ final class LocaleExtensions extends AbstractExtension
}
/**
* Returns the currency symbol.
*
* @param string $currency
* @return string
*/