Release 2.36.0 (#5514)

This commit is contained in:
Kevin Papst
2025-06-02 16:07:48 +02:00
committed by GitHub
parent 594385f741
commit 2f2ebd6293
27 changed files with 466 additions and 257 deletions

383
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -63,24 +63,6 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
}
public function serializeValidationExceptionToJson(JsonSerializationVisitor $visitor, ValidationFailedException $exception, array $type, Context $context)
{
$errors = [];
/** @var ConstraintViolationInterface $error */
foreach (iterator_to_array($exception->getViolations()) as $error) {
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error);
}
return [
'code' => '400',
'message' => $this->translator->trans($exception->getMessage(), [], 'validators'),
'errors' => [
'children' => $errors
],
];
}
private function getErrorMessage(ConstraintViolationInterface $error): string
{
$locale = \Locale::getDefault();
/** @var User $user */
@@ -90,6 +72,24 @@ final class ValidationFailedExceptionErrorHandler implements SubscribingHandlerI
$locale = $user->getLanguage();
}
$errors = [];
/** @var ConstraintViolationInterface $error */
foreach (iterator_to_array($exception->getViolations()) as $error) {
$errors[$error->getPropertyPath()]['errors'][] = $this->getErrorMessage($error, $locale);
}
return [
'code' => '400',
'message' => $this->translator->trans($exception->getMessage(), [], 'validators', $locale),
'errors' => [
'children' => $errors
],
];
}
private function getErrorMessage(ConstraintViolationInterface $error, string $locale): string
{
if (null !== $error->getPlural()) {
return $this->translator->trans($error->getMessageTemplate(), ['%count%' => $error->getPlural()] + $error->getParameters(), 'validators', $locale);
}

View File

@@ -315,7 +315,7 @@ final class TimesheetController extends BaseApiController
if ($form->isValid()) {
try {
$this->service->saveNewTimesheet($timesheet);
$this->service->saveTimesheet($timesheet);
$view = new View($timesheet, 200);
@@ -372,7 +372,7 @@ final class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->service->updateTimesheet($timesheet);
$this->service->saveTimesheet($timesheet);
$view = new View($timesheet, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -547,7 +547,7 @@ final class TimesheetController extends BaseApiController
$copyTimesheet = clone $timesheet;
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
$this->service->saveNewTimesheet($copyTimesheet);
$this->service->saveTimesheet($copyTimesheet);
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
$view = new View($copyTimesheet, 200);
@@ -571,7 +571,7 @@ final class TimesheetController extends BaseApiController
$timesheet->setExported(!$timesheet->isExported());
$this->service->updateTimesheet($timesheet);
$this->service->saveTimesheet($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -601,7 +601,7 @@ final class TimesheetController extends BaseApiController
$meta->setValue($paramFetcher->get('value'));
$this->service->updateTimesheet($timesheet);
$this->service->saveTimesheet($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -97,7 +97,7 @@ class ActivityService
$errors = $this->validator->validate($activity, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors, 'Validation Failed');
throw new ValidationFailedException($errors);
}
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.35.1';
public const VERSION = '2.36.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23501;
public const VERSION_ID = 23600;
/**
* The software name
*/

View File

@@ -152,7 +152,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->service->updateTimesheet($entry);
$this->service->saveTimesheet($entry);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute());
@@ -184,7 +184,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($createForm->isSubmitted() && $createForm->isValid()) {
try {
$this->service->saveNewTimesheet($entry);
$this->service->saveTimesheet($entry);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute());
@@ -216,7 +216,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->dispatcher->dispatch(new TimesheetDuplicatePreEvent($copyTimesheet, $timesheet));
$this->service->saveNewTimesheet($copyTimesheet);
$this->service->saveTimesheet($copyTimesheet);
$this->dispatcher->dispatch(new TimesheetDuplicatePostEvent($copyTimesheet, $timesheet));
$this->flashSuccess('action.update.success');

View File

@@ -126,7 +126,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
}
foreach ($newTimesheets as $newTimesheet) {
$this->service->saveNewTimesheet($newTimesheet);
$this->service->saveTimesheet($newTimesheet);
}
$this->flashSuccess('action.update.success');

View File

@@ -101,7 +101,7 @@ final class CustomerService
$errors = $this->validator->validate($customer, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors, 'Validation Failed');
throw new ValidationFailedException($errors);
}
}

View File

@@ -59,8 +59,7 @@ class TimesheetEditForm extends AbstractType
$timezone = $options['timezone'];
$isNew = true;
if (isset($options['data'])) {
/** @var Timesheet $entry */
if (isset($options['data']) && $options['data'] instanceof Timesheet) {
$entry = $options['data'];
$activity = $entry->getActivity();
@@ -338,7 +337,7 @@ class TimesheetEditForm extends AbstractType
function (FormEvent $event) {
/** @var Timesheet|null $timesheet */
$timesheet = $event->getData();
if (null === $timesheet || $timesheet->isRunning()) {
if (null === $timesheet || ($timesheet instanceof Timesheet && $timesheet->isRunning())) {
$event->getForm()->get('duration')->setData(null);
}
}

View File

@@ -218,9 +218,7 @@ final class InvoiceModel
}
/**
* Returns the user who is currently creating the invoice.
*
* @return User|null
* Returns the user currently creating the invoice.
*/
public function getUser(): ?User
{

View File

@@ -105,7 +105,7 @@ final class ProjectService
$errors = $this->validator->validate($project, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors, 'Validation Failed');
throw new ValidationFailedException($errors);
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Saml;
use App\Configuration\SamlConfigurationInterface;
use App\Saml\Security\SamlAuthenticationFailureHandler;
use App\Saml\Security\SamlAuthenticationSuccessHandler;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -40,7 +41,8 @@ class SamlAuthenticator extends AbstractAuthenticator
private readonly SamlAuthenticationFailureHandler $failureHandler,
private readonly SamlAuthFactory $samlAuthFactory,
private readonly SamlProvider $samlProvider,
private readonly SamlConfigurationInterface $configuration
private readonly SamlConfigurationInterface $configuration,
private readonly LoggerInterface $logger
) {
}
@@ -86,6 +88,7 @@ class SamlAuthenticator extends AbstractAuthenticator
// file_put_contents(__DIR__ . '/../../var/log/saml.xml', $oneLoginAuth->getLastResponseXML());
if (\count($oneLoginAuth->getErrors()) > 0) {
$this->logger->critical('SAML login failed: ' . $oneLoginAuth->getLastErrorReason());
throw new AuthenticationException($oneLoginAuth->getLastErrorReason());
}
@@ -102,7 +105,9 @@ class SamlAuthenticator extends AbstractAuthenticator
if (isset($this->options['username_attribute'])) {
if (!\array_key_exists($this->options['username_attribute'], $attributes)) {
throw new \Exception(\sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']));
$errorMessage = \sprintf("Attribute '%s' not found in SAML data", $this->options['username_attribute']);
$this->logger->critical($errorMessage);
throw new \Exception($errorMessage);
}
$username = $attributes[$this->options['username_attribute']][0];

View File

@@ -45,12 +45,12 @@ final class TimesheetService
private array $doNotValidateCodes = [];
public function __construct(
private SystemConfiguration $configuration,
private TimesheetRepository $repository,
private TrackingModeService $trackingModeService,
private EventDispatcherInterface $dispatcher,
private AuthorizationCheckerInterface $auth,
private ValidatorInterface $validator
private readonly SystemConfiguration $configuration,
private readonly TimesheetRepository $repository,
private readonly TrackingModeService $trackingModeService,
private readonly EventDispatcherInterface $dispatcher,
private readonly AuthorizationCheckerInterface $auth,
private readonly ValidatorInterface $validator
) {
}
@@ -101,7 +101,7 @@ final class TimesheetService
public function restartTimesheet(Timesheet $timesheet, Timesheet $copyFrom): Timesheet
{
$this->dispatcher->dispatch(new TimesheetRestartPreEvent($timesheet, $copyFrom));
$this->saveNewTimesheet($timesheet);
$this->saveNewTimesheet($timesheet); // @phpstan-ignore method.deprecated
$this->dispatcher->dispatch(new TimesheetRestartPostEvent($timesheet, $copyFrom));
return $timesheet;
@@ -111,6 +111,7 @@ final class TimesheetService
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
* @throws InvalidArgumentException for already persisted timesheets
* @throws AccessDeniedException if user is not allowed to start timesheet
* @deprecated since 2.36.0 - use saveTimesheet() instead
*/
public function saveNewTimesheet(Timesheet $timesheet): Timesheet
{
@@ -150,12 +151,19 @@ final class TimesheetService
return $timesheet;
}
public function saveTimesheet(Timesheet $timesheet): Timesheet
{
if ($timesheet->getId() === null) {
return $this->saveNewTimesheet($timesheet); // @phpstan-ignore method.deprecated
} else {
return $this->updateTimesheet($timesheet); // @phpstan-ignore method.deprecated
}
}
/**
* Does NOT validate the given timesheet!
* Does NOT validate the given timesheet.
*
* @param Timesheet $timesheet
* @return Timesheet
* @throws \Exception
* @deprecated since 2.36.0 - use saveTimesheet() instead
*/
public function updateTimesheet(Timesheet $timesheet): Timesheet
{
@@ -245,7 +253,7 @@ final class TimesheetService
continue;
}
throw new ValidationFailedException($errors, 'Validation Failed');
throw new ValidationFailedException($errors);
}
}
}

View File

@@ -112,7 +112,7 @@ class UserService
$errors = $this->validator->validate($user, null, $groups);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors, 'Validation Failed');
throw new ValidationFailedException($errors);
}
}

View File

@@ -0,0 +1,26 @@
<?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\Validator\Constraints;
final class TimesheetNegativeDuration extends TimesheetConstraint
{
public const NEGATIVE_DURATION_ERROR = 'kimai-timesheet-negative-duration-01';
protected const ERROR_NAMES = [
self::NEGATIVE_DURATION_ERROR => 'Duration cannot be negative.',
];
public string $message = 'Duration cannot be negative.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,43 @@
<?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\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetNegativeDurationValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetNegativeDuration)) {
throw new UnexpectedTypeException($constraint, TimesheetNegativeDuration::class);
}
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
if ($value->isRunning()) {
return;
}
$duration = $value->getCalculatedDuration();
if ($duration !== null && $duration < 0) {
$this->context->buildViolation($constraint->message)
->atPath('duration')
->setTranslationDomain('validators')
->setCode(TimesheetNegativeDuration::NEGATIVE_DURATION_ERROR)
->addViolation();
}
}
}

