Release 2.13 (#4659)

This commit is contained in:
Kevin Papst
2024-03-10 15:35:59 +01:00
committed by GitHub
parent a78e8ed8c4
commit dee90bb15e
79 changed files with 1019 additions and 932 deletions

View File

@@ -44,10 +44,10 @@ final class ActivityController extends BaseApiController
public const GROUPS_RATE = ['Default', 'Entity', 'Activity_Rate'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private ActivityRepository $repository,
private EventDispatcherInterface $dispatcher,
private ActivityRateRepository $activityRateRepository
private readonly ViewHandlerInterface $viewHandler,
private readonly ActivityRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityRateRepository $activityRateRepository
) {
}
@@ -128,6 +128,7 @@ final class ActivityController extends BaseApiController
#[Route(methods: ['GET'], path: '/{id}', name: 'get_activity', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'activity')]
public function getAction(Activity $activity): Response
{
$view = new View($activity, 200);

View File

@@ -44,10 +44,10 @@ final class CustomerController extends BaseApiController
public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private CustomerRepository $repository,
private EventDispatcherInterface $dispatcher,
private CustomerRateRepository $customerRateRepository
private readonly ViewHandlerInterface $viewHandler,
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly CustomerRateRepository $customerRateRepository
) {
}
@@ -105,6 +105,7 @@ final class CustomerController extends BaseApiController
#[Route(methods: ['GET'], path: '/{id}', name: 'get_customer', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'customer')]
public function getAction(Customer $customer): Response
{
$view = new View($customer, 200);

View File

@@ -46,11 +46,11 @@ final class ProjectController extends BaseApiController
public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private ProjectRepository $repository,
private EventDispatcherInterface $dispatcher,
private ProjectRateRepository $projectRateRepository,
private ProjectService $projectService
private readonly ViewHandlerInterface $viewHandler,
private readonly ProjectRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly ProjectRateRepository $projectRateRepository,
private readonly ProjectService $projectService
) {
}
@@ -159,6 +159,7 @@ final class ProjectController extends BaseApiController
#[Route(methods: ['GET'], path: '/{id}', name: 'get_project', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')]
#[IsGranted('view', 'project')]
public function getAction(Project $project): Response
{
$view = new View($project, 200);

View File

@@ -54,11 +54,11 @@ final class TimesheetController extends BaseApiController
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'Timesheet', 'Expanded'];
public function __construct(
private ViewHandlerInterface $viewHandler,
private TimesheetRepository $repository,
private TagRepository $tagRepository,
private EventDispatcherInterface $dispatcher,
private TimesheetService $service
private readonly ViewHandlerInterface $viewHandler,
private readonly TimesheetRepository $repository,
private readonly TagRepository $tagRepository,
private readonly EventDispatcherInterface $dispatcher,
private readonly TimesheetService $service
) {
}
@@ -99,6 +99,7 @@ final class TimesheetController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{
$query = new TimesheetQuery(false);
$query->setCurrentUser($this->getUser());
$seeAll = false;
if ($this->isGranted('view_other_timesheet')) {

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.12.0';
public const VERSION = '2.13.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 21200;
public const VERSION_ID = 21300;
/**
* The software name
*/

View File

@@ -67,7 +67,7 @@ final class CustomerController extends AbstractController
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
$form = $this->getToolbarForm($query, $request);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('admin_customer');
}
@@ -473,7 +473,7 @@ final class CustomerController extends AbstractController
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
$form = $this->getToolbarForm($query);
$form = $this->getToolbarForm($query, $request);
$form->setData($query);
$form->submit($request->query->all(), false);
@@ -526,12 +526,12 @@ final class CustomerController extends AbstractController
}
/**
* @param CustomerQuery $query
* @return FormInterface<CustomerQuery>
*/
private function getToolbarForm(CustomerQuery $query): FormInterface
private function getToolbarForm(CustomerQuery $query, Request $request): FormInterface
{
return $this->createSearchForm(CustomerToolbarForm::class, $query, [
'locale' => $request->getLocale(),
'action' => $this->generateUrl('admin_customer', [
'page' => $query->getPage(),
])
@@ -539,7 +539,6 @@ final class CustomerController extends AbstractController
}
/**
* @param CustomerComment $comment
* @return FormInterface<CustomerComment>
*/
private function getCommentForm(CustomerComment $comment): FormInterface
@@ -555,7 +554,6 @@ final class CustomerController extends AbstractController
}
/**
* @param Customer $customer
* @return FormInterface<Customer>
*/
private function createEditForm(Customer $customer): FormInterface

View File

@@ -45,21 +45,28 @@ final class PluginController extends AbstractController
private function getPluginInformation(HttpClientInterface $client, CacheInterface $cache): array
{
return $cache->get('kimai.marketplace_extensions', function (ItemInterface $item) use ($client) {
$response = $client->request('GET', 'https://www.kimai.org/plugins.json');
try {
$response = $client->request('GET', 'https://www.kimai.org/plugins.json');
if ($response->getStatusCode() !== 200) {
return [];
if ($response->getStatusCode() !== 200) {
return [];
}
$json = json_decode($response->getContent(), true);
if ($json === null) {
return [];
}
$item->expiresAfter(86400); // one day
return $response->toArray();
} catch (\Exception $exception) {
$this->logException($exception);
$this->flashError('Could not download plugin information');
}
$json = json_decode($response->getContent(), true);
if ($json === null) {
return [];
}
$item->expiresAfter(86400); // one day
return $response->toArray();
return [];
});
}
}

View File

@@ -32,9 +32,9 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class PasswordResetController extends AbstractController
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private UserService $userService,
private SystemConfiguration $configuration
private readonly EventDispatcherInterface $eventDispatcher,
private readonly UserService $userService,
private readonly SystemConfiguration $configuration
) {
}
@@ -62,6 +62,10 @@ final class PasswordResetController extends AbstractController
}
$username = $request->request->get('username');
if (!\is_string($username) || trim($username) === '') {
throw $this->createAccessDeniedException('Username cannot be empty');
}
$user = $this->userService->findUserByUsernameOrEmail($username);
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {

View File

@@ -18,7 +18,10 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class MenuSubscriber implements EventSubscriberInterface
{
public function __construct(private Security $security, private ContextHelper $helper)
public function __construct(
private readonly Security $security,
private readonly ContextHelper $helper
)
{
}
@@ -134,19 +137,19 @@ final class MenuSubscriber implements EventSubscriberInterface
$menu = $event->getAdminMenu();
if ($auth->isGranted('view_customer') || $auth->isGranted('view_teamlead_customer') || $auth->isGranted('view_team_customer')) {
$customers = new MenuItemModel('customer_admin', 'customers', 'admin_customer', [], 'customer');
$customers = new MenuItemModel('customers', 'customers', 'admin_customer', [], 'customer');
$customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'customer_details', 'admin_customer_edit', 'admin_customer_delete']);
$menu->addChild($customers);
}
if ($auth->isGranted('view_project') || $auth->isGranted('view_teamlead_project') || $auth->isGranted('view_team_project')) {
$projects = new MenuItemModel('project_admin', 'projects', 'admin_project', [], 'project');
$projects = new MenuItemModel('projects', 'projects', 'admin_project', [], 'project');
$projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'project_details', 'admin_project_edit', 'admin_project_delete']);
$menu->addChild($projects);
}
if ($auth->isGranted('view_activity') || $auth->isGranted('view_teamlead_activity') || $auth->isGranted('view_team_activity')) {
$activities = new MenuItemModel('activity_admin', 'activities', 'admin_activity', [], 'activity');
$activities = new MenuItemModel('activities', 'activities', 'admin_activity', [], 'activity');
$activities->setChildRoutes(['admin_activity_create', 'activity_details', 'admin_activity_edit', 'admin_activity_delete']);
$menu->addChild($activities);
}
@@ -163,18 +166,18 @@ final class MenuSubscriber implements EventSubscriberInterface
$menu = $event->getSystemMenu();
if ($auth->isGranted('view_user')) {
$users = new MenuItemModel('user_admin', 'users', 'admin_user', [], 'users');
$users = new MenuItemModel('users', 'users', 'admin_user', [], 'users');
$users->setChildRoutes(['admin_user_create', 'admin_user_delete', 'user_profile', 'user_profile_edit', 'user_profile_password', 'user_profile_api_token', 'user_profile_roles', 'user_profile_teams', 'user_profile_preferences', 'user_profile_2fa']);
$menu->addChild($users);
}
if ($auth->isGranted('role_permissions')) {
$users = new MenuItemModel('admin_user_permissions', 'profile.roles', 'admin_user_permissions', [], 'permissions');
$users = new MenuItemModel('roles', 'profile.roles', 'admin_user_permissions', [], 'permissions');
$menu->addChild($users);
}
if ($auth->isGranted('view_team')) {
$teams = new MenuItemModel('user_team', 'teams', 'admin_team', [], 'team');
$teams = new MenuItemModel('teams', 'teams', 'admin_team', [], 'team');
$teams->setChildRoutes(['admin_team_create', 'admin_team_edit']);
$menu->addChild($teams);
}
@@ -190,7 +193,7 @@ final class MenuSubscriber implements EventSubscriberInterface
}
if ($auth->isGranted('system_configuration')) {
$systemConfig = new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], 'configuration');
$systemConfig = new MenuItemModel('configurations', 'menu.system_configuration', 'system_configuration', [], 'configuration');
$systemConfig->setChildRoutes(['system_configuration_update', 'system_configuration_section']);
$menu->addChild($systemConfig);
}

