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
/.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 ###

View File

@@ -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
View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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
*/

View File

@@ -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),
]
);
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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)) {

View File

@@ -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
{

View File

@@ -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',

View File

@@ -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));
}
}

View File

@@ -28,7 +28,7 @@ class SpreadsheetExporter
/**
* @var CellFormatterInterface[]
*/
private $formatter = [];
private array $formatter = [];
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] : []),
]);
}
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,
]);
}
}

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
{
$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());

View File

@@ -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]);

View File

@@ -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()) {

View File

@@ -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);

View File

@@ -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>

View File

@@ -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">

View File

@@ -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);

View File

@@ -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