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
This commit is contained in:
Kevin Papst
2023-11-19 16:05:43 +01:00
committed by GitHub
parent 9d8e5ecd7a
commit 20164295f8
27 changed files with 186 additions and 169 deletions

7
.gitignore vendored
View File

@@ -2,7 +2,7 @@
/public/.htaccess /public/.htaccess
/.env-* /.env-*
/.idea/ /.idea/
/.DS_Store .DS_Store
/phpstan.sh /phpstan.sh
# custom apache rules e.g. to deactivate ioncube loader # custom apache rules e.g. to deactivate ioncube loader
@@ -22,12 +22,11 @@
/var/data/* /var/data/*
/var/cache/* /var/cache/*
/var/invoices* /var/invoices*
/var/export/* /var/export*
/var/log/* /var/log/*
/var/sessions/* /var/sessions/*
/var/packages/* /var/packages/*
/var/plugins/* /var/plugins*
/var/plugins_old/
/var/plugins/*/*.disabled /var/plugins/*/*.disabled
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###

View File

@@ -59,6 +59,7 @@
"symfony/http-client": "^6.0", "symfony/http-client": "^6.0",
"symfony/intl": "^6.0", "symfony/intl": "^6.0",
"symfony/mailer": "^6.0", "symfony/mailer": "^6.0",
"symfony/mime": "^6.0",
"symfony/monolog-bundle": "^3.4", "symfony/monolog-bundle": "^3.4",
"symfony/rate-limiter": "^6.0", "symfony/rate-limiter": "^6.0",
"symfony/runtime": "^6.0", "symfony/runtime": "^6.0",
@@ -173,8 +174,7 @@
"bin/console lint:container", "bin/console lint:container",
"bin/console lint:yaml config --parse-tags", "bin/console lint:yaml config --parse-tags",
"bin/console lint:twig templates --show-deprecations", "bin/console lint:twig templates --show-deprecations",
"bin/console lint:xliff translations", "bin/console lint:xliff translations"
"bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction"
], ],
"tests": "vendor/bin/phpunit tests/", "tests": "vendor/bin/phpunit tests/",
"tests-unit": "vendor/bin/phpunit --exclude-group integration tests/", "tests-unit": "vendor/bin/phpunit --exclude-group integration tests/",

26
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "28da6d01423fca5a17395df418603aa5", "content-hash": "1f5d701618a13718f0b3f15d5b8200b3",
"packages": [ "packages": [
{ {
"name": "azuyalabs/yasumi", "name": "azuyalabs/yasumi",
@@ -1907,20 +1907,20 @@
}, },
{ {
"name": "ezyang/htmlpurifier", "name": "ezyang/htmlpurifier",
"version": "v4.16.0", "version": "v4.17.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git", "url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "523407fb06eb9e5f3d59889b3978d5bfe94299c8" "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/523407fb06eb9e5f3d59889b3978d5bfe94299c8", "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/bbc513d79acf6691fa9cf10f192c90dd2957f18c",
"reference": "523407fb06eb9e5f3d59889b3978d5bfe94299c8", "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c",
"shasum": "" "shasum": ""
}, },
"require": { "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": { "require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0", "cerdic/css-tidy": "^1.7 || ^2.0",
@@ -1962,9 +1962,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues", "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", "name": "friendsofsymfony/rest-bundle",
@@ -11166,16 +11166,16 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "1.10.41", "version": "1.10.42",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan.git", "url": "https://github.com/phpstan/phpstan.git",
"reference": "c6174523c2a69231df55bdc65b61655e72876d76" "reference": "fc2316508de5453140b5cb3d3f8683a33e92f26a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6174523c2a69231df55bdc65b61655e72876d76", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fc2316508de5453140b5cb3d3f8683a33e92f26a",
"reference": "c6174523c2a69231df55bdc65b61655e72876d76", "reference": "fc2316508de5453140b5cb3d3f8683a33e92f26a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11224,7 +11224,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2023-11-05T12:57:57+00:00" "time": "2023-11-17T15:26:57+00:00"
}, },
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",

View File

@@ -25,6 +25,7 @@ doctrine:
orm: orm:
auto_generate_proxy_classes: '%kernel.debug%' auto_generate_proxy_classes: '%kernel.debug%'
default_entity_manager: default default_entity_manager: default
enable_lazy_ghost_objects: true
entity_managers: entity_managers:
default: default:
report_fields_where_declared: true report_fields_where_declared: true

View File

@@ -1,43 +1,49 @@
monolog:
channels: ["deprecation"]
when@prod: when@prod:
monolog: monolog:
# channels: ["deprecation"]
handlers: handlers:
main: main:
type: fingers_crossed type: fingers_crossed
action_level: error action_level: notice
handler: nested handler: nested
excluded_http_codes: [403, 404] excluded_http_codes: [403, 404]
channels: ["!deprecation"]
nested: nested:
type: stream type: stream
level: info level: info
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
formatter: monolog.formatter.kimai
console: console:
type: console type: console
process_psr_3_messages: false 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 # deactivated, because currently there are too many deprecations cause by gedmo and doctrine
# deprecation: # deprecation:
# type: stream # type: stream
# channels: ["deprecation"] # channels: ["deprecation"]
# path: "%kernel.logs_dir%/deprecations.log" # path: "%kernel.logs_dir%/deprecations.log"
# formatter: monolog.formatter.deprecation
when@dev: when@dev:
monolog: monolog:
channels: ["deprecation"]
handlers: handlers:
main: main:
type: stream type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: info level: debug
channels: ["!event"] channels: ["!event", "!deprecation"]
formatter: monolog.formatter.kimai
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"] channels: ["!event", "!doctrine", "!deprecation"]
deprecation: deprecation:
type: stream type: stream
channels: ["deprecation"] channels: ["deprecation"]
path: "%kernel.logs_dir%/deprecations.log" path: "%kernel.logs_dir%/deprecations.log"
formatter: monolog.formatter.deprecation
when@test: when@test:
monolog: monolog:
@@ -47,3 +53,4 @@ when@test:
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: info level: info
channels: ["!event"] channels: ["!event"]
formatter: monolog.formatter.kimai

View File

@@ -223,3 +223,15 @@ services:
class: App\Repository\WorkingTimeRepository class: App\Repository\WorkingTimeRepository
factory: ['@doctrine.orm.entity_manager', getRepository] factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\WorkingTime'] 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"

View File

@@ -207,6 +207,7 @@ final class UserController extends BaseApiController
'include_active_flag' => ($profile->getId() !== $this->getUser()->getId()), 'include_active_flag' => ($profile->getId() !== $this->getUser()->getId()),
'include_preferences' => $this->isGranted('preferences', $profile), 'include_preferences' => $this->isGranted('preferences', $profile),
'include_supervisor' => $this->isGranted('supervisor', $profile), 'include_supervisor' => $this->isGranted('supervisor', $profile),
'include_password_reset' => $this->isGranted('password', $profile),
]); ]);
$form->setData($profile); $form->setData($profile);

View File

@@ -399,7 +399,7 @@ final class SystemConfiguration
public function getTimesheetTrackingMode(): string public function getTimesheetTrackingMode(): string
{ {
return (string) $this->find('timesheet.mode'); return $this->getString('timesheet.mode', 'default');
} }
public function isTimesheetMarkdownEnabled(): bool public function isTimesheetMarkdownEnabled(): bool
@@ -442,19 +442,6 @@ final class SystemConfiguration
return (int) $this->find('timesheet.rounding.default.duration'); 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 public function getTimesheetIncrementDuration(): int
{ {
return $this->getIncrement('timesheet.duration_increment', $this->getTimesheetDefaultRoundingDuration(), 0); 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; 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;
}
} }

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.3.0'; public const VERSION = '2.4.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 20300; public const VERSION_ID = 20400;
/** /**
* The software name * The software name
*/ */