View File

@@ -9,9 +9,12 @@
namespace App\Form\Toolbar;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Intl\Countries;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -22,9 +25,38 @@ final class CustomerToolbarForm extends AbstractType
{
use ToolbarFormTrait;
public function __construct(private readonly CustomerRepository $customerRepository)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addSearchTermInputField($builder);
// fetch countries
$qb = $this->customerRepository->createQueryBuilder('c');
$qb
->select('c.country')
->distinct(true);
$countries = $qb->getQuery()->getSingleColumnResult();
$choices = [];
foreach ($countries as $country) {
if (\is_string($country) && \is_string($options['locale'])) {
$choices[$country] = Countries::getName($country, $options['locale']);
}
}
if (\count($choices) > 0) {
$choices = array_flip($choices);
ksort($choices);
$builder->add('country', ChoiceType::class, [
'label' => 'country',
'choices' => $choices,
'required' => false,
]);
}
$this->addVisibilityChoice($builder);
$this->addPageSizeChoice($builder);
$this->addHiddenPagination($builder);
@@ -37,6 +69,8 @@ final class CustomerToolbarForm extends AbstractType
$resolver->setDefaults([
'data_class' => CustomerQuery::class,
'csrf_protection' => false,
'locale' => locale_get_default(),
]);
$resolver->setAllowedTypes('locale', ['string']);
}
}

View File

@@ -19,8 +19,8 @@ final class TagsType extends AbstractType
private ?int $count = null;
public function __construct(
private AuthorizationCheckerInterface $auth,
private TagRepository $repository
private readonly AuthorizationCheckerInterface $auth,
private readonly TagRepository $repository
) {
}

