Release 2.32 (#5411)

* bump packages
* dynamic invoice options
* make sure that invoice previews can be detected
* support for mpdf associated files
* do not include any future times in work contract calculation
* re-add username column in Excel spreadsheet
* deactivate internal rate editing
* show if plugin update exists
* shorten name to Kimai only, without Time-Tracking
* remove check for existing id in work contract
* fix metafield already defined in search
* helper methods to unlock months
* new translation
* send event on unlock month
This commit is contained in:
Kevin Papst
2025-04-06 09:53:48 +02:00
committed by GitHub
parent 2a75cd6230
commit 2e6b700b43
58 changed files with 749 additions and 437 deletions

View File

@@ -9,18 +9,13 @@
namespace App\API\Authentication;
use App\Entity\User;
use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
final class ApiTokenUpgradeBadge implements BadgeInterface
{
/**
* @param string|null $plaintextApiToken
* @param PasswordUpgraderInterface<User> $passwordUpgrader
*/
public function __construct(private ?string $plaintextApiToken, private PasswordUpgraderInterface $passwordUpgrader)
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)
{
}
@@ -36,9 +31,6 @@ final class ApiTokenUpgradeBadge implements BadgeInterface
return $password;
}
/**
* @return PasswordUpgraderInterface<User>
*/
public function getPasswordUpgrader(): PasswordUpgraderInterface
{
return $this->passwordUpgrader;

View File

@@ -454,10 +454,12 @@ final class TranslationCommand extends Command
foreach ($xml->file->body->{'trans-unit'} as $unit) {
$source = $unit->source;
if (!isset($unit['resname'])) {
if (!isset($unit['resname']) && $source !== null) {
$unit['resname'] = $source;
}
$unit['id'] = $this->generateId($unit['resname']);
if ($unit['resname'] !== null) {
$unit['id'] = $this->generateId($unit['resname']); // @phpstan-ignore offsetAssign.valueType
}
}
$xmlDocument = new \DOMDocument('1.0');
@@ -491,7 +493,7 @@ final class TranslationCommand extends Command
);
}
$unit->target[0] = $translations[$key];
$unit->target['state'] = 'needs-translation';
$unit->target['state'] = 'needs-translation'; // @phpstan-ignore assign.propertyType
$foundEmpty = true;
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.31.0';
public const VERSION = '2.32.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23100;
public const VERSION_ID = 23200;
/**
* The software name
*/

View File

@@ -164,6 +164,7 @@ final class InvoiceController extends AbstractController
try {
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
$model->setPreview(true);
return $this->service->renderInvoice($model, $this->dispatcher, true);
} catch (Exception $ex) {

View File

@@ -34,11 +34,20 @@ final class PluginController extends AbstractController
$page = new PageSetup('menu.plugin');
$page->setHelp('plugins.html');
$all = $this->getPluginInformation($client, $cache);
$bundles = [];
foreach ($all as $item) {
if ($item['bundle'] !== null) {
$bundles[$item['bundle']] = $item;
}
}
return $this->render('plugin/index.html.twig', [
'page_setup' => $page,
'plugins' => $plugins,
'installed' => $installed,
'extensions' => $this->getPluginInformation($client, $cache)
'extensions' => $all,
'bundles' => $bundles,
]);
}

View File

