Release 2.27 (#5212)

This commit is contained in:
Kevin Papst
2024-12-22 22:50:42 +01:00
committed by GitHub
parent 4fdfb6f478
commit 4332ef95a2
56 changed files with 354 additions and 256 deletions

View File

@@ -11,13 +11,13 @@ namespace App\Configuration;
final class MailConfiguration
{
public function __construct(private string $mailFrom)
public function __construct(private readonly string $mailFrom)
{
}
public function getFromAddress(): ?string
{
if (empty($this->mailFrom)) {
if (trim($this->mailFrom) === '') {
return null;
}

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.26.0';
public const VERSION = '2.27.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22600;
public const VERSION_ID = 22700;
/**
* The software name
*/

View File

@@ -16,7 +16,6 @@ use App\Model\CustomerBudgetStatisticModel;
use App\Model\CustomerStatistic;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
@@ -36,7 +35,7 @@ class CustomerStatisticService
/**
* WARNING: this method does not respect the budget type. Your results will always be with the "full lifetime data" or the "selected date-range".
*/
public function getCustomerStatistics(Customer $customer, ?DateTime $begin = null, ?DateTime $end = null): CustomerStatistic
public function getCustomerStatistics(Customer $customer, ?DateTimeInterface $begin = null, ?DateTimeInterface $end = null): CustomerStatistic
{
$statistics = $this->getBudgetStatistic([$customer], $begin, $end);
$event = new CustomerStatisticEvent($customer, array_pop($statistics), $begin, $end);
@@ -51,7 +50,7 @@ class CustomerStatisticService
$stats->setStatisticTotal($this->getCustomerStatistics($customer));
$begin = null;
$end = DateTime::createFromInterface($today);
$end = DateTimeImmutable::createFromInterface($today);
if ($customer->isMonthlyBudget()) {
$dateFactory = new DateTimeFactory($today->getTimezone());

View File

@@ -14,7 +14,7 @@ use App\Model\CustomerStatistic;
final class CustomerStatisticEvent extends AbstractCustomerEvent
{
public function __construct(Customer $customer, private CustomerStatistic $statistic, private ?\DateTime $begin = null, private ?\DateTime $end = null)
public function __construct(Customer $customer, private readonly CustomerStatistic $statistic, private readonly ?\DateTimeInterface $begin = null, private readonly ?\DateTimeInterface $end = null)
{
parent::__construct($customer);
}
@@ -24,12 +24,12 @@ final class CustomerStatisticEvent extends AbstractCustomerEvent
return $this->statistic;
}
public function getBegin(): ?\DateTime
public function getBegin(): ?\DateTimeInterface
{
return $this->begin;
}
public function getEnd(): ?\DateTime
public function getEnd(): ?\DateTimeInterface
{
return $this->end;
}

View File

@@ -15,10 +15,14 @@ use Symfony\Contracts\EventDispatcher\Event;
/**
* Working time for every day of the given year.
* Will be reflected in the working-time summary row.
*
* Only to be used with already approved entries.
*
* Can be locked before, but also can be locked by the system.
*/
final class WorkingTimeYearEvent extends Event
{
public function __construct(private Year $year, private \DateTimeInterface $until)
public function __construct(private readonly Year $year, private readonly \DateTimeInterface $until)
{
}

View File

@@ -266,7 +266,7 @@ trait ToolbarFormTrait
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($name, $multiProject, $activityOptions) {
function (FormEvent $event) use ($name, $multiProject, $activityOptions, $options) {
/** @var array<string, mixed> $data */
$data = $event->getData();
$event->getForm()->add($name, ActivityType::class, array_merge($activityOptions, [
@@ -299,7 +299,7 @@ trait ToolbarFormTrait
return $repo->getQueryBuilderForFormType($query);
},
]));
], $options));
}
);
}

View File

@@ -18,7 +18,7 @@ final class InvoiceModelActivityHydrator implements InvoiceModelHydrator
{
use BudgetHydratorTrait;
public function __construct(private ActivityStatisticService $activityStatistic)
public function __construct(private readonly ActivityStatisticService $activityStatistic)
{
}
@@ -70,8 +70,9 @@ final class InvoiceModelActivityHydrator implements InvoiceModelHydrator
$prefix . 'invoice_text' => $activity->getInvoiceText() ?? '',
];
if ($model->getQuery()?->getEnd() !== null) {
$statistic = $this->activityStatistic->getBudgetStatisticModel($activity, $model->getQuery()->getEnd());
$end = $model->getQuery()?->getEnd();
if ($end !== null) {
$statistic = $this->activityStatistic->getBudgetStatisticModel($activity, $end);
$values = array_merge($values, $this->getBudgetValues($prefix, $statistic, $model));
}

View File

@@ -17,7 +17,7 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
{
use BudgetHydratorTrait;
public function __construct(private CustomerStatisticService $customerStatisticService)
public function __construct(private readonly CustomerStatisticService $customerStatisticService)
{
}
@@ -29,34 +29,37 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
return [];
}
$prefix = 'customer.';
$values = [
'customer.id' => $customer->getId(),
'customer.address' => $customer->getAddress() ?? '',
'customer.name' => $customer->getName() ?? '',
'customer.contact' => $customer->getContact() ?? '',
'customer.company' => $customer->getCompany() ?? '',
'customer.vat' => $customer->getVatId() ?? '', // deprecated since 2.0.15
'customer.vat_id' => $customer->getVatId() ?? '',
'customer.number' => $customer->getNumber() ?? '',
'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage() ?? '',
'customer.comment' => $customer->getComment() ?? '',
'customer.email' => $customer->getEmail() ?? '',
'customer.fax' => $customer->getFax() ?? '',
'customer.phone' => $customer->getPhone() ?? '',
'customer.mobile' => $customer->getMobile() ?? '',
'customer.invoice_text' => $customer->getInvoiceText() ?? '',
$prefix . 'id' => $customer->getId(),
$prefix . 'address' => $customer->getAddress() ?? '',
$prefix . 'name' => $customer->getName() ?? '',
$prefix . 'contact' => $customer->getContact() ?? '',
$prefix . 'company' => $customer->getCompany() ?? '',
$prefix . 'vat' => $customer->getVatId() ?? '', // deprecated since 2.0.15
$prefix . 'vat_id' => $customer->getVatId() ?? '',
$prefix . 'number' => $customer->getNumber() ?? '',
$prefix . 'country' => $customer->getCountry(),
$prefix . 'homepage' => $customer->getHomepage() ?? '',
$prefix . 'comment' => $customer->getComment() ?? '',
$prefix . 'email' => $customer->getEmail() ?? '',
$prefix . 'fax' => $customer->getFax() ?? '',
$prefix . 'phone' => $customer->getPhone() ?? '',
$prefix . 'mobile' => $customer->getMobile() ?? '',
$prefix . 'invoice_text' => $customer->getInvoiceText() ?? '',
];
/** @var \DateTime $end */
$end = $model->getQuery()->getEnd();
$statistic = $this->customerStatisticService->getBudgetStatisticModel($customer, $end);
$end = $model->getQuery()?->getEnd();
if ($end !== null) {
$statistic = $this->customerStatisticService->getBudgetStatisticModel($customer, $end);
$values = array_merge($values, $this->getBudgetValues('customer.', $statistic, $model));
$values = array_merge($values, $this->getBudgetValues($prefix, $statistic, $model));
}
foreach ($customer->getMetaFields() as $metaField) {
$values = array_merge($values, [
'customer.meta.' . $metaField->getName() => $metaField->getValue(),
$prefix . 'meta.' . $metaField->getName() => $metaField->getValue(),
]);
}

View File

@@ -18,7 +18,7 @@ final class InvoiceModelProjectHydrator implements InvoiceModelHydrator
{
use BudgetHydratorTrait;
public function __construct(private ProjectStatisticService $projectStatistic)
public function __construct(private readonly ProjectStatisticService $projectStatistic)
{
}
@@ -83,8 +83,9 @@ final class InvoiceModelProjectHydrator implements InvoiceModelHydrator
$prefix . 'invoice_text' => $project->getInvoiceText() ?? '',
];
if ($model->getQuery()?->getEnd() !== null) {
$statistic = $this->projectStatistic->getBudgetStatisticModel($project, $model->getQuery()->getEnd());
$end = $model->getQuery()?->getEnd();
if ($end !== null) {
$statistic = $this->projectStatistic->getBudgetStatisticModel($project, $end);
$values = array_merge($values, $this->getBudgetValues($prefix, $statistic, $model));
}

View File

@@ -12,17 +12,19 @@ namespace App\Invoice\Renderer;
use App\Invoice\InvoiceModel;
use App\Invoice\RendererInterface;
use App\Model\InvoiceDocument;
use App\Twig\TwigRendererTrait;
use App\Twig\LocaleFormatExtensions;
use App\Twig\SecurityPolicy\InvoicePolicy;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
/**
* @internal
*/
abstract class AbstractTwigRenderer implements RendererInterface
{
use TwigRendererTrait;
public function __construct(private Environment $twig)
public function __construct(private readonly Environment $twig)
{
}
@@ -45,6 +47,65 @@ abstract class AbstractTwigRenderer implements RendererInterface
'entries' => $entries
], $options);
// cloning twig, because we don't want to change the
return $this->renderTwigTemplateWithLanguage($this->twig, $template, $options, $language, $formatLocale);
}
private function renderTwigTemplateWithLanguage(Environment $twig, string $template, array $options = [], ?string $language = null, ?string $formatLocale = null): string
{
$previousTranslation = null;
$previousFormatLocale = null;
if ($language !== null) {
$previousTranslation = $this->switchTranslationLocale($twig, $language);
}
if ($formatLocale !== null) {
$previousFormatLocale = $this->switchFormatLocale($twig, $formatLocale);
}
if (!$twig->hasExtension(SandboxExtension::class)) {
$twig->addExtension(new SandboxExtension(new InvoicePolicy()));
}
$sandbox = $twig->getExtension(SandboxExtension::class);
$sandbox->enableSandbox();
$content = $twig->render($template, $options);
$sandbox->disableSandbox();
if ($previousTranslation !== null) {
$this->switchTranslationLocale($twig, $previousTranslation);
}
if ($previousFormatLocale !== null) {
$this->switchFormatLocale($twig, $previousFormatLocale);
}
return $content;
}
private function switchTranslationLocale(Environment $twig, string $language): string
{
/** @var TranslationExtension $extension */
$extension = $twig->getExtension(TranslationExtension::class);
$translator = $extension->getTranslator();
if (!$translator instanceof LocaleAwareInterface) {
throw new \Exception('Translator is expected to be of type LocaleAwareInterface');
}
$previous = $translator->getLocale();
$translator->setLocale($language);
return $previous;
}
private function switchFormatLocale(Environment $twig, string $language): string
{
/** @var LocaleFormatExtensions $extension */
$extension = $twig->getExtension(LocaleFormatExtensions::class);
$previous = $extension->getLocale();
$extension->setLocale($language);
return $previous;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Mail;
use App\Configuration\MailConfiguration;
use App\Constants;
use App\Entity\User;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\MailerInterface;
@@ -36,7 +37,7 @@ final class KimaiMailer implements MailerInterface
if ($fallback === null) {
throw new \RuntimeException('Missing email "from" address');
}
$message->from(new Address($fallback, 'Kimai'));
$message->from(new Address($fallback, Constants::SOFTWARE));
}
$this->mailer->send($message);

View File

@@ -199,7 +199,7 @@ class ProjectStatisticService
$stats->setStatisticTotal($this->getProjectStatistics($project));
$begin = null;
$end = $today;
$end = DateTimeImmutable::createFromInterface($today);
if ($project->isMonthlyBudget()) {
$dateFactory = new DateTimeFactory($today->getTimezone());

View File

@@ -114,7 +114,7 @@ class TagRepository extends EntityRepository
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select($qb->expr()->count('tag.id'))
->select($qb->expr()->count('tag'))
;
/** @var int<0, max> $counter */
$counter = (int) $qb->getQuery()->getSingleScalarResult();

View File

@@ -459,7 +459,7 @@ class TimesheetRepository extends EntityRepository
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select($qb->expr()->count('t.id'))
->select($qb->expr()->count('t'))
;
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
@@ -868,7 +868,7 @@ class TimesheetRepository extends EntityRepository
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select($qb->expr()->count('t.id'))
->select($qb->expr()->count('t'))
->from(Timesheet::class, 't')
;

View File

@@ -1,78 +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\Twig;
use App\Twig\SecurityPolicy\InvoicePolicy;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
/**
* @internal
*/
trait TwigRendererTrait
{
protected function renderTwigTemplateWithLanguage(Environment $twig, string $template, array $options = [], ?string $language = null, ?string $formatLocale = null): string
{
$previousTranslation = null;
$previousFormatLocale = null;
if ($language !== null) {
$previousTranslation = $this->switchTranslationLocale($twig, $language);
}
if ($formatLocale !== null) {
$previousFormatLocale = $this->switchFormatLocale($twig, $formatLocale);
}
// enable basic security measures
if (!$twig->hasExtension(SandboxExtension::class)) {
$sandbox = new SandboxExtension(new InvoicePolicy());
$sandbox->enableSandbox();
$twig->addExtension($sandbox);
}
$content = $twig->render($template, $options);
if ($previousTranslation !== null) {
$this->switchTranslationLocale($twig, $previousTranslation);
}
if ($previousFormatLocale !== null) {
$this->switchFormatLocale($twig, $previousFormatLocale);
}
return $content;
}
protected function switchTranslationLocale(Environment $twig, string $language): string
{
/** @var TranslationExtension $extension */
$extension = $twig->getExtension(TranslationExtension::class);
$translator = $extension->getTranslator();
if (!$translator instanceof LocaleAwareInterface) {
throw new \Exception('Translator is expected to be of type LocaleAwareInterface');
}
$previous = $translator->getLocale();
$translator->setLocale($language);
return $previous;
}
protected function switchFormatLocale(Environment $twig, string $language): string
{
/** @var LocaleFormatExtensions $extension */
$extension = $twig->getExtension(LocaleFormatExtensions::class);
$previous = $extension->getLocale();
$extension->setLocale($language);
return $previous;
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Utils;
use App\Repository\Query\BaseQuery;
use Pagerfanta\Adapter\AdapterInterface;
use Pagerfanta\Adapter\ArrayAdapter;
use Pagerfanta\Pagerfanta;
final class Pagination extends Pagerfanta
@@ -19,6 +20,10 @@ final class Pagination extends Pagerfanta
{
parent::__construct($adapter);
if ($adapter instanceof ArrayAdapter && ($size = $adapter->getNbResults()) > 0) {
$this->setMaxPerPage($size);
}
if ($query === null || !$query->isApiCall()) {
$this->setNormalizeOutOfRangePages(true);
}