Release 2.15 (#4749)

This commit is contained in:
Kevin Papst
2024-04-12 19:03:09 +02:00
committed by GitHub
parent b76a5d5c35
commit 7219b3f421
56 changed files with 441 additions and 575 deletions

View File

@@ -26,7 +26,7 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/actions')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Actions')]
final class ActionsController extends BaseApiController
{

View File

@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/activities')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Activity')]
final class ActivityController extends BaseApiController
{

View File

@@ -19,7 +19,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Default')]
final class ConfigurationController extends BaseApiController
{

View File

@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/customers')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Customer')]
final class CustomerController extends BaseApiController
{

View File

@@ -35,7 +35,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
#[Route(path: '/projects')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Project')]
final class ProjectController extends BaseApiController
{

View File

@@ -20,7 +20,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Default')]
final class StatusController extends BaseApiController
{

View File

@@ -23,7 +23,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/tags')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Tag')]
final class TagController extends BaseApiController
{

View File

@@ -30,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/teams')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Team')]
final class TeamController extends BaseApiController
{

View File

@@ -42,7 +42,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
#[Route(path: '/timesheets')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Timesheet')]
final class TimesheetController extends BaseApiController
{
@@ -85,14 +85,14 @@ final class TimesheetController extends BaseApiController
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
#[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: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'modified_after', requirements: [new 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.')]
#[Rest\QueryParam(name: 'modified_after', requirements: [new 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 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{
$query = new TimesheetQuery(false);
@@ -398,7 +398,7 @@ final class TimesheetController extends BaseApiController
#[IsGranted('view_own_timesheet')]
#[OA\Response(response: 200, description: 'Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
public function recentAction(ParamFetcherInterface $paramFetcher): Response
{

View File

@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/users')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
#[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'User')]
final class UserController extends BaseApiController
{

View File

@@ -127,6 +127,8 @@ final class SystemConfiguration
*/
public function offsetExists($offset): bool
{
@trigger_error('The method "SystemConfiguration::offsetExists()" is deprecated, use "has()" instead', E_USER_DEPRECATED);
return $this->has($offset);
}
@@ -135,6 +137,8 @@ final class SystemConfiguration
*/
public function offsetGet($offset): mixed
{
@trigger_error('The method "SystemConfiguration::offsetGet()" is deprecated, use "find()" instead', E_USER_DEPRECATED);
return $this->find($offset);
}
@@ -143,16 +147,9 @@ final class SystemConfiguration
*/
public function offsetSet(mixed $offset, mixed $value): void
{
$this->set($offset, $value);
}
@trigger_error('The method "SystemConfiguration::offsetSet()" is deprecated, use "set()" instead', E_USER_DEPRECATED);
/**
* @deprecated since 2.0.35
* @throws \BadMethodCallException
*/
public function offsetUnset(mixed $offset): void
{
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
$this->set($offset, $value);
}
// ========== Authentication configurations ==========

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.14.0';
public const VERSION = '2.15.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 21400;
public const VERSION_ID = 21500;
/**
* The software name
*/

View File

@@ -199,7 +199,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
}
protected function logException(\Exception $ex): void
protected function logException(\Throwable $ex): void
{
$this->container->get('logger')->critical($ex->getMessage());
}

View File

@@ -265,6 +265,7 @@ final class DoctorController extends AbstractController
'sys_temp_dir',
'date.timezone',
'session.gc_maxlifetime',
'disable_functions'
];
$settings = [];

View File

@@ -61,7 +61,7 @@ final class PluginController extends AbstractController
$item->expiresAfter(86400); // one day
return $response->toArray();
} catch (\Exception $exception) {
} catch (\Throwable $exception) {
$this->logException($exception);
$this->flashError('Could not download plugin information');
}

View File

@@ -88,12 +88,12 @@ abstract class TimesheetAbstractController extends AbstractController
if ($canSeeRate) {
$table->addColumn('hourlyRate', ['class' => 'text-end d-none text-nowrap']);
$table->addColumn('internalRate', ['class' => 'text-end text-nowrap d-none d-md-table-cell']);
$table->addColumn('internalRate', ['class' => 'text-end text-nowrap d-none d-xxl-table-cell']);
$table->addColumn('rate', ['class' => 'text-end text-nowrap']);
}
$table->addColumn('customer', ['class' => 'd-none d-md-table-cell']);
$table->addColumn('project', ['class' => 'd-none d-lg-table-cell']);
$table->addColumn('project', ['class' => 'd-none d-xl-table-cell']);
$table->addColumn('activity', ['class' => 'd-none d-xl-table-cell']);
$table->addColumn('description', ['class' => 'd-none']);
$table->addColumn('tags', ['class' => 'd-none badges', 'orderBy' => false]);
@@ -103,7 +103,7 @@ abstract class TimesheetAbstractController extends AbstractController
}
if ($canSeeUsername) {
$table->addColumn('username', ['class' => 'd-none d-sm-table-cell', 'orderBy' => false]);
$table->addColumn('username', ['class' => 'd-none d-md-table-cell', 'orderBy' => false]);
}
$table->addColumn('billable', ['class' => 'text-center d-none w-min', 'orderBy' => false]);