View File

@@ -19,7 +19,10 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
{
private ?InvoiceModel $model = null;
public function __construct(private InvoiceRepository $repository, private SystemConfiguration $configuration)
public function __construct(
private readonly InvoiceRepository $repository,
private readonly SystemConfiguration $configuration
)
{
}
@@ -33,9 +36,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
$this->model = $model;
}
/**
* @return string
*/
public function getInvoiceNumber(): string
{
$format = $this->configuration->find('invoice.number_format');
@@ -68,6 +68,10 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
'm' => $invoiceDate->format('n'),
'D' => $invoiceDate->format('d'),
'd' => $invoiceDate->format('j'),
'YY' => (int) $invoiceDate->format('Y') + $increaseBy,
'yy' => (int) $invoiceDate->format('y') + $increaseBy,
'MM' => (int) $invoiceDate->format('m') + $increaseBy,
'DD' => (int) $invoiceDate->format('d') + $increaseBy,
'date' => $invoiceDate->format('ymd'),
'cc' => $this->repository->getCounterForCustomerAllTime($this->model->getCustomer()) + $increaseBy,
'ccy' => $this->repository->getCounterForYear($invoiceDate, $this->model->getCustomer()) + $increaseBy,
@@ -92,7 +96,7 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
do {
$result = $numberGenerator->getNumber($increaseBy);
$increaseBy++;
} while ($this->repository->hasInvoice($result) && $loops++ < 99);
} while ((int) $result < 0 || ($this->repository->hasInvoice($result) && $loops++ < 99));
return $result;
}

View File

