Release 2.22.0 (#5043)

This commit is contained in:
Kevin Papst
2024-09-20 14:30:11 +02:00
committed by GitHub
parent 3e0dadc0c8
commit 537c120ad9
57 changed files with 435 additions and 572 deletions

View File

@@ -117,7 +117,7 @@ class ActivityStatisticService
/**
* @param Activity[] $activities
* @return array<int, ActivityStatistic>
* @return array<int|string, ActivityStatistic>
*/
private function getBudgetStatistic(array $activities, ?DateTimeInterface $begin = null, ?DateTimeInterface $end = null): array
{
@@ -159,6 +159,9 @@ class ActivityStatisticService
return $statistics;
}
/**
* @param Activity[] $activities
*/
private function createStatisticQueryBuilder(array $activities, \DateTimeInterface $begin = null, ?\DateTimeInterface $end = null): QueryBuilder
{
$qb = $this->timesheetRepository->createQueryBuilder('t');

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.21.0';
public const VERSION = '2.22.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22100;
public const VERSION_ID = 22200;
/**
* The software name
*/

View File

@@ -53,7 +53,7 @@ final class ContractController extends AbstractController
/** @var User $profile */
$profile = $values->getUser();
if ($this->getUser() !== $profile && !$canChangeUser) {
if (!$this->isGranted('hours', $profile)) {
throw $this->createAccessDeniedException('Cannot access user contract settings');
}
@@ -79,9 +79,10 @@ final class ContractController extends AbstractController
$boxConfiguration = new BoxConfiguration();
$boxConfiguration->setDecimal(false);
$boxConfiguration->setCollapsed($profile->hasWorkHourConfiguration() && $summary->count() > 0);
$boxConfiguration->setCollapsed($summary->count() > 0);
return $this->render('contract/status.html.twig', [
'withWorkHourConfiguration' => $profile->hasWorkHourConfiguration(),
'box_configuration' => $boxConfiguration,
'page_setup' => $page,
'decimal' => $boxConfiguration->isDecimal(),

View File

@@ -61,9 +61,8 @@ final class TeamController extends AbstractController
$table->setReloadEvents('kimai.teamUpdate');
$table->addColumn('name', ['class' => 'alwaysVisible']);
$table->addColumn('teamlead', ['class' => 'd-none badges', 'orderBy' => false]);
$table->addColumn('teamlead_avatar', ['title' => 'team.member', 'translation_domain' => 'teams', 'class' => 'd-none d-lg-table-cell avatars avatar-list avatar-list-stacked', 'orderBy' => false]);
$table->addColumn('user', ['class' => 'd-none badges', 'orderBy' => false, 'title' => 'user']);
$table->addColumn('avatar', ['title' => 'team.member', 'translation_domain' => 'teams', 'class' => 'd-none d-sm-table-cell avatars avatar-list avatar-list-stacked', 'orderBy' => false]);
$table->addColumn('amount', ['title' => 'amount', 'class' => 'd-sm-none text-center', 'orderBy' => false]);
$table->addColumn('actions', ['class' => 'actions']);
$page = new PageSetup('teams');

View File

@@ -1117,9 +1117,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
$initial = mb_substr($initial, 0, $length, 'UTF-8');
}
$initial = mb_strtoupper($initial);
return $initial;
return mb_strtoupper($initial);
}
public function getAccountNumber(): ?string

View File

@@ -71,6 +71,9 @@ final class ConfigureMainMenuEvent extends Event
return $this->menu->getChild('reporting');
}
/**
* @deprecated since 2.22 - use getMenu() or getAdminMenu() instead
*/
public function getAppsMenu(): MenuItemModel
{
return $this->apps;

View File

@@ -17,7 +17,11 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final class UserSubscriber extends AbstractActionsSubscriber
{
public function __construct(AuthorizationCheckerInterface $auth, UrlGeneratorInterface $urlGenerator, private EventDispatcherInterface $eventDispatcher)
public function __construct(
AuthorizationCheckerInterface $auth,
UrlGeneratorInterface $urlGenerator,
private readonly EventDispatcherInterface $eventDispatcher
)
{
parent::__construct($auth, $urlGenerator);
}
@@ -47,6 +51,10 @@ final class UserSubscriber extends AbstractActionsSubscriber
$event->addActionToSubmenu('edit', $id, $action);
}
if ($this->isGranted('hours', $user)) {
$event->addActionToSubmenu('report', 'work_times', ['url' => $this->path('user_contract', ['user' => $user->getId()]), 'title' => 'work_times']);
}
if (($event->getUser()->getId() === $user->getId() && $this->isGranted('report:user')) || $this->isGranted('report:other')) {
$event->addActionToSubmenu('report', 'weekly', ['url' => $this->path('report_user_week', ['user' => $user->getId()]), 'translation_domain' => 'reporting', 'title' => 'report_user_week']);
$event->addActionToSubmenu('report', 'monthly', ['url' => $this->path('report_user_month', ['user' => $user->getId()]), 'translation_domain' => 'reporting', 'title' => 'report_user_month']);

View File

@@ -43,8 +43,8 @@ final class MenuBuilderSubscriber implements EventSubscriberInterface
$event->addItem($child);
}
if ($menuEvent->getAppsMenu()->hasChildren()) {
$event->addItem($menuEvent->getAppsMenu());
if ($menuEvent->getAppsMenu()->hasChildren()) { // @phpstan-ignore-line
$event->addItem($menuEvent->getAppsMenu()); // @phpstan-ignore-line
}
if ($menuEvent->getAdminMenu()->hasChildren()) {
$event->addItem($menuEvent->getAdminMenu());

View File

@@ -94,7 +94,7 @@ final class MenuSubscriber implements EventSubscriberInterface
}
$contract = new MenuItemModel('contract', 'work_contract', null, [], 'contract');
if ($user->hasContractSettings() || $auth->isGranted('hours_other_profile')) {
if ($auth->isGranted('hours', $user)) {
$contract->addChild(new MenuItemModel('contract_status', 'work_times', 'user_contract', [], 'work_times'));
}

View File

@@ -25,19 +25,11 @@ final class DocumentationLinkExtension extends AbstractTypeExtension
return [FormType::class];
}
/**
* @param FormView $view
* @param FormInterface $form
* @param array $options
*/
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['docu_chapter'] = $options['docu_chapter'] ?? null;
}
/**
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined(['docu_chapter']);

View File

@@ -26,11 +26,6 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
return [EntityType::class, ChoiceType::class];
}
/**
* @param FormView $view
* @param FormInterface $form
* @param array $options
*/
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if (isset($options['selectpicker']) && false === $options['selectpicker']) {
@@ -59,7 +54,7 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
// there is a very weird logic in vendor/symfony/twig-bridge/Resources/views/Form/form_div_layout.html.twig
// in block "block choice_widget_collapsed" that resets "{% set required = false %}", so we fake it into the select
if (true === $options['required'] && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
if (true === $options['required'] && \is_array($options['attr']) && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
$extendedOptions['required'] = 'required';
$extendedOptions['placeholder'] = '';
}

View File

@@ -25,11 +25,6 @@ final class IconExtension extends AbstractTypeExtension
return [TextType::class];
}
/**
* @param FormView $view
* @param FormInterface $form
* @param array $options
*/
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['icon'] = $options['icon'] ?? null;

View File

@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*/
final class SelectWithApiDataExtension extends AbstractTypeExtension
{
public function __construct(private UrlGeneratorInterface $router)
public function __construct(private readonly UrlGeneratorInterface $router)
{
}
@@ -30,11 +30,6 @@ final class SelectWithApiDataExtension extends AbstractTypeExtension
return [EntityType::class];
}
/**
* @param FormView $view
* @param FormInterface $form
* @param array $options
*/
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if (!isset($options['api_data'])) {
@@ -69,7 +64,7 @@ final class SelectWithApiDataExtension extends AbstractTypeExtension
$parent = $form->getParent();
do {
$formPrefixes[] = $parent->getName();
} while (($parent = $parent->getParent()) !== null);
} while (($parent = $parent?->getParent()) !== null);
$formPrefix = implode('_', array_reverse($formPrefixes));
$formField = $apiData['select'];

View File

@@ -45,7 +45,7 @@ final class MenuChoiceType extends AbstractType
$this->eventDispatcher->dispatch($event);
$choices = $this->getChoicesFromMenu($event->getMenu(), $filter);
$choices += $this->getChoicesFromMenu($event->getAppsMenu(), $filter);
$choices += $this->getChoicesFromMenu($event->getAppsMenu(), $filter); // @phpstan-ignore-line
$choices += $this->getChoicesFromMenu($event->getAdminMenu(), $filter);
$choices += $this->getChoicesFromMenu($event->getSystemMenu(), $filter);

View File

@@ -82,12 +82,14 @@ final class ActivityLoader implements LoaderInterface
}
// required on "Activity listing" page for non super-admins
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'teams')
->from(Activity::class, 'a')
->leftJoin('a.teams', 'teams')
->andWhere($qb->expr()->in('a.id', $activityIds))
->getQuery()
->execute();
if (\count($activityIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'teams')
->from(Activity::class, 'a')
->leftJoin('a.teams', 'teams')
->andWhere($qb->expr()->in('a.id', $activityIds))
->getQuery()
->execute();
}
}
}

