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

View File

@@ -23,7 +23,11 @@ use Symfony\Contracts\Translation\TranslatorInterface;
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)
{
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');
@@ -55,7 +59,7 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
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)

View File

@@ -483,25 +483,17 @@ final class SystemConfiguration
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>
*/
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 = [];
foreach ($config as $item) {

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ final class PluginController extends AbstractController
$installed = [];
$plugins = $manager->getPlugins();
foreach ($plugins as $plugin) {
$installed[] = $plugin->getId();
$installed[$plugin->getId()] = $plugin;
}
$page = new PageSetup('menu.plugin');
@@ -36,18 +36,23 @@ final class PluginController extends AbstractController
$all = $this->getPluginInformation($client, $cache);
$bundles = [];
$updates = [];
foreach ($all as $item) {
if ($item['bundle'] !== null) {
$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', [
'page_setup' => $page,
'plugins' => $plugins,
'installed' => $installed,
'installed' => array_keys($installed),
'extensions' => $all,
'bundles' => $bundles,
'updates' => $updates,
]);
}

View File

@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Event\QuickEntryMetaDisplayEvent;
use App\Form\QuickEntryForm;
use App\Form\WeekByUserForm;
use App\Model\QuickEntryWeek;
@@ -19,6 +20,7 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\FavoriteRecordService;
use App\Timesheet\TimesheetService;
use App\Utils\PageSetup;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -34,7 +36,8 @@ final class QuickEntryController extends AbstractController
private readonly SystemConfiguration $configuration,
private readonly TimesheetService $timesheetService,
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');
$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);
foreach ($rows as $id => $row) {
@@ -168,10 +176,12 @@ final class QuickEntryController extends AbstractController
$model->addTimesheet($day['entry']);
}
}
$model->setMetaFields($metaFields);
}
// create prototype model
$empty = $formModel->createRow($user);
$empty->setMetaFields($metaFields);
$empty->markAsPrototype();
foreach ($week as $dayId => $day) {
$tmp = $this->timesheetService->createNewTimesheet($user);
@@ -219,7 +229,11 @@ final class QuickEntryController extends AbstractController
foreach ($tmpModel->getTimesheets() as $timesheet) {
if ($timesheet->getId() !== null) {
$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;
} else {
$saveTimesheets[] = $timesheet;
@@ -263,6 +277,7 @@ final class QuickEntryController extends AbstractController
'page_setup' => $page,
'days' => $week,
'form' => $form->createView(),
'metaColumns' => $metaFields,
]);
}
}

View File

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

View File

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

View File

@@ -25,4 +25,9 @@ trait ModifiedTrait
{
$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->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
// and the calculators will not run
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$this->markAsModified();
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
@@ -659,7 +659,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
$this->id = null;
}
$this->setModifiedAt(new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$this->markAsModified();
$this->exported = false;
$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
{
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());
}
$route = $event->getRequest()->get('_route');
$route = $event->getRequest()->attributes->get('_route');
if (!\is_string($route)) {
return;
}

View File

@@ -33,6 +33,18 @@ final class QuickEntryForm extends AbstractType
$builder->addModelTransformer(new CallbackTransformer(
function ($value) {
// 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;
},
function (?QuickEntryWeek $value) {
@@ -49,6 +61,24 @@ final class QuickEntryForm extends AbstractType
foreach ($row->getTimesheets() as $timesheet) {
$timesheet->setProject($project);
$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;
}
// if the user did not change the time, make sure to keep the seconds
$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;
}
@@ -276,9 +276,9 @@ class TimesheetEditForm extends AbstractType
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;
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;
}

View File

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

View File

@@ -13,12 +13,11 @@ use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
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;
}
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())));
}
@@ -78,6 +77,6 @@ final class LdapCredentialsSubscriber implements EventSubscriberInterface
}
// 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;
use App\Entity\Activity;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
/**
* @internal
@@ -24,9 +27,18 @@ class QuickEntryModel
* @var Timesheet[]
*/
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
@@ -184,4 +196,36 @@ class QuickEntryModel
$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 = [];
public function __construct(private \DateTime $startDate)
public function __construct(private readonly \DateTime $startDate)
{
}
@@ -79,7 +79,7 @@ class QuickEntryWeek
$result = strcmp((string) $aName, (string) $bName);
}
return $result < 0 ? -1 : 1;
return $result < 0 ? -1 : 1;
}
/**

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
protected function determineTargetUrl(Request $request): string
{
$relayState = $request->get('RelayState');
$relayState = $request->request->get('RelayState', $request->query->get('RelayState'));
if (\is_scalar($relayState)) {
$relayState = (string) $relayState;
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);
}
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) {
return;
}
@@ -42,7 +42,7 @@ abstract class AbstractTrackingMode implements TrackingModeInterface
$entry->setBegin($start);
// only check for an end date if a begin date was given
$end = $request->get('end');
$end = $request->query->get('end');
if (null === $end) {
return;
}
@@ -59,33 +59,33 @@ abstract class AbstractTrackingMode implements TrackingModeInterface
$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');
if (null === $from) {
$from = $request->query->get('from');
if (!\is_string($from)) {
return;
}
try {
$from = new DateTime($from, $this->getTimezone($entry));
$entry->setBegin($from);
} catch (\Exception $ex) {
return;
}
$entry->setBegin($from);
$to = $request->get('to');
if (null === $to) {
// only check for an end date if a valid begin date was given
$to = $request->query->get('to');
if (!\is_string($to)) {
return;
}
try {
$to = new DateTime($to, $this->getTimezone($entry));
$entry->setEnd($to);
} catch (\Exception $ex) {
return;
}
$entry->setEnd($to);
$entry->setDuration($to->getTimestamp() - $from->getTimestamp());
}
}

View File

@@ -63,12 +63,12 @@ final class ThemeExtension implements RuntimeExtensionInterface
public function getJavascriptTranslations(): array
{
$event = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated
$event = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecatedClass
$this->eventDispatcher->dispatch($event);
$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]);
}

View File

@@ -34,11 +34,11 @@ final class ForbiddenPolicy implements SecurityPolicyInterface
* @param array<string> $forbiddenFunctions
*/
public function __construct(
private array $forbiddenTags = [],
private array $forbiddenFilters = [],
private readonly array $forbiddenTags = [],
private readonly array $forbiddenFilters = [],
array $forbiddenMethods = [],
private array $forbiddenProperties = [],
private array $forbiddenFunctions = []
private readonly array $forbiddenProperties = [],
private readonly array $forbiddenFunctions = []
)
{
$this->forbiddenMethods = [];
@@ -70,7 +70,7 @@ final class ForbiddenPolicy implements SecurityPolicyInterface
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;
}

View File

@@ -204,7 +204,7 @@ final class InvoicePolicy implements SecurityPolicyInterface
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;
}

View File

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