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
+