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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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 ###
|
||||
|
||||
@@ -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/",
|
||||
|
||||
26
composer.lock
generated
26
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ class Tag
|
||||
use ColorTrait;
|
||||
|
||||
/**
|
||||
* This is ONLY here, so we can count the amount of timesheets.
|
||||
*
|
||||
* @var Collection<Timesheet>
|
||||
*/
|
||||
#[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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class SpreadsheetExporter
|
||||
/**
|
||||
* @var CellFormatterInterface[]
|
||||
*/
|
||||
private $formatter = [];
|
||||
private array $formatter = [];
|
||||
|
||||
public function __construct(private TranslatorInterface $translator)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
28
src/Logger/LogProcessor.php
Normal file
28
src/Logger/LogProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;"}) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@@ -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);
|
||||
|
||||
@@ -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 %}
|
||||
<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') }}">
|
||||
{{ widgets.user_avatar(entry.user) }}
|
||||
</td>
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
<fieldset class="form-fieldset form-fieldset-light">
|
||||
{{ form_row(form.alias) }}
|
||||
{{ form_row(form.email) }}
|
||||
{% if form.systemAccount is defined %}
|
||||
{{ form_row(form.systemAccount) }}
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form-fieldset form-fieldset-light">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user