Release 2.30 (#5345)

- added missing InvoiceTemplate company, title) field validator
- graceful fallback for missing working-contract mode
- improve email test command (use configured MAIL_FROM)
- additional form types for simple usage in SystemConfiguration and UserPreferences
- allow to extend the working time query via event
This commit is contained in:
Kevin Papst
2025-02-17 08:32:22 +01:00
committed by GitHub
parent d30166e691
commit b341358d0a
20 changed files with 333 additions and 17 deletions

View File

@@ -43,6 +43,7 @@ $fixer
'single_line_after_imports' => true,
'switch_case_semicolon_to_colon' => true,
'switch_case_space' => true,
'php_unit_method_casing' => true,
'array_syntax' => [
'syntax' => 'short'
],

View File

@@ -9,6 +9,7 @@
namespace App\Command;
use App\Constants;
use App\Event\EmailEvent;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -19,7 +20,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mime\Email;
#[AsCommand(name: 'kimai:mail:test', description: 'Send a test email')]
#[AsCommand(name: 'kimai:mail:test', description: 'Send a test email using MAILER_URL and MAILER_FROM')]
final class MailTestCommand extends Command
{
public function __construct(private readonly EventDispatcherInterface $dispatcher)
@@ -30,16 +31,24 @@ final class MailTestCommand extends Command
protected function configure(): void
{
$this->addArgument('to', InputArgument::REQUIRED, 'The email address to send the email to');
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, 'The sender of the message', 'kimai@example.org');
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, 'Deprecated: uses the MAILER_FROM env variable.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$to = $input->getArgument('to');
if (!\is_string($to) || $to === '') {
throw new \InvalidArgumentException('Need a non-empty "to" address');
}
if ($input->getOption('from') !== null) {
throw new \InvalidArgumentException('The "from" option is deprecated and will be ignored');
}
$message = new Email();
$message->to((string) $input->getArgument('to')); // @phpstan-ignore-line
$message->from((string) $input->getOption('from')); // @phpstan-ignore-line
$message->subject('Kimai test email');
$message->text('This is an email for testing the text body.');
$message->to($to);
$message->subject('Test email - ' . Constants::SOFTWARE);
$message->text('This is a test email from your time-tracker');
$this->dispatcher->dispatch(new EmailEvent($message));

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.29.0';
public const VERSION = '2.30.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22900;
public const VERSION_ID = 23000;
/**
* The software name
*/

View File

@@ -30,9 +30,11 @@ class InvoiceTemplate
#[Assert\Length(min: 1, max: 60)]
private ?string $name = null;
#[ORM\Column(name: 'title', type: 'string', length: 255, nullable: false)]
#[Assert\Length(max: 255)]
#[Assert\NotBlank]
private ?string $title = null;
#[ORM\Column(name: 'company', type: 'string', length: 255, nullable: false)]
#[Assert\Length(max: 255)]
#[Assert\NotBlank]
private ?string $company = null;
#[ORM\Column(name: 'vat_id', type: 'string', length: 50, nullable: true)]

View File

@@ -0,0 +1,46 @@
<?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 Doctrine\ORM\QueryBuilder;
use Symfony\Contracts\EventDispatcher\Event;
final class WorkingTimeQueryStatsEvent extends Event
{
public function __construct(
private readonly QueryBuilder $queryBuilder,
private readonly User $user,
private readonly \DateTimeInterface $begin,
private readonly \DateTimeInterface $end
)
{
}
public function getQueryBuilder(): QueryBuilder
{
return $this->queryBuilder;
}
public function getUser(): User
{
return $this->user;
}
public function getBegin(): \DateTimeInterface
{
return $this->begin;
}
public function getEnd(): \DateTimeInterface
{
return $this->end;
}
}

View File

@@ -0,0 +1,38 @@
<?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\Form\DataTransformer;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\DataTransformerInterface;
final class EntityByIdTransformer implements DataTransformerInterface // @phpstan-ignore missingType.generics
{
public function __construct(private readonly EntityRepository $repository) // @phpstan-ignore missingType.generics
{
}
public function transform(mixed $value): mixed
{
if (is_numeric($value)) {
return $this->repository->find($value);
}
return $value;
}
public function reverseTransform(mixed $value): mixed
{
if (\is_object($value) && method_exists($value, 'getId') && $value->getId() !== null) {
return (string) $value->getId();
}
return $value;
}
}

View File

@@ -0,0 +1,32 @@
<?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\Form\Type;
use App\Form\DataTransformer\EntityByIdTransformer;
use App\Repository\ActivityRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
final class ActivityByIdType extends AbstractType
{
public function __construct(private readonly ActivityRepository $repository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
}
public function getParent(): string
{
return ActivityType::class;
}
}

View File

@@ -0,0 +1,32 @@
<?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\Form\Type;
use App\Form\DataTransformer\EntityByIdTransformer;
use App\Repository\CustomerRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
final class CustomerByIdType extends AbstractType
{
public function __construct(private readonly CustomerRepository $repository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
}
public function getParent(): string
{
return CustomerType::class;
}
}

View File

@@ -0,0 +1,32 @@
<?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\Form\Type;
use App\Form\DataTransformer\EntityByIdTransformer;
use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
final class ProjectByIdType extends AbstractType
{
public function __construct(private readonly ProjectRepository $repository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
}
public function getParent(): string
{
return ProjectType::class;
}
}

View File

@@ -0,0 +1,32 @@
<?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\Form\Type;
use App\Form\DataTransformer\EntityByIdTransformer;
use App\Repository\TeamRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
final class TeamByIdType extends AbstractType
{
public function __construct(private readonly TeamRepository $repository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
}
public function getParent(): string
{
return TeamType::class;
}
}

View File

@@ -0,0 +1,32 @@
<?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\Form\Type;
use App\Form\DataTransformer\EntityByIdTransformer;
use App\Repository\UserRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
final class UserByIdType extends AbstractType
{
public function __construct(private readonly UserRepository $repository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(new EntityByIdTransformer($this->repository));
}
public function getParent(): string
{
return UserType::class;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\WorkingTime\Mode;
use App\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class WorkingTimeModeFactory
@@ -19,7 +20,8 @@ final class WorkingTimeModeFactory
*/
public function __construct(
#[TaggedIterator(WorkingTimeMode::class)]
private readonly iterable $modes
private readonly iterable $modes,
private readonly LoggerInterface $logger
)
{
}
@@ -39,7 +41,15 @@ final class WorkingTimeModeFactory
public function getModeForUser(User $user): WorkingTimeMode
{
return $this->getMode($user->getWorkContractMode());
try {
return $this->getMode($user->getWorkContractMode());
} catch (\InvalidArgumentException $ex) {
$this->logger->error(
\sprintf('Unknown mode "%s" requested for user %s', $user->getWorkContractMode(), $user->getId())
);
return new WorkingTimeModeNone(); // @CloudRequired
}
}
public function getMode(string $contractMode): WorkingTimeMode

View File

@@ -12,6 +12,7 @@ namespace App\WorkingTime;
use App\Entity\User;
use App\Entity\WorkingTime;
use App\Event\WorkingTimeApproveMonthEvent;
use App\Event\WorkingTimeQueryStatsEvent;
use App\Event\WorkingTimeYearEvent;
use App\Event\WorkingTimeYearSummaryEvent;
use App\Repository\TimesheetRepository;
@@ -235,6 +236,9 @@ final class WorkingTimeService
->addGroupBy('day')
;
$event = new WorkingTimeQueryStatsEvent($qb, $user, $begin, $end);
$this->eventDispatcher->dispatch($event);
$results = $qb->getQuery()->getResult();
$durations = [];

View File

@@ -25,7 +25,7 @@ class ActionsControllerTest extends APIControllerBaseTestCase
$this->assertUrlIsSecured('/api/actions/timesheet/1/index/en');
}
public function test_getTimesheetActions(): void
public function testGetTimesheetActions(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -73,7 +73,7 @@ class ActionsControllerTest extends APIControllerBaseTestCase
}
}
public function test_getActivityActions(): void
public function testGetActivityActions(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
@@ -125,7 +125,7 @@ class ActionsControllerTest extends APIControllerBaseTestCase
}
}
public function test_getProjectActions(): void
public function testGetProjectActions(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
@@ -176,7 +176,7 @@ class ActionsControllerTest extends APIControllerBaseTestCase
}
}
public function test_getCustomerActions(): void
public function testGetCustomerActions(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);

