Release 2.21.0 (#5014)
This commit is contained in:
@@ -46,6 +46,7 @@ final class TagController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'name', strict: true, nullable: true, description: 'Search term to filter tag list')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
// not using a query, as these are too expensive to fetch, the simple "name" list is easier to use
|
||||
$filter = $paramFetcher->get('name');
|
||||
|
||||
$data = $this->repository->findAllTagNames($filter);
|
||||
@@ -57,7 +58,7 @@ final class TagController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tags by filter as entities
|
||||
* Fetch tags by filter (as full entities)
|
||||
*/
|
||||
#[OA\Response(response: 200, description: 'Find the collection of all matching tags', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TagEntity')))]
|
||||
#[Route(path: '/find', name: 'get_tags_full', methods: ['GET'])]
|
||||
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.20.0';
|
||||
public const VERSION = '2.21.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 22000;
|
||||
public const VERSION_ID = 22100;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -41,12 +41,14 @@ final class PermissionController extends AbstractController
|
||||
{
|
||||
public const TOKEN_NAME = 'user_role_permissions';
|
||||
|
||||
public function __construct(private RolePermissionManager $manager, private RoleRepository $roleRepository)
|
||||
public function __construct(
|
||||
private readonly RolePermissionManager $manager,
|
||||
private readonly RoleRepository $roleRepository
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route(path: '', name: 'admin_user_permissions', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('role_permissions')]
|
||||
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService, UserRepository $userRepository): Response
|
||||
{
|
||||
$all = $this->roleRepository->findAll();
|
||||
@@ -167,7 +169,6 @@ final class PermissionController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/roles/create', name: 'admin_user_roles', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('role_permissions')]
|
||||
public function createRole(Request $request): Response
|
||||
{
|
||||
$role = new Role();
|
||||
@@ -200,8 +201,7 @@ final class PermissionController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/roles/{id}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('role_permissions')]
|
||||
#[Route(path: '/roles/{role}/delete/{csrfToken}', name: 'admin_user_role_delete', methods: ['GET', 'POST'])]
|
||||
public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
|
||||
@@ -230,8 +230,7 @@ final class PermissionController extends AbstractController
|
||||
return $this->redirectToRoute('admin_user_permissions');
|
||||
}
|
||||
|
||||
#[Route(path: '/roles/{id}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
|
||||
#[IsGranted('role_permissions')]
|
||||
#[Route(path: '/roles/{role}/{name}/{value}/{csrfToken}', name: 'admin_user_permission_save', methods: ['POST'])]
|
||||
public function savePermission(Role $role, string $name, bool $value, string $csrfToken, PermissionService $permissionService, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
|
||||
|
||||
@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
final class Date extends FunctionNode
|
||||
{
|
||||
private Node|string|null $value;
|
||||
private Node|string $value;
|
||||
|
||||
public function getSql(SqlWalker $sqlWalker): string
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
final class Day extends FunctionNode
|
||||
{
|
||||
private Node|string|null $value;
|
||||
private Node|string $value;
|
||||
|
||||
public function getSql(SqlWalker $sqlWalker): string
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
final class Month extends FunctionNode
|
||||
{
|
||||
private Node|string|null $value;
|
||||
private Node|string $value;
|
||||
|
||||
public function getSql(SqlWalker $sqlWalker): string
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
final class Year extends FunctionNode
|
||||
{
|
||||
private Node|string|null $value;
|
||||
private Node|string $value;
|
||||
|
||||
public function getSql(SqlWalker $sqlWalker): string
|
||||
{
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Utils\Color;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -53,21 +51,8 @@ class Tag
|
||||
|
||||
use ColorTrait;
|
||||
|
||||
/**
|
||||
* This is ONLY here, so we can count the amount of timesheets.
|
||||
*
|
||||
* See TagRepository "SIZE(t.timesheets)"
|
||||
* Removing this makes the count more complicated.
|
||||
* Should be refactored at some point in the future.
|
||||
*
|
||||
* @var Collection<Timesheet>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')]
|
||||
private Collection $timesheets;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->timesheets = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
|
||||
@@ -200,7 +200,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
#[ORM\JoinTable(name: 'kimai2_timesheet_tags')]
|
||||
#[ORM\JoinColumn(name: 'timesheet_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'tag_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'timesheets', cascade: ['persist'])]
|
||||
#[ORM\ManyToMany(targetEntity: Tag::class, cascade: ['persist'])]
|
||||
#[Assert\Valid]
|
||||
private Collection $tags;
|
||||
/**
|
||||
|
||||
@@ -16,14 +16,17 @@ use App\Entity\User;
|
||||
* - once per side load for page actions
|
||||
* - once for every entity item (table row)
|
||||
*
|
||||
* @property array{'actions': array, 'view': string} $payload
|
||||
* @property array{actions: array<string, string|array<mixed>>, view: string} $payload
|
||||
*/
|
||||
class PageActionsEvent extends ThemeEvent
|
||||
{
|
||||
private int $divider = 0;
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(User $user, array $payload, private string $action, private string $view)
|
||||
/**
|
||||
* @param array<mixed> $payload
|
||||
*/
|
||||
public function __construct(User $user, array $payload, private readonly string $action, private readonly string $view)
|
||||
{
|
||||
// only for BC reasons, do not access it directly!
|
||||
if (!\array_key_exists('actions', $payload)) {
|
||||
@@ -58,8 +61,6 @@ class PageActionsEvent extends ThemeEvent
|
||||
|
||||
/**
|
||||
* Custom view can only be table listings.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isCustomView(): bool
|
||||
{
|
||||
@@ -71,6 +72,9 @@ class PageActionsEvent extends ThemeEvent
|
||||
return $this->view;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<mixed>>
|
||||
*/
|
||||
public function getActions(): array
|
||||
{
|
||||
$actions = $this->payload['actions'];
|
||||
@@ -99,28 +103,38 @@ class PageActionsEvent extends ThemeEvent
|
||||
|
||||
public function hasSubmenu(string $submenu): bool
|
||||
{
|
||||
if (!$this->hasAction($submenu)) {
|
||||
if (!\array_key_exists($submenu, $this->payload['actions'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \array_key_exists('children', $this->payload['actions'][$submenu]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $action
|
||||
*/
|
||||
public function addActionToSubmenu(string $submenu, string $key, array $action): void
|
||||
{
|
||||
if ($this->hasAction($submenu)) {
|
||||
if (!\array_key_exists('children', $this->payload['actions'][$submenu])) {
|
||||
$this->payload['actions'][$submenu]['children'] = [];
|
||||
}
|
||||
if (!\array_key_exists($submenu, $this->payload['actions'])) {
|
||||
$this->payload['actions'][$submenu] = ['children' => []];
|
||||
}
|
||||
if (!\array_key_exists('children', $this->payload['actions'][$submenu])) {
|
||||
$this->payload['actions'][$submenu]['children'] = [];
|
||||
}
|
||||
$this->payload['actions'][$submenu]['children'][$key] = $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $action
|
||||
*/
|
||||
public function replaceAction(string $key, array $action): void
|
||||
{
|
||||
$this->payload['actions'][$key] = $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $action
|
||||
*/
|
||||
public function addAction(string $key, array $action): void
|
||||
{
|
||||
if (!$this->hasAction($key)) {
|
||||
|
||||
@@ -28,7 +28,7 @@ class ThemeEvent extends Event
|
||||
/**
|
||||
* @param array<string, mixed|array<mixed>> $payload
|
||||
*/
|
||||
public function __construct(private ?User $user = null, protected array $payload = [])
|
||||
public function __construct(private readonly ?User $user = null, protected array $payload = [])
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -14,42 +14,54 @@ use App\Validator\Constraints\Duration as DurationConstraint;
|
||||
use Symfony\Component\Form\DataTransformerInterface;
|
||||
use Symfony\Component\Form\Exception\TransformationFailedException;
|
||||
|
||||
/**
|
||||
* @implements DataTransformerInterface<string|int|null, string|null>
|
||||
*/
|
||||
final class DurationStringToSecondsTransformer implements DataTransformerInterface
|
||||
{
|
||||
/**
|
||||
* @param int $intToFormat
|
||||
* @return string|null
|
||||
*/
|
||||
public function transform(mixed $intToFormat): ?string
|
||||
public function transform(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new Duration())->format($intToFormat);
|
||||
if (!\is_int($value) && is_numeric($value)) {
|
||||
$value = (int) $value;
|
||||
}
|
||||
|
||||
if (!\is_int($value)) {
|
||||
// do not throw an exception, that would break the frontend, make it null / empty instead
|
||||
return null;
|
||||
}
|
||||
|
||||
return (new Duration())->format($value);
|
||||
} catch (\Exception | \TypeError $e) {
|
||||
throw new TransformationFailedException($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $formatToInt
|
||||
* @return int|null
|
||||
*/
|
||||
public function reverseTransform(mixed $formatToInt): ?int
|
||||
public function reverseTransform(mixed $value): ?int
|
||||
{
|
||||
if (null === $formatToInt) {
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($formatToInt)) {
|
||||
if ($value === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (\is_int($value) || \is_float($value)) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
|
||||
// we need this one here, because the data transformer is executed BEFORE the constraint is called
|
||||
if (!preg_match((new DurationConstraint())->pattern, $formatToInt)) {
|
||||
if (!preg_match((new DurationConstraint())->pattern, $value)) {
|
||||
throw new TransformationFailedException('Invalid duration format given');
|
||||
}
|
||||
|
||||
try {
|
||||
$seconds = (new Duration())->parseDurationString($formatToInt);
|
||||
$seconds = (new Duration())->parseDurationString($value);
|
||||
|
||||
// DateTime throws if a duration with too many seconds is passed and an amount of so
|
||||
// many seconds is likely not required in a time-tracking application ;-)
|
||||
|
||||
@@ -143,8 +143,8 @@ class ProjectStatisticService
|
||||
)
|
||||
)
|
||||
)
|
||||
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
|
||||
->setParameter('end', $end, Types::DATETIME_MUTABLE)
|
||||
->setParameter('begin', DateTimeImmutable::createFromInterface($begin), Types::DATETIME_IMMUTABLE)
|
||||
->setParameter('end', DateTimeImmutable::createFromInterface($end), Types::DATETIME_IMMUTABLE)
|
||||
;
|
||||
|
||||
if (!$query->isIncludeNoWork()) {
|
||||
@@ -323,14 +323,14 @@ class ProjectStatisticService
|
||||
if ($begin !== null) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->gte('t.begin', ':begin'))
|
||||
->setParameter('begin', $begin, Types::DATETIME_MUTABLE)
|
||||
->setParameter('begin', DateTimeImmutable::createFromInterface($begin), Types::DATETIME_IMMUTABLE)
|
||||
;
|
||||
}
|
||||
|
||||
if ($end !== null) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->lte('t.begin', ':end'))
|
||||
->setParameter('end', $end, Types::DATETIME_MUTABLE)
|
||||
->setParameter('end', DateTimeImmutable::createFromInterface($end), Types::DATETIME_IMMUTABLE)
|
||||
;
|
||||
}
|
||||
|
||||
@@ -646,7 +646,7 @@ class ProjectStatisticService
|
||||
)
|
||||
)
|
||||
->addGroupBy('p')
|
||||
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
|
||||
->setParameter('project_end', DateTimeImmutable::createFromInterface($today), Types::DATETIME_IMMUTABLE)
|
||||
;
|
||||
|
||||
if ($query->getCustomer() !== null) {
|
||||
@@ -732,7 +732,7 @@ class ProjectStatisticService
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) = :start_date')
|
||||
->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
|
||||
->setParameter('start_date', DateTimeImmutable::createFromInterface($today), Types::DATETIME_IMMUTABLE)
|
||||
;
|
||||
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
@@ -744,8 +744,8 @@ class ProjectStatisticService
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
|
||||
->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
|
||||
->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
|
||||
->setParameter('start_date', DateTimeImmutable::createFromInterface($startOfWeek), Types::DATETIME_IMMUTABLE)
|
||||
->setParameter('end_date', DateTimeImmutable::createFromInterface($endOfWeek), Types::DATETIME_IMMUTABLE)
|
||||
;
|
||||
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
@@ -757,8 +757,8 @@ class ProjectStatisticService
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
|
||||
->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
|
||||
->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
|
||||
->setParameter('start_date', DateTimeImmutable::createFromInterface($startMonth), Types::DATETIME_IMMUTABLE)
|
||||
->setParameter('end_date', DateTimeImmutable::createFromInterface($endMonth), Types::DATETIME_IMMUTABLE)
|
||||
;
|
||||
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
|
||||
@@ -91,6 +91,9 @@ class ActivityRepository extends EntityRepository
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Team> $teams
|
||||
*/
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): void
|
||||
{
|
||||
$permissions = $this->getPermissionCriteria($qb, $user, $teams, $globalsOnly);
|
||||
@@ -99,6 +102,9 @@ class ActivityRepository extends EntityRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Team> $teams
|
||||
*/
|
||||
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [], bool $globalsOnly = false): Andx
|
||||
{
|
||||
$andX = $qb->expr()->andX();
|
||||
|
||||
@@ -14,35 +14,45 @@ use App\Entity\User;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
/**
|
||||
* @extends \Doctrine\ORM\EntityRepository<Bookmark>
|
||||
* @extends EntityRepository<Bookmark>
|
||||
*/
|
||||
class BookmarkRepository extends EntityRepository
|
||||
{
|
||||
/** @var array<string, array<string, array<string, Bookmark>>> */
|
||||
private array $userCache = [];
|
||||
|
||||
public function saveBookmark(Bookmark $bookmark)
|
||||
public function saveBookmark(Bookmark $bookmark): void
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->persist($bookmark);
|
||||
$entityManager->flush();
|
||||
|
||||
$this->clearCache($bookmark->getUser());
|
||||
if ($bookmark->getUser()) {
|
||||
$this->clearCache($bookmark->getUser());
|
||||
}
|
||||
}
|
||||
|
||||
private function clearCache(User $user): void
|
||||
private function clearCache(?User $user): void
|
||||
{
|
||||
if ($user === null || $user->getId() === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = 'user_' . $user->getId();
|
||||
if (\array_key_exists($key, $this->userCache)) {
|
||||
unset($this->userCache[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteBookmark(Bookmark $bookmark)
|
||||
public function deleteBookmark(Bookmark $bookmark): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$em->remove($bookmark);
|
||||
$em->flush();
|
||||
$this->clearCache($bookmark->getUser());
|
||||
|
||||
if ($bookmark->getUser()) {
|
||||
$this->clearCache($bookmark->getUser());
|
||||
}
|
||||
}
|
||||
|
||||
public function getSearchDefaultOptions(User $user, string $name): ?Bookmark
|
||||
@@ -59,7 +69,7 @@ class BookmarkRepository extends EntityRepository
|
||||
$this->userCache[$key] = [];
|
||||
$all = $this->findBy(['user' => $user->getId()]);
|
||||
foreach ($all as $item) {
|
||||
$this->userCache[$key][$item->getType()][mb_substr($item->getName(), 0, 50)] = $item;
|
||||
$this->userCache[$key][$item->getType()][mb_substr($item->getName() ?? '__DEFAULT__', 0, 50)] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ class CustomerRepository extends EntityRepository
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Team> $teams
|
||||
*/
|
||||
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
|
||||
{
|
||||
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
|
||||
@@ -87,6 +90,9 @@ class CustomerRepository extends EntityRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Team> $teams
|
||||
*/
|
||||
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
|
||||
{
|
||||
$andX = $qb->expr()->andX();
|
||||
@@ -349,6 +355,9 @@ class CustomerRepository extends EntityRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<CustomerComment>
|
||||
*/
|
||||
public function getComments(Customer $customer): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
@@ -83,13 +83,14 @@ class InvoiceRepository extends EntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/** @var array{'counter': int|numeric-string}|null $result */
|
||||
$result = $qb->getQuery()->getOneOrNullResult();
|
||||
|
||||
if ($result === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $result['counter'];
|
||||
return (int) $result['counter'];
|
||||
}
|
||||
|
||||
public function getCounterForDay(\DateTimeInterface $date, ?Customer $customer = null, ?User $user = null): int
|
||||
@@ -137,7 +138,10 @@ class InvoiceRepository extends EntityRepository
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
|
||||
/**
|
||||
* @param array<Team> $teams
|
||||
*/
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
|
||||
{
|
||||
// make sure that all queries without a user see all projects
|
||||
if (null === $user && empty($teams)) {
|
||||
|
||||
@@ -71,7 +71,7 @@ final class TimesheetLoader implements LoaderInterface
|
||||
|
||||
if ($this->fullyHydrated) {
|
||||
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
|
||||
return $project->getCustomer()->getId();
|
||||
return $project->getCustomer()?->getId();
|
||||
}, $projects)), function ($value) { return $value !== null; });
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
|
||||
@@ -13,6 +13,7 @@ use Pagerfanta\Adapter\AdapterInterface;
|
||||
|
||||
/**
|
||||
* @template-covariant T
|
||||
* @extends AdapterInterface<T>
|
||||
*/
|
||||
interface PaginatorInterface extends AdapterInterface
|
||||
{
|
||||
|
||||
@@ -85,6 +85,9 @@ class ProjectRepository extends EntityRepository
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Team> $teams
|
||||
*/
|
||||
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
|
||||
{
|
||||
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
|
||||
@@ -93,6 +96,9 @@ class ProjectRepository extends EntityRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Team> $teams
|
||||
*/
|
||||
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
|
||||
{
|
||||
$andX = $qb->expr()->andX();
|
||||
@@ -429,6 +435,9 @@ class ProjectRepository extends EntityRepository
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ProjectComment>
|
||||
*/
|
||||
public function getComments(Project $project): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
@@ -40,12 +40,12 @@ trait RepositorySearchTrait
|
||||
|
||||
private function addSearchTerm(QueryBuilder $qb, BaseQuery $query): void
|
||||
{
|
||||
if (!$query->hasSearchTerm()) {
|
||||
$searchTerm = $query->getSearchTerm();
|
||||
|
||||
if ($searchTerm === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$searchTerm = $query->getSearchTerm();
|
||||
|
||||
if (!$this->supportsMetaFields() && !$searchTerm->hasSearchTerm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ final class TimesheetResult
|
||||
return $this->statisticCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<Timesheet>
|
||||
*/
|
||||
public function toIterable(): iterable
|
||||
{
|
||||
return $this->query->toIterable();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Repository\Paginator\QueryPaginator;
|
||||
use App\Repository\Query\TagFormTypeQuery;
|
||||
use App\Repository\Query\TagQuery;
|
||||
@@ -131,7 +132,11 @@ class TagRepository extends EntityRepository
|
||||
{
|
||||
$qb = $this->createQueryBuilder('tag');
|
||||
|
||||
$qb->select('tag.id, tag.name, tag.color, tag.visible, SIZE(tag.timesheets) as amount');
|
||||
$qb1 = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb1->from(Timesheet::class, 't')->select('COUNT(tags)')->innerJoin('t.tags', 'tags')->where('tags.id = tag.id');
|
||||
|
||||
$qb->select('tag.id, tag.name, tag.color, tag.visible');
|
||||
$qb->addSelect('(' . $qb1->getDQL() . ') as amount');
|
||||
|
||||
$orderBy = $query->getOrderBy();
|
||||
$orderBy = match ($orderBy) {
|
||||
@@ -149,8 +154,8 @@ class TagRepository extends EntityRepository
|
||||
|
||||
$qb->addOrderBy($orderBy, $query->getOrder());
|
||||
|
||||
if ($query->hasSearchTerm()) {
|
||||
$searchTerm = $query->getSearchTerm();
|
||||
$searchTerm = $query->getSearchTerm();
|
||||
if ($searchTerm !== null) {
|
||||
$searchAnd = $qb->expr()->andX();
|
||||
|
||||
if ($searchTerm->hasSearchTerm()) {
|
||||
|
||||
@@ -117,14 +117,14 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
|
||||
*/
|
||||
public function findOneBy(array $criteria, array $orderBy = null): ?object
|
||||
{
|
||||
if (\count($criteria) === 1 && isset($criteria['username'])) {
|
||||
if (\count($criteria) === 1 && isset($criteria['username']) && \is_string($criteria['username'])) {
|
||||
return $this->loadUserByIdentifier($criteria['username']);
|
||||
}
|
||||
|
||||
return parent::findOneBy($criteria, $orderBy);
|
||||
}
|
||||
|
||||
public function findByUsername($username): ?User
|
||||
public function findByUsername(string $username): ?User
|
||||
{
|
||||
return parent::findOneBy(['username' => $username]);
|
||||
}
|
||||
@@ -321,9 +321,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
|
||||
$qb->setParameter('system', $query->getSystemAccount(), Types::BOOLEAN);
|
||||
}
|
||||
|
||||
if ($query->hasSearchTerm()) {
|
||||
$searchTerm = $query->getSearchTerm();
|
||||
if ($searchTerm !== null) {
|
||||
$searchAnd = $qb->expr()->andX();
|
||||
$searchTerm = $query->getSearchTerm();
|
||||
|
||||
foreach ($searchTerm->getSearchFields() as $metaName => $metaValue) {
|
||||
$qb->leftJoin('u.preferences', 'meta');
|
||||
|
||||
@@ -17,13 +17,13 @@ use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
|
||||
final class RoundingService
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
|
||||
* @var null|non-empty-array<array-key, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
|
||||
*/
|
||||
private ?array $rulesCache = null;
|
||||
|
||||
/**
|
||||
* @param RoundingInterface[] $roundingModes
|
||||
* @param array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}> $rules
|
||||
* @param array<string, array{'days': string, 'begin': int, 'end': int, 'duration': int, 'mode': string}> $rules
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly SystemConfiguration $configuration,
|
||||
@@ -35,13 +35,13 @@ final class RoundingService
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
|
||||
* @return non-empty-array<array-key, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
|
||||
*/
|
||||
private function getRoundingRules(): array
|
||||
{
|
||||
if ($this->rulesCache === null) {
|
||||
$rules = $this->rules;
|
||||
$rules['default']['days'] = $this->configuration->getTimesheetDefaultRoundingDays();
|
||||
$rules['default']['days'] = $this->parseDays($this->configuration->getTimesheetDefaultRoundingDays());
|
||||
$rules['default']['begin'] = $this->configuration->getTimesheetDefaultRoundingBegin();
|
||||
$rules['default']['end'] = $this->configuration->getTimesheetDefaultRoundingEnd();
|
||||
$rules['default']['duration'] = $this->configuration->getTimesheetDefaultRoundingDuration();
|
||||
@@ -49,17 +49,13 @@ final class RoundingService
|
||||
|
||||
// see AppExtension, conversion from string to array due to system configuration not allowing to store arrays
|
||||
foreach ($rules as $key => $settings) {
|
||||
if (\is_array($settings['days'])) {
|
||||
continue;
|
||||
if (\is_string($settings['days'])) {
|
||||
if ($settings['days'] === '') {
|
||||
$rules[$key]['days'] = [];
|
||||
continue;
|
||||
}
|
||||
$rules[$key]['days'] = array_map('strtolower', array_map('trim', explode(',', $settings['days'])));
|
||||
}
|
||||
if ($settings['days'] === '') {
|
||||
$rules[$key]['days'] = [];
|
||||
continue;
|
||||
}
|
||||
$days = explode(',', $settings['days']);
|
||||
$days = array_map('trim', $days);
|
||||
$days = array_map('strtolower', $days);
|
||||
$rules[$key]['days'] = $days;
|
||||
}
|
||||
$this->rulesCache = $rules; // @phpstan-ignore-line
|
||||
}
|
||||
@@ -67,6 +63,14 @@ final class RoundingService
|
||||
return $this->rulesCache; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
private function parseDays(string $days): array
|
||||
{
|
||||
return array_map('strtolower', array_map('trim', explode(',', $days)));
|
||||
}
|
||||
|
||||
public function roundBegin(Timesheet $record): void
|
||||
{
|
||||
foreach ($this->getRoundingRules() as $rounding) {
|
||||
|
||||
Reference in New Issue
Block a user