Release 2.40.0 (#5621)

This commit is contained in:
Kevin Papst
2025-09-26 10:49:06 +02:00
committed by GitHub
parent 6d78c6ba36
commit 7937fa281a
54 changed files with 531 additions and 399 deletions

View File

@@ -13,7 +13,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class NotFoundException extends NotFoundHttpException
{
public function __construct(string $message = 'Not found', \Exception $previous = null, int $code = 404, array $headers = [])
public function __construct(string $message = 'Not found', ?\Throwable $previous = null, int $code = 404, array $headers = [])
{
parent::__construct($message, $previous, $code, $headers);
}

View File

@@ -11,6 +11,7 @@ namespace App\API;
use App\Entity\AccessToken;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
use App\Form\API\UserApiCreateForm;
use App\Form\API\UserApiEditForm;
@@ -23,10 +24,12 @@ use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -224,4 +227,44 @@ final class UserController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Update user preferences
*/
#[IsGranted('edit', 'profile')]
#[OA\Response(response: 200, description: 'Sets the value of a consifgured preference. You cannot create unknown preferences: if the given name is not configured, an exception will be raised.', content: new OA\JsonContent(ref: '#/components/schemas/UserEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'User ID to set the custom-field value for', required: true)]
#[OA\RequestBody(required: true, content: new OA\JsonContent(type: 'array', items: new OA\Items(new Model(type: UserPreference::class))))]
#[Route(methods: ['PATCH'], path: '/{id}/preferences', requirements: ['id' => '\d+'])]
public function updateUserPreference(User $profile, Request $request, EventDispatcherInterface $dispatcher): Response
{
$event = new PrepareUserEvent($profile, false);
$dispatcher->dispatch($event);
foreach ($request->request->all() as $preference) {
// why is this not handled by FosRestBundle ?
if (!\is_array($preference)) {
throw new BadRequestHttpException('Invalid request, array expected');
}
if (!\array_key_exists('name', $preference) || !\array_key_exists('value', $preference)) {
throw new BadRequestHttpException('Missing required parameter "name" or "value"');
}
$name = $preference['name'];
$value = $preference['value'];
if (null === ($meta = $profile->getPreference($name))) {
throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name));
}
$meta->setValue($value);
}
$this->repository->saveUser($profile);
$view = new View($profile, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
}

View File

@@ -162,7 +162,7 @@ class ActivityStatisticService
/**
* @param Activity[] $activities
*/
private function createStatisticQueryBuilder(array $activities, \DateTimeInterface $begin = null, ?\DateTimeInterface $end = null): QueryBuilder
private function createStatisticQueryBuilder(array $activities, ?\DateTimeInterface $begin = null, ?\DateTimeInterface $end = null): QueryBuilder
{
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb

View File

@@ -457,7 +457,7 @@ final class TranslationCommand extends Command
$unit['resname'] = $source;
}
if ($unit['resname'] !== null) {
$unit['id'] = $this->generateId($unit['resname']); // @phpstan-ignore offsetAssign.valueType
$unit['id'] = $this->generateId($unit['resname']);
}
}
@@ -492,7 +492,7 @@ final class TranslationCommand extends Command
);
}
$unit->target[0] = $translations[$key];
$unit->target['state'] = 'needs-translation'; // @phpstan-ignore assign.propertyType
$unit->target['state'] = 'needs-translation';
$foundEmpty = true;
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.39.0';
public const VERSION = '2.40.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23900;
public const VERSION_ID = 24000;
/**
* The software name
*/

View File

@@ -239,7 +239,8 @@ final class ExportController extends AbstractController
}
return $this->render('export/template.html.twig', [
'form' => $form->createView()
'form' => $form->createView(),
'template' => $exportTemplate,
]);
}
}

View File