View File

@@ -318,6 +318,7 @@ final class ProfileController extends AbstractController
'include_active_flag' => ($user->getId() !== $this->getUser()->getId()), 'include_active_flag' => ($user->getId() !== $this->getUser()->getId()),
'include_preferences' => true, 'include_preferences' => true,
'include_supervisor' => $this->isGranted('supervisor', $user), 'include_supervisor' => $this->isGranted('supervisor', $user),
'include_password_reset' => $this->isGranted('password', $user),
] ]
); );
} }

View File

@@ -13,25 +13,20 @@ use Doctrine\Common\Collections\Collection;
interface ExportableItem interface ExportableItem
{ {
public function getId(): ?int;
/** /**
* Whether this item was already exported. * Whether this item was already exported.
*
* @return bool
*/ */
public function isExported(): bool; public function isExported(): bool;
/** /**
* Whether this item should be included in invoices. * Whether this item should be included in invoices.
*
* @return bool
*/ */
public function isBillable(): bool; public function isBillable(): bool;
/** /**
* Returns the named meta field or null. * Returns the named meta field or null.
*
* @param string $name
* @return MetaTableTypeInterface|null
*/ */
public function getMetaField(string $name): ?MetaTableTypeInterface; public function getMetaField(string $name): ?MetaTableTypeInterface;
@@ -44,8 +39,6 @@ interface ExportableItem
/** /**
* Returns the amount for this item. * Returns the amount for this item.
*
* @return float
*/ */
public function getAmount(): float; public function getAmount(): float;
@@ -83,15 +76,13 @@ interface ExportableItem
/** /**
* A name representation for this type of item. * A name representation for this type of item.
* * Example: "timesheet"
* @return string
*/ */
public function getType(): string; public function getType(): string;
/** /**
* A name representation for the category of this item. * A name representation for the category of this item.
* * Example: "work"
* @return string
*/ */
public function getCategory(): string; public function getCategory(): string;
} }

