Release 2.34 (#5465)

* fix timing issue in timesheet edit form with deactivated rounding
* bump packages
* only show update messages for newer plugin versions
* replace deprecated method
* remove internal from API
* remove technical terms from translation
* prevent calls to internal symfony methods
* helper method to flag entry as modified
* support meta-fields in weekly-hourse view
* fix running timesheets were deleted in weekly-hourse
This commit is contained in:
Kevin Papst
2025-05-09 14:22:47 +02:00
committed by GitHub
parent 452a8d9390
commit dfd97fd6f3
35 changed files with 428 additions and 362 deletions

409
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1907,46 +1907,16 @@ parameters:
count: 1 count: 1
path: src/Form/Extension/SelectWithApiDataExtension.php path: src/Form/Extension/SelectWithApiDataExtension.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, bool\\|float\\|int\\|string given\\.$#"
count: 2
path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
path: src/Form/Helper/ActivityHelper.php
- -
message: "#^Property App\\\\Form\\\\Helper\\\\ActivityHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#" message: "#^Property App\\\\Form\\\\Helper\\\\ActivityHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
count: 1 count: 1
path: src/Form/Helper/ActivityHelper.php path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, bool\\|float\\|int\\|string given\\.$#"
count: 2
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
path: src/Form/Helper/CustomerHelper.php
- -
message: "#^Property App\\\\Form\\\\Helper\\\\CustomerHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#" message: "#^Property App\\\\Form\\\\Helper\\\\CustomerHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
count: 1 count: 1
path: src/Form/Helper/CustomerHelper.php path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, bool\\|float\\|int\\|string given\\.$#"
count: 2
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
path: src/Form/Helper/ProjectHelper.php
- -
message: "#^Property App\\\\Form\\\\Helper\\\\ProjectHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#" message: "#^Property App\\\\Form\\\\Helper\\\\ProjectHelper\\:\\:\\$pattern \\(string\\|null\\) does not accept bool\\|float\\|int\\|string\\|null\\.$#"
count: 1 count: 1
@@ -3262,11 +3232,6 @@ parameters:
count: 1 count: 1
path: src/Ldap/LdapAuthenticator.php path: src/Ldap/LdapAuthenticator.php
-
message: "#^Instanceof between Symfony\\\\Component\\\\Security\\\\Http\\\\Authenticator\\\\Passport\\\\Passport and Symfony\\\\Component\\\\Security\\\\Http\\\\Authenticator\\\\Passport\\\\Passport will always evaluate to true\\.$#"
count: 1
path: src/Ldap/LdapCredentialsSubscriber.php
- -
message: "#^Method App\\\\Ldap\\\\LdapCredentialsSubscriber\\:\\:onCheckPassport\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Ldap\\\\LdapCredentialsSubscriber\\:\\:onCheckPassport\\(\\) has no return type specified\\.$#"
count: 1 count: 1
@@ -3912,26 +3877,6 @@ parameters:
count: 1 count: 1
path: src/Timesheet/TimesheetStatisticService.php path: src/Timesheet/TimesheetStatisticService.php
-
message: "#^Method App\\\\Timesheet\\\\TrackingMode\\\\AbstractTrackingMode\\:\\:setBeginEndFromRequest\\(\\) has no return type specified\\.$#"
count: 1
path: src/Timesheet/TrackingMode/AbstractTrackingMode.php
-
message: "#^Method App\\\\Timesheet\\\\TrackingMode\\\\AbstractTrackingMode\\:\\:setFromToFromRequest\\(\\) has no return type specified\\.$#"
count: 1
path: src/Timesheet/TrackingMode/AbstractTrackingMode.php
-
message: "#^Parameter \\#1 \\$datetime of class DateTime constructor expects string, mixed given\\.$#"
count: 2
path: src/Timesheet/TrackingMode/AbstractTrackingMode.php
-
message: "#^Parameter \\#2 \\$datetime of static method DateTime\\:\\:createFromFormat\\(\\) expects string, mixed given\\.$#"
count: 2
path: src/Timesheet/TrackingMode/AbstractTrackingMode.php
- -
message: "#^Method App\\\\Twig\\\\Configuration\\:\\:__call\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Twig\\\\Configuration\\:\\:__call\\(\\) has no return type specified\\.$#"
count: 1 count: 1

View File

@@ -23,7 +23,11 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerInterface final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerInterface
{ {
public function __construct(private TranslatorInterface $translator, private FlattenExceptionHandler $exceptionHandler, private Security $security) public function __construct(
private readonly TranslatorInterface $translator,
private readonly FlattenExceptionHandler $exceptionHandler, // @phpstan-ignore-line
private readonly Security $security
)
{ {
} }
@@ -47,7 +51,7 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
public function serializeExceptionToJson(JsonSerializationVisitor $visitor, FlattenException $exception, array $type, Context $context) public function serializeExceptionToJson(JsonSerializationVisitor $visitor, FlattenException $exception, array $type, Context $context)
{ {
if ($exception->getClass() !== ValidationFailedException::class) { if ($exception->getClass() !== ValidationFailedException::class) {
return $this->exceptionHandler->serializeToJson($visitor, $exception, $type, $context); return $this->exceptionHandler->serializeToJson($visitor, $exception, $type, $context); // @phpstan-ignore method.internalClass
} }
$original = $context->getAttribute('exception'); $original = $context->getAttribute('exception');
@@ -55,7 +59,7 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
return $this->serializeValidationExceptionToJson($visitor, $original, $type, $context); return $this->serializeValidationExceptionToJson($visitor, $original, $type, $context);
} }
return $this->exceptionHandler->serializeToJson($visitor, $exception, $type, $context); return $this->exceptionHandler->serializeToJson($visitor, $exception, $type, $context); // @phpstan-ignore method.internalClass
} }
public function serializeValidationExceptionToJson(JsonSerializationVisitor $visitor, ValidationFailedException $exception, array $type, Context $context) public function serializeValidationExceptionToJson(JsonSerializationVisitor $visitor, ValidationFailedException $exception, array $type, Context $context)

View File

@@ -483,25 +483,17 @@ final class SystemConfiguration
return (bool) $this->find('theme.avatar_url'); return (bool) $this->find('theme.avatar_url');
} }
/**
* @internal will be made private soon after 2.18.0 - do not access this method directly, but through getThemeColors()
*/
public function getThemeColorChoices(): string
{
$config = $this->find('theme.color_choices');
if (\is_string($config) && $config !== '') {
return $config;
}
return 'Silver|#c0c0c0';
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
public function getThemeColors(): array public function getThemeColors(): array
{ {
$config = explode(',', $this->getThemeColorChoices()); $config = $this->find('theme.color_choices');
if (!\is_string($config) || $config === '') {
return ['Silver' => '#c0c0c0'];
}
$config = explode(',', $config);
$colors = []; $colors = [];
foreach ($config as $item) { foreach ($config as $item) {

View File

@@ -17,11 +17,11 @@ final class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.33.0'; public const VERSION = '2.34.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 23300; public const VERSION_ID = 23400;
/** /**
* The software name * The software name
*/ */

View File

@@ -60,10 +60,10 @@ use Twig\Environment;
final class InvoiceController extends AbstractController final class InvoiceController extends AbstractController
{ {
public function __construct( public function __construct(
private ServiceInvoice $service, private readonly ServiceInvoice $service,
private InvoiceTemplateRepository $templateRepository, private readonly InvoiceTemplateRepository $templateRepository,
private InvoiceRepository $invoiceRepository, private readonly InvoiceRepository $invoiceRepository,
private EventDispatcherInterface $dispatcher private readonly EventDispatcherInterface $dispatcher
) { ) {
} }
@@ -333,7 +333,7 @@ final class InvoiceController extends AbstractController
{ {
$invoice = null; $invoice = null;
if (null !== ($id = $request->get('id'))) { if (null !== ($id = $request->query->get('id'))) {
$invoice = $this->invoiceRepository->find($id); $invoice = $this->invoiceRepository->find($id);
} }

View File

@@ -28,7 +28,7 @@ final class PluginController extends AbstractController
$installed = []; $installed = [];
$plugins = $manager->getPlugins(); $plugins = $manager->getPlugins();
foreach ($plugins as $plugin) { foreach ($plugins as $plugin) {
$installed[] = $plugin->getId(); $installed[$plugin->getId()] = $plugin;
} }
$page = new PageSetup('menu.plugin'); $page = new PageSetup('menu.plugin');
@@ -36,18 +36,23 @@ final class PluginController extends AbstractController
$all = $this->getPluginInformation($client, $cache); $all = $this->getPluginInformation($client, $cache);
$bundles = []; $bundles = [];
$updates = [];
foreach ($all as $item) { foreach ($all as $item) {
if ($item['bundle'] !== null) { if ($item['bundle'] !== null) {
$bundles[$item['bundle']] = $item; $bundles[$item['bundle']] = $item;
if (\array_key_exists($item['bundle'], $installed)) {
$updates[$item['bundle']] = version_compare($installed[$item['bundle']]->getMetadata()->getVersion(), $item['latest_release']) === -1;
}
} }
} }
return $this->render('plugin/index.html.twig', [ return $this->render('plugin/index.html.twig', [
'page_setup' => $page, 'page_setup' => $page,
'plugins' => $plugins, 'plugins' => $plugins,
'installed' => $installed, 'installed' => array_keys($installed),
'extensions' => $all, 'extensions' => $all,
'bundles' => $bundles, 'bundles' => $bundles,
'updates' => $updates,
]); ]);
} }

View File

@@ -10,6 +10,7 @@
namespace App\Controller; namespace App\Controller;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Event\QuickEntryMetaDisplayEvent;
use App\Form\QuickEntryForm; use App\Form\QuickEntryForm;
use App\Form\WeekByUserForm; use App\Form\WeekByUserForm;
use App\Model\QuickEntryWeek; use App\Model\QuickEntryWeek;
@@ -19,6 +20,7 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\FavoriteRecordService; use App\Timesheet\FavoriteRecordService;
use App\Timesheet\TimesheetService; use App\Timesheet\TimesheetService;
use App\Utils\PageSetup; use App\Utils\PageSetup;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -34,7 +36,8 @@ final class QuickEntryController extends AbstractController
private readonly SystemConfiguration $configuration, private readonly SystemConfiguration $configuration,
private readonly TimesheetService $timesheetService, private readonly TimesheetService $timesheetService,
private readonly TimesheetRepository $repository, private readonly TimesheetRepository $repository,
private readonly FavoriteRecordService $favoriteRecordService private readonly FavoriteRecordService $favoriteRecordService,
private readonly EventDispatcherInterface $dispatcher,
) )
{ {
} }
@@ -149,6 +152,11 @@ final class QuickEntryController extends AbstractController
$defaultHour = (int) $defaultBegin->format('H'); $defaultHour = (int) $defaultBegin->format('H');
$defaultMinute = (int) $defaultBegin->format('i'); $defaultMinute = (int) $defaultBegin->format('i');
// find additional meta-fields exclusively for QuickEntry view
$event = new QuickEntryMetaDisplayEvent($query);
$this->dispatcher->dispatch($event);
$metaFields = $event->getFields();
$formModel = new QuickEntryWeek($startWeek); $formModel = new QuickEntryWeek($startWeek);
foreach ($rows as $id => $row) { foreach ($rows as $id => $row) {
@@ -168,10 +176,12 @@ final class QuickEntryController extends AbstractController
$model->addTimesheet($day['entry']); $model->addTimesheet($day['entry']);
} }
} }
$model->setMetaFields($metaFields);
} }
// create prototype model // create prototype model
$empty = $formModel->createRow($user); $empty = $formModel->createRow($user);
$empty->setMetaFields($metaFields);
$empty->markAsPrototype(); $empty->markAsPrototype();
foreach ($week as $dayId => $day) { foreach ($week as $dayId => $day) {
$tmp = $this->timesheetService->createNewTimesheet($user); $tmp = $this->timesheetService->createNewTimesheet($user);
@@ -219,7 +229,11 @@ final class QuickEntryController extends AbstractController
foreach ($tmpModel->getTimesheets() as $timesheet) { foreach ($tmpModel->getTimesheets() as $timesheet) {
if ($timesheet->getId() !== null) { if ($timesheet->getId() !== null) {
$duration = $timesheet->getDuration(false); $duration = $timesheet->getDuration(false);
if ($duration === null || $timesheet->isRunning()) { // previously running timesheets were deleted, which was wrong
// so now we distinguish between running timesheets and null duration
if ($timesheet->isRunning()) {
$saveTimesheets[] = $timesheet;
} elseif ($duration === null) {
$deleteTimesheets[] = $timesheet; $deleteTimesheets[] = $timesheet;
} else { } else {
$saveTimesheets[] = $timesheet; $saveTimesheets[] = $timesheet;
@@ -263,6 +277,7 @@ final class QuickEntryController extends AbstractController
'page_setup' => $page, 'page_setup' => $page,
'days' => $week, 'days' => $week,
'form' => $form->createView(), 'form' => $form->createView(),
'metaColumns' => $metaFields,
]); ]);
} }
} }

View File

@@ -147,9 +147,7 @@ abstract class TimesheetAbstractController extends AbstractController
$event = new TimesheetMetaDefinitionEvent($entry); $event = new TimesheetMetaDefinitionEvent($entry);
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);
$page = $request->get('page'); $editForm = $this->getEditForm($entry);
$page = is_numeric($page) ? (int) $page : 1;
$editForm = $this->getEditForm($entry, $page);
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
@@ -157,7 +155,7 @@ abstract class TimesheetAbstractController extends AbstractController
$this->service->updateTimesheet($entry); $this->service->updateTimesheet($entry);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]); return $this->redirectToRoute($this->getTimesheetRoute());
} catch (\Exception $ex) { } catch (\Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
} }
@@ -494,14 +492,13 @@ abstract class TimesheetAbstractController extends AbstractController
]); ]);
} }
private function getEditForm(Timesheet $entry, int $page): FormInterface private function getEditForm(Timesheet $entry): FormInterface
{ {
$mode = $this->getTrackingMode(); $mode = $this->getTrackingMode();
return $this->createForm($this->getEditFormClassName(), $entry, [ return $this->createForm($this->getEditFormClassName(), $entry, [
'action' => $this->generateUrl($this->getEditRoute(), [ 'action' => $this->generateUrl($this->getEditRoute(), [
'id' => $entry->getId(), 'id' => $entry->getId(),
'page' => $page,
]), ]),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry), 'include_exported' => $this->isGranted('edit_export', $entry),

View File

@@ -9,9 +9,6 @@
namespace App\Doctrine\Behavior; namespace App\Doctrine\Behavior;
/**
* @internal
*/
interface CreatedAt interface CreatedAt
{ {
public function getCreatedAt(): ?\DateTimeImmutable; public function getCreatedAt(): ?\DateTimeImmutable;

View File

@@ -25,4 +25,9 @@ trait ModifiedTrait
{ {
$this->modifiedAt = $dateTime; $this->modifiedAt = $dateTime;
} }
public function markAsModified(): void
{
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
}
} }

View File

@@ -224,7 +224,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{ {
$this->tags = new ArrayCollection(); $this->tags = new ArrayCollection();
$this->meta = new ArrayCollection(); $this->meta = new ArrayCollection();
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); $this->markAsModified();
} }
/** /**
@@ -611,7 +611,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{ {
// this needs to be done, otherwise doctrine will not see the item as changed // this needs to be done, otherwise doctrine will not see the item as changed
// and the calculators will not run // and the calculators will not run
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); $this->markAsModified();
if (null === ($current = $this->getMetaField($meta->getName()))) { if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this); $meta->setEntity($this);
@@ -659,7 +659,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
$this->id = null; $this->id = null;
} }
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); $this->markAsModified();
$this->exported = false; $this->exported = false;
$currentMeta = $this->meta; $currentMeta = $this->meta;

View File

@@ -0,0 +1,25 @@
<?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\Event;
use App\Repository\Query\TimesheetQuery;
/**
* Dynamically find possible meta fields for the quick-entry screen.
*
* @method TimesheetQuery getQuery()
*/
final class QuickEntryMetaDisplayEvent extends AbstractMetaDisplayEvent
{
public function __construct(TimesheetQuery $query)
{
parent::__construct($query, 'quick-entry');
}
}

View File

@@ -21,7 +21,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
*/ */
final class MenuBuilderSubscriber implements EventSubscriberInterface final class MenuBuilderSubscriber implements EventSubscriberInterface
{ {
public function __construct(private MenuService $menuService) public function __construct(private readonly MenuService $menuService)
{ {
} }
@@ -53,7 +53,7 @@ final class MenuBuilderSubscriber implements EventSubscriberInterface
$event->addItem($menuEvent->getSystemMenu()); $event->addItem($menuEvent->getSystemMenu());
} }
$route = $event->getRequest()->get('_route'); $route = $event->getRequest()->attributes->get('_route');
if (!\is_string($route)) { if (!\is_string($route)) {
return; return;
} }

View File

@@ -33,6 +33,18 @@ final class QuickEntryForm extends AbstractType
$builder->addModelTransformer(new CallbackTransformer( $builder->addModelTransformer(new CallbackTransformer(
function ($value) { function ($value) {
// page is loaded, nothing to do // page is loaded, nothing to do
if (!$value instanceof QuickEntryWeek) {
return $value;
}
foreach ($value->getRows() as $row) {
foreach ($row->getTimesheets() as $timesheet) {
foreach ($timesheet->getMetaFields() as $metaField) {
$row->setMetaFieldValue($metaField);
}
}
}
return $value; return $value;
}, },
function (?QuickEntryWeek $value) { function (?QuickEntryWeek $value) {
@@ -49,6 +61,24 @@ final class QuickEntryForm extends AbstractType
foreach ($row->getTimesheets() as $timesheet) { foreach ($row->getTimesheets() as $timesheet) {
$timesheet->setProject($project); $timesheet->setProject($project);
$timesheet->setActivity($activity); $timesheet->setActivity($activity);
foreach ($row->getMetaFields() as $metaField) {
if (null === ($name = $metaField->getName())) {
continue;
}
if (null === $timesheet->getMetaField($name)) {
$timesheet->setMetaField($metaField);
}
if (null === ($tmpField = $timesheet->getMetaField($name))) {
continue;
}
if ($tmpField->getValue() !== $metaField->getValue()) {
$tmpField->setValue($metaField->getValue());
$timesheet->markAsModified();
}
}
} }
} }

View File

@@ -214,9 +214,9 @@ class TimesheetEditForm extends AbstractType
return; return;
} }
// if the user did not change the time, make sure to keep the seconds
$seconds = 0; $seconds = 0;
if ($data->getBegin()?->format('H:i') === $time->format('H:i')) { // if the user did not change the time, make sure to keep the seconds (ONLY if the timesheet is already existing)
if ($data->getBegin()?->format('H:i') === $time->format('H:i') && $data->getId() !== null) {
$seconds = $data->getBegin()->format('s') ?? 0; $seconds = $data->getBegin()->format('s') ?? 0;
} }
@@ -276,9 +276,9 @@ class TimesheetEditForm extends AbstractType
throw new \Exception('Cannot work with timesheets without start time'); throw new \Exception('Cannot work with timesheets without start time');
} }
// if the user did not change the time, make sure to keep the seconds
$seconds = 0; $seconds = 0;
if ($oldEnd !== null && $oldEnd->format('H:i') === $end->format('H:i')) { // if the user did not change the time, make sure to keep the seconds (ONLY if the timesheet is already existing)
if ($oldEnd !== null && $oldEnd->format('H:i') === $end->format('H:i') && $timesheet->getId() !== null) {
$seconds = $oldEnd->format('s') ?? 0; $seconds = $oldEnd->format('s') ?? 0;
} }

View File

@@ -92,6 +92,8 @@ final class QuickEntryWeekType extends AbstractType
}; };
$builder->addEventListener(FormEvents::PRE_SUBMIT, $activityPreSubmitFunction); $builder->addEventListener(FormEvents::PRE_SUBMIT, $activityPreSubmitFunction);
$builder->add('metaFields', MetaFieldsCollectionType::class);
$builder->add('timesheets', CollectionType::class, [ $builder->add('timesheets', CollectionType::class, [
'entry_type' => QuickEntryTimesheetType::class, 'entry_type' => QuickEntryTimesheetType::class,
'label' => false, 'label' => false,

View File

@@ -13,12 +13,11 @@ use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException; use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Event\CheckPassportEvent; use Symfony\Component\Security\Http\Event\CheckPassportEvent;
final class LdapCredentialsSubscriber implements EventSubscriberInterface final class LdapCredentialsSubscriber implements EventSubscriberInterface
{ {
public function __construct(private LdapManager $ldapManager) public function __construct(private readonly LdapManager $ldapManager)
{ {
} }
@@ -40,7 +39,7 @@ final class LdapCredentialsSubscriber implements EventSubscriberInterface
return; return;
} }
if (!$passport instanceof Passport || !$passport->hasBadge(PasswordCredentials::class)) { if (!$passport->hasBadge(PasswordCredentials::class)) {
throw new \LogicException(\sprintf('LDAP authentication requires a passport containing a user and password credentials, authenticator "%s" does not fulfill these requirements.', \get_class($event->getAuthenticator()))); throw new \LogicException(\sprintf('LDAP authentication requires a passport containing a user and password credentials, authenticator "%s" does not fulfill these requirements.', \get_class($event->getAuthenticator())));
} }
@@ -78,6 +77,6 @@ final class LdapCredentialsSubscriber implements EventSubscriberInterface
} }
// make sure that the normal auth process is not triggered // make sure that the normal auth process is not triggered
$passwordCredentials->markResolved(); $passwordCredentials->markResolved(); // @phpstan-ignore method.internal
} }
} }

