diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index a60efc4b..1001853a 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -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' ], diff --git a/src/Command/MailTestCommand.php b/src/Command/MailTestCommand.php index 7507d756..9f4b54d7 100644 --- a/src/Command/MailTestCommand.php +++ b/src/Command/MailTestCommand.php @@ -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)); diff --git a/src/Constants.php b/src/Constants.php index eaab08d3..3d094805 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -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 */ diff --git a/src/Entity/InvoiceTemplate.php b/src/Entity/InvoiceTemplate.php index 26286071..e87b7a94 100644 --- a/src/Entity/InvoiceTemplate.php +++ b/src/Entity/InvoiceTemplate.php @@ -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)] diff --git a/src/Event/WorkingTimeQueryStatsEvent.php b/src/Event/WorkingTimeQueryStatsEvent.php new file mode 100644 index 00000000..9e657cb1 --- /dev/null +++ b/src/Event/WorkingTimeQueryStatsEvent.php @@ -0,0 +1,46 @@ +queryBuilder; + } + + public function getUser(): User + { + return $this->user; + } + + public function getBegin(): \DateTimeInterface + { + return $this->begin; + } + + public function getEnd(): \DateTimeInterface + { + return $this->end; + } +} diff --git a/src/Form/DataTransformer/EntityByIdTransformer.php b/src/Form/DataTransformer/EntityByIdTransformer.php new file mode 100644 index 00000000..5fcb2aca --- /dev/null +++ b/src/Form/DataTransformer/EntityByIdTransformer.php @@ -0,0 +1,38 @@ +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; + } +} diff --git a/src/Form/Type/ActivityByIdType.php b/src/Form/Type/ActivityByIdType.php new file mode 100644 index 00000000..422a494a --- /dev/null +++ b/src/Form/Type/ActivityByIdType.php @@ -0,0 +1,32 @@ +addModelTransformer(new EntityByIdTransformer($this->repository)); + } + + public function getParent(): string + { + return ActivityType::class; + } +} diff --git a/src/Form/Type/CustomerByIdType.php b/src/Form/Type/CustomerByIdType.php new file mode 100644 index 00000000..9a3399e5 --- /dev/null +++ b/src/Form/Type/CustomerByIdType.php @@ -0,0 +1,32 @@ +addModelTransformer(new EntityByIdTransformer($this->repository)); + } + + public function getParent(): string + { + return CustomerType::class; + } +} diff --git a/src/Form/Type/ProjectByIdType.php b/src/Form/Type/ProjectByIdType.php new file mode 100644 index 00000000..824d232c --- /dev/null +++ b/src/Form/Type/ProjectByIdType.php @@ -0,0 +1,32 @@ +addModelTransformer(new EntityByIdTransformer($this->repository)); + } + + public function getParent(): string + { + return ProjectType::class; + } +} diff --git a/src/Form/Type/TeamByIdType.php b/src/Form/Type/TeamByIdType.php new file mode 100644 index 00000000..09c0f8d8 --- /dev/null +++ b/src/Form/Type/TeamByIdType.php @@ -0,0 +1,32 @@ +addModelTransformer(new EntityByIdTransformer($this->repository)); + } + + public function getParent(): string + { + return TeamType::class; + } +} diff --git a/src/Form/Type/UserByIdType.php b/src/Form/Type/UserByIdType.php new file mode 100644 index 00000000..08725ff6 --- /dev/null +++ b/src/Form/Type/UserByIdType.php @@ -0,0 +1,32 @@ +addModelTransformer(new EntityByIdTransformer($this->repository)); + } + + public function getParent(): string + { + return UserType::class; + } +} diff --git a/src/WorkingTime/Mode/WorkingTimeModeFactory.php b/src/WorkingTime/Mode/WorkingTimeModeFactory.php index 71336498..2b004936 100644 --- a/src/WorkingTime/Mode/WorkingTimeModeFactory.php +++ b/src/WorkingTime/Mode/WorkingTimeModeFactory.php @@ -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 diff --git a/src/WorkingTime/WorkingTimeService.php b/src/WorkingTime/WorkingTimeService.php index e6600271..5937503e 100644 --- a/src/WorkingTime/WorkingTimeService.php +++ b/src/WorkingTime/WorkingTimeService.php @@ -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 = []; diff --git a/tests/API/ActionsControllerTest.php b/tests/API/ActionsControllerTest.php index 805d4ec9..9f28a6cc 100644 --- a/tests/API/ActionsControllerTest.php +++ b/tests/API/ActionsControllerTest.php @@ -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); diff --git a/tests/Twig/LocaleFormatExtensionsTest.php b/tests/Twig/LocaleFormatExtensionsTest.php index 7bad91f5..72027fad 100644 --- a/tests/Twig/LocaleFormatExtensionsTest.php +++ b/tests/Twig/LocaleFormatExtensionsTest.php @@ -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'); diff --git a/tests/WorkingTime/Mode/WorkingTimeModeFactoryTest.php b/tests/WorkingTime/Mode/WorkingTimeModeFactoryTest.php index d07bba6b..4726ba26 100644 --- a/tests/WorkingTime/Mode/WorkingTimeModeFactoryTest.php +++ b/tests/WorkingTime/Mode/WorkingTimeModeFactoryTest.php @@ -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'); } } diff --git a/translations/daterangepicker.de.xlf b/translations/daterangepicker.de.xlf index 39a6089b..47c342f5 100644 --- a/translations/daterangepicker.de.xlf +++ b/translations/daterangepicker.de.xlf @@ -34,6 +34,18 @@ daterangepicker.allTime Gesamter Zeitraum + + daterangepicker.thisMonth + Dieser Monat + + + daterangepicker.lastMonth + Letzter Monat + + + daterangepicker.thisFinancialYear + Dieses Geschäftsjahr + diff --git a/translations/daterangepicker.en.xlf b/translations/daterangepicker.en.xlf index d5aac50c..a203cf66 100644 --- a/translations/daterangepicker.en.xlf +++ b/translations/daterangepicker.en.xlf @@ -34,6 +34,18 @@ daterangepicker.allTime Total period + + daterangepicker.thisMonth + This month + + + daterangepicker.lastMonth + Last month + + + daterangepicker.thisFinancialYear + This financial year + diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 2d2b49d5..e158bb7b 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -1750,6 +1750,10 @@ booking_allow_only_work_days Erlaube Zeiteinträge nur an Tagen, für die im Arbeitsvertrag Sollstunden hinterlegt sind + + attendance_only_project + Nur Einträge des ausgewählten Projekts als Arbeitszeit zählen + Expiry date Ablaufdatum diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 406c9473..0e659315 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -1750,6 +1750,10 @@ booking_allow_only_work_days Allow time entries only for days for which expected hours are defined in the employment contract + + attendance_only_project + Only count entries of the selected project as working time + Expiry date Expiry date