View File

@@ -52,6 +52,8 @@ class Tag
use ColorTrait; use ColorTrait;
/** /**
* This is ONLY here, so we can count the amount of timesheets.
*
* @var Collection<Timesheet> * @var Collection<Timesheet>
*/ */
#[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')] #[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')]
@@ -89,26 +91,6 @@ class Tag
$this->visible = $visible; $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 public function __toString(): string
{ {
return $this->getName(); return $this->getName();

View File

@@ -411,10 +411,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
return $this->internalRate; return $this->internalRate;
} }
/**
* @param Tag $tag
* @return Timesheet
*/
public function addTag(Tag $tag): Timesheet public function addTag(Tag $tag): Timesheet
{ {
if ($this->tags->contains($tag)) { if ($this->tags->contains($tag)) {
@@ -425,9 +421,6 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
return $this; return $this;
} }
/**
* @param Tag $tag
*/
public function removeTag(Tag $tag): void public function removeTag(Tag $tag): void
{ {
if (!$this->tags->contains($tag)) { if (!$this->tags->contains($tag)) {

View File

@@ -151,16 +151,13 @@ class PageActionsEvent extends ThemeEvent
$this->addAction('create', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' : ''), 'title' => 'create', 'accesskey' => 'a']); $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. * Link to a configuration section.
*
* @param string $url
* @return void
*/ */
public function addSettings(string $url): void public function addSettings(string $url): void
{ {

View File

@@ -25,20 +25,20 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
$timesheet = $payload['timesheet']; $timesheet = $payload['timesheet'];
if ($timesheet->getId() !== null) { if ($timesheet->getId() !== null) {
if ($timesheet->isRunning() && $this->isGranted('stop', $timesheet)) { 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)) { 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)) { 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)) { if ($this->isGranted('duplicate', $timesheet)) {
$class = $event->isView('edit') ? '' : 'modal-ajax-form'; $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) { if ($event->countActions() > 0) {
@@ -48,7 +48,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
if (($event->isIndexView() || $event->isView('calendar')) && $this->isGranted('delete', $timesheet)) { if (($event->isIndexView() || $event->isView('calendar')) && $this->isGranted('delete', $timesheet)) {
$event->addAction('trash', [ $event->addAction('trash', [
'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]), 'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]),
'class' => 'api-link text-red', 'class' => 'api-link text-red dd-ts-trash',
'translation_domain' => 'actions', 'translation_domain' => 'actions',
'attr' => [ 'attr' => [
'data-event' => 'kimai.timesheetDelete', 'data-event' => 'kimai.timesheetDelete',

View File

@@ -73,8 +73,12 @@ abstract class AbstractSpreadsheetRenderer
'end' => [], 'end' => [],
'duration' => [], 'duration' => [],
'rate' => [], 'rate' => [],
'rate_internal' => [], 'rate_internal' => [
'user' => [], 'label' => 'internalRate', // different translation key
],
'user' => [
'label' => 'name'
],
'username' => [], 'username' => [],
'customer' => [], 'customer' => [],
'project' => [], 'project' => [],
@@ -309,13 +313,6 @@ abstract class AbstractSpreadsheetRenderer
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $username); $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'])) { if (isset($columns['customer']) && !isset($columns['customer']['render'])) {
@@ -675,7 +672,7 @@ abstract class AbstractSpreadsheetRenderer
$amount = $settings['header']($sheet, $recordsHeaderRow, $recordsHeaderColumn); $amount = $settings['header']($sheet, $recordsHeaderRow, $recordsHeaderColumn);
$recordsHeaderColumn += $amount; $recordsHeaderColumn += $amount;
} else { } 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));
} }
} }

View File

@@ -28,7 +28,7 @@ class SpreadsheetExporter
/** /**
* @var CellFormatterInterface[] * @var CellFormatterInterface[]
*/ */
private $formatter = []; private array $formatter = [];
public function __construct(private TranslatorInterface $translator) public function __construct(private TranslatorInterface $translator)
{ {

View File

@@ -96,6 +96,14 @@ class UserEditType extends AbstractType
'ignore_users' => ($user instanceof User && $user->getId() !== null ? [$user] : []), '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 public function configureOptions(OptionsResolver $resolver): void
@@ -109,6 +117,7 @@ class UserEditType extends AbstractType
'include_active_flag' => true, 'include_active_flag' => true,
'include_preferences' => true, 'include_preferences' => true,
'include_supervisor' => true, 'include_supervisor' => true,
'include_password_reset' => true,
]); ]);
} }
} }

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Logger;
use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;
final class LogProcessor
{
#[AsMonologProcessor]
public function __invoke(LogRecord $record): LogRecord
{
if (\array_key_exists('bundle', $record->context)) {
$record->extra['channel'] = strtoupper($record->context['bundle']);
} else {
$record->extra['channel'] = $record->channel;
}
return $record;
}
}

View File

@@ -100,16 +100,16 @@ class TagRepository extends EntityRepository
public function getTagCount(TagQuery $query): Pagination public function getTagCount(TagQuery $query): Pagination
{ {
$qb = $this->getQueryBuilderForQuery($query); $qb = $this->getQueryBuilderForQuery($query);
$qb1 = clone $qb;
$qb $qb
->resetDQLPart('select') ->resetDQLPart('select')
->resetDQLPart('orderBy') ->resetDQLPart('orderBy')
->select($qb->expr()->count('tag.name')) ->select($qb->expr()->count('tag.id'))
; ;
$counter = (int) $qb->getQuery()->getSingleScalarResult(); $counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query); $paginator = new QueryBuilderPaginator($qb1, $counter);
$paginator = new QueryBuilderPaginator($qb, $counter);
$pager = new Pagination($paginator); $pager = new Pagination($paginator);
$pager->setMaxPerPage($query->getPageSize()); $pager->setMaxPerPage($query->getPageSize());

View File

@@ -142,6 +142,11 @@ class UserService
return $this->repository->findByUsername($name); return $this->repository->findByUsername($name);
} }
public function findUserByDisplayName(string $name): ?User
{
return $this->repository->findOneBy(['alias' => $name]);
}
public function findUserByConfirmationToken(string $token): ?User public function findUserByConfirmationToken(string $token): ?User
{ {
return $this->repository->findOneBy(['confirmationToken' => $token]); return $this->repository->findOneBy(['confirmationToken' => $token]);

View File

@@ -105,7 +105,7 @@ final class TimesheetVoter extends Voter
break; break;
case 'duplicate': case 'duplicate':
if (!$this->canDuplicate($user, $subject)) { if (!$this->canStart($subject)) {
return false; return false;
} }
$permission = self::EDIT; $permission = self::EDIT;
@@ -194,15 +194,6 @@ final class TimesheetVoter extends Voter
return true; 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 private function isAllowedExported(User $user, Timesheet $timesheet): bool
{ {
if (!$timesheet->isExported()) { if (!$timesheet->isExported()) {

View File

@@ -51,7 +51,7 @@
{% if widget.hasForm() %} {% if widget.hasForm() %}
{{ card_tool_button('configuration', {'title': 'settings', 'translation_domain': 'actions', 'class': 'modal-ajax-form', 'url': path('tasks_create')}) }} {{ card_tool_button('configuration', {'title': 'settings', 'translation_domain': 'actions', 'class': 'modal-ajax-form', 'url': path('tasks_create')}) }}
{% endif %} {% 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;"}) }}
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@@ -89,6 +89,7 @@
resizeGrid(); resizeGrid();
}); });
{% if widgets|length > 0 %}
document.addEventListener('kimai.initialized', function() { document.addEventListener('kimai.initialized', function() {
grid = GridStack.init({ grid = GridStack.init({
'float': false, 'float': false,
@@ -110,10 +111,18 @@
document.dispatchEvent(new Event('dashboard.initialized')); document.dispatchEvent(new Event('dashboard.initialized'));
document.getElementById('dashboard-grid-container').style.visibility = 'visible'; document.getElementById('dashboard-grid-container').style.visibility = 'visible';
}); });
{% endif %}
{% if form is defined %} {% 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 + ']'); const widget = document.querySelector('div[data-widget=' + widgetId + ']');
if (widget !== null) { if (widget !== null) {
grid.removeWidget(widget); grid.removeWidget(widget);

View File

@@ -197,6 +197,7 @@
{% endif %} {% endif %}
{{ tables.datatable_header(tableName, columns, query) }} {{ tables.datatable_header(tableName, columns, query) }}
{% set edit_route = is_granted('view_other_timesheet') ? 'admin_timesheet_edit' : 'timesheet_edit' %}
{% for entry in entries %} {% for entry in entries %}
{% set currency = entry.project.customer.currency %} {% set currency = entry.project.customer.currency %}
{% if entry.fixedRate is not null %} {% if entry.fixedRate is not null %}
@@ -204,7 +205,8 @@
{% else %} {% else %}
{% set rate = entry.hourlyRate %} {% set rate = entry.hourlyRate %}
{% endif %} {% endif %}
<tr> <tr{%- if entry.type == 'timesheet' and is_granted('edit', entry) %}
class="modal-ajax-form open-edit" data-href="{{ path(edit_route, {'id': entry.id}) }}"{% endif -%}>
<td class="{{ tables.data_table_column_class(tableName, columns, 'avatar') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'avatar') }}">
{{ widgets.user_avatar(entry.user) }} {{ widgets.user_avatar(entry.user) }}
</td> </td>

View File

@@ -9,9 +9,6 @@
<fieldset class="form-fieldset form-fieldset-light"> <fieldset class="form-fieldset form-fieldset-light">
{{ form_row(form.alias) }} {{ form_row(form.alias) }}
{{ form_row(form.email) }} {{ form_row(form.email) }}
{% if form.systemAccount is defined %}
{{ form_row(form.systemAccount) }}
{% endif %}
</fieldset> </fieldset>
<fieldset class="form-fieldset form-fieldset-light"> <fieldset class="form-fieldset form-fieldset-light">

View File

@@ -32,7 +32,7 @@ class TimesheetVoterTest extends AbstractVoterTest
return $this->getLockdownVoter(); 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()); $token = new UsernamePasswordToken($user, 'bar', $user->getRoles());
$sut = $this->getVoter(TimesheetVoter::class); $sut = $this->getVoter(TimesheetVoter::class);
@@ -43,18 +43,18 @@ class TimesheetVoterTest extends AbstractVoterTest
/** /**
* @dataProvider getTestData * @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); $this->assertVote($user, $subject, $attribute, $result);
} }
public function getTestData() public function getTestData()
{ {
$user0 = $this->getUser(0, 'unknown'); $user0 = $this->getTestUser(0, 'unknown');
$user1 = $this->getUser(1, User::ROLE_USER); $user1 = $this->getTestUser(1, User::ROLE_USER);
$user2 = $this->getUser(2, User::ROLE_TEAMLEAD); $user2 = $this->getTestUser(2, User::ROLE_TEAMLEAD);
$user3 = $this->getUser(3, User::ROLE_ADMIN); $user3 = $this->getTestUser(3, User::ROLE_ADMIN);
$user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); $user4 = $this->getTestUser(4, User::ROLE_SUPER_ADMIN);
$timesheet1 = $this->getTimesheet($user1); $timesheet1 = $this->getTimesheet($user1);
$timesheet2 = $this->getTimesheet($user2); $timesheet2 = $this->getTimesheet($user2);
@@ -63,7 +63,7 @@ class TimesheetVoterTest extends AbstractVoterTest
$timesheet5 = $this->getTimesheet($user2); $timesheet5 = $this->getTimesheet($user2);
$timesheet5->setExported(true); $timesheet5->setExported(true);
$timesheet6 = $this->getTimesheet($user1); $timesheet6 = $this->getTimesheet($user1);
$timesheet6->getActivity()->setVisible(false); $timesheet6->getActivity()?->setVisible(false);
$result = VoterInterface::ACCESS_GRANTED; $result = VoterInterface::ACCESS_GRANTED;
$times = [ $times = [
@@ -99,9 +99,9 @@ class TimesheetVoterTest extends AbstractVoterTest
/** /**
* @dataProvider getLockDownTestData * @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 = new \DateTime('now');
$begin->modify($beginModifier); $begin->modify($beginModifier);
@@ -119,19 +119,20 @@ class TimesheetVoterTest extends AbstractVoterTest
public function getLockDownTestData() public function getLockDownTestData()
{ {
yield ['view', VoterInterface::ACCESS_GRANTED, '+1 days', 'first day of this month', 'last day of this month', '+10 days']; 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 ['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 ['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 ['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']; 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); $user1 = $this->getTestUser(1, User::ROLE_USER);
$user2 = $this->getUser(2, User::ROLE_TEAMLEAD); $user2 = $this->getTestUser(2, User::ROLE_TEAMLEAD);
$user3 = $this->getUser(3, User::ROLE_ADMIN); $user3 = $this->getTestUser(3, User::ROLE_ADMIN);
$user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN); $user4 = $this->getTestUser(4, User::ROLE_SUPER_ADMIN);
// unknown attribute // unknown attribute
$timesheet = $this->getTimesheet($user3); $timesheet = $this->getTimesheet($user3);
@@ -148,17 +149,17 @@ class TimesheetVoterTest extends AbstractVoterTest
// hidden activities might not be started // hidden activities might not be started
$timesheet = $this->getTimesheet($user1); $timesheet = $this->getTimesheet($user1);
$timesheet->getActivity()->setVisible(false); $timesheet->getActivity()?->setVisible(false);
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED); $this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
// hidden projects might not be started // hidden projects might not be started
$timesheet = $this->getTimesheet($user1); $timesheet = $this->getTimesheet($user1);
$timesheet->getProject()->setVisible(false); $timesheet->getProject()?->setVisible(false);
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED); $this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
// hidden customers might not be started // hidden customers might not be started
$timesheet = $this->getTimesheet($user1); $timesheet = $this->getTimesheet($user1);
$timesheet->getProject()->getCustomer()->setVisible(false); $timesheet->getProject()?->getCustomer()?->setVisible(false);
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED); $this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
// cannot start timesheet without activity // cannot start timesheet without activity
$timesheet = new Timesheet(); $timesheet = new Timesheet();
@@ -172,7 +173,7 @@ class TimesheetVoterTest extends AbstractVoterTest
$this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED); $this->assertVote($user2, $timesheet, 'start', VoterInterface::ACCESS_DENIED);
} }
protected function getTimesheet($user) protected function getTimesheet($user): Timesheet
{ {
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet->setUser($user); $timesheet->setUser($user);
@@ -193,7 +194,7 @@ class TimesheetVoterTest extends AbstractVoterTest
* @param string $role * @param string $role
* @return User * @return User
*/ */
protected function getUser($id, $role) protected function getTestUser(int $id, string $role): User
{ {
$user = $this->createMock(User::class); $user = $this->createMock(User::class);
$user->method('getId')->willReturn($id); $user->method('getId')->willReturn($id);

View File

@@ -10052,11 +10052,6 @@ parameters:
count: 1 count: 1
path: Voter/TeamVoterTest.php 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\\.$#" message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:assertVote\\(\\) has parameter \\$attribute with no type specified\\.$#"
count: 1 count: 1
@@ -10082,26 +10077,11 @@ parameters:
count: 1 count: 1
path: Voter/TimesheetVoterTest.php 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\\.$#" message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:getTimesheet\\(\\) has parameter \\$user with no type specified\\.$#"
count: 1 count: 1
path: Voter/TimesheetVoterTest.php 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\\.$#" message: "#^Method App\\\\Tests\\\\Voter\\\\TimesheetVoterTest\\:\\:testVote\\(\\) has parameter \\$attribute with no type specified\\.$#"
count: 1 count: 1
@@ -10117,11 +10097,6 @@ parameters:
count: 1 count: 1
path: Voter/TimesheetVoterTest.php 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\\.$#" message: "#^Method App\\\\Tests\\\\Voter\\\\UserVoterTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1 count: 1