View File

@@ -44,7 +44,7 @@ final class TimesheetZeroDurationValidator extends ConstraintValidator
$duration = $value->getCalculatedDuration();
}
if ($duration <= 0) {
if ($duration === 0) {
$this->context->buildViolation($constraint->message)
->atPath('duration')
->setTranslationDomain('validators')

View File

@@ -14,7 +14,7 @@ final class ValidationException extends \RuntimeException
public function __construct(string $message = null)
{
if ($message === null) {
$message = 'Validation failed';
$message = 'Validation Failed';
}
parent::__construct($message, 400);
}

View File

@@ -13,10 +13,10 @@ use Symfony\Component\Validator\ConstraintViolationListInterface;
final class ValidationFailedException extends \RuntimeException
{
public function __construct(private ConstraintViolationListInterface $violations, ?string $message = null)
public function __construct(private readonly ConstraintViolationListInterface $violations, ?string $message = null)
{
if ($message === null) {
$message = 'Validation failed';
$message = 'Validation Failed';
}
parent::__construct($message, 400);
}

View File

@@ -251,7 +251,7 @@ class TimesheetValidationTest extends KernelTestCase
$entity->setBegin($begin);
$entity->setEnd($end);
$this->assertHasViolationForField($entity, 'end_date');
$this->assertHasViolationForField($entity, ['end_date', 'duration']);
// allow same begin and end
$entity = $this->getEntity();

View File

@@ -65,6 +65,9 @@ class TimesheetServiceTest extends TestCase
return $service;
}
/**
* @group legacy
*/
public function testCannotSavePersistedTimesheetAsNew(): void
{
$timesheet = $this->createMock(Timesheet::class);
@@ -75,7 +78,7 @@ class TimesheetServiceTest extends TestCase
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Cannot create timesheet, already persisted');
$sut->saveNewTimesheet($timesheet);
$sut->saveNewTimesheet($timesheet); // @phpstan-ignore method.deprecated
}
public function testCannotStartTimesheet(): void
@@ -88,7 +91,7 @@ class TimesheetServiceTest extends TestCase
$this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('You are not allowed to start this timesheet record');
$sut->saveNewTimesheet(new Timesheet());
$sut->saveTimesheet(new Timesheet());
}
public function testSaveNewTimesheetHasValidationError(): void
@@ -107,7 +110,7 @@ class TimesheetServiceTest extends TestCase
$this->expectException(ValidationFailedException::class);
$this->expectExceptionMessage('Validation Failed');
$sut->saveNewTimesheet(new Timesheet());
$sut->saveTimesheet(new Timesheet());
}
public function testSaveNewTimesheetStopsActiveRecords(): void
@@ -134,7 +137,7 @@ class TimesheetServiceTest extends TestCase
$sut = $this->getSut($authorizationChecker, null, null, $repository);
$sut->saveNewTimesheet($newTimesheet);
$sut->saveTimesheet($newTimesheet);
}
public function testSaveNewTimesheetFixesTimezone(): void
@@ -155,11 +158,14 @@ class TimesheetServiceTest extends TestCase
$authorizationChecker->expects($this->once())->method('isGranted')->willReturn(true);
$sut = $this->getSut($authorizationChecker);
$sut->saveNewTimesheet($timesheet);
$sut->saveTimesheet($timesheet);
self::assertEquals('Europe/Paris', $timesheet->getTimezone());
}
/**
* @group legacy
*/
public function testUpdateTimesheetFixesTimezone(): void
{
$user = new User();
@@ -176,7 +182,7 @@ class TimesheetServiceTest extends TestCase
$sut = $this->getSut();
$sut->updateTimesheet($timesheet);
$sut->updateTimesheet($timesheet); // @phpstan-ignore method.deprecated
self::assertEquals('Europe/Paris', $timesheet->getTimezone());
}