@@ -15,7 +15,7 @@ use Symfony\Contracts\EventDispatcher\Event;
final class InvoiceCreatedEvent extends Event
{
public function __construct(private Invoice $invoice, private InvoiceModel $model)
public function __construct(private readonly Invoice $invoice, private readonly InvoiceModel $model)
{
}

View File

@@ -47,6 +47,9 @@ final class InvoiceDocumentsEvent extends Event
$this->documents = $documents;
}
/**
* @CloudRequired
*/
public function setMaximumAllowedDocuments(int $max): void
{
$this->maximum = $max;

View File

@@ -16,7 +16,11 @@ use Symfony\Contracts\EventDispatcher\Event;
final class InvoicePreRenderEvent extends Event
{
public function __construct(private InvoiceModel $model, private InvoiceDocument $document, private RendererInterface $renderer)
public function __construct(
private readonly InvoiceModel $model,
private readonly InvoiceDocument $document,
private readonly RendererInterface $renderer
)
{
}

View File

@@ -11,6 +11,9 @@ namespace App\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @deprecated since 2.32 - use TranslatorInterface directly
*/
final class ThemeJavascriptTranslationsEvent extends Event
{
/**

View File

@@ -15,13 +15,19 @@ use Symfony\Contracts\EventDispatcher\Event;
final class WorkingTimeApproveMonthEvent extends Event
{
public function __construct(private User $user, private Month $month, private \DateTimeInterface $approvalDate, private User $approver)
public function __construct(
private readonly Month $month,
private readonly User $approvedBy
)
{
}
/**
* @deprecated use getMonth()->getUser() instead)
*/
public function getUser(): User
{
return $this->user;
return $this->getMonth()->getUser();
}
public function getMonth(): Month
@@ -29,13 +35,8 @@ final class WorkingTimeApproveMonthEvent extends Event
return $this->month;
}
public function getApprovalDate(): \DateTimeInterface
public function getApprovedBy(): User
{
return $this->approvalDate;
}
public function getApprover(): User
{
return $this->approver;
return $this->approvedBy;
}
}

View File

@@ -0,0 +1,34 @@
<?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\Entity\User;
use App\WorkingTime\Model\Month;
use Symfony\Contracts\EventDispatcher\Event;
final class WorkingTimeUnlockMonthEvent extends Event
{
public function __construct(
private readonly Month $month,
private readonly User $unlockedBy
)
{
}
public function getMonth(): Month
{
return $this->month;
}
public function getUnlockedBy(): User
{
return $this->unlockedBy;
}
}

View File

@@ -164,7 +164,8 @@ final class SpreadsheetRenderer
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
}
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);

View File

@@ -360,16 +360,13 @@ class TimesheetEditForm extends AbstractType
return;
}
$moneyOptions = ['currency' => $currency];
$builder
->add('fixedRate', FixedRateType::class, [
'currency' => $currency,
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
])
->add('internalRate', InternalRateType::class, [
'currency' => $currency,
]);
->add('fixedRate', FixedRateType::class, $moneyOptions)
->add('hourlyRate', HourlyRateType::class, $moneyOptions)
//->add('internalRate', InternalRateType::class, $moneyOptions)
;
}
protected function addUser(FormBuilderInterface $builder, array $options): void

View File

@@ -52,6 +52,11 @@ final class InvoiceModel
private array $itemHydrator = [];
private ?string $invoiceNumber = null;
private bool $hideZeroTax = false;
private bool $isPreview = false;
/**
* @var array<string, string|array<string|int, mixed>|null|bool|int|float>
*/
private array $options = [];
/**
* @internal use InvoiceModelFactory
@@ -68,6 +73,22 @@ final class InvoiceModel
$this->addItemHydrator(new InvoiceItemDefaultHydrator());
}
/**
* @param string|array<string|int, mixed>|null|bool|int|float $value
*/
public function setOption(string $key, string|array|null|bool|int|float $value): void
{
$this->options[$key] = $value;
}
/**
* @return array<string, string|array<string|int, mixed>|null|bool|int|float>
*/
public function getOptions(): array
{
return $this->options;
}
public function getQuery(): ?InvoiceQuery
{
return $this->query;
@@ -265,4 +286,14 @@ final class InvoiceModel
{
$this->hideZeroTax = $hideZeroTax;
}
public function isPreview(): bool
{
return $this->isPreview;
}
public function setPreview(bool $preview): void
{
$this->isPreview = $preview;
}
}

View File