@@ -74,11 +74,9 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
}
/**
* @param User $user
* @param string|null $timezone
* @return array<UserPreference>
*/
private function getUserPreferences(User $user, string $timezone = null): array
private function getUserPreferences(User $user, ?string $timezone = null): array
{
$preferences = [];

View File

@@ -77,7 +77,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'company', type: Types::STRING, length: 100, nullable: true)]
#[Assert\Length(max: 100)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'company')]
private ?string $company = null;
#[ORM\Column(name: 'vat_id', type: Types::STRING, length: 50, nullable: true)]
@@ -102,7 +102,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Assert\Country]
#[Assert\Length(max: 2)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'country')]
private ?string $country = null;
#[ORM\Column(name: 'currency', type: Types::STRING, length: 3, nullable: false)]
@@ -110,25 +110,25 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Assert\Currency]
#[Assert\Length(max: 3)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'currency')]
private ?string $currency = self::DEFAULT_CURRENCY;
#[ORM\Column(name: 'phone', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Serializer\Groups(['Customer'])]
#[Exporter\Expose(label: 'phone')]
private ?string $phone = null;
#[ORM\Column(name: 'fax', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Serializer\Groups(['Customer'])]
#[Exporter\Expose(label: 'fax')]
private ?string $fax = null;
#[ORM\Column(name: 'mobile', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Serializer\Groups(['Customer'])]
#[Exporter\Expose(label: 'mobile')]
private ?string $mobile = null;
/**
@@ -143,7 +143,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
#[Assert\Length(max: 100)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Serializer\Groups(['Customer'])]
#[Exporter\Expose(label: 'homepage')]
private ?string $homepage = null;
/**
@@ -154,7 +154,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Assert\Timezone]
#[Assert\Length(max: 64)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Serializer\Groups(['Customer'])]
#[Exporter\Expose(label: 'timezone')]
private ?string $timezone = null;
/**

View File

@@ -60,6 +60,11 @@ class ExportTemplate
return $this->id;
}
public function isNew(): bool
{
return $this->id === null;
}
public function setTitle(?string $title): void
{
$this->title = $title;

View File

@@ -96,7 +96,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/
#[ORM\Column(name: 'start', type: Types::DATETIME_MUTABLE, nullable: true)]
#[Serializer\Expose]
#[Serializer\Groups(['Project'])]
#[Serializer\Groups(['Default'])]
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
#[Serializer\Accessor(getter: 'getStart')]
private ?\DateTime $start = null;
@@ -107,7 +107,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/
#[ORM\Column(name: 'end', type: Types::DATETIME_MUTABLE, nullable: true)]
#[Serializer\Expose]
#[Serializer\Groups(['Project'])]
#[Serializer\Groups(['Default'])]
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
#[Serializer\Accessor(getter: 'getEnd')]
private ?\DateTime $end = null;

View File

@@ -106,7 +106,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[ORM\Column(name: 'avatar', type: Types::STRING, length: 255, nullable: true)]
#[Assert\Length(max: 255, groups: ['Profile'])]
#[Serializer\Expose]
#[Serializer\Groups(['User_Entity'])]
#[Serializer\Groups(['Default'])]
private ?string $avatar = null;
/**
* API token (password) for this user
@@ -965,7 +965,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this;
}
public function setLastLogin(\DateTime $time = null): User
public function setLastLogin(?\DateTime $time = null): User
{
$this->lastLogin = $time;

View File

@@ -30,9 +30,9 @@ class WorkingTime
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?User $user = null;
#[ORM\Column(name: 'date', type: Types::DATE_MUTABLE, nullable: false)]
#[ORM\Column(name: 'date', type: Types::DATE_IMMUTABLE, nullable: false)]
#[Assert\NotNull]
private \DateTimeInterface $date;
private \DateTimeImmutable $date;
#[ORM\Column(name: 'expected', type: Types::INTEGER, nullable: false)]
#[Assert\NotNull]
private int $expectedTime = 0;
@@ -46,7 +46,7 @@ class WorkingTime
#[Assert\NotNull]
private ?\DateTimeImmutable $approvedAt = null;
public function __construct(User $user, \DateTimeInterface $date)
public function __construct(User $user, \DateTimeImmutable $date)
{
$this->user = $user;
$this->date = $date;
@@ -62,7 +62,7 @@ class WorkingTime
return $this->user;
}
public function getDate(): \DateTimeInterface
public function getDate(): \DateTimeImmutable
{
return $this->date;
}

View File

@@ -88,6 +88,14 @@ class Kernel extends BaseKernel
}
$plugins = [];
$finder = new Finder();
$finder->ignoreUnreadableDirs()->directories()->name('*Bundle-*');
foreach ($finder->in($pluginsDir) as $bundleDir) {
throw new \Exception(
\sprintf('Bundle "%s" has invalid directory name. Remove the version number, see https://www.kimai.org/documentation/plugin-management.html.', $bundleDir->getRelativePathname())
);
}
$finder = new Finder();
$finder->ignoreUnreadableDirs()->directories()->name('*Bundle');
/** @var SplFileInfo $bundleDir */