View File

@@ -66,13 +66,15 @@ final class CustomerLoader implements LoaderInterface
$em = $this->entityManager;
// required where we need to check team permissions, e.g. "Customer listing"
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'teams')
->from(Customer::class, 'c')
->leftJoin('c.teams', 'teams')
->andWhere($qb->expr()->in('c.id', $customerIds))
->getQuery()
->execute();
if (\count($customerIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'teams')
->from(Customer::class, 'c')
->leftJoin('c.teams', 'teams')
->andWhere($qb->expr()->in('c.id', $customerIds))
->getQuery()
->execute();
}
// do not load team members or leads by default, because they will only be used on detail pages
if ($hydrateTeamMembers) {

View File

@@ -37,6 +37,7 @@ final class ProjectLoader implements LoaderInterface
return;
}
/** @var array<int> $projectIds */
$projectIds = array_filter(array_unique(array_map(function (Project $project) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$project->getName();
@@ -48,7 +49,7 @@ final class ProjectLoader implements LoaderInterface
$em = $this->entityManager;
if ($this->hydrateTeams) {
if ($this->hydrateTeams && \count($projectIds) > 0) {
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $results)), function ($value) { return $value !== null; });
@@ -61,13 +62,15 @@ final class ProjectLoader implements LoaderInterface
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL customer.{id}', 'teams')
->from(Customer::class, 'customer')
->leftJoin('customer.teams', 'teams')
->andWhere($qb->expr()->in('customer.id', $customerIds))
->getQuery()
->execute();
if (\count($customerIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL customer.{id}', 'teams')
->from(Customer::class, 'customer')
->leftJoin('customer.teams', 'teams')
->andWhere($qb->expr()->in('customer.id', $customerIds))
->getQuery()
->execute();
}
}
// do not load team members or leads by default, because they will only be used on detail pages

View File

@@ -45,24 +45,26 @@ final class TeamLoader implements LoaderInterface
$em = $this->entityManager;
// required wherever users are shown, e.g. on "Custom details" page
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'members', 'user')
->from(Team::class, 'team')
->leftJoin('team.members', 'members')
->leftJoin('members.user', 'user')
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
if (\count($teamIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'members', 'user')
->from(Team::class, 'team')
->leftJoin('team.members', 'members')
->leftJoin('members.user', 'user')
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
// used in UserTeamProjects widget
$qb = $em->createQueryBuilder();
/** @var array<Team> $teams */
$teams = $qb->select('PARTIAL team.{id}', 'projects')
->from(Team::class, 'team')
->leftJoin('team.projects', 'projects')
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
// used in UserTeamProjects widget
$qb = $em->createQueryBuilder();
/** @var array<Team> $teams */
$teams = $qb->select('PARTIAL team.{id}', 'projects')
->from(Team::class, 'team')
->leftJoin('team.projects', 'projects')
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
}
$projectIds = [];
foreach ($results as $team) {
@@ -71,7 +73,7 @@ final class TeamLoader implements LoaderInterface
}
}
if ($this->loadCustomer) {
if ($this->loadCustomer && \count($projectIds) > 0) {
// used in UserTeamProjects widget
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'customer')

View File

@@ -135,8 +135,9 @@ class TagRepository extends EntityRepository
$qb1 = $this->getEntityManager()->createQueryBuilder();
$qb1->from(Timesheet::class, 't')->select('COUNT(tags)')->innerJoin('t.tags', 'tags')->where('tags.id = tag.id');
$dql = $qb1->getDQL(); // see https://github.com/phpstan/phpstan-doctrine/issues/606
$qb->select('tag.id, tag.name, tag.color, tag.visible');
$qb->addSelect('(' . $qb1->getDQL() . ') as amount');
$qb->addSelect('(' . $dql . ') as amount');
$orderBy = $query->getOrderBy();
$orderBy = match ($orderBy) {

View File

@@ -19,11 +19,7 @@ final class Util
}
/**
* Calculates the rate for a hourly rate and a given duration in seconds.
*
* @param float $hourlyRate
* @param int $seconds
* @return float
* Calculates the rate by an hourly rate and a given duration in seconds.
*/
public static function calculateRate(float $hourlyRate, int $seconds): float
{

View File

@@ -18,7 +18,6 @@ use App\Utils\JavascriptFormatConverter;
use App\Utils\LocaleFormatter;
use DateTime;
use DateTimeInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
@@ -30,7 +29,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
private ?LocaleFormatter $formatter = null;
private ?string $locale = null;
public function __construct(private LocaleService $localeService, private Security $security)
public function __construct(private readonly LocaleService $localeService)
{
}
@@ -111,7 +110,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
return $this->locale;
}
public function isWeekend(\DateTimeInterface|string|null $dateTime, ?User $user = null): bool
public function isWeekend(\DateTimeInterface|string|null $dateTime): bool
{
if (!$dateTime instanceof \DateTimeInterface) {
return false;
@@ -119,12 +118,6 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
$day = (int) $dateTime->format('N');
/** @var User|null $tmp */
$tmp = $user ?? $this->security->getUser();
if ($tmp !== null && $tmp->hasWorkHourConfiguration()) {
return !$tmp->isWorkDay($dateTime);
}
return ($day === 6 || $day === 7);
}

View File

@@ -74,10 +74,6 @@ final class UserVoter extends Voter
return $this->permissionManager->hasRolePermission($user, 'contract_other_profile');
}
if ($attribute === 'hours') {
return $this->permissionManager->hasRolePermission($user, 'hours_other_profile');
}
if ($attribute === 'access_user') {
return $user->canSeeUser($subject);
}