@@ -23,7 +23,7 @@ final class PdfRenderer extends AbstractTwigRenderer implements DispositionInlin
{
use PDFRendererTrait;
public function __construct(Environment $twig, private HtmlToPdfConverter $converter)
public function __construct(Environment $twig, private readonly HtmlToPdfConverter $converter)
{
parent::__construct($twig);
}
@@ -45,7 +45,7 @@ final class PdfRenderer extends AbstractTwigRenderer implements DispositionInlin
$context->setOption('margin_bottom', '8');
$content = $this->renderTwigTemplate($document, $model, ['pdfContext' => $context]);
$content = $this->converter->convertToPdf($content, $context->getOptions());
$content = $this->converter->convertToPdf($content, array_merge($model->getOptions(), $context->getOptions()));
return $this->createPdfResponse($content, $context);
}

View File

@@ -15,9 +15,7 @@ interface HtmlToPdfConverter
* Returns the binary content of the PDF, which can be saved as file.
* Throws an exception if conversion fails.
*
* @param string $html
* @param array $options
* @return string
* @param array<string, mixed|array<string, mixed>> $options
* @throws \Exception
*/
public function convertToPdf(string $html, array $options = []): string;

View File

@@ -18,22 +18,29 @@ use Mpdf\Output\Destination;
final class MPdfConverter implements HtmlToPdfConverter
{
public function __construct(private FileHelper $fileHelper, private string $cacheDirectory)
public function __construct(
private readonly FileHelper $fileHelper,
private readonly string $cacheDirectory
)
{
}
/**
* @param array<string, mixed|array<string, mixed>> $options
* @return array<string, mixed|array<string, mixed>>
*/
private function sanitizeOptions(array $options): array
{
$configs = new ConfigVariables();
$fonts = new FontVariables();
$allowed = [
'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top',
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts',
];
$filtered = array_filter($options, function ($key) use ($allowed, $configs, $fonts): bool {
$filtered = array_filter($options, function ($key): bool {
$allowed = [
'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top',
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts', 'associated_files'
];
if (!\in_array($key, $allowed)) {
$configs = new ConfigVariables();
if (!\array_key_exists($key, $configs->getDefaults())) {
$fonts = new FontVariables();
return \array_key_exists($key, $fonts->getDefaults());
}
}
@@ -49,10 +56,7 @@ final class MPdfConverter implements HtmlToPdfConverter
}
/**
* @param string $html
* @param array $options
* @return string
* @throws \Mpdf\MpdfException
* @param array<string, mixed|array<string, mixed>> $options
*/
public function convertToPdf(string $html, array $options = []): string
{
@@ -93,17 +97,26 @@ final class MPdfConverter implements HtmlToPdfConverter
}
/**
* @param array<string, array<mixed>> $options
* @return Mpdf
* @param array<string, mixed|array<string, mixed>> $options
*/
private function initMpdf(array $options): Mpdf
{
$options['fontDir'] = $this->getFontDirectories();
$options['fontdata'] = $this->mergeFontData($options);
$associatedFiles = [];
if (\array_key_exists('associated_files', $options) && \is_array($options['associated_files'])) {
$associatedFiles = $options['associated_files'];
unset($options['associated_files']);
}
$mpdf = new Mpdf($options);
$mpdf->creator = Constants::SOFTWARE;
if (\count($associatedFiles) > 0) {
$mpdf->SetAssociatedFiles($associatedFiles);
}
return $mpdf;
}
@@ -120,8 +133,8 @@ final class MPdfConverter implements HtmlToPdfConverter
}
/**
* @param array<string, array<mixed>> $options
* @return array<string, array<mixed>>
* @param array<string, mixed|array<string, mixed>> $options
* @return array<string, mixed|array<string, mixed>>
*/
private function mergeFontData(array $options): array
{

View File

@@ -15,12 +15,9 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @template-implements PasswordUpgraderInterface<User>
*/
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
{
public function __construct(private UserRepository $userRepository)
public function __construct(private readonly UserRepository $userRepository)
{
}

View File

@@ -68,6 +68,7 @@ trait RepositorySearchTrait
$alias = 'meta' . $a++;
$paramName = 'metaName' . $i++;
$paramValue = 'metaValue' . $c++;
$subqueryName = 'metaNotExists' . $metaName;
if ($metaValue === '*') {
$qb->leftJoin($rootAlias . '.meta', $alias);
@@ -76,7 +77,7 @@ trait RepositorySearchTrait
$and->add($qb->expr()->isNotNull($alias . '.value'));
} elseif ($metaValue === '~') {
$and->add(
\sprintf('NOT EXISTS(SELECT metaNotExists FROM %s metaNotExists WHERE metaNotExists.%s = %s.id)', $this->getMetaFieldClass(), $this->getMetaFieldName(), $rootAlias)
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id)', $subqueryName, $this->getMetaFieldClass(), $subqueryName, $subqueryName, $this->getMetaFieldName(), $rootAlias)
);
} elseif ($metaValue === '' || $metaValue === null) {
$qb->leftJoin($rootAlias . '.meta', $alias);
@@ -86,7 +87,7 @@ trait RepositorySearchTrait
$qb->expr()->eq($alias . '.name', ':' . $paramName),
$qb->expr()->isNull($alias . '.value')
),
\sprintf('NOT EXISTS(SELECT metaNotExists FROM %s metaNotExists WHERE metaNotExists.%s = %s.id)', $this->getMetaFieldClass(), $this->getMetaFieldName(), $rootAlias)
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id)', $subqueryName, $this->getMetaFieldClass(), $subqueryName, $subqueryName, $this->getMetaFieldName(), $rootAlias)
)
);
$qb->setParameter($paramName, $metaName);

View File

@@ -35,7 +35,6 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* @extends EntityRepository<User>
* @template-implements PasswordUpgraderInterface<User>
* @template-implements UserProviderInterface<User>
*/
class UserRepository extends EntityRepository implements UserLoaderInterface, UserProviderInterface, PasswordUpgraderInterface

View File

@@ -21,18 +21,10 @@ class WorkingTimeRepository extends EntityRepository
{
private bool $pendingUpdate = false;
public function deleteWorkingTime(WorkingTime $workingTime): void
public function scheduleWorkingTimeDelete(WorkingTime $workingTime): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($workingTime);
$entityManager->flush();
}
public function saveWorkingTime(WorkingTime $workingTime): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($workingTime);
$entityManager->flush();
$this->pendingUpdate = true;
$this->getEntityManager()->remove($workingTime);
}
public function scheduleWorkingTimeUpdate(WorkingTime $workingTime): void

View File

@@ -19,7 +19,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* @template-implements PasswordUpgraderInterface<User>
* @template-implements UserProviderInterface<User>
*/
final class KimaiUserProvider implements UserProviderInterface, PasswordUpgraderInterface
@@ -29,7 +28,7 @@ final class KimaiUserProvider implements UserProviderInterface, PasswordUpgrader
/**
* @param iterable<UserProviderInterface<User>> $providers
*/
public function __construct(private iterable $providers, private SystemConfiguration $configuration)
public function __construct(private readonly iterable $providers, private readonly SystemConfiguration $configuration)
{
}

View File

@@ -166,12 +166,7 @@ final class DateTimeFactory
return $date->modify('23:59:59');
}
/**
* @param string $format
* @param null|string $datetime
* @return bool|DateTime
*/
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now'): bool|DateTime
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now'): false|DateTime
{
return DateTime::createFromFormat($format, $datetime ?? 'now', $this->getTimezone());
}