@@ -130,7 +130,7 @@ final class MPdfConverter implements HtmlToPdfConverter
// lowercase all font names, otherwise they cannot be loaded
// see https://github.com/kimai/www.kimai.org/issues/280
if (\array_key_exists('fonts', $options)) {
if (\array_key_exists('fonts', $options) && \is_array($options['fonts'])) {
$fonts = [];
foreach ($options['fonts'] as $name => $values) {
$fonts[strtolower($name)] = $values;

View File

@@ -20,15 +20,15 @@ use Doctrine\ORM\Query\Expr\Join;
final class CustomerMonthlyProjectsRepository
{
public function __construct(private TimesheetRepository $repository, private EntityManagerInterface $entityManager)
public function __construct(
private readonly TimesheetRepository $repository,
private readonly EntityManagerInterface $entityManager
)
{
}
/**
* @param DateTime $begin
* @param DateTime $end
* @param User[] $users
* @param Customer|null $customer
* @return array
* @internal
*/

View File

@@ -55,10 +55,6 @@ class CustomerRepository extends EntityRepository
return $customers;
}
/**
* @param Customer $customer
* @throws ORMException
*/
public function saveCustomer(Customer $customer): void
{
$entityManager = $this->getEntityManager();
@@ -75,7 +71,7 @@ class CustomerRepository extends EntityRepository
return $this->count([]);
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
if ($permissions->count() > 0) {
@@ -124,9 +120,6 @@ class CustomerRepository extends EntityRepository
/**
* Returns a query builder that is used for CustomerType and your own 'query_builder' option.
*
* @param CustomerFormTypeQuery $query
* @return QueryBuilder
*/
public function getQueryBuilderForFormType(CustomerFormTypeQuery $query): QueryBuilder
{
@@ -177,6 +170,13 @@ class CustomerRepository extends EntityRepository
->from(Customer::class, 'c')
;
if ($query->getCountry() !== null) {
$qb
->andWhere($qb->expr()->eq('c.country', ':country'))
->setParameter('country', $query->getCountry())
;
}
foreach ($query->getOrderGroups() as $orderBy => $order) {
switch ($orderBy) {
case 'vat_id':

View File

@@ -18,11 +18,24 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
'phone', 'currency', 'address', 'contact', 'company', 'vat_id', 'budget', 'timeBudget', 'visible'
];
private ?string $country = null;
public function __construct()
{
$this->setDefaults([
'orderBy' => 'name',
'visibility' => VisibilityInterface::SHOW_VISIBLE,
'country' => null,
]);
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): void
{
$this->country = $country;
}
}

View File

@@ -20,8 +20,8 @@ use Symfony\Component\HttpFoundation\RequestStack;
class SamlAuthFactory
{
public function __construct(
private RequestStack $request,
private SamlConfigurationInterface $configuration
private readonly RequestStack $request,
private readonly SamlConfigurationInterface $configuration
) {
}

View File

@@ -35,12 +35,12 @@ class SamlAuthenticator extends AbstractAuthenticator
];
public function __construct(
private HttpUtils $httpUtils,
private SamlAuthenticationSuccessHandler $successHandler,
private SamlAuthenticationFailureHandler $failureHandler,
private SamlAuthFactory $samlAuthFactory,
private SamlProvider $samlProvider,
private SamlConfigurationInterface $configuration
private readonly HttpUtils $httpUtils,
private readonly SamlAuthenticationSuccessHandler $successHandler,
private readonly SamlAuthenticationFailureHandler $failureHandler,
private readonly SamlAuthFactory $samlAuthFactory,
private readonly SamlProvider $samlProvider,
private readonly SamlConfigurationInterface $configuration
) {
}

View File

@@ -13,7 +13,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
final class SamlBadge implements BadgeInterface
{
public function __construct(private SamlLoginAttributes $samlToken)
public function __construct(private readonly SamlLoginAttributes $samlToken)
{
}

View File

@@ -15,7 +15,7 @@ use Symfony\Component\Security\Http\Event\LogoutEvent;
final class SamlLogoutSubscriber implements EventSubscriberInterface
{
public function __construct(private SamlAuthFactory $samlAuth)
public function __construct(private readonly SamlAuthFactory $samlAuth)
{
}
@@ -26,7 +26,7 @@ final class SamlLogoutSubscriber implements EventSubscriberInterface
];
}
public function logout(LogoutEvent $event)
public function logout(LogoutEvent $event): void
{
$token = $event->getToken();

View File

@@ -22,9 +22,9 @@ final class SamlProvider
* @param UserProviderInterface<User> $userProvider
*/
public function __construct(
private UserRepository $repository,
private UserProviderInterface $userProvider,
private SamlConfigurationInterface $configuration
private readonly UserRepository $repository,
private readonly UserProviderInterface $userProvider,
private readonly SamlConfigurationInterface $configuration
) {
}
@@ -123,7 +123,7 @@ final class SamlProvider
$user->setAuth(User::AUTH_SAML);
}
private function getPropertyValue(SamlLoginAttributes $token, $attribute)
private function getPropertyValue(SamlLoginAttributes $token, $attribute): string
{
$results = [];
$attributes = $token->getAttributes();

View File

@@ -29,11 +29,14 @@ final class RolePermissionManager
private bool $isInitialized = false;
/**
* @param PermissionService $service
* @param array<string, array<string, bool>> $permissions as defined in kimai.yaml
* @param array<string, bool> $permissionNames as defined in kimai.yaml
*/
public function __construct(private PermissionService $service, private array $permissions, private array $permissionNames)
public function __construct(
private readonly PermissionService $service,
private array $permissions,
private readonly array $permissionNames
)
{
}
@@ -47,11 +50,6 @@ final class RolePermissionManager
$perm = (string) $item['permission'];
$role = (string) $item['role'];
// these permissions may not be revoked at any time, because super admin would lose the ability to reactivate any permission
if ($role === User::ROLE_SUPER_ADMIN && \array_key_exists($perm, self::SUPER_ADMIN_PERMISSIONS)) {
continue;
}
if (!\array_key_exists($role, $this->permissions)) {
$this->permissions[$role] = [];
}
@@ -59,6 +57,7 @@ final class RolePermissionManager
$this->permissions[$role][$perm] = (bool) $item['allowed'];
}
// these permissions may not be revoked at any time, because super admin would lose the ability to reactivate any permission
foreach (self::SUPER_ADMIN_PERMISSIONS as $perm => $value) {
$this->permissions[User::ROLE_SUPER_ADMIN][$perm] = $value;
}
@@ -68,14 +67,9 @@ final class RolePermissionManager
/**
* Only permissions which were registered through the Symfony configuration stack will be acknowledged here.
*
* @param string $permission
* @return bool
*/
public function isRegisteredPermission(string $permission): bool
{
$this->init();
return \array_key_exists($permission, $this->permissionNames);
}
@@ -89,7 +83,7 @@ final class RolePermissionManager
return false;
}
return \array_key_exists($permission, $this->permissions[$role]) ? $this->permissions[$role][$permission] : false;
return \array_key_exists($permission, $this->permissions[$role]) && $this->permissions[$role][$permission];
}
public function hasRolePermission(User $user, string $permission): bool
@@ -112,8 +106,6 @@ final class RolePermissionManager
*/
public function getPermissions(): array
{
$this->init();
return array_keys($this->permissionNames);
}
}

View File

@@ -18,7 +18,7 @@ use DateTimeInterface;
final class TimesheetStatisticService
{
public function __construct(private TimesheetRepository $repository)
public function __construct(private readonly TimesheetRepository $repository)
{
}
@@ -53,10 +53,10 @@ final class TimesheetStatisticService
->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users)
->groupBy('year')
->addGroupBy('month')
@@ -125,10 +125,10 @@ final class TimesheetStatisticService
->addSelect('IDENTITY(t.activity) as activity')
->addSelect('DATE(t.date) as date')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users)
->groupBy('date')
->addGroupBy('project')
@@ -173,8 +173,6 @@ final class TimesheetStatisticService
/**
* @internal only for core development
* @param DateTimeInterface $begin
* @param DateTimeInterface $end
* @param User[] $users
* @return array
*/
@@ -203,10 +201,10 @@ final class TimesheetStatisticService
->addSelect('YEAR(t.date) as year')
->addSelect('MONTH(t.date) as month')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users)
->groupBy('year')
->addGroupBy('month')
@@ -253,7 +251,7 @@ final class TimesheetStatisticService
public function findFirstRecordDate(User $user): ?\DateTimeImmutable
{
$result = $this->repository->createQueryBuilder('t')
->select('MIN(t.begin)')
->select('MIN(t.date)')
->where('t.user = :user')
->setParameter('user', $user)
->getQuery()
@@ -295,10 +293,10 @@ final class TimesheetStatisticService
->addSelect('YEAR(t.date) as year')
->addSelect('IDENTITY(t.user) as user')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users)
->groupBy('year')
->addGroupBy('month')

View File

@@ -192,6 +192,8 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
public function getJavascriptConfiguration(User $user): array
{
return [
'locale' => $this->locale,
'language' => $user->getLanguage(),
'formatDuration' => $this->localeService->getDurationFormat($this->locale),
'formatDate' => $this->localeService->getDateFormat($this->locale),
'defaultColor' => Constants::DEFAULT_COLOR,

View File

@@ -76,7 +76,7 @@ final class PaginationExtension extends AbstractExtension
$propertyAccessor = PropertyAccess::createPropertyAccessor();
$propertyAccessor->setValue($routeParams, $pagePropertyPath, $page);
return $router->generate($routeName, $routeParams);
return $router->generate($routeName, $routeParams); // @phpstan-ignore-line
};
}
}