View File

@@ -381,7 +381,7 @@ class LocaleFormatExtensionsTest extends TestCase
/**
* @dataProvider getMoneyData62_1
*/
public function testMoney62_1(string $result, null|int|float $amount, string $currency, string $locale): void
public function testMoney621(string $result, null|int|float $amount, string $currency, string $locale): void
{
IntlTestHelper::requireFullIntl($this, '62.1');

View File

@@ -14,6 +14,8 @@ use App\WorkingTime\Mode\WorkingTimeModeDay;
use App\WorkingTime\Mode\WorkingTimeModeFactory;
use App\WorkingTime\Mode\WorkingTimeModeNone;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* @covers \App\WorkingTime\Mode\WorkingTimeModeFactory
@@ -25,7 +27,7 @@ class WorkingTimeModeFactoryTest extends TestCase
$none = new WorkingTimeModeNone();
$day = new WorkingTimeModeDay();
$modes = [$none, $day];
$sut = new WorkingTimeModeFactory($modes);
$sut = new WorkingTimeModeFactory($modes, new NullLogger());
self::assertEquals($modes, $sut->getAll());
self::assertSame($none, $sut->getMode('none'));
self::assertSame($day, $sut->getMode('day'));
@@ -35,12 +37,24 @@ class WorkingTimeModeFactoryTest extends TestCase
self::assertSame($day, $sut->getModeForUser($user));
}
public function testFallbackMode(): void
{
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('error');
$modes = [new WorkingTimeModeNone(), new WorkingTimeModeDay()];
$sut = new WorkingTimeModeFactory($modes, $logger);
$user = new User();
$user->setWorkContractMode('foo');
self::assertInstanceOf(WorkingTimeModeNone::class, $sut->getModeForUser($user));
}
public function testException(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Unknown working contract mode: foo');
$sut = new WorkingTimeModeFactory([]);
$sut = new WorkingTimeModeFactory([], new NullLogger());
$sut->getMode('foo');
}
}