View File

@@ -24,15 +24,17 @@ use Twig\Extension\RuntimeExtensionInterface;
final class ThemeExtension implements RuntimeExtensionInterface
{
public function __construct(private EventDispatcherInterface $eventDispatcher, private TranslatorInterface $translator, private SystemConfiguration $configuration, private Security $security)
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly TranslatorInterface $translator,
private readonly SystemConfiguration $configuration,
private readonly Security $security
)
{
}
/**
* @param Environment $environment
* @param string $eventName
* @param array<string, mixed> $payload
* @return ThemeEvent
*/
public function trigger(Environment $environment, string $eventName, array $payload = []): ThemeEvent
{
@@ -61,7 +63,7 @@ final class ThemeExtension implements RuntimeExtensionInterface
public function getJavascriptTranslations(): array
{
$event = new ThemeJavascriptTranslationsEvent();
$event = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated
$this->eventDispatcher->dispatch($event);
@@ -97,7 +99,7 @@ final class ThemeExtension implements RuntimeExtensionInterface
public function generateTitle(?string $prefix = null, string $delimiter = ' '): string
{
return ($prefix ?? '') . Constants::SOFTWARE . $delimiter . $this->translator->trans('time_tracking', [], 'messages');
return ($prefix ?? '') . Constants::SOFTWARE;
}
public function colorize(?string $color, ?string $identifier = null): string

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Entity\WorkingTime;
use App\Event\WorkingTimeApproveMonthEvent;
use App\Event\WorkingTimeQueryStatsEvent;
use App\Event\WorkingTimeUnlockMonthEvent;
use App\Event\WorkingTimeYearEvent;
use App\Event\WorkingTimeYearSummaryEvent;
use App\Repository\TimesheetRepository;
@@ -155,12 +156,14 @@ final class WorkingTimeService
$dayDate = $day->getDay();
$result = new WorkingTime($user, $dayDate);
if (($firstDay === null || $firstDay <= $dayDate) && ($lastDay === null || $lastDay >= $dayDate)) {
$result->setExpectedTime($calculator->getWorkHoursForDay($dayDate));
}
if ($dayDate <= $until) {
if (($firstDay === null || $firstDay <= $dayDate) && ($lastDay === null || $lastDay >= $dayDate)) {
$result->setExpectedTime($calculator->getWorkHoursForDay($dayDate));
}
if (\array_key_exists($key, $stats)) {
$result->setActualTime($stats[$key]);
if (\array_key_exists($key, $stats)) {
$result->setActualTime($stats[$key]);
}
}
$day->setWorkingTime($result);
@@ -181,6 +184,7 @@ final class WorkingTimeService
return $year->getMonth($monthDate);
}
// deprecated 3.0 remove $user, fetch from $month->getUser() instead
public function approveMonth(User $user, Month $month, \DateTimeInterface $approvalDate, User $approvedBy): void
{
foreach ($month->getDays() as $day) {
@@ -189,10 +193,6 @@ final class WorkingTimeService
continue;
}
if ($workingTime->getId() !== null) {
continue;
}
if ($month->isLocked() || $workingTime->isApproved()) {
continue;
}
@@ -205,15 +205,38 @@ final class WorkingTimeService
$this->workingTimeRepository->persistScheduledWorkingTimes();
// $user = $month->getUser();
$user->setPreferenceValue(self::LATEST_APPROVAL_PREF, $this->workingTimeRepository->getLatestApprovalDate($user)?->format(self::LATEST_APPROVAL_FORMAT));
$this->userRepository->saveUser($user);
$this->eventDispatcher->dispatch(new WorkingTimeApproveMonthEvent($user, $month, $approvalDate, $approvedBy));
$this->eventDispatcher->dispatch(new WorkingTimeApproveMonthEvent($month, $approvedBy));
}
public function unlockMonth(Month $month, User $unlockedBy): void
{
foreach ($month->getDays() as $day) {
$workingTime = $day->getWorkingTime();
if ($workingTime === null || $workingTime->getId() === null) {
continue;
}
if (!$workingTime->isApproved()) {
continue;
}
$this->workingTimeRepository->scheduleWorkingTimeDelete($workingTime);
}
$this->workingTimeRepository->persistScheduledWorkingTimes();
$user = $month->getUser();
$user->setPreferenceValue(self::LATEST_APPROVAL_PREF, $this->workingTimeRepository->getLatestApprovalDate($user)?->format(self::LATEST_APPROVAL_FORMAT));
$this->userRepository->saveUser($user);
$this->eventDispatcher->dispatch(new WorkingTimeUnlockMonthEvent($month, $unlockedBy));
}
/**
* @param \DateTimeInterface $year
* @param User $user
* @return array<string, int>
*/
private function getYearStatistics(\DateTimeInterface $year, User $user): array