View File

@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
final class Date extends FunctionNode
{
@@ -26,9 +26,9 @@ final class Date extends FunctionNode
public function parse(Parser $parser): void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
final class Day extends FunctionNode
{
@@ -26,9 +26,9 @@ final class Day extends FunctionNode
public function parse(Parser $parser): void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
final class Month extends FunctionNode
{
@@ -26,9 +26,9 @@ final class Month extends FunctionNode
public function parse(Parser $parser): void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
final class Year extends FunctionNode
{
@@ -26,9 +26,9 @@ final class Year extends FunctionNode
public function parse(Parser $parser): void
{
$parser->match(Lexer::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS);
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Doctrine;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\DateTimeType;
use Doctrine\DBAL\Types\Types;
final class UTCDateTimeType extends DateTimeType
{
@@ -67,7 +68,7 @@ final class UTCDateTimeType extends DateTimeType
if (!$converted) {
throw ConversionException::conversionFailedFormat(
$value,
$this->getName(),
Types::DATETIME_MUTABLE,
$platform->getDateTimeFormatString()
);
}

View File

@@ -15,6 +15,8 @@ use App\Invoice\InvoiceModel;
final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
{
private const DATE_PROCESS_FORMAT = 'Y-m-d h:i:s';
private InvoiceModel $model;
public function setInvoiceModel(InvoiceModel $model): void
@@ -75,17 +77,24 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()),
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
'entry.duration_minutes' => (int) ($item->getDuration() / 60),
'entry.begin' => $formatter->getFormattedDateTime($begin),
'entry.begin_time' => $formatter->getFormattedTime($begin),
'entry.begin_timestamp' => $begin->getTimestamp(),
'entry.end' => $formatter->getFormattedDateTime($end),
'entry.end_time' => $formatter->getFormattedTime($end),
'entry.end_timestamp' => $end->getTimestamp(),
'entry.date' => $formatter->getFormattedDateTime($begin),
'entry.week' => \intval($begin->format('W')),
'entry.weekyear' => $begin->format('o'),
];
if ($begin !== null) {
$values['entry.begin'] = $formatter->getFormattedDateTime($begin);
$values['entry.begin_time'] = $formatter->getFormattedTime($begin);
$values['entry.begin_timestamp'] = $begin->getTimestamp();
$values['entry.date'] = $formatter->getFormattedDateTime($begin);
$values['entry.date_process'] = $begin->format(self::DATE_PROCESS_FORMAT); // since 2.14
$values['entry.week'] = \intval($begin->format('W'));
$values['entry.weekyear'] = $begin->format('o');
}
if ($end !== null) {
$values['entry.end'] = $formatter->getFormattedDateTime($end);
$values['entry.end_time'] = $formatter->getFormattedTime($end);
$values['entry.end_timestamp'] = $end->getTimestamp();
}
if (null !== $user) {
$values = array_merge($values, [
'entry.user_id' => $user->getId(),

View File

@@ -66,6 +66,8 @@ final class InvoiceModelActivityHydrator implements InvoiceModelHydrator
$prefix . 'id' => $activity->getId(),
$prefix . 'name' => $activity->getName() ?? '',
$prefix . 'comment' => $activity->getComment() ?? '',
$prefix . 'number' => $activity->getNumber() ?? '',
$prefix . 'invoice_text' => $activity->getInvoiceText() ?? '',
];
if ($model->getQuery()?->getEnd() !== null) {

View File

@@ -79,6 +79,8 @@ final class InvoiceModelProjectHydrator implements InvoiceModelHydrator
$prefix . 'budget_time' => $project->getTimeBudget(),
$prefix . 'budget_time_decimal' => $formatter->getFormattedDecimalDuration($project->getTimeBudget()),
$prefix . 'budget_time_minutes' => (int) ($project->getTimeBudget() / 60),
$prefix . 'number' => $project->getNumber() ?? '',
$prefix . 'invoice_text' => $project->getInvoiceText() ?? '',
];
if ($model->getQuery()?->getEnd() !== null) {

View File

@@ -17,7 +17,14 @@ use App\Entity\Project;
class ActivityQuery extends ProjectQuery
{
public const ACTIVITY_ORDER_ALLOWED = [
'name', 'description' => 'comment', 'customer', 'project', 'budget', 'timeBudget', 'visible'
'name',
'description' => 'comment',
'activity_number' => 'number',
'customer',
'project',
'budget',
'timeBudget',
'visible'
];
/**

View File

@@ -16,8 +16,17 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
use VisibilityTrait;
public const PROJECT_ORDER_ALLOWED = [
'name', 'description' => 'comment', 'customer', 'orderNumber', 'orderDate',
'project_start', 'project_end', 'budget', 'timeBudget', 'visible'
'name',
'description' => 'comment',
'project_number' => 'number',
'customer',
'orderNumber',
'orderDate',
'project_start',
'project_end',
'budget',
'timeBudget',
'visible'
];
/**

View File

@@ -62,7 +62,11 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!($user instanceof User)) {
if (!($user instanceof User) || !$user->isInternalUser()) {
return;
}
if ($user->getPassword() === $newHashedPassword) {
return;
}

View File

@@ -29,7 +29,9 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
}
$relayState = $request->get('RelayState');
if (null !== $relayState && $relayState !== $this->httpUtils->generateUri($request, $this->options['login_path'])) {
$loginUrl = $this->httpUtils->generateUri($request, $this->options['login_path']);
if ($relayState !== null && $relayState !== '' && $relayState !== $loginUrl) {
return $relayState;
}

View File

@@ -52,9 +52,9 @@ final class TimesheetStatisticService
->addSelect('DAY(t.date) as day')
->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users)
@@ -124,9 +124,9 @@ final class TimesheetStatisticService
->addSelect('IDENTITY(t.project) as project')
->addSelect('IDENTITY(t.activity) as activity')
->addSelect('DATE(t.date) as date')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users)
@@ -200,9 +200,9 @@ final class TimesheetStatisticService
->addSelect('IDENTITY(t.activity) as activity')
->addSelect('YEAR(t.date) as year')
->addSelect('MONTH(t.date) as month')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users)
@@ -292,9 +292,9 @@ final class TimesheetStatisticService
->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year')
->addSelect('IDENTITY(t.user) as user')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users)

View File

@@ -1,206 +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\Widget\DataProvider;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Repository\TimesheetRepository;
use DateTime;
use DateTimeInterface;
/**
* This class should really be deleted and replaced by TimesheetStatisticService::getDailyStatistics()
* @deprecated since 2.0
* @codeCoverageIgnore
* @internal
* @final
*/
class DailyWorkingTimeChartProvider
{
public function __construct(private readonly TimesheetRepository $repository)
{
}
/**
* In case this method is called with one timezone and the results are from another timezone,
* it might return rows outside the time-range.
*
* @return array<mixed>
*/
protected function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, ?User $user = null): array
{
$qb = $this->repository->createQueryBuilder('t');
$or = $qb->expr()->orX();
$or->add($qb->expr()->between(':begin', 't.begin', 't.end'));
$or->add($qb->expr()->between(':end', 't.begin', 't.end'));
$or->add($qb->expr()->between('t.begin', ':begin', ':end'));
$or->add($qb->expr()->between('t.end', ':begin', ':end'));
$qb->select('t, p, a, c')
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere($or)
->orderBy('t.begin', 'DESC')
->setParameter('begin', $begin)
->setParameter('end', $end)
->leftJoin('t.activity', 'a')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
;
if (null !== $user) {
$qb
->andWhere($qb->expr()->eq('t.user', ':user'))
->setParameter('user', $user)
;
}
$timesheets = $qb->getQuery()->getResult();
$results = [];
/** @var Timesheet $result */
foreach ($timesheets as $result) {
/** @var DateTime $beginTmp */
$beginTmp = $result->getBegin();
/** @var DateTime $endTmp */
$endTmp = $result->getEnd();
$dateKeyEnd = $endTmp->format('Ymd');
do {
$dateKey = $beginTmp->format('Ymd');
if ($dateKey !== $dateKeyEnd) {
$newDateBegin = clone $beginTmp;
$newDateBegin->add(new \DateInterval('P1D'));
// overlapping records should always start at midnight
$newDateBegin->setTime(0, 0, 0);
} else {
$newDateBegin = clone $endTmp;
}
// make sure to exclude entries that are outside the requested time-range:
// these entries can exist if you have long running entries that started before $begin
// for statistical reasons we have to include everything between $begin and $end while
// excluding everything that is outside of that range
// --------------------------------------------------------------------------------------
// Be aware that this will NOT filter every record, in case there is a timezone mismatch between the
// begin/end dates and the ones from the database (eg. recorded in UTC) - which might actually be
// before $begin (which happens thanks to the timezone conversion when querying the database)
if ($newDateBegin > $begin && $beginTmp < $end) {
if (!isset($results[$dateKey])) {
$results[$dateKey] = [
'rate' => 0,
'duration' => 0,
'billable' => 0, // duration
'month' => $beginTmp->format('n'),
'year' => $beginTmp->format('Y'),
'day' => $beginTmp->format('j'),
'details' => []
];
}
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
$durationPercent = 0;
if ($result->getDuration() !== null && $result->getDuration() > 0) {
$durationPercent = $duration / $result->getDuration();
}
$rate = $result->getRate() * $durationPercent;
$results[$dateKey]['rate'] += $rate;
$results[$dateKey]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['billable'] += $duration;
}
$detailsId =
$result->getProject()->getCustomer()->getId()
. '_' . $result->getProject()->getId()
. '_' . $result->getActivity()->getId()
;
if (!isset($results[$dateKey]['details'][$detailsId])) {
$results[$dateKey]['details'][$detailsId] = [
'project' => $result->getProject(),
'activity' => $result->getActivity(),
'duration' => 0,
'rate' => 0,
'billable' => 0, // duration
];
}
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
if ($result->isBillable()) {
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
}
}
$beginTmp = $newDateBegin;
// yes, we only want to compare the day, not the time
if ((int) $end->format('Ymd') < (int) $newDateBegin->format('Ymd')) {
break;
}
} while ($dateKey !== $dateKeyEnd);
}
ksort($results);
foreach ($results as $key => $value) {
$results[$key]['details'] = array_values($results[$key]['details']);
}
return array_values($results);
}
/**
* @deprecated since 2.0 - use TimesheetStatisticService::getDailyStatistics() instead
*
* @param User|null $user
* @param DateTimeInterface $begin
* @param DateTimeInterface $end
* @return Day[]
* @throws \Exception
*/
public function getData(?User $user, DateTimeInterface $begin, DateTimeInterface $end): array
{
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = DateTime::createFromInterface($end);
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') >= $until) {
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
$tmp->modify('-1 day');
}
$results = $this->getDailyData($begin, $end, $user);
foreach ($results as $statRow) {
$dateTime = DateTime::createFromInterface($begin);
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
$dateTime->setTime(0, 0, 0);
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
$day->setTotalDurationBillable($statRow['billable']);
$day->setDetails($statRow['details']);
$dateKey = $dateTime->format('Ymd');
// make sure entries from other timezones are filtered
if (!\array_key_exists($dateKey, $days)) {
continue;
}
$days[$dateKey] = $day;
}
ksort($days);
return array_values($days);
}
}

View File

@@ -11,17 +11,21 @@ namespace App\Widget\Type;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use App\Widget\DataProvider\DailyWorkingTimeChartProvider;
use App\Widget\WidgetInterface;
use DateTime;
use DateTimeInterface;
/**
* This is rendered inside the PaginatedWorkingTimeChart.
*/
final class DailyWorkingTimeChart extends AbstractWidget
{
public function __construct(private DailyWorkingTimeChartProvider $dailyWorkingTimeChartProvider)
public function __construct(private readonly TimesheetRepository $repository)
{
}
@@ -47,7 +51,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
@return array<string, string|bool|int|null|array<string, mixed>>
* @return array<string, string|bool|int|null|array<string, mixed>>
*/
public function getOptions(array $options = []): array
{
@@ -71,7 +75,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
$begin = $options['begin'];
if (!($begin instanceof \DateTimeInterface)) {
if (\is_string($begin)) {
$begin = new DateTime($begin, new \DateTimeZone($user->getTimezone()));
$begin = new \DateTimeImmutable($begin, new \DateTimeZone($user->getTimezone()));
} else {
$begin = $dateTimeFactory->getStartOfWeek();
}
@@ -80,14 +84,14 @@ final class DailyWorkingTimeChart extends AbstractWidget
$end = $options['end'];
if (!($end instanceof \DateTimeInterface)) {
if (\is_string($end)) {
$end = new DateTime($end, new \DateTimeZone($user->getTimezone()));
$end = new \DateTimeImmutable($end, new \DateTimeZone($user->getTimezone()));
} else {
$end = $dateTimeFactory->getEndOfWeek($begin);
}
}
$activities = [];
$statistics = $this->dailyWorkingTimeChartProvider->getData($user, $begin, $end);
$statistics = $this->getPreparedData($user, $begin, $end);
foreach ($statistics as $day) {
foreach ($day->getDetails() as $entry) {
@@ -125,4 +129,120 @@ final class DailyWorkingTimeChart extends AbstractWidget
{
return 'widget/widget-dailyworkingtimechart.html.twig';
}
/**
* @return list<array{'duration': int, 'billable': int, 'month': numeric-string, 'year': numeric-string, 'day': numeric-string, 'details': array<int|string, array<mixed>>}>
*/
private function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
{
$qb = $this->repository->createQueryBuilder('t');
$qb->select('t, p, a, c')
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->eq('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $user)
->leftJoin('t.activity', 'a')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
;
$timesheets = $qb->getQuery()->getResult();
$results = [];
/** @var Timesheet $result */
foreach ($timesheets as $result) {
/** @var DateTime $beginTmp */
$beginTmp = $result->getBegin();
$dateKey = $beginTmp->format('Ymd');
if (!isset($results[$dateKey])) {
$results[$dateKey] = [
'duration' => 0,
'billable' => 0, // duration
'month' => $beginTmp->format('n'),
'year' => $beginTmp->format('Y'),
'day' => $beginTmp->format('j'),
'details' => []
];
}
$duration = $result->getDuration() ?? 0;
$results[$dateKey]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['billable'] += $duration;
}
$detailsId =
$result->getProject()->getCustomer()->getId()
. '_' . ($result->getProject()?->getId() ?? '')
. '_' . ($result->getActivity()?->getId() ?? '')
;
if (!isset($results[$dateKey]['details'][$detailsId])) {
$results[$dateKey]['details'][$detailsId] = [
'project' => $result->getProject(),
'activity' => $result->getActivity(),
'duration' => 0,
'billable' => 0, // duration
];
}
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
}
}
ksort($results);
foreach ($results as $key => $value) {
$results[$key]['details'] = array_values($value['details']);
}
return array_values($results);
}
/**
* @return Day[]
* @throws \Exception
*/
private function getPreparedData(User $user, DateTimeInterface $begin, DateTimeInterface $end): array
{
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = DateTime::createFromInterface($end);
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') >= $until) {
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
$tmp->modify('-1 day');
}
// TODO replace with TimesheetStatisticService::getDailyStatistics()
$results = $this->getDailyData($begin, $end, $user);
foreach ($results as $statRow) {
$dateTime = DateTime::createFromInterface($begin);
$dateTime->setDate((int) $statRow['year'], (int) $statRow['month'], (int) $statRow['day']);
$dateTime->setTime(0, 0, 0);
$day = new Day($dateTime, (int) $statRow['duration'], 0.00); // rate is not used in frontend
$day->setTotalDurationBillable($statRow['billable']);
$day->setDetails($statRow['details']);
$dateKey = $dateTime->format('Ymd');
// make sure entries from other timezones are filtered
if (!\array_key_exists($dateKey, $days)) {
continue;
}
$days[$dateKey] = $day;
}
ksort($days);
return array_values($days);
}
}