View File

@@ -0,0 +1,25 @@
<?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\Tests\Validator\Constraints;
use App\Validator\Constraints\TimesheetConstraint;
use App\Validator\Constraints\TimesheetNegativeDuration;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Validator\Constraints\TimesheetNegativeDuration
*/
class TimesheetNegativeDurationTest extends TestCase
{
public function testIsTimesheetConstraint(): void
{
self::assertInstanceOf(TimesheetConstraint::class, new TimesheetNegativeDuration());
}
}

View File

@@ -0,0 +1,90 @@
<?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\Tests\Validator\Constraints;
use App\Entity\Timesheet;
use App\Validator\Constraints\TimesheetNegativeDuration;
use App\Validator\Constraints\TimesheetNegativeDurationValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetNegativeDuration
* @covers \App\Validator\Constraints\TimesheetNegativeDurationValidator
* @extends ConstraintValidatorTestCase<TimesheetNegativeDurationValidator>
*/
class TimesheetNegativeDurationValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): TimesheetNegativeDurationValidator
{
return new TimesheetNegativeDurationValidator();
}
public function testConstraintIsInvalid(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetNegativeDuration(['message' => 'Duration cannot be negative.']));
}
public function testNegativeDurationIsNotAllowed(): void
{
$begin = new \DateTime();
$timesheet = new Timesheet();
$timesheet->setBegin(clone $begin);
$timesheet->setEnd(clone $begin);
$timesheet->setBreak(3600);
$this->validator->validate($timesheet, new TimesheetNegativeDuration(['message' => 'Duration cannot be negative.']));
$this->buildViolation('Duration cannot be negative.')
->atPath('property.path.duration')
->setCode(TimesheetNegativeDuration::NEGATIVE_DURATION_ERROR)
->assertRaised();
}
public function testZeroDurationIsAllowed(): void
{
$begin = new \DateTime();
$timesheet = new Timesheet();
$timesheet->setBegin(clone $begin);
$timesheet->setEnd(clone $begin);
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($timesheet, new TimesheetNegativeDuration(['message' => 'Duration cannot be negative.']));
$this->assertNoViolation();
}
public function testDoesNotTriggerOnRunningTimesheet(): void
{
$begin = new \DateTime();
$timesheet = new Timesheet();
$timesheet->setBegin(clone $begin);
$timesheet->setBreak(3600);
$this->validator = $this->createValidator();
$this->validator->initialize($this->context);
$this->validator->validate($timesheet, new TimesheetNegativeDuration(['message' => 'Duration cannot be negative.']));
$this->assertNoViolation();
}
}

