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:
@@ -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'
|
||||
],
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)]
|
||||
|
||||
46
src/Event/WorkingTimeQueryStatsEvent.php
Normal file
46
src/Event/WorkingTimeQueryStatsEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/Form/DataTransformer/EntityByIdTransformer.php
Normal file
38
src/Form/DataTransformer/EntityByIdTransformer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/ActivityByIdType.php
Normal file
32
src/Form/Type/ActivityByIdType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/CustomerByIdType.php
Normal file
32
src/Form/Type/CustomerByIdType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/ProjectByIdType.php
Normal file
32
src/Form/Type/ProjectByIdType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/TeamByIdType.php
Normal file
32
src/Form/Type/TeamByIdType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Form/Type/UserByIdType.php
Normal file
32
src/Form/Type/UserByIdType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user