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:
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user