View File

@@ -21,7 +21,7 @@ class ValidationExceptionTest extends TestCase
{
$sut = new ValidationException();
self::assertEquals(400, $sut->getCode());
self::assertEquals('Validation failed', $sut->getMessage());
self::assertEquals('Validation Failed', $sut->getMessage());
}
public function testConstruct(): void

View File

@@ -23,7 +23,7 @@ class ValidationFailedExceptionTest extends TestCase
$list = new ConstraintViolationList();
$sut = new ValidationFailedException($list);
self::assertEquals(400, $sut->getCode());
self::assertEquals('Validation failed', $sut->getMessage());
self::assertEquals('Validation Failed', $sut->getMessage());
self::assertSame($list, $sut->getViolations());
}

View File

@@ -162,6 +162,10 @@
<source>Selected period cannot be locked: unconfirmed absence requests are pending.</source>
<target>Ausgewählter Zeitraum kann nicht gesperrt werden: unbestätigte Abwesenheitsanträge stehen an.</target>
</trans-unit>
<trans-unit id="KVyAzBQ" resname="Duration cannot be negative.">
<source>Duration cannot be negative.</source>
<target>Die Dauer kann nicht negativ sein.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -162,6 +162,10 @@
<source>Selected period cannot be locked: unconfirmed absence requests are pending.</source>
<target>Selected period cannot be locked: unconfirmed absence requests are pending.</target>
</trans-unit>
<trans-unit id="KVyAzBQ" resname="Duration cannot be negative.">
<source>Duration cannot be negative.</source>
<target>Duration cannot be negative.</target>
</trans-unit>
</body>
</file>
</xliff>