View File

@@ -38,9 +38,7 @@ final class QuickEntryTimesheetValidator extends ConstraintValidator
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
$timesheet = $value;
if ($timesheet->getId() === null && $timesheet->getDuration(false) === null) {
if ($value->getId() === null && $value->getDuration(false) === null) {
return;
}
@@ -49,7 +47,7 @@ final class QuickEntryTimesheetValidator extends ConstraintValidator
->getValidator()
->inContext($this->context)
->atPath('duration')
->validate($timesheet, $innerConstraint, [Constraint::DEFAULT_GROUP]);
->validate($value, $innerConstraint, [Constraint::DEFAULT_GROUP]);
}
}
}

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetBasicValidator extends ConstraintValidator
{
public function __construct(private SystemConfiguration $systemConfiguration)
public function __construct(private readonly SystemConfiguration $systemConfiguration)
{
}
@@ -36,10 +36,6 @@ final class TimesheetBasicValidator extends ConstraintValidator
$this->validateActivityAndProject($value, $this->context);
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context): void
{
$begin = $timesheet->getBegin();
@@ -64,10 +60,6 @@ final class TimesheetBasicValidator extends ConstraintValidator
}
}
/**
* @param TimesheetEntity $timesheet
* @param ExecutionContextInterface $context
*/
protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context): void
{
$activity = $timesheet->getActivity();

View File

@@ -13,7 +13,7 @@ use App\Activity\ActivityStatisticService;
use App\Configuration\LocaleService;
use App\Configuration\SystemConfiguration;
use App\Customer\CustomerStatisticService;
use App\Entity\Timesheet;
use App\Entity\Timesheet as TimesheetEntity;
use App\Model\BudgetStatisticModel;
use App\Project\ProjectStatisticService;
use App\Repository\TimesheetRepository;
@@ -29,53 +29,49 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetBudgetUsedValidator extends ConstraintValidator
{
public function __construct(
private SystemConfiguration $configuration,
private CustomerStatisticService $customerStatisticService,
private ProjectStatisticService $projectStatisticService,
private ActivityStatisticService $activityStatisticService,
private TimesheetRepository $timesheetRepository,
private RateServiceInterface $rateService,
private AuthorizationCheckerInterface $security,
private LocaleService $localeService
private readonly SystemConfiguration $configuration,
private readonly CustomerStatisticService $customerStatisticService,
private readonly ProjectStatisticService $projectStatisticService,
private readonly ActivityStatisticService $activityStatisticService,
private readonly TimesheetRepository $timesheetRepository,
private readonly RateServiceInterface $rateService,
private readonly AuthorizationCheckerInterface $security,
private readonly LocaleService $localeService
) {
}
/**
* @param Timesheet $timesheet
* @param Constraint $constraint
*/
public function validate(mixed $timesheet, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetBudgetUsed)) {
throw new UnexpectedTypeException($constraint, TimesheetBudgetUsed::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof Timesheet)) {
throw new UnexpectedTypeException($timesheet, Timesheet::class);
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
if ($this->configuration->isTimesheetAllowOverbookingBudget()) {
return;
}
$begin = $timesheet->getBegin();
$begin = $value->getBegin();
// we can only work with stopped entries
if ($begin === null || $timesheet->getEnd() === null || $timesheet->getUser() === null) {
if ($begin === null || $value->getEnd() === null || $value->getUser() === null) {
return;
}
// budgets need only be calculated for billable records
if (!$timesheet->isBillable()) {
if (!$value->isBillable()) {
return;
}
// this validator needs a project to calculate the rates
if ($timesheet->getProject() === null) {
if ($value->getProject() === null) {
return;
}
$id = $timesheet->getId();
$id = $value->getId();
// when changing the date via the calendar and/or the API, the duration will not be reset by the
// duration calculator (which runs after validation!) so we manually reset the duration before
@@ -92,9 +88,9 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
// $timesheet->setDuration(null);
// $duration = $timesheet->getDuration();
$duration = $timesheet->getCalculatedDuration();
$duration = $value->getCalculatedDuration();
$timeRate = $this->rateService->calculate($timesheet);
$timeRate = $this->rateService->calculate($value);
$rate = $timeRate->getRate();
$activityDuration = $duration ?? 0;
@@ -116,10 +112,10 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
// this could for example happen when export flag is changed OR if "prevent overbooking" config was recently activated and this is an old entry
if ($duration === $rawData['duration'] &&
$rate === $rawData['rate'] &&
$timesheet->isBillable() === $rawData['billable'] &&
$value->isBillable() === $rawData['billable'] &&
$begin->format('Y.m.d') === $rawData['begin']->format('Y.m.d') &&
$timesheet->getProject()->getId() === $projectId &&
($timesheet->getActivity() === null || $timesheet->getActivity()->getId() === $activityId)
$value->getProject()->getId() === $projectId &&
($value->getActivity() === null || $value->getActivity()->getId() === $activityId)
) {
return;
}
@@ -129,18 +125,18 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
// only subtract the previously logged data in case the record was billable
// if it wasn't billable, then its values are not included in the statistic models used later on
if ($rawData['billable']) {
if (null !== $timesheet->getActivity() && $activityId === $timesheet->getActivity()->getId()) {
if (null !== $value->getActivity() && $activityId === $value->getActivity()->getId()) {
$activityDuration -= $rawData['duration'];
$activityRate -= $rawData['rate'];
}
if (null !== $timesheet->getProject()) {
if ($projectId === $timesheet->getProject()->getId()) {
if (null !== $value->getProject()) {
if ($projectId === $value->getProject()->getId()) {
$projectDuration -= $rawData['duration'];
$projectRate -= $rawData['rate'];
}
if ($customerId === $timesheet->getProject()->getCustomer()->getId()) {
if ($customerId === $value->getProject()->getCustomer()->getId()) {
$customerDuration -= $rawData['duration'];
$customerRate -= $rawData['rate'];
}
@@ -153,23 +149,23 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
$now = new DateTime('now', $begin->getTimezone());
$recordDate = $begin;
if (null !== ($activity = $timesheet->getActivity()) && $activity->hasBudgets()) {
if (null !== ($activity = $value->getActivity()) && $activity->hasBudgets()) {
$dateTime = $activity->isMonthlyBudget() ? $recordDate : $now;
if ($activity->isMonthlyBudget() && $monthWasChanged) {
$activityDuration = $duration;
}
$stat = $this->activityStatisticService->getBudgetStatisticModel($activity, $dateTime);
$this->checkBudgets($constraint, $stat, $timesheet, $activityDuration, $activityRate, 'activity');
$this->checkBudgets($constraint, $stat, $value, $activityDuration, $activityRate, 'activity');
}
if (null !== ($project = $timesheet->getProject())) {
if (null !== ($project = $value->getProject())) {
if ($project->hasBudgets()) {
$dateTime = $project->isMonthlyBudget() ? $recordDate : $now;
if ($project->isMonthlyBudget() && $monthWasChanged) {
$projectDuration = $duration;
}
$stat = $this->projectStatisticService->getBudgetStatisticModel($project, $dateTime);
$this->checkBudgets($constraint, $stat, $timesheet, $projectDuration, $projectRate, 'project');
$this->checkBudgets($constraint, $stat, $value, $projectDuration, $projectRate, 'project');
}
if (null !== ($customer = $project->getCustomer()) && $customer->hasBudgets()) {
$dateTime = $customer->isMonthlyBudget() ? $recordDate : $now;
@@ -177,12 +173,12 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
$customerDuration = $duration;
}
$stat = $this->customerStatisticService->getBudgetStatisticModel($customer, $dateTime);
$this->checkBudgets($constraint, $stat, $timesheet, $customerDuration, $customerRate, 'customer');
$this->checkBudgets($constraint, $stat, $value, $customerDuration, $customerRate, 'customer');
}
}
}
private function checkBudgets(TimesheetBudgetUsed $constraint, BudgetStatisticModel $stat, Timesheet $timesheet, int $duration, float $rate, string $field): bool
private function checkBudgets(TimesheetBudgetUsed $constraint, BudgetStatisticModel $stat, TimesheetEntity $timesheet, int $duration, float $rate, string $field): bool
{
$fullRate = ($stat->getBudgetSpent() + $rate);
@@ -203,7 +199,7 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
return false;
}
private function addBudgetViolation(TimesheetBudgetUsed $constraint, Timesheet $timesheet, string $field, float $budget, float $rate): void
private function addBudgetViolation(TimesheetBudgetUsed $constraint, TimesheetEntity $timesheet, string $field, float $budget, float $rate): void
{
// using the locale of the assigned user is not the best solution, but allows to be independent of the request stack
$helper = new LocaleFormatter($this->localeService, $timesheet->getUser()?->getLocale() ?? 'en');

View File

@@ -17,29 +17,25 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetExportedValidator extends ConstraintValidator
{
public function __construct(private Security $security)
public function __construct(private readonly Security $security)
{
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate(mixed $timesheet, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetExported)) {
throw new UnexpectedTypeException($constraint, TimesheetExported::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
if ($timesheet->getId() === null) {
if ($value->getId() === null) {
return;
}
if (!$timesheet->isExported()) {
if (!$value->isExported()) {
return;
}
@@ -47,7 +43,7 @@ final class TimesheetExportedValidator extends ConstraintValidator
// can trigger is right when the "export" flag ist set from the "edit form".
// most teamleads should not have "edit_exported_timesheet" but only "edit_export_other_timesheet"
if (null !== $this->security->getUser() && $this->security->isGranted('edit_export', $timesheet)) {
if (null !== $this->security->getUser() && $this->security->isGranted('edit_export', $value)) {
return;
}

View File

@@ -17,14 +17,10 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetFutureTimesValidator extends ConstraintValidator
{
public function __construct(private SystemConfiguration $configuration)
public function __construct(private readonly SystemConfiguration $configuration)
{
}
/**
* @param TimesheetEntity $value
* @param Constraint $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetFutureTimes)) {

View File

@@ -18,29 +18,28 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetLockdownValidator extends ConstraintValidator
{
public function __construct(private Security $security, private LockdownService $lockdownService)
public function __construct(
private readonly Security $security,
private readonly LockdownService $lockdownService
)
{
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate(mixed $timesheet, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetLockdown)) {
throw new UnexpectedTypeException($constraint, TimesheetLockdown::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
if (!$this->lockdownService->isLockdownActive()) {
return;
}
if (null === ($timesheetStart = $timesheet->getBegin())) {
if (null === ($timesheetStart = $value->getBegin())) {
return;
}
@@ -67,7 +66,7 @@ final class TimesheetLockdownValidator extends ConstraintValidator
$allowEditInGracePeriod = true;
}
if ($this->lockdownService->isEditable($timesheet, $now, $allowEditInGracePeriod)) {
if ($this->lockdownService->isEditable($value, $now, $allowEditInGracePeriod)) {
return;
}

View File

@@ -17,30 +17,26 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetLongRunningValidator extends ConstraintValidator
{
public function __construct(private SystemConfiguration $systemConfiguration)
public function __construct(private readonly SystemConfiguration $systemConfiguration)
{
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate(mixed $timesheet, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetLongRunning)) {
throw new UnexpectedTypeException($constraint, TimesheetLongRunning::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
if ($timesheet->isRunning()) {
if ($value->isRunning()) {
return;
}
/** @var int $duration */
$duration = $timesheet->getCalculatedDuration();
$duration = $value->getCalculatedDuration();
// one year is currently the maximum that can be logged (which is already not logically)
// the database column could hold more data, but let's limit it here

View File

@@ -18,26 +18,25 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetOverlappingValidator extends ConstraintValidator
{
public function __construct(private SystemConfiguration $configuration, private TimesheetRepository $repository)
public function __construct(
private readonly SystemConfiguration $configuration,
private readonly TimesheetRepository $repository
)
{
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate(mixed $timesheet, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetOverlapping)) {
throw new UnexpectedTypeException($constraint, TimesheetOverlapping::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
$begin = $value->getBegin();
$end = $value->getEnd();
// this case is handled in TimesheetValidator and should not raise a second validation
if ($begin !== null && $end !== null && $begin > $end) {
@@ -48,7 +47,7 @@ final class TimesheetOverlappingValidator extends ConstraintValidator
return;
}
if (!$this->repository->hasRecordForTime($timesheet)) {
if (!$this->repository->hasRecordForTime($value)) {
return;
}

View File

@@ -18,28 +18,27 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetRestartValidator extends ConstraintValidator
{
public function __construct(private Security $security, private TrackingModeService $trackingModeService)
public function __construct(
private readonly Security $security,
private readonly TrackingModeService $trackingModeService
)
{
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate(mixed $timesheet, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetRestart)) {
throw new UnexpectedTypeException($constraint, TimesheetRestart::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
// special case that would otherwise need to be validated in several controllers:
// an entry is edited and the end date is removed (or duration deleted) would restart the record,
// which might be disallowed for the current user
if (null !== $timesheet->getEnd()) {
if (null !== $value->getEnd()) {
return;
}
@@ -47,7 +46,7 @@ final class TimesheetRestartValidator extends ConstraintValidator
return;
}
if (null !== $this->security->getUser() && $this->security->isGranted('start', $timesheet)) {
if (null !== $this->security->getUser() && $this->security->isGranted('start', $value)) {
return;
}

View File

@@ -28,18 +28,14 @@ final class TimesheetValidator extends ConstraintValidator
{
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate(mixed $timesheet, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetEntityConstraint)) {
throw new UnexpectedTypeException($constraint, TimesheetEntityConstraint::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
$groups = [Constraint::DEFAULT_GROUP];
@@ -51,7 +47,7 @@ final class TimesheetValidator extends ConstraintValidator
$this->context
->getValidator()
->inContext($this->context)
->validate($timesheet, $innerConstraint, $groups);
->validate($value, $innerConstraint, $groups);
}
}
}

View File

@@ -17,35 +17,31 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetZeroDurationValidator extends ConstraintValidator
{
public function __construct(private SystemConfiguration $configuration)
public function __construct(private readonly SystemConfiguration $configuration)
{
}
/**
* @param TimesheetEntity $timesheet
* @param Constraint $constraint
*/
public function validate(mixed $timesheet, Constraint $constraint): void
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetZeroDuration)) {
throw new UnexpectedTypeException($constraint, TimesheetZeroDuration::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($timesheet, TimesheetEntity::class);
if (!\is_object($value) || !($value instanceof TimesheetEntity)) {
throw new UnexpectedTypeException($value, TimesheetEntity::class);
}
if ($this->configuration->isTimesheetAllowZeroDuration()) {
return;
}
if ($timesheet->isRunning()) {
if ($value->isRunning()) {
return;
}
$duration = 0;
if ($timesheet->getEnd() !== null && $timesheet->getBegin() !== null) {
$duration = $timesheet->getCalculatedDuration();
if ($value->getEnd() !== null && $value->getBegin() !== null) {
$duration = $value->getCalculatedDuration();
}
if ($duration <= 0) {

View File

@@ -35,21 +35,23 @@ final class ActivityVoter extends Voter
'permissions',
];
public function __construct(private RolePermissionManager $permissionManager)
public function __construct(private readonly RolePermissionManager $permissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return str_contains($subjectType, Activity::class);
}
protected function supports(string $attribute, mixed $subject): bool
{
if (!($subject instanceof Activity)) {
return false;
}
if (!\in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
return $subject instanceof Activity && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
@@ -65,7 +67,7 @@ final class ActivityVoter extends Voter
}
// those cannot be assigned to teams
if (\in_array($attribute, ['create', 'delete'])) {
if (\in_array($attribute, ['create', 'delete'], true)) {
return false;
}

View File

@@ -39,21 +39,23 @@ final class CustomerVoter extends Voter
'access',
];
public function __construct(private RolePermissionManager $permissionManager)
public function __construct(private readonly RolePermissionManager $permissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return str_contains($subjectType, Customer::class);
}
protected function supports(string $attribute, mixed $subject): bool
{
if (!($subject instanceof Customer)) {
return false;
}
if (!\in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
return $subject instanceof Customer && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool

View File

@@ -38,17 +38,22 @@ final class EntityMultiRoleVoter extends Voter
'activity',
];
public function __construct(private RolePermissionManager $permissionManager)
public function __construct(private readonly RolePermissionManager $permissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
protected function supports(string $attribute, mixed $subject): bool
{
if (!\in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
if (!$this->supportsAttribute($attribute)) {
return false;
}
if (\is_string($subject) && \in_array($subject, self::ALLOWED_SUBJECTS)) {
if (\is_string($subject) && \in_array($subject, self::ALLOWED_SUBJECTS, true)) {
return true;
}
@@ -69,7 +74,7 @@ final class EntityMultiRoleVoter extends Voter
$suffix = null;
if (\is_string($subject) && \in_array($subject, self::ALLOWED_SUBJECTS)) {
if (\is_string($subject) && \in_array($subject, self::ALLOWED_SUBJECTS, true)) {
$suffix = $subject;
} elseif ($subject instanceof Activity) {
$suffix = 'activity';

View File

@@ -37,21 +37,23 @@ final class ProjectVoter extends Voter
'details',
];
public function __construct(private RolePermissionManager $permissionManager)
public function __construct(private readonly RolePermissionManager $permissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return str_contains($subjectType, Project::class);
}
protected function supports(string $attribute, mixed $subject): bool
{
if (!($subject instanceof Project)) {
return false;
}
if (!\in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
return $subject instanceof Project && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool

View File

@@ -20,13 +20,21 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*/
final class QuickEntryVoter extends Voter
{
public function __construct(private RolePermissionManager $permissionManager, private TrackingModeService $trackingModeService)
public function __construct(
private readonly RolePermissionManager $permissionManager,
private readonly TrackingModeService $trackingModeService
)
{
}
public function supportsAttribute(string $attribute): bool
{
return 'quick-entry' === $attribute;
}
protected function supports(string $attribute, mixed $subject): bool
{
return 'quick-entry' === $attribute;
return $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool

View File

@@ -26,13 +26,23 @@ final class ReportingVoter extends Voter
'report:user',
];
public function __construct(private RolePermissionManager $permissionManager)
public function __construct(private readonly RolePermissionManager $permissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return $subjectType === 'null';
}
protected function supports(string $attribute, mixed $subject): bool
{
return $subject === null && \in_array($attribute, self::ALLOWED_ATTRIBUTES);
return $subject === null && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool

View File

@@ -21,18 +21,24 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*/
final class RolePermissionVoter extends Voter
{
public function __construct(private RolePermissionManager $permissionManager)
public function __construct(private readonly RolePermissionManager $permissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return $this->permissionManager->isRegisteredPermission($attribute);
}
public function supportsType(string $subjectType): bool
{
// we only work on single strings that have no subject
return $subjectType === 'null';
}
protected function supports(string $attribute, mixed $subject): bool
{
// we only work on single strings that have no subject
if (null !== $subject) {
return false;
}
return $this->permissionManager->isRegisteredPermission($attribute);
return $subject === null && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool

View File

@@ -29,21 +29,23 @@ final class TeamVoter extends Voter
'delete',
];
public function __construct(private RolePermissionManager $permissionManager)
public function __construct(private readonly RolePermissionManager $permissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return str_contains($subjectType, Team::class);
}
protected function supports(string $attribute, mixed $subject): bool
{
if (!($subject instanceof Team)) {
return false;
}
if (!\in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
return $subject instanceof Team && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool

View File

@@ -11,6 +11,7 @@ namespace App\Voter;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\Model\MultiUserTimesheet;
use App\Security\RolePermissionManager;
use App\Timesheet\LockdownService;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -55,21 +56,26 @@ final class TimesheetVoter extends Voter
private ?bool $editExported = null;
private ?\DateTime $now = null;
public function __construct(private RolePermissionManager $permissionManager, private LockdownService $lockdownService)
public function __construct(
private readonly RolePermissionManager $permissionManager,
private readonly LockdownService $lockdownService
)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return str_contains($subjectType, Timesheet::class) || str_contains($subjectType, MultiUserTimesheet::class);
}
protected function supports(string $attribute, mixed $subject): bool
{
if (!($subject instanceof Timesheet)) {
return false;
}
if (!\in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
return $subject instanceof Timesheet && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool

View File

@@ -38,21 +38,23 @@ final class UserVoter extends Voter
'supervisor',
];
public function __construct(private RolePermissionManager $permissionManager)
public function __construct(private readonly RolePermissionManager $permissionManager)
{
}
public function supportsAttribute(string $attribute): bool
{
return \in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return str_contains($subjectType, User::class);
}
protected function supports(string $attribute, mixed $subject): bool
{
if (!($subject instanceof User)) {
return false;
}
if (!\in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
return $subject instanceof User && $this->supportsAttribute($attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool

View File

@@ -25,7 +25,7 @@ use DateTimeInterface;
*/
class DailyWorkingTimeChartProvider
{
public function __construct(private TimesheetRepository $repository)
public function __construct(private readonly TimesheetRepository $repository)
{
}
@@ -33,9 +33,6 @@ class DailyWorkingTimeChartProvider
* In case this method is called with one timezone and the results are from another timezone,
* it might return rows outside the time-range.
*
* @param DateTimeInterface $begin
* @param DateTimeInterface $end
* @param User|null $user
* @return array<mixed>
*/
protected function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, ?User $user = null): array

View File

@@ -144,10 +144,10 @@ final class WorkingTimeService
->select('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('DATE(t.date) as day')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->eq('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $user->getId())
->addGroupBy('day')
;