From 20164295f87331e6fe2aaf8e5a28c2bc74f42a36 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Sun, 19 Nov 2023 16:05:43 +0100 Subject: [PATCH] Release 2.4.0 (#4427) * button to duplicate old timesheets, even those from lockdown period * bump composer packages * fixes tooltip remains in view #4426 * fix js error if all widgets were removed * allow to open timesheet edit dialog from export listing * css classes for timesheet context menu, so they can be hidden * added flag to force a user to set password upon login * enable lazy-ghost-objects to fix deprecation * change user, username, internal_rate export column labels * helper method to find user by displayname * log improvements and format changes * deactivate broken schema validation --- .gitignore | 7 ++- composer.json | 4 +- composer.lock | 26 +++++----- config/packages/doctrine.yaml | 1 + config/packages/monolog.yaml | 21 ++++++--- config/services.yaml | 12 +++++ src/API/UserController.php | 1 + src/Configuration/SystemConfiguration.php | 47 +++++++++++++------ src/Constants.php | 4 +- src/Controller/ProfileController.php | 1 + src/Entity/ExportableItem.php | 17 ++----- src/Entity/Tag.php | 22 +-------- src/Entity/Timesheet.php | 7 --- src/Event/PageActionsEvent.php | 7 +-- .../Actions/AbstractTimesheetSubscriber.php | 10 ++-- .../Base/AbstractSpreadsheetRenderer.php | 17 +++---- .../Spreadsheet/SpreadsheetExporter.php | 2 +- src/Form/UserEditType.php | 9 ++++ src/Logger/LogProcessor.php | 28 +++++++++++ src/Repository/TagRepository.php | 8 ++-- src/User/UserService.php | 5 ++ src/Voter/TimesheetVoter.php | 11 +---- templates/dashboard/grid.html.twig | 13 ++++- templates/export/index.html.twig | 4 +- templates/user/profile.html.twig | 3 -- tests/Voter/TimesheetVoterTest.php | 43 ++++++++--------- tests/phpstan.neon | 25 ---------- 27 files changed, 186 insertions(+), 169 deletions(-) create mode 100644 src/Logger/LogProcessor.php diff --git a/.gitignore b/.gitignore index 9dd9ded2..832a3db2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ /public/.htaccess /.env-* /.idea/ -/.DS_Store +.DS_Store /phpstan.sh # custom apache rules e.g. to deactivate ioncube loader @@ -22,12 +22,11 @@ /var/data/* /var/cache/* /var/invoices* -/var/export/* +/var/export* /var/log/* /var/sessions/* /var/packages/* -/var/plugins/* -/var/plugins_old/ +/var/plugins* /var/plugins/*/*.disabled ###> symfony/framework-bundle ### diff --git a/composer.json b/composer.json index d924ff80..5a657872 100644 --- a/composer.json +++ b/composer.json @@ -59,6 +59,7 @@ "symfony/http-client": "^6.0", "symfony/intl": "^6.0", "symfony/mailer": "^6.0", + "symfony/mime": "^6.0", "symfony/monolog-bundle": "^3.4", "symfony/rate-limiter": "^6.0", "symfony/runtime": "^6.0", @@ -173,8 +174,7 @@ "bin/console lint:container", "bin/console lint:yaml config --parse-tags", "bin/console lint:twig templates --show-deprecations", - "bin/console lint:xliff translations", - "bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction" + "bin/console lint:xliff translations" ], "tests": "vendor/bin/phpunit tests/", "tests-unit": "vendor/bin/phpunit --exclude-group integration tests/", diff --git a/composer.lock b/composer.lock index 990919fe..580abb1a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "28da6d01423fca5a17395df418603aa5", + "content-hash": "1f5d701618a13718f0b3f15d5b8200b3", "packages": [ { "name": "azuyalabs/yasumi", @@ -1907,20 +1907,20 @@ }, { "name": "ezyang/htmlpurifier", - "version": "v4.16.0", + "version": "v4.17.0", "source": { "type": "git", "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "523407fb06eb9e5f3d59889b3978d5bfe94299c8" + "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/523407fb06eb9e5f3d59889b3978d5bfe94299c8", - "reference": "523407fb06eb9e5f3d59889b3978d5bfe94299c8", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/bbc513d79acf6691fa9cf10f192c90dd2957f18c", + "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c", "shasum": "" }, "require": { - "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0" + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" }, "require-dev": { "cerdic/css-tidy": "^1.7 || ^2.0", @@ -1962,9 +1962,9 @@ ], "support": { "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.16.0" + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.17.0" }, - "time": "2022-09-18T07:06:19+00:00" + "time": "2023-11-17T15:01:25+00:00" }, { "name": "friendsofsymfony/rest-bundle", @@ -11166,16 +11166,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.41", + "version": "1.10.42", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "c6174523c2a69231df55bdc65b61655e72876d76" + "reference": "fc2316508de5453140b5cb3d3f8683a33e92f26a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6174523c2a69231df55bdc65b61655e72876d76", - "reference": "c6174523c2a69231df55bdc65b61655e72876d76", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fc2316508de5453140b5cb3d3f8683a33e92f26a", + "reference": "fc2316508de5453140b5cb3d3f8683a33e92f26a", "shasum": "" }, "require": { @@ -11224,7 +11224,7 @@ "type": "tidelift" } ], - "time": "2023-11-05T12:57:57+00:00" + "time": "2023-11-17T15:26:57+00:00" }, { "name": "phpstan/phpstan-doctrine", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 78baf712..15941a2d 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -25,6 +25,7 @@ doctrine: orm: auto_generate_proxy_classes: '%kernel.debug%' default_entity_manager: default + enable_lazy_ghost_objects: true entity_managers: default: report_fields_where_declared: true diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index d2bb1b6f..a7975752 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -1,43 +1,49 @@ +monolog: + channels: ["deprecation"] + when@prod: monolog: -# channels: ["deprecation"] handlers: main: type: fingers_crossed - action_level: error + action_level: notice handler: nested excluded_http_codes: [403, 404] + channels: ["!deprecation"] nested: type: stream level: info path: "%kernel.logs_dir%/%kernel.environment%.log" + formatter: monolog.formatter.kimai console: type: console process_psr_3_messages: false - channels: ["!event", "!doctrine"] + channels: ["!event", "!doctrine", "!deprecation"] # deactivated, because currently there are too many deprecations cause by gedmo and doctrine # deprecation: # type: stream # channels: ["deprecation"] # path: "%kernel.logs_dir%/deprecations.log" +# formatter: monolog.formatter.deprecation when@dev: monolog: - channels: ["deprecation"] handlers: main: type: stream path: "%kernel.logs_dir%/%kernel.environment%.log" - level: info - channels: ["!event"] + level: debug + channels: ["!event", "!deprecation"] + formatter: monolog.formatter.kimai console: type: console process_psr_3_messages: false - channels: ["!event", "!doctrine", "!console"] + channels: ["!event", "!doctrine", "!deprecation"] deprecation: type: stream channels: ["deprecation"] path: "%kernel.logs_dir%/deprecations.log" + formatter: monolog.formatter.deprecation when@test: monolog: @@ -47,3 +53,4 @@ when@test: path: "%kernel.logs_dir%/%kernel.environment%.log" level: info channels: ["!event"] + formatter: monolog.formatter.kimai diff --git a/config/services.yaml b/config/services.yaml index 6637ab24..200a9c33 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -223,3 +223,15 @@ services: class: App\Repository\WorkingTimeRepository factory: ['@doctrine.orm.entity_manager', getRepository] arguments: ['App\Entity\WorkingTime'] + + monolog.formatter.kimai: + class: Monolog\Formatter\LineFormatter + arguments: + - "[%%datetime%%] [%%extra.channel%%] %%level_name%%: %%message%% %%context%% %%extra%%\n" + - "Y-m-d H:i:s" + + monolog.formatter.deprecation: + class: Monolog\Formatter\LineFormatter + arguments: + - "[%%datetime%%] %%message%% %%context%%\n" + - "Y-m-d H:i:s" diff --git a/src/API/UserController.php b/src/API/UserController.php index a02fcf5e..5f8ee6b1 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -207,6 +207,7 @@ final class UserController extends BaseApiController 'include_active_flag' => ($profile->getId() !== $this->getUser()->getId()), 'include_preferences' => $this->isGranted('preferences', $profile), 'include_supervisor' => $this->isGranted('supervisor', $profile), + 'include_password_reset' => $this->isGranted('password', $profile), ]); $form->setData($profile); diff --git a/src/Configuration/SystemConfiguration.php b/src/Configuration/SystemConfiguration.php index 18a1c991..efdccd07 100644 --- a/src/Configuration/SystemConfiguration.php +++ b/src/Configuration/SystemConfiguration.php @@ -399,7 +399,7 @@ final class SystemConfiguration public function getTimesheetTrackingMode(): string { - return (string) $this->find('timesheet.mode'); + return $this->getString('timesheet.mode', 'default'); } public function isTimesheetMarkdownEnabled(): bool @@ -442,19 +442,6 @@ final class SystemConfiguration return (int) $this->find('timesheet.rounding.default.duration'); } - private function getIncrement(string $key, int $fallback, int $min = 1): int - { - $config = $this->find($key); - - if ($config === null || trim($config) === '') { - return $fallback; - } - - $config = (int) $config; - - return max($config, $min); - } - public function getTimesheetIncrementDuration(): int { return $this->getIncrement('timesheet.duration_increment', $this->getTimesheetDefaultRoundingDuration(), 0); @@ -511,4 +498,36 @@ final class SystemConfiguration { return $this->find('project.copy_teams_on_create') === true; } + + // ========== Helper functions ========== + + private function getIncrement(string $key, int $fallback, int $min = 1): int + { + $config = $this->find($key); + + if ($config === null || trim($config) === '') { + return $fallback; + } + + $config = (int) $config; + + return max($config, $min); + } + + private function getString(string $key, string $fallback): string + { + $config = $this->find($key); + + if ($config === null) { + return $fallback; + } + + $config = (string) $config; + + if (trim($config) === '') { + return $fallback; + } + + return $config; + } } diff --git a/src/Constants.php b/src/Constants.php index 914de401..7a98d629 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -17,11 +17,11 @@ class Constants /** * The current release version */ - public const VERSION = '2.3.0'; + public const VERSION = '2.4.0'; /** * The current release: major * 10000 + minor * 100 + patch */ - public const VERSION_ID = 20300; + public const VERSION_ID = 20400; /** * The software name */ diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index d2a91f67..c80a0366 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -318,6 +318,7 @@ final class ProfileController extends AbstractController 'include_active_flag' => ($user->getId() !== $this->getUser()->getId()), 'include_preferences' => true, 'include_supervisor' => $this->isGranted('supervisor', $user), + 'include_password_reset' => $this->isGranted('password', $user), ] ); } diff --git a/src/Entity/ExportableItem.php b/src/Entity/ExportableItem.php index 1d00a311..5b7ed4a2 100644 --- a/src/Entity/ExportableItem.php +++ b/src/Entity/ExportableItem.php @@ -13,25 +13,20 @@ use Doctrine\Common\Collections\Collection; interface ExportableItem { + public function getId(): ?int; + /** * Whether this item was already exported. - * - * @return bool */ public function isExported(): bool; /** * Whether this item should be included in invoices. - * - * @return bool */ public function isBillable(): bool; /** * Returns the named meta field or null. - * - * @param string $name - * @return MetaTableTypeInterface|null */ public function getMetaField(string $name): ?MetaTableTypeInterface; @@ -44,8 +39,6 @@ interface ExportableItem /** * Returns the amount for this item. - * - * @return float */ public function getAmount(): float; @@ -83,15 +76,13 @@ interface ExportableItem /** * A name representation for this type of item. - * - * @return string + * Example: "timesheet" */ public function getType(): string; /** * A name representation for the category of this item. - * - * @return string + * Example: "work" */ public function getCategory(): string; } diff --git a/src/Entity/Tag.php b/src/Entity/Tag.php index dc95b04f..a2c7a757 100644 --- a/src/Entity/Tag.php +++ b/src/Entity/Tag.php @@ -52,6 +52,8 @@ class Tag use ColorTrait; /** + * This is ONLY here, so we can count the amount of timesheets. + * * @var Collection */ #[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')] @@ -89,26 +91,6 @@ class Tag $this->visible = $visible; } - public function addTimesheet(Timesheet $timesheet): void - { - if ($this->timesheets->contains($timesheet)) { - return; - } - - $this->timesheets->add($timesheet); - $timesheet->addTag($this); - } - - public function removeTimesheet(Timesheet $timesheet): void - { - if (!$this->timesheets->contains($timesheet)) { - return; - } - - $this->timesheets->removeElement($timesheet); - $timesheet->removeTag($this); - } - public function __toString(): string { return $this->getName(); diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index 99064bbe..182ef163 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -411,10 +411,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem return $this->internalRate; } - /** - * @param Tag $tag - * @return Timesheet - */ public function addTag(Tag $tag): Timesheet { if ($this->tags->contains($tag)) { @@ -425,9 +421,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem return $this; } - /** - * @param Tag $tag - */ public function removeTag(Tag $tag): void { if (!$this->tags->contains($tag)) { diff --git a/src/Event/PageActionsEvent.php b/src/Event/PageActionsEvent.php index 75edf051..b8ce69e4 100644 --- a/src/Event/PageActionsEvent.php +++ b/src/Event/PageActionsEvent.php @@ -151,16 +151,13 @@ class PageActionsEvent extends ThemeEvent $this->addAction('create', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' : ''), 'title' => 'create', 'accesskey' => 'a']); } - public function addEdit(string $url, bool $modal = true): void + public function addEdit(string $url, bool $modal = true, string $class = ''): void { - $this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' : ''), 'translation_domain' => 'actions', 'title' => 'edit']); + $this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'translation_domain' => 'actions', 'title' => 'edit']); } /** * Link to a configuration section. - * - * @param string $url - * @return void */ public function addSettings(string $url): void { diff --git a/src/EventSubscriber/Actions/AbstractTimesheetSubscriber.php b/src/EventSubscriber/Actions/AbstractTimesheetSubscriber.php index 90324936..5ee26a43 100644 --- a/src/EventSubscriber/Actions/AbstractTimesheetSubscriber.php +++ b/src/EventSubscriber/Actions/AbstractTimesheetSubscriber.php @@ -25,20 +25,20 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber $timesheet = $payload['timesheet']; if ($timesheet->getId() !== null) { if ($timesheet->isRunning() && $this->isGranted('stop', $timesheet)) { - $event->addAction('stop', ['url' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]); + $event->addAction('stop', ['url' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-stop', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]); } if (!$timesheet->isRunning() && $this->isGranted('start', $timesheet)) { - $event->addAction('repeat', ['title' => 'repeat', 'translation_domain' => 'actions', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]); + $event->addAction('repeat', ['title' => 'repeat', 'translation_domain' => 'actions', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]); } if ($this->isGranted('edit', $timesheet)) { - $event->addEdit($this->path($routeEdit, ['id' => $timesheet->getId()]), !$event->isView('edit')); + $event->addEdit($this->path($routeEdit, ['id' => $timesheet->getId()]), !$event->isView('edit'), 'dd-ts-edit'); } if ($this->isGranted('duplicate', $timesheet)) { $class = $event->isView('edit') ? '' : 'modal-ajax-form'; - $event->addAction('copy', ['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class]); + $event->addAction('copy', ['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']); } if ($event->countActions() > 0) { @@ -48,7 +48,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber if (($event->isIndexView() || $event->isView('calendar')) && $this->isGranted('delete', $timesheet)) { $event->addAction('trash', [ 'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]), - 'class' => 'api-link text-red', + 'class' => 'api-link text-red dd-ts-trash', 'translation_domain' => 'actions', 'attr' => [ 'data-event' => 'kimai.timesheetDelete', diff --git a/src/Export/Base/AbstractSpreadsheetRenderer.php b/src/Export/Base/AbstractSpreadsheetRenderer.php index 90d0410b..a61a35b0 100644 --- a/src/Export/Base/AbstractSpreadsheetRenderer.php +++ b/src/Export/Base/AbstractSpreadsheetRenderer.php @@ -73,8 +73,12 @@ abstract class AbstractSpreadsheetRenderer 'end' => [], 'duration' => [], 'rate' => [], - 'rate_internal' => [], - 'user' => [], + 'rate_internal' => [ + 'label' => 'internalRate', // different translation key + ], + 'user' => [ + 'label' => 'name' + ], 'username' => [], 'customer' => [], 'project' => [], @@ -309,13 +313,6 @@ abstract class AbstractSpreadsheetRenderer $sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $username); }; } - if (!isset($columns['username']['header'])) { - $columns['username']['header'] = function (Worksheet $sheet, int $row, int $column): int { - $sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('name')); - - return 1; - }; - } } if (isset($columns['customer']) && !isset($columns['customer']['render'])) { @@ -675,7 +672,7 @@ abstract class AbstractSpreadsheetRenderer $amount = $settings['header']($sheet, $recordsHeaderRow, $recordsHeaderColumn); $recordsHeaderColumn += $amount; } else { - $sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans($label)); + $sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans((\array_key_exists('label', $settings) && \is_string($settings['label'])) ? $settings['label'] : $label)); } } diff --git a/src/Export/Spreadsheet/SpreadsheetExporter.php b/src/Export/Spreadsheet/SpreadsheetExporter.php index 1e8e8516..e6cd5b88 100644 --- a/src/Export/Spreadsheet/SpreadsheetExporter.php +++ b/src/Export/Spreadsheet/SpreadsheetExporter.php @@ -28,7 +28,7 @@ class SpreadsheetExporter /** * @var CellFormatterInterface[] */ - private $formatter = []; + private array $formatter = []; public function __construct(private TranslatorInterface $translator) { diff --git a/src/Form/UserEditType.php b/src/Form/UserEditType.php index fd1e3667..c73fe932 100644 --- a/src/Form/UserEditType.php +++ b/src/Form/UserEditType.php @@ -96,6 +96,14 @@ class UserEditType extends AbstractType 'ignore_users' => ($user instanceof User && $user->getId() !== null ? [$user] : []), ]); } + + if ($options['include_password_reset']) { + $builder->add('requiresPasswordReset', YesNoType::class, [ + 'label' => 'force_password_change', + 'help' => 'force_password_change_help', + 'required' => false, + ]); + } } public function configureOptions(OptionsResolver $resolver): void @@ -109,6 +117,7 @@ class UserEditType extends AbstractType 'include_active_flag' => true, 'include_preferences' => true, 'include_supervisor' => true, + 'include_password_reset' => true, ]); } } diff --git a/src/Logger/LogProcessor.php b/src/Logger/LogProcessor.php new file mode 100644 index 00000000..6f6c4c7f --- /dev/null +++ b/src/Logger/LogProcessor.php @@ -0,0 +1,28 @@ +context)) { + $record->extra['channel'] = strtoupper($record->context['bundle']); + } else { + $record->extra['channel'] = $record->channel; + } + + return $record; + } +} diff --git a/src/Repository/TagRepository.php b/src/Repository/TagRepository.php index 0fcee997..92f391c8 100644 --- a/src/Repository/TagRepository.php +++ b/src/Repository/TagRepository.php @@ -100,16 +100,16 @@ class TagRepository extends EntityRepository public function getTagCount(TagQuery $query): Pagination { $qb = $this->getQueryBuilderForQuery($query); + $qb1 = clone $qb; + $qb ->resetDQLPart('select') ->resetDQLPart('orderBy') - ->select($qb->expr()->count('tag.name')) + ->select($qb->expr()->count('tag.id')) ; $counter = (int) $qb->getQuery()->getSingleScalarResult(); - $qb = $this->getQueryBuilderForQuery($query); - - $paginator = new QueryBuilderPaginator($qb, $counter); + $paginator = new QueryBuilderPaginator($qb1, $counter); $pager = new Pagination($paginator); $pager->setMaxPerPage($query->getPageSize()); diff --git a/src/User/UserService.php b/src/User/UserService.php index 0ad868fd..12d33393 100644 --- a/src/User/UserService.php +++ b/src/User/UserService.php @@ -142,6 +142,11 @@ class UserService return $this->repository->findByUsername($name); } + public function findUserByDisplayName(string $name): ?User + { + return $this->repository->findOneBy(['alias' => $name]); + } + public function findUserByConfirmationToken(string $token): ?User { return $this->repository->findOneBy(['confirmationToken' => $token]); diff --git a/src/Voter/TimesheetVoter.php b/src/Voter/TimesheetVoter.php index 428b2caf..aba0b661 100644 --- a/src/Voter/TimesheetVoter.php +++ b/src/Voter/TimesheetVoter.php @@ -105,7 +105,7 @@ final class TimesheetVoter extends Voter break; case 'duplicate': - if (!$this->canDuplicate($user, $subject)) { + if (!$this->canStart($subject)) { return false; } $permission = self::EDIT; @@ -194,15 +194,6 @@ final class TimesheetVoter extends Voter return true; } - private function canDuplicate(User $user, Timesheet $timesheet): bool - { - if (!$this->isAllowedInLockdown($user, $timesheet)) { - return false; - } - - return true; - } - private function isAllowedExported(User $user, Timesheet $timesheet): bool { if (!$timesheet->isExported()) { diff --git a/templates/dashboard/grid.html.twig b/templates/dashboard/grid.html.twig index 788c06f1..77eb31da 100644 --- a/templates/dashboard/grid.html.twig +++ b/templates/dashboard/grid.html.twig @@ -51,7 +51,7 @@ {% if widget.hasForm() %} {{ card_tool_button('configuration', {'title': 'settings', 'translation_domain': 'actions', 'class': 'modal-ajax-form', 'url': path('tasks_create')}) }} {% endif %} - {{ card_tool_button('delete', {'title': 'widget_remove', 'translation_domain': 'actions', 'url': '#', 'onclick': "removeWidget('" ~ widget.id ~ "'); return false;"}) }} + {{ card_tool_button('delete', {'title': 'widget_remove', 'translation_domain': 'actions', 'url': '#', 'onclick': "removeWidget(this, '" ~ widget.id ~ "'); return false;"}) }}
@@ -89,6 +89,7 @@ resizeGrid(); }); + {% if widgets|length > 0 %} document.addEventListener('kimai.initialized', function() { grid = GridStack.init({ 'float': false, @@ -110,10 +111,18 @@ document.dispatchEvent(new Event('dashboard.initialized')); document.getElementById('dashboard-grid-container').style.visibility = 'visible'; }); + {% endif %} {% if form is defined %} - function removeWidget(widgetId) + function removeWidget(button, widgetId) { + {# hackish way to remove tooltip, as we do not have a reference in the frontend to the bootstrap tooltip classes #} + const id = button.attributes['aria-describedBy'].value; + const tooltip = document.getElementById(id); + if (tooltip !== null) { + tooltip.remove(); + } + const widget = document.querySelector('div[data-widget=' + widgetId + ']'); if (widget !== null) { grid.removeWidget(widget); diff --git a/templates/export/index.html.twig b/templates/export/index.html.twig index fa8888e6..93e4912e 100644 --- a/templates/export/index.html.twig +++ b/templates/export/index.html.twig @@ -197,6 +197,7 @@ {% endif %} {{ tables.datatable_header(tableName, columns, query) }} + {% set edit_route = is_granted('view_other_timesheet') ? 'admin_timesheet_edit' : 'timesheet_edit' %} {% for entry in entries %} {% set currency = entry.project.customer.currency %} {% if entry.fixedRate is not null %} @@ -204,7 +205,8 @@ {% else %} {% set rate = entry.hourlyRate %} {% endif %} - + {{ widgets.user_avatar(entry.user) }} diff --git a/templates/user/profile.html.twig b/templates/user/profile.html.twig index 99e9d941..236f82a4 100644 --- a/templates/user/profile.html.twig +++ b/templates/user/profile.html.twig @@ -9,9 +9,6 @@
{{ form_row(form.alias) }} {{ form_row(form.email) }} - {% if form.systemAccount is defined %} - {{ form_row(form.systemAccount) }} - {% endif %}
diff --git a/tests/Voter/TimesheetVoterTest.php b/tests/Voter/TimesheetVoterTest.php index 80cf5397..98b10fe7 100644 --- a/tests/Voter/TimesheetVoterTest.php +++ b/tests/Voter/TimesheetVoterTest.php @@ -32,7 +32,7 @@ class TimesheetVoterTest extends AbstractVoterTest return $this->getLockdownVoter(); } - protected function assertVote(User $user, $subject, $attribute, $result) + protected function assertVote(User $user, $subject, $attribute, $result): void { $token = new UsernamePasswordToken($user, 'bar', $user->getRoles()); $sut = $this->getVoter(TimesheetVoter::class); @@ -43,18 +43,18 @@ class TimesheetVoterTest extends AbstractVoterTest /** * @dataProvider getTestData */ - public function testVote(User $user, $subject, $attribute, $result) + public function testVote(User $user, $subject, $attribute, $result): void { $this->assertVote($user, $subject, $attribute, $result); } public function getTestData() { - $user0 = $this->getUser(0, 'unknown'); - $user1 = $this->getUser(1, User::ROLE_USER); - $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); - $user3 = $this->getUser(3, User::ROLE_ADMIN); - $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + $user0 = $this->getTestUser(0, 'unknown'); + $user1 = $this->getTestUser(1, User::ROLE_USER); + $user2 = $this->getTestUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getTestUser(3, User::ROLE_ADMIN); + $user4 = $this->getTestUser(4, User::ROLE_SUPER_ADMIN); $timesheet1 = $this->getTimesheet($user1); $timesheet2 = $this->getTimesheet($user2); @@ -63,7 +63,7 @@ class TimesheetVoterTest extends AbstractVoterTest $timesheet5 = $this->getTimesheet($user2); $timesheet5->setExported(true); $timesheet6 = $this->getTimesheet($user1); - $timesheet6->getActivity()->setVisible(false); + $timesheet6->getActivity()?->setVisible(false); $result = VoterInterface::ACCESS_GRANTED; $times = [ @@ -99,9 +99,9 @@ class TimesheetVoterTest extends AbstractVoterTest /** * @dataProvider getLockDownTestData */ - public function testWithLockdown(string $permission, int $expected, string $beginModifier, string $lockdownBegin, string $lockdownEnd, ?string $lockdownGrace) + public function testWithLockdown(string $permission, int $expected, string $beginModifier, string $lockdownBegin, string $lockdownEnd, ?string $lockdownGrace): void { - $user = $this->getUser(1, User::ROLE_USER); + $user = $this->getTestUser(1, User::ROLE_USER); $begin = new \DateTime('now'); $begin->modify($beginModifier); @@ -119,19 +119,20 @@ class TimesheetVoterTest extends AbstractVoterTest public function getLockDownTestData() { yield ['view', VoterInterface::ACCESS_GRANTED, '+1 days', 'first day of this month', 'last day of this month', '+10 days']; - yield ['duplicate', VoterInterface::ACCESS_GRANTED, '+1 days', 'first day of this month', 'last day of this month', '+10 days']; + yield ['start', VoterInterface::ACCESS_DENIED, '+1 days', 'first day of this month', 'last day of this month', '+10 days']; + yield ['duplicate', VoterInterface::ACCESS_DENIED, '+1 days', 'first day of this month', 'last day of this month', '+10 days']; yield ['delete', VoterInterface::ACCESS_GRANTED, '+1 days', 'first day of this month', 'last day of this month', '+10 days']; yield ['edit', VoterInterface::ACCESS_DENIED, '-50 days', 'first day of last month', 'last day of last month', '+1 days']; yield ['duplicate', VoterInterface::ACCESS_DENIED, '-50 days', 'first day of last month', 'last day of last month', '+1 days']; yield ['delete', VoterInterface::ACCESS_DENIED, '-50 days', 'first day of last month', 'last day of last month', '+1 days']; } - public function testSpecialCases() + public function testSpecialCases(): void { - $user1 = $this->getUser(1, User::ROLE_USER); - $user2 = $this->getUser(2, User::ROLE_TEAMLEAD); - $user3 = $this->getUser(3, User::ROLE_ADMIN); - $user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); + $user1 = $this->getTestUser(1, User::ROLE_USER); + $user2 = $this->getTestUser(2, User::ROLE_TEAMLEAD); + $user3 = $this->getTestUser(3, User::ROLE_ADMIN); + $user4 = $this->getTestUser(4, User::ROLE_SUPER_ADMIN); // unknown attribute $timesheet = $this->getTimesheet($user3); @@ -148,17 +149,17 @@ class TimesheetVoterTest extends AbstractVoterTest // hidden activities might not be started $timesheet = $this->getTimesheet($user1); - $timesheet->getActivity()->setVisible(false); + $timesheet->getActivity()?->setVisible(false); $this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED); // hidden projects might not be started $timesheet = $this->getTimesheet($user1); - $timesheet->getProject()->setVisible(false); + $timesheet->getProject()?->setVisible(false); $this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED); // hidden customers might not be started $timesheet = $this->getTimesheet($user1); - $timesheet->getProject()->getCustomer()->setVisible(false); + $timesheet->getProject()?->getCustomer()?->setVisible(false); $this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED); // cannot start timesheet without activity $timesheet = new Timesheet(); @@ -172,7 +173,7 @@ class TimesheetVoterTest extends AbstractVoterTest $this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED); } - protected function getTimesheet($user) + protected function getTimesheet($user): Timesheet { $timesheet = new Timesheet(); $timesheet->setUser($user); @@ -193,7 +194,7 @@ class TimesheetVoterTest extends AbstractVoterTest * @param string $role * @return User */ - protected function getUser($id, $role) + protected function getTestUser(int $id, string $role): User { $user = $this->createMock(User::class); $user->method('getId')->willReturn($id); diff --git a/tests/phpstan.neon b/tests/phpstan.neon index cb8d4bc0..cb0d0d3a 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -10052,11 +10052,6 @@ parameters: count: 1 path: Voter/TeamVoterTest.php - - - message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:assertVote\\(\\) has no return type specified\\.$#" - count: 1 - path: Voter/TimesheetVoterTest.php - - message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:assertVote\\(\\) has parameter \\$attribute with no type specified\\.$#" count: 1 @@ -10082,26 +10077,11 @@ parameters: count: 1 path: Voter/TimesheetVoterTest.php - - - message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:getTimesheet\\(\\) has no return type specified\\.$#" - count: 1 - path: Voter/TimesheetVoterTest.php - - message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:getTimesheet\\(\\) has parameter \\$user with no type specified\\.$#" count: 1 path: Voter/TimesheetVoterTest.php - - - message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:testSpecialCases\\(\\) has no return type specified\\.$#" - count: 1 - path: Voter/TimesheetVoterTest.php - - - - message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:testVote\\(\\) has no return type specified\\.$#" - count: 1 - path: Voter/TimesheetVoterTest.php - - message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:testVote\\(\\) has parameter \\$attribute with no type specified\\.$#" count: 1 @@ -10117,11 +10097,6 @@ parameters: count: 1 path: Voter/TimesheetVoterTest.php - - - message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:testWithLockdown\\(\\) has no return type specified\\.$#" - count: 1 - path: Voter/TimesheetVoterTest.php - - message: "#^Method App\\\\Tests\\\\Voter\\\\UserVoterTest\\:\\:getTestData\\(\\) has no return type specified\\.$#" count: 1