View File

@@ -74,7 +74,7 @@ final class LdapAuthenticator implements AuthenticationEntryPointInterface, Inte
return false;
}
public function start(Request $request, AuthenticationException $authException = null): Response
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
if (!$this->authenticator instanceof AuthenticationEntryPointInterface) {
throw new NotAnEntryPointException(\sprintf('Decorated authenticator "%s" does not implement interface "%s".', get_debug_type($this->authenticator), AuthenticationEntryPointInterface::class));

View File

@@ -721,6 +721,7 @@ class ProjectStatisticService
$qb = clone $tplQb;
$qb->addSelect('MAX(t.date) as lastRecord');
$result = $qb->getQuery()->getScalarResult();
/** @var array{id: int, lastRecord: string, duration: int, rate: float} $row */
foreach ($result as $row) {
if ($row['lastRecord'] !== null) {
// might be the wrong timezone
@@ -736,6 +737,7 @@ class ProjectStatisticService
;
$result = $qb->getQuery()->getScalarResult();
/** @var array{id: int, lastRecord: string, duration: int, rate: float} $row */
foreach ($result as $row) {
$projectViews[$row['id']]->setDurationDay($row['duration'] ?? 0);
}
@@ -749,6 +751,7 @@ class ProjectStatisticService
;
$result = $qb->getQuery()->getScalarResult();
/** @var array{id: int, lastRecord: string, duration: int, rate: float} $row */
foreach ($result as $row) {
$projectViews[$row['id']]->setDurationWeek($row['duration']);
}
@@ -762,6 +765,7 @@ class ProjectStatisticService
;
$result = $qb->getQuery()->getScalarResult();
/** @var array{id: int, lastRecord: string, duration: int, rate: float} $row */
foreach ($result as $row) {
$projectViews[$row['id']]->setDurationMonth($row['duration']);
}

View File

@@ -116,7 +116,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
* Overwritten to fetch preferences when using the Profile controller actions.
* Depends on the query, some magic mechanisms like the ParamConverter will use this method to fetch the user.
*/
public function findOneBy(array $criteria, array $orderBy = null): ?object
public function findOneBy(array $criteria, ?array $orderBy = null): ?object
{
if (\count($criteria) === 1 && isset($criteria['username']) && \is_string($criteria['username'])) {
return $this->loadUserByIdentifier($criteria['username']);

View File

@@ -24,6 +24,10 @@ final class DefaultRounding implements RoundingInterface
return;
}
if ($record->getBegin() === null) {
return;
}
$timestamp = $record->getBegin()->getTimestamp();
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;
@@ -43,6 +47,10 @@ final class DefaultRounding implements RoundingInterface
return;
}
if ($record->getEnd() === null) {
return;
}
$timestamp = $record->getEnd()->getTimestamp();
$seconds = $minutes * 60;
$diff = $timestamp % $seconds;

View File

@@ -29,7 +29,7 @@ final class FileHelper
$this->dataDir = $directory;
}
public function getDataDirectory(string $subDirectory = null): string
public function getDataDirectory(?string $subDirectory = null): string
{
$directory = $this->dataDir . '/';