View File

@@ -34,6 +34,18 @@
<source>daterangepicker.allTime</source>
<target>Gesamter Zeitraum</target>
</trans-unit>
<trans-unit id="8f61k.L" resname="daterangepicker.thisMonth">
<source>daterangepicker.thisMonth</source>
<target>Dieser Monat</target>
</trans-unit>
<trans-unit id="AdEC6w4" resname="daterangepicker.lastMonth">
<source>daterangepicker.lastMonth</source>
<target>Letzter Monat</target>
</trans-unit>
<trans-unit id="mgJ2Z7M" resname="daterangepicker.thisFinancialYear">
<source>daterangepicker.thisFinancialYear</source>
<target>Dieses Geschäftsjahr</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -34,6 +34,18 @@
<source>daterangepicker.allTime</source>
<target>Total period</target>
</trans-unit>
<trans-unit id="8f61k.L" resname="daterangepicker.thisMonth">
<source>daterangepicker.thisMonth</source>
<target>This month</target>
</trans-unit>
<trans-unit id="AdEC6w4" resname="daterangepicker.lastMonth">
<source>daterangepicker.lastMonth</source>
<target>Last month</target>
</trans-unit>
<trans-unit id="mgJ2Z7M" resname="daterangepicker.thisFinancialYear">
<source>daterangepicker.thisFinancialYear</source>
<target>This financial year</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1750,6 +1750,10 @@
<source>booking_allow_only_work_days</source>
<target>Erlaube Zeiteinträge nur an Tagen, für die im Arbeitsvertrag Sollstunden hinterlegt sind</target>
</trans-unit>
<trans-unit id="HQ2YM1b" resname="attendance_only_project">
<source>attendance_only_project</source>
<target>Nur Einträge des ausgewählten Projekts als Arbeitszeit zählen</target>
</trans-unit>
<trans-unit id="q4ooRfF" resname="expires">
<source>Expiry date</source>
<target>Ablaufdatum</target>

View File

@@ -1750,6 +1750,10 @@
<source>booking_allow_only_work_days</source>
<target>Allow time entries only for days for which expected hours are defined in the employment contract</target>
</trans-unit>
<trans-unit id="HQ2YM1b" resname="attendance_only_project">
<source>attendance_only_project</source>
<target>Only count entries of the selected project as working time</target>
</trans-unit>
<trans-unit id="q4ooRfF" resname="expires">
<source>Expiry date</source>
<target>Expiry date</target>