View File

@@ -10,9 +10,12 @@
namespace App\Model; namespace App\Model;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
/** /**
* @internal * @internal
@@ -24,9 +27,18 @@ class QuickEntryModel
* @var Timesheet[] * @var Timesheet[]
*/ */
private array $timesheets = []; private array $timesheets = [];
/**
* @var Collection<int, MetaTableTypeInterface>
*/
private Collection $metaFields;
public function __construct(private User $user, private ?Project $project = null, private ?Activity $activity = null) public function __construct(
private readonly User $user,
private ?Project $project = null,
private ?Activity $activity = null
)
{ {
$this->metaFields = new ArrayCollection();
} }
public function markAsPrototype(): void public function markAsPrototype(): void
@@ -184,4 +196,36 @@ class QuickEntryModel
$this->timesheets[] = clone $record; $this->timesheets[] = clone $record;
} }
} }
/**
* @return Collection<int, MetaTableTypeInterface>
*/
public function getMetaFields(): Collection
{
return $this->metaFields;
}
public function setMetaFieldValue(MetaTableTypeInterface $metaField): void
{
// we only update the value for fields which were previously registered
// as we do NOT want new fields to show up by accident
foreach ($this->metaFields as $field) {
if ($field->getName() === $metaField->getName() && $metaField->getValue() !== null) {
$field->setValue($metaField->getValue());
break;
}
}
}
/**
* @param array<MetaTableTypeInterface> $metaFields
*/
public function setMetaFields(array $metaFields): void
{
$collection = new ArrayCollection();
foreach($metaFields as $field) {
$collection->add(clone $field);
}
$this->metaFields = $collection;
}
} }

View File

@@ -23,7 +23,7 @@ class QuickEntryWeek
*/ */
private array $rows = []; private array $rows = [];
public function __construct(private \DateTime $startDate) public function __construct(private readonly \DateTime $startDate)
{ {
} }

View File

@@ -9,9 +9,6 @@
namespace App\Model; namespace App\Model;
/**
* @internal
*/
class TimesheetCountedStatistic implements \JsonSerializable class TimesheetCountedStatistic implements \JsonSerializable
{ {
private int $counter = 0; private int $counter = 0;
@@ -34,8 +31,6 @@ class TimesheetCountedStatistic implements \JsonSerializable
/** /**
* For unified access, used in frontend. * For unified access, used in frontend.
*
* @return int
*/ */
public function getCounter(): int public function getCounter(): int
{ {
@@ -84,8 +79,6 @@ class TimesheetCountedStatistic implements \JsonSerializable
/** /**
* For unified access, used in frontend. * For unified access, used in frontend.
*
* @return int
*/ */
public function getValue(): int public function getValue(): int
{ {
@@ -104,8 +97,6 @@ class TimesheetCountedStatistic implements \JsonSerializable
/** /**
* For unified access, used in frontend. * For unified access, used in frontend.
*
* @return int
*/ */
public function getDuration(): int public function getDuration(): int
{ {
@@ -114,8 +105,6 @@ class TimesheetCountedStatistic implements \JsonSerializable
/** /**
* For unified access, used in frontend. * For unified access, used in frontend.
*
* @return float
*/ */
public function getRate(): float public function getRate(): float
{ {
@@ -134,8 +123,6 @@ class TimesheetCountedStatistic implements \JsonSerializable
/** /**
* Returns the total internal rate of all included timesheet records. * Returns the total internal rate of all included timesheet records.
*
* @return float
*/ */
public function getInternalRate(): float public function getInternalRate(): float
{ {

View File

@@ -324,7 +324,6 @@ class BaseQuery
* @template T of BaseQuery * @template T of BaseQuery
* @param T $query * @param T $query
* @return T * @return T
* @internal
*/ */
final public function copyTo(BaseQuery $query): BaseQuery final public function copyTo(BaseQuery $query): BaseQuery
{ {

View File

@@ -24,7 +24,7 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
protected function determineTargetUrl(Request $request): string protected function determineTargetUrl(Request $request): string
{ {
$relayState = $request->get('RelayState'); $relayState = $request->request->get('RelayState', $request->query->get('RelayState'));
if (\is_scalar($relayState)) { if (\is_scalar($relayState)) {
$relayState = (string) $relayState; $relayState = (string) $relayState;
if ($relayState !== $this->httpUtils->generateUri($request, (string) $this->options['login_path'])) { if ($relayState !== $this->httpUtils->generateUri($request, (string) $this->options['login_path'])) {

View File

@@ -27,9 +27,9 @@ abstract class AbstractTrackingMode implements TrackingModeInterface
$this->setFromToFromRequest($timesheet, $request); $this->setFromToFromRequest($timesheet, $request);
} }
protected function setBeginEndFromRequest(Timesheet $entry, Request $request) protected function setBeginEndFromRequest(Timesheet $entry, Request $request): void
{ {
$start = $request->get('begin'); $start = $request->query->get('begin');
if (null === $start) { if (null === $start) {
return; return;
} }
@@ -42,7 +42,7 @@ abstract class AbstractTrackingMode implements TrackingModeInterface
$entry->setBegin($start); $entry->setBegin($start);
// only check for an end date if a begin date was given // only check for an end date if a begin date was given
$end = $request->get('end'); $end = $request->query->get('end');
if (null === $end) { if (null === $end) {
return; return;
} }
@@ -59,33 +59,33 @@ abstract class AbstractTrackingMode implements TrackingModeInterface
$entry->setDuration($end->getTimestamp() - $start->getTimestamp()); $entry->setDuration($end->getTimestamp() - $start->getTimestamp());
} }
protected function setFromToFromRequest(Timesheet $entry, Request $request) protected function setFromToFromRequest(Timesheet $entry, Request $request): void
{ {
$from = $request->get('from'); $from = $request->query->get('from');
if (null === $from) { if (!\is_string($from)) {
return; return;
} }
try { try {
$from = new DateTime($from, $this->getTimezone($entry)); $from = new DateTime($from, $this->getTimezone($entry));
$entry->setBegin($from);
} catch (\Exception $ex) { } catch (\Exception $ex) {
return; return;
} }
$entry->setBegin($from); // only check for an end date if a valid begin date was given
$to = $request->query->get('to');
$to = $request->get('to'); if (!\is_string($to)) {
if (null === $to) {
return; return;
} }
try { try {
$to = new DateTime($to, $this->getTimezone($entry)); $to = new DateTime($to, $this->getTimezone($entry));
$entry->setEnd($to);
} catch (\Exception $ex) { } catch (\Exception $ex) {
return; return;
} }
$entry->setEnd($to);
$entry->setDuration($to->getTimestamp() - $from->getTimestamp()); $entry->setDuration($to->getTimestamp() - $from->getTimestamp());
} }
} }

View File

@@ -63,12 +63,12 @@ final class ThemeExtension implements RuntimeExtensionInterface
public function getJavascriptTranslations(): array public function getJavascriptTranslations(): array
{ {
$event = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated $event = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecatedClass
$this->eventDispatcher->dispatch($event); $this->eventDispatcher->dispatch($event);
$all = []; $all = [];
foreach ($event->getTranslations() as $key => $translation) { foreach ($event->getTranslations() as $key => $translation) { // @phpstan-ignore method.deprecatedClass
$all[$key] = $this->translator->trans($translation[0], [], $translation[1]); $all[$key] = $this->translator->trans($translation[0], [], $translation[1]);
} }

View File

@@ -34,11 +34,11 @@ final class ForbiddenPolicy implements SecurityPolicyInterface
* @param array<string> $forbiddenFunctions * @param array<string> $forbiddenFunctions
*/ */
public function __construct( public function __construct(
private array $forbiddenTags = [], private readonly array $forbiddenTags = [],
private array $forbiddenFilters = [], private readonly array $forbiddenFilters = [],
array $forbiddenMethods = [], array $forbiddenMethods = [],
private array $forbiddenProperties = [], private readonly array $forbiddenProperties = [],
private array $forbiddenFunctions = [] private readonly array $forbiddenFunctions = []
) )
{ {
$this->forbiddenMethods = []; $this->forbiddenMethods = [];
@@ -70,7 +70,7 @@ final class ForbiddenPolicy implements SecurityPolicyInterface
public function checkMethodAllowed($obj, $method): void public function checkMethodAllowed($obj, $method): void
{ {
if ($obj instanceof Template || $obj instanceof Markup) { if ($obj instanceof Template || $obj instanceof Markup) { // @phpstan-ignore instanceof.internalClass
return; return;
} }

View File

@@ -204,7 +204,7 @@ final class InvoicePolicy implements SecurityPolicyInterface
public function checkMethodAllowed($obj, $method): void public function checkMethodAllowed($obj, $method): void
{ {
if ($obj instanceof Template || $obj instanceof Markup || $obj instanceof UnicodeString) { if ($obj instanceof Template || $obj instanceof Markup || $obj instanceof UnicodeString) { // @phpstan-ignore instanceof.internalClass
return; return;
} }

View File

@@ -27,9 +27,6 @@ use App\WorkingTime\Model\Year;
use App\WorkingTime\Model\YearPerUserSummary; use App\WorkingTime\Model\YearPerUserSummary;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
/**
* @internal this API and the entire namespace is experimental: expect changes!
*/
final class WorkingTimeService final class WorkingTimeService
{ {
private const LATEST_APPROVAL_PREF = '_latest_approval'; private const LATEST_APPROVAL_PREF = '_latest_approval';

View File

@@ -35,6 +35,10 @@
{{ form_row(form.duration, {row_attr: {class: 'p-0'}}) }} {{ form_row(form.duration, {row_attr: {class: 'p-0'}}) }}
{%- endblock %} {%- endblock %}
{% block _quick_entry_form_rows_entry_metaFields_entry_widget %}
{{ form_row(form, {row_attr: {class: 'p-0'}}) }}
{%- endblock %}
{% block quick_entry_week_row %} {% block quick_entry_week_row %}
<tr{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group qe-entry-week-row' ~ (not valid ? ' is-invalid'))|trim})} %}{{ block('attributes') }}{% endwith %}> <tr{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group qe-entry-week-row' ~ (not valid ? ' is-invalid'))|trim})} %}{{ block('attributes') }}{% endwith %}>
<td> <td>
@@ -43,6 +47,11 @@
<td> <td>
{{ form_row(form.activity, {row_attr: {class: 'p-0'}}) }} {{ form_row(form.activity, {row_attr: {class: 'p-0'}}) }}
</td> </td>
{% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
<td>
{{ form_row(meta.value, {label: false}) }}
</td>
{% endfor %}
{% for timesheet in form.timesheets %} {% for timesheet in form.timesheets %}
<td class="text-center{% if timesheet.vars.data.begin is weekend %} weekend{% endif %}{% if timesheet.vars.data.begin is today %} today{% endif %}"> <td class="text-center{% if timesheet.vars.data.begin is weekend %} weekend{% endif %}{% if timesheet.vars.data.begin is today %} today{% endif %}">
{{ form_widget(timesheet) }} {{ form_widget(timesheet) }}

View File

@@ -29,7 +29,7 @@
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}">
{{ plugin.metadata.description }} {{ plugin.metadata.description }}
{% if bundles[plugin.id] is defined and bundles[plugin.id]['latest_release'] is defined and bundles[plugin.id]['latest_release'] != plugin.metadata.version %} {% if updates[plugin.id] is defined and updates[plugin.id] == true %}
{{ widgets.alert('warning', 'update_available'|trans({'%version%': bundles[plugin.id]['latest_release']}, 'plugins')) }} {{ widgets.alert('warning', 'update_available'|trans({'%version%': bundles[plugin.id]['latest_release']}, 'plugins')) }}
{% endif %} {% endif %}
</td> </td>

View File

@@ -28,6 +28,9 @@
<tr> <tr>
<th>{{ 'project'|trans }}</th> <th>{{ 'project'|trans }}</th>
<th>{{ 'activity'|trans }}</th> <th>{{ 'activity'|trans }}</th>
{% for field in metaColumns|sort((a, b) => a.order <=> b.order) %}
<th style="min-width: 70px">{{ field.label }}</th>
{% endfor %}
{% for id, week in days %} {% for id, week in days %}
<th class="text-center{% if week.day is weekend %} weekend{% endif %}{% if week.day is today %} today{% endif %}"> <th class="text-center{% if week.day is weekend %} weekend{% endif %}{% if week.day is today %} today{% endif %}">
{{ week.day|date_weekday }} {{ week.day|date_weekday }}
@@ -43,6 +46,9 @@
<tr class="summary"> <tr class="summary">
<td>{{ 'stats.durationTotal'|trans }}</td> <td>{{ 'stats.durationTotal'|trans }}</td>
<td></td> <td></td>
{% for field in metaColumns %}
<td></td>
{% endfor %}
{% for id, week in days %} {% for id, week in days %}
<td class="text-center" id="qe-totals-day-{{ loop.index0 }}"></td> <td class="text-center" id="qe-totals-day-{{ loop.index0 }}"></td>
{% endfor %} {% endfor %}

View File

@@ -136,7 +136,6 @@ class SystemConfigurationTest extends TestCase
self::assertEquals('GBP', $sut->find('defaults.customer.currency')); self::assertEquals('GBP', $sut->find('defaults.customer.currency'));
self::assertFalse($sut->find('timesheet.rules.allow_future_times')); self::assertFalse($sut->find('timesheet.rules.allow_future_times'));
self::assertEquals(99, $sut->find('timesheet.active_entries.hard_limit')); self::assertEquals(99, $sut->find('timesheet.active_entries.hard_limit'));
self::assertEquals('Maroon|#800000,Brown|#a52a2a,Red|#ff0000,Orange|#ffa500,#ffffff,,|#000000', $sut->getThemeColorChoices());
self::assertEquals(['Maroon' => '#800000', 'Brown' => '#a52a2a', 'Red' => '#ff0000', 'Orange' => '#ffa500', '#ffffff' => '#ffffff', '#000000' => '#000000'], $sut->getThemeColors()); self::assertEquals(['Maroon' => '#800000', 'Brown' => '#a52a2a', 'Red' => '#ff0000', 'Orange' => '#ffa500', '#ffffff' => '#ffffff', '#000000' => '#000000'], $sut->getThemeColors());
} }
@@ -160,7 +159,6 @@ class SystemConfigurationTest extends TestCase
]); ]);
self::assertFalse($sut->find('timesheet.rules.allow_future_times')); self::assertFalse($sut->find('timesheet.rules.allow_future_times'));
self::assertTrue($sut->isSamlActive()); self::assertTrue($sut->isSamlActive());
self::assertEquals('Silver|#c0c0c0', $sut->getThemeColorChoices());
self::assertEquals(['Silver' => '#c0c0c0'], $sut->getThemeColors()); self::assertEquals(['Silver' => '#c0c0c0'], $sut->getThemeColors());
self::assertEquals('2020-03-27', $sut->getFinancialYearStart()); self::assertEquals('2020-03-27', $sut->getFinancialYearStart());
} }

View File

@@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase;
/** /**
* @covers \App\Event\ThemeJavascriptTranslationsEvent * @covers \App\Event\ThemeJavascriptTranslationsEvent
* @group legacy
*/ */
class ThemeJavascriptTranslationsEventTest extends TestCase class ThemeJavascriptTranslationsEventTest extends TestCase
{ {
@@ -21,18 +22,18 @@ class ThemeJavascriptTranslationsEventTest extends TestCase
public function testDefaultValues(): void public function testDefaultValues(): void
{ {
$sut = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated $sut = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecatedClass
self::assertCount(self::COUNTER, $sut->getTranslations()); self::assertCount(self::COUNTER, $sut->getTranslations()); // @phpstan-ignore method.deprecatedClass
} }
public function testGetterAndSetter(): void public function testGetterAndSetter(): void
{ {
$sut = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated $sut = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecatedClass
$sut->setTranslation('foo', 'bar'); $sut->setTranslation('foo', 'bar'); // @phpstan-ignore method.deprecatedClass
$sut->setTranslation('hello', 'world', 'testing'); $sut->setTranslation('hello', 'world', 'testing'); // @phpstan-ignore method.deprecatedClass
$result = $sut->getTranslations(); $result = $sut->getTranslations(); // @phpstan-ignore method.deprecatedClass
self::assertCount(self::COUNTER + 2, $result); self::assertCount(self::COUNTER + 2, $result);
self::assertArrayHasKey('foo', $result); self::assertArrayHasKey('foo', $result);
self::assertEquals(['bar', 'messages'], $result['foo']); self::assertEquals(['bar', 'messages'], $result['foo']);

View File

@@ -180,19 +180,19 @@
</trans-unit> </trans-unit>
<trans-unit id="jmsE4EY" resname="Ceil"> <trans-unit id="jmsE4EY" resname="Ceil">
<source>Ceil</source> <source>Ceil</source>
<target>Ceil: Start, Ende und Dauer werden nach oben gerundet</target> <target>Start, Ende und Dauer werden nach oben gerundet</target>
</trans-unit> </trans-unit>
<trans-unit id="Y_qmsDz" resname="Closest"> <trans-unit id="Y_qmsDz" resname="Closest">
<source>Closest</source> <source>Closest</source>
<target>Closest: Mathematische Rundung zum nächsten Wert</target> <target>Mathematische Rundung zum nächsten Wert</target>
</trans-unit> </trans-unit>
<trans-unit id="IbERy.5" resname="Default"> <trans-unit id="IbERy.5" resname="Default">
<source>Default</source> <source>Default</source>
<target>Standard: Start wird nach unten, Ende und Dauer nach oben gerundet</target> <target>Start wird nach unten, Ende und Dauer nach oben gerundet</target>
</trans-unit> </trans-unit>
<trans-unit id="0mjJxEk" resname="Floor"> <trans-unit id="0mjJxEk" resname="Floor">
<source>Floor</source> <source>Floor</source>
<target>Floor: Start, Ende und Dauer werden nach unten gerundet</target> <target>Start, Ende und Dauer werden nach unten gerundet</target>
</trans-unit> </trans-unit>
<trans-unit id="_0OBfZf" resname="timesheet.default_begin" xml:space="preserve"> <trans-unit id="_0OBfZf" resname="timesheet.default_begin" xml:space="preserve">
<source>timesheet.default_begin</source> <source>timesheet.default_begin</source>

View File

@@ -180,19 +180,19 @@
</trans-unit> </trans-unit>
<trans-unit id="jmsE4EY" resname="Ceil"> <trans-unit id="jmsE4EY" resname="Ceil">
<source>Ceil</source> <source>Ceil</source>
<target>Ceil: begin, end and duration will be rounded up</target> <target>Begin, end and duration will be rounded up</target>
</trans-unit> </trans-unit>
<trans-unit id="Y_qmsDz" resname="Closest"> <trans-unit id="Y_qmsDz" resname="Closest">
<source>Closest</source> <source>Closest</source>
<target>Closest: mathematical rounding to the nearest value</target> <target>Mathematical rounding to the nearest value</target>
</trans-unit> </trans-unit>
<trans-unit id="IbERy.5" resname="Default"> <trans-unit id="IbERy.5" resname="Default">
<source>Default</source> <source>Default</source>
<target>Standard: begin will be rounded down, end and duration up</target> <target>Begin will be rounded down, end and duration up</target>
</trans-unit> </trans-unit>
<trans-unit id="0mjJxEk" resname="Floor"> <trans-unit id="0mjJxEk" resname="Floor">
<source>Floor</source> <source>Floor</source>
<target>Floor: begin, end and duration will be rounded down</target> <target>Begin, end and duration will be rounded down</target>
</trans-unit> </trans-unit>
<trans-unit id="_0OBfZf" resname="timesheet.default_begin"> <trans-unit id="_0OBfZf" resname="timesheet.default_begin">
<source>timesheet.default_begin</source> <source>timesheet.default_begin</source>