diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 0ba597cd..4b148a00 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -51,7 +51,7 @@ jobs: run: vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=5 --no-progress --error-format=checkstyle | cs2pr - name: Lint codebase - run: composer kimai:code-lint + run: composer linting - name: Install LDAP package run: composer require laminas/laminas-ldap diff --git a/SECURITY.md b/SECURITY.md index 54d71d77..5c835441 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,8 +7,8 @@ As announced in the [README](README.md) I only support the latest available rele | Version | Supported | | ------- | ------------------ | | master | :white_check_mark: | -| 1.14 | :white_check_mark: | -| < 1.14 | :x: | +| 1.15 | :white_check_mark: | +| < 1.15 | :x: | ## Reporting a Vulnerability diff --git a/composer.json b/composer.json index 422300a6..f9aef36d 100644 --- a/composer.json +++ b/composer.json @@ -146,7 +146,7 @@ "kimai:pre-commit": [ "@kimai:codestyle", "@kimai:phpstan", - "@kimai:code-lint", + "@linting", "@kimai:tests-unit" ], "kimai:code-check": "@code-check", @@ -154,7 +154,8 @@ "@kimai:pre-commit", "@kimai:tests-integration" ], - "kimai:code-lint": [ + "kimai:code-lint": "@linting", + "linting": [ "composer validate --strict", "bin/console lint:container", "bin/console lint:yaml config --parse-tags", diff --git a/src/Constants.php b/src/Constants.php index 9dd42abf..e7ff7de9 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -25,7 +25,7 @@ class Constants /** * The current release status, either "stable" or "dev" */ - public const STATUS = 'dev'; + public const STATUS = 'stable'; /** * The software name */ diff --git a/src/Controller/PermissionController.php b/src/Controller/PermissionController.php index 00665b5f..a0ba581d 100644 --- a/src/Controller/PermissionController.php +++ b/src/Controller/PermissionController.php @@ -203,12 +203,12 @@ final class PermissionController extends AbstractController } /** - * @Route(path="/roles/{id}/delete/{token}", name="admin_user_role_delete", methods={"GET", "POST"}) + * @Route(path="/roles/{id}/delete/{csrfToken}", name="admin_user_role_delete", methods={"GET", "POST"}) * @Security("is_granted('role_permissions')") */ - public function deleteRole(Role $role, string $token, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response + public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response { - if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $token)) { + if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) { $this->flashUpdateException(new \Exception('Invalid CSRF token')); return $this->redirectToRoute('admin_user_permissions'); @@ -235,12 +235,12 @@ final class PermissionController extends AbstractController } /** - * @Route(path="/roles/{id}/{name}/{value}/{token}", name="admin_user_permission_save", methods={"POST"}) + * @Route(path="/roles/{id}/{name}/{value}/{csrfToken}", name="admin_user_permission_save", methods={"POST"}) * @Security("is_granted('role_permissions')") */ - public function savePermission(Role $role, string $name, bool $value, string $token, RolePermissionRepository $rolePermissionRepository, CsrfTokenManagerInterface $csrfTokenManager): Response + public function savePermission(Role $role, string $name, bool $value, string $csrfToken, RolePermissionRepository $rolePermissionRepository, CsrfTokenManagerInterface $csrfTokenManager): Response { - if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $token)) { + if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) { throw new BadRequestHttpException('Invalid CSRF token'); } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index aeb39abd..43b98a03 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -126,7 +126,7 @@ final class UserController extends AbstractController $this->flashSuccess('action.update.success'); - if ($editForm->get('create_more')->getData() !== true) { + if ($editForm->has('create_more') && $editForm->get('create_more')->getData() !== true) { return $this->redirectToRoute('user_profile_edit', ['username' => $user->getUsername()]); } diff --git a/src/Migrations/Version20180715160326.php b/src/Migrations/Version20180715160326.php index f8ebe923..08012ca2 100644 --- a/src/Migrations/Version20180715160326.php +++ b/src/Migrations/Version20180715160326.php @@ -52,11 +52,11 @@ final class Version20180715160326 extends AbstractMigration $this->addSql('UPDATE ' . $users . ' set username_canonical = username'); $this->addSql('UPDATE ' . $users . ' set email_canonical = email'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:16:"ROLE_SUPER_ADMIN";}\' WHERE roles LIKE "%ROLE_SUPER_ADMIN%"'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:10:"ROLE_ADMIN";}\' WHERE roles LIKE "%ROLE_ADMIN%"'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:13:"ROLE_TEAMLEAD";}\' WHERE roles LIKE "%ROLE_TEAMLEAD%"'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'a:0:{}\' WHERE roles LIKE "%ROLE_USER%"'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:13:"ROLE_CUSTOMER";}\' WHERE roles LIKE "%ROLE_CUSTOMER%"'); + $this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:16:"ROLE_SUPER_ADMIN";}\' WHERE roles LIKE \'%ROLE_SUPER_ADMIN%\''); + $this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:10:"ROLE_ADMIN";}\' WHERE roles LIKE \'%ROLE_ADMIN%\''); + $this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:13:"ROLE_TEAMLEAD";}\' WHERE roles LIKE \'%ROLE_TEAMLEAD%\''); + $this->addSql('UPDATE ' . $users . ' SET roles = \'a:0:{}\' WHERE roles LIKE \'%ROLE_USER%\''); + $this->addSql('UPDATE ' . $users . ' SET roles = \'a:1:{i:0;s:13:"ROLE_CUSTOMER";}\' WHERE roles LIKE \'%ROLE_CUSTOMER%\''); $this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE92FC23A8 ON ' . $users . ' (username_canonical)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEA0D96FBF ON ' . $users . ' (email_canonical)'); @@ -81,11 +81,11 @@ final class Version20180715160326 extends AbstractMigration $this->addSql('ALTER TABLE ' . $users . ' CHANGE username name VARCHAR(60) NOT NULL COLLATE utf8mb4_unicode_ci, CHANGE email mail VARCHAR(160) NOT NULL COLLATE utf8mb4_unicode_ci, DROP username_canonical, DROP email_canonical, DROP salt, DROP last_login, DROP confirmation_token, DROP password_requested_at, CHANGE password password VARCHAR(254) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE roles roles LONGTEXT NOT NULL COMMENT \'(DC2Type:array)\', CHANGE alias alias VARCHAR(60) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE registration_date registration_date DATETIME DEFAULT NULL, CHANGE title title VARCHAR(50) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE avatar avatar VARCHAR(255) DEFAULT NULL COLLATE utf8mb4_unicode_ci, CHANGE enabled active TINYINT(1) NOT NULL'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_SUPER_ADMIN"]\' WHERE roles LIKE "%ROLE_SUPER_ADMIN%"'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_ADMIN"]\' WHERE roles LIKE "%ROLE_ADMIN%"'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_TEAMLEAD"]\' WHERE roles LIKE "%ROLE_TEAMLEAD%"'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_USER"]\' WHERE roles LIKE "%ROLE_USER%"'); - $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_CUSTOMER"]\' WHERE roles LIKE "%ROLE_CUSTOMER%"'); + $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_SUPER_ADMIN"]\' WHERE roles LIKE \'%ROLE_SUPER_ADMIN%\''); + $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_ADMIN"]\' WHERE roles LIKE \'%ROLE_ADMIN%\''); + $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_TEAMLEAD"]\' WHERE roles LIKE \'%ROLE_TEAMLEAD%\''); + $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_USER"]\' WHERE roles LIKE \'%ROLE_USER%\''); + $this->addSql('UPDATE ' . $users . ' SET roles = \'["ROLE_CUSTOMER"]\' WHERE roles LIKE \'%ROLE_CUSTOMER%\''); $this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5E237E06 ON ' . $users . ' (name)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5126AC48 ON ' . $users . ' (mail)'); diff --git a/src/Migrations/Version20190201150324.php b/src/Migrations/Version20190201150324.php index 8db8e7bd..d20e85af 100644 --- a/src/Migrations/Version20190201150324.php +++ b/src/Migrations/Version20190201150324.php @@ -28,7 +28,7 @@ final class Version20190201150324 extends AbstractMigration $this->addSql('ALTER TABLE kimai2_timesheet ADD timezone VARCHAR(64) NOT NULL'); - $this->addSql('UPDATE kimai2_timesheet SET timezone = "' . $timezone . '"'); + $this->addSql("UPDATE kimai2_timesheet SET timezone = '" . $timezone . "'"); } public function down(Schema $schema): void diff --git a/src/Migrations/Version20210802174320.php b/src/Migrations/Version20210802174320.php index 85a570bd..9baec874 100644 --- a/src/Migrations/Version20210802174320.php +++ b/src/Migrations/Version20210802174320.php @@ -27,7 +27,10 @@ final class Version20210802174320 extends AbstractMigration public function up(Schema $schema): void { $teams = $schema->getTable('kimai2_teams'); - $teams->removeForeignKey('FK_3BEDDC7F8F7DE5D7'); + // @see https://github.com/kevinpapst/kimai2/issues/2706 + if ($teams->hasForeignKey('FK_3BEDDC7F8F7DE5D7')) { + $teams->removeForeignKey('FK_3BEDDC7F8F7DE5D7'); + } $teams->dropColumn('teamlead_id'); } diff --git a/src/Model/TimesheetStatistic.php b/src/Model/TimesheetStatistic.php index 1903d6bc..0e1434de 100644 --- a/src/Model/TimesheetStatistic.php +++ b/src/Model/TimesheetStatistic.php @@ -16,29 +16,16 @@ use DateTime; */ class TimesheetStatistic { - /** - * @var int - */ - protected $durationThisMonth = 0; - /** - * @var int - */ - protected $durationTotal = 0; - /** - * @var float - */ - protected $amountThisMonth = 0; - /** - * @var float - */ - protected $amountTotal = 0; /** * @var \DateTime|null */ protected $firstEntry; - /** - * @var int - */ + protected $durationThisMonth = 0; + protected $durationTotal = 0; + protected $amountThisMonth = 0.0; + protected $amountTotal = 0.0; + protected $amountThisMonthBillable = 0.0; + protected $amountTotalBillable = 0.0; protected $recordsTotal = 0; public function getDurationThisMonth(): int @@ -46,12 +33,9 @@ class TimesheetStatistic return $this->durationThisMonth; } - /** - * @param int $durationThisMonth - */ - public function setDurationThisMonth($durationThisMonth) + public function setDurationThisMonth(int $durationThisMonth): void { - $this->durationThisMonth = (int) $durationThisMonth; + $this->durationThisMonth = $durationThisMonth; } /** @@ -69,11 +53,21 @@ class TimesheetStatistic * * @param float|int $amountTotal */ - public function setAmountTotal($amountTotal) + public function setAmountTotal($amountTotal): void { $this->amountTotal = (float) $amountTotal; } + public function getRateTotalBillable(): float + { + return $this->amountTotalBillable; + } + + public function setRateTotalBillable(float $amountTotal): void + { + $this->amountTotalBillable = $amountTotal; + } + public function getDurationTotal(): int { return $this->durationTotal; @@ -82,7 +76,7 @@ class TimesheetStatistic /** * @param int $durationTotal */ - public function setDurationTotal($durationTotal) + public function setDurationTotal($durationTotal): void { $this->durationTotal = (int) $durationTotal; } @@ -102,11 +96,21 @@ class TimesheetStatistic * * @param float|int $amountThisMonth */ - public function setAmountThisMonth($amountThisMonth) + public function setAmountThisMonth($amountThisMonth): void { $this->amountThisMonth = (float) $amountThisMonth; } + public function getRateThisMonthBillable(): float + { + return $this->amountThisMonthBillable; + } + + public function setRateThisMonthBillable(float $amountThisMonth): void + { + $this->amountThisMonthBillable = $amountThisMonth; + } + /** * @deprecated since 1.15 use TimesheetStatisticService::findFirstRecordDate() instead, will be removed with 2.0 */ @@ -115,30 +119,18 @@ class TimesheetStatistic return $this->firstEntry; } - /** - * @param DateTime $firstEntry - */ - public function setFirstEntry(DateTime $firstEntry) + public function setFirstEntry(DateTime $firstEntry): void { $this->firstEntry = $firstEntry; } - /** - * @return int - */ public function getRecordsTotal(): int { return $this->recordsTotal; } - /** - * @param int $recordsTotal - * @return TimesheetStatistic - */ - public function setRecordsTotal(int $recordsTotal) + public function setRecordsTotal(int $recordsTotal): void { $this->recordsTotal = $recordsTotal; - - return $this; } } diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index a50ab97b..fd7ddf1f 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -245,7 +245,7 @@ class TimesheetRepository extends EntityRepository * @return int|mixed * @throws \Doctrine\ORM\NonUniqueResultException */ - public function getStatistic(string $type, ?DateTime $begin, ?DateTime $end, ?User $user) + public function getStatistic(string $type, ?DateTime $begin, ?DateTime $end, ?User $user, ?bool $billable = null) { switch ($type) { case self::STATS_QUERY_ACTIVE: @@ -262,6 +262,7 @@ class TimesheetRepository extends EntityRepository break; case self::STATS_QUERY_RATE: $what = 'COALESCE(SUM(t.rate), 0)'; + $billable = true; break; case self::STATS_QUERY_USER: $what = 'COUNT(DISTINCT(t.user))'; @@ -273,7 +274,7 @@ class TimesheetRepository extends EntityRepository throw new InvalidArgumentException('Invalid query type: ' . $type); } - return $this->queryTimeRange($what, $begin, $end, $user); + return $this->queryTimeRange($what, $begin, $end, $user, $billable); } /** @@ -281,10 +282,11 @@ class TimesheetRepository extends EntityRepository * @param DateTime|null $begin * @param DateTime|null $end * @param User|null $user + * @param bool|null $billable * @return int|mixed * @throws \Doctrine\ORM\NonUniqueResultException */ - protected function queryTimeRange($select, ?DateTime $begin, ?DateTime $end, ?User $user) + protected function queryTimeRange($select, ?DateTime $begin, ?DateTime $end, ?User $user, ?bool $billable = null) { $selects = $select; if (!\is_array($select)) { @@ -315,6 +317,11 @@ class TimesheetRepository extends EntityRepository ->setParameter('user', $user); } + if (null !== $billable) { + $qb->andWhere('t.billable = :billable') + ->setParameter('billable', $billable); + } + if (\is_array($select)) { return $qb->getQuery()->getOneOrNullResult(); } @@ -331,31 +338,44 @@ class TimesheetRepository extends EntityRepository */ public function getUserStatistics(User $user, bool $bcSafe = true): TimesheetStatistic { + $stats = new TimesheetStatistic(); + $allTimeData = $this->queryTimeRange([ 'COALESCE(SUM(t.duration), 0) as duration', 'COALESCE(SUM(t.rate), 0) as rate', 'COUNT(t.id) as amount' ], null, null, $user); + $stats->setAmountTotal($allTimeData['rate']); + $stats->setDurationTotal($allTimeData['duration']); + $stats->setRecordsTotal($allTimeData['amount']); + + $billableAllTime = $this->getStatistic(self::STATS_QUERY_RATE, null, null, $user, true); + $stats->setRateTotalBillable($billableAllTime); + $timezone = new \DateTimeZone($user->getTimezone()); $begin = new DateTime('first day of this month 00:00:00', $timezone); $end = new DateTime('last day of this month 23:59:59', $timezone); $monthData = $this->queryTimeRange( [ - 'COALESCE(SUM(t.rate), 0) as rate', - 'COALESCE(SUM(t.duration), 0) as duration' + 'COALESCE(SUM(t.rate), 0) as rate', + 'COALESCE(SUM(t.duration), 0) as duration' ], $begin, $end, $user ); - $stats = new TimesheetStatistic(); + $stats->setAmountThisMonth($monthData['rate']); + $stats->setDurationThisMonth($monthData['duration']); + + $billableMonth = $this->getStatistic(self::STATS_QUERY_RATE, $begin, $end, $user, true); + $stats->setRateThisMonthBillable($billableMonth); if ($bcSafe) { $firstEntry = $this->getEntityManager() - ->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user AND 1=12') + ->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user') ->setParameter('user', $user) ->getSingleScalarResult(); @@ -372,12 +392,6 @@ class TimesheetRepository extends EntityRepository } } - $stats->setAmountTotal($allTimeData['rate']); - $stats->setDurationTotal($allTimeData['duration']); - $stats->setAmountThisMonth($monthData['rate']); - $stats->setDurationThisMonth($monthData['duration']); - $stats->setRecordsTotal($allTimeData['amount']); - return $stats; } diff --git a/src/Timesheet/TimesheetService.php b/src/Timesheet/TimesheetService.php index dfaf039e..ecacdb7a 100644 --- a/src/Timesheet/TimesheetService.php +++ b/src/Timesheet/TimesheetService.php @@ -226,7 +226,11 @@ final class TimesheetService public function stopTimesheet(Timesheet $timesheet): void { if (null !== $timesheet->getEnd()) { - throw new ValidationException('Timesheet entry already stopped'); + // timesheet already stopped, nothing to do. in previous version, this method did throw a: + // new ValidationException('Timesheet entry already stopped'); + // but this was removed, because it can happen in the frontend when using multiple tabs/devices and should + // simply be ignored - showing the message to the user with a "danger status" is not necessary + return; } $begin = clone $timesheet->getBegin(); diff --git a/src/Utils/LocaleHelper.php b/src/Utils/LocaleHelper.php index 7708fe47..55fcdc21 100644 --- a/src/Utils/LocaleHelper.php +++ b/src/Utils/LocaleHelper.php @@ -24,6 +24,10 @@ final class LocaleHelper * @var NumberFormatter */ private $numberFormatter; + /** + * @var NumberFormatter + */ + private $durationFormatter; /** * @var NumberFormatter */ @@ -51,10 +55,12 @@ final class LocaleHelper $value = 0; } - return $this->getNumberFormatter()->format((float) $value); + return $this->getDurationFormatter()->format((float) $value); } /** + * Only used in twig filter |amount and invoice templates + * * @param string|float $amount * @return bool|false|string */ @@ -133,6 +139,16 @@ final class LocaleHelper return $this->numberFormatter; } + private function getDurationFormatter(): NumberFormatter + { + if (null === $this->numberFormatter) { + $this->durationFormatter = new NumberFormatter($this->locale, NumberFormatter::DECIMAL); + $this->durationFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + } + + return $this->durationFormatter; + } + private function getMoneyFormatter(bool $withCurrency = true): NumberFormatter { if ($withCurrency) { diff --git a/templates/macros/widgets.html.twig b/templates/macros/widgets.html.twig index 626c9bb6..f970c752 100644 --- a/templates/macros/widgets.html.twig +++ b/templates/macros/widgets.html.twig @@ -155,7 +155,7 @@ {# for @internal use only #} {% macro label_color_dot(type, isVisible, name, url, color) %} {% apply spaceless %} - + diff --git a/templates/permission/permissions.html.twig b/templates/permission/permissions.html.twig index bd812743..65c8c934 100644 --- a/templates/permission/permissions.html.twig +++ b/templates/permission/permissions.html.twig @@ -12,7 +12,7 @@ {% set options = {'class': 'alwaysVisible text-center'} %} {% if canEditPermissions and role.name not in system_roles|keys %} {% set widget %} -  {{ widgets.icon('trash') }} +  {{ widgets.icon('trash') }} {% endset %} {% set options = options|merge({'html_after': widget}) %} {% endif %} @@ -47,10 +47,10 @@ {% if value %} {{ widgets.label('yes'|trans, 'warning') }} {% else %} - {{ widgets.label('no'|trans, 'danger') }} + {{ widgets.label('no'|trans, 'danger') }} {% endif %} {% else %} - {{ widgets.label_boolean(value) }} + {{ widgets.label_boolean(value) }} {% endif %} {% endfor %} diff --git a/templates/user/stats.html.twig b/templates/user/stats.html.twig index 77e0f8c6..288c8307 100644 --- a/templates/user/stats.html.twig +++ b/templates/user/stats.html.twig @@ -130,8 +130,7 @@ {% if seeOwnRate %} {{ 'stats.amountMonth'|trans }} - {# TODO which currency shall we use here? #} - {{ stats.amountThisMonth|money }} + {{ stats.rateThisMonthBillable|money }} {% endif %} @@ -141,8 +140,7 @@ {% if seeOwnRate %} {{ 'stats.amountTotal'|trans }} - {# TODO which currency shall we use here? #} - {{ stats.amountTotal|money }} + {{ stats.rateTotalBillable|money }} {% endif %} @@ -185,7 +183,7 @@ {% if seeOwnRate %}
-
{{ stats.amountThisMonth|money }}
+
{{ stats.rateThisMonthBillable|money }}
{{ 'stats.amountMonth'|trans }}
@@ -199,7 +197,7 @@ {% if seeOwnRate %}
-
{{ stats.amountTotal|money }}
+
{{ stats.rateTotalBillable|money }}
{{ 'stats.amountTotal'|trans }}
diff --git a/tests/API/TimesheetControllerTest.php b/tests/API/TimesheetControllerTest.php index d00646b1..be499b18 100644 --- a/tests/API/TimesheetControllerTest.php +++ b/tests/API/TimesheetControllerTest.php @@ -796,16 +796,6 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertApiCallValidationError($client->getResponse(), ['duration' => 'Maximum 12:30 hours allowed.']); } - public function testStopActionFailsOnStoppedEntry() - { - $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); - $timesheets = $this->importFixtureForUser(User::ROLE_USER); - $id = $timesheets[0]->getId(); - $this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH'); - - $this->assertApi500Exception($client->getResponse(), 'Timesheet entry already stopped'); - } - public function testStopThrowsNotFound() { $this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/11/stop', [], 'App\\Entity\\Timesheet object not found by the @ParamConverter annotation.'); diff --git a/tests/Timesheet/TimesheetServiceTest.php b/tests/Timesheet/TimesheetServiceTest.php index 16b820f1..029b8d47 100644 --- a/tests/Timesheet/TimesheetServiceTest.php +++ b/tests/Timesheet/TimesheetServiceTest.php @@ -21,7 +21,6 @@ use App\Event\TimesheetRestartPreEvent; use App\Repository\TimesheetRepository; use App\Timesheet\TimesheetService; use App\Timesheet\TrackingModeService; -use App\Validator\ValidationException; use App\Validator\ValidationFailedException; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -246,15 +245,15 @@ class TimesheetServiceTest extends TestCase public function testStoppedEntriesCannotBeStoppedAgain() { + $dateTime = new \DateTime('-2 hours'); $timesheet = new Timesheet(); - $timesheet->setEnd(new \DateTime()); + $timesheet->setEnd($dateTime); $sut = $this->getSut(); - $this->expectException(ValidationException::class); - $this->expectExceptionMessage('Timesheet entry already stopped'); - $sut->stopTimesheet($timesheet); + + self::assertSame($dateTime->getTimestamp(), $timesheet->getEnd()->getTimestamp()); } public function testDeleteDispatchesEvent() diff --git a/tests/Twig/LocaleFormatExtensionsTest.php b/tests/Twig/LocaleFormatExtensionsTest.php index 519b326a..51e4c255 100644 --- a/tests/Twig/LocaleFormatExtensionsTest.php +++ b/tests/Twig/LocaleFormatExtensionsTest.php @@ -455,7 +455,7 @@ class LocaleFormatExtensionsTest extends TestCase $sut = $this->getSut($this->localeEn, 'en'); $this->assertEquals('00:00 h', $sut->duration(null)); - $this->assertEquals('0', $sut->duration(null, true)); + $this->assertEquals('0.00', $sut->duration(null, true)); } public function testDurationChart() @@ -483,15 +483,15 @@ class LocaleFormatExtensionsTest extends TestCase // test negative duration $sut = $this->getSut($this->localeEn, 'en'); - $this->assertEquals('0', $sut->durationDecimal(-1)); + $this->assertEquals('0.00', $sut->durationDecimal(-1)); // test zero duration $sut = $this->getSut($this->localeEn, 'en'); - $this->assertEquals('0', $sut->durationDecimal(0)); + $this->assertEquals('0.00', $sut->durationDecimal(0)); $sut = $this->getSut($this->localeEn, 'en'); - $this->assertEquals('0', $sut->durationDecimal(null)); + $this->assertEquals('0.00', $sut->durationDecimal(null)); } protected function getTimesheet($seconds) diff --git a/tests/Utils/LocaleHelperTest.php b/tests/Utils/LocaleHelperTest.php index 262e7756..d27435fc 100644 --- a/tests/Utils/LocaleHelperTest.php +++ b/tests/Utils/LocaleHelperTest.php @@ -199,18 +199,18 @@ class LocaleHelperTest extends TestCase $sut = $this->getSut('de'); $this->assertEquals('2,62', $sut->durationDecimal($record->getDuration())); $this->assertEquals('6.328,89', $sut->durationDecimal(22784012)); - $this->assertEquals('1', $sut->durationDecimal(3600)); + $this->assertEquals('1,00', $sut->durationDecimal(3600)); $this->assertEquals('1,01', $sut->durationDecimal(3630)); $this->assertEquals('1,02', $sut->durationDecimal(3661)); - $this->assertEquals('1,1', $sut->durationDecimal(3960)); + $this->assertEquals('1,10', $sut->durationDecimal(3960)); // test negative duration $sut = $this->getSut('en'); - $this->assertEquals('0', $sut->durationDecimal(-1)); + $this->assertEquals('0.00', $sut->durationDecimal(-1)); // test zero duration $sut = $this->getSut('en'); - $this->assertEquals('0', $sut->durationDecimal(0)); + $this->assertEquals('0.00', $sut->durationDecimal(0)); $this->assertEquals('6,328.89', $sut->durationDecimal(22784012)); } diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 33087404..924f271a 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -460,7 +460,7 @@ label.my_team_projects - Team Projekte & Budgets + Meine Projekte label.progress @@ -1126,6 +1126,10 @@ export.warn_result_amount Ihre Suche führt zu %count% Ergebnissen. Sollte der Export fehlschlagen, müssen Sie die Suche weiter eingrenzen. + + label.type + Typ + diff --git a/translations/messages.de_CH.xlf b/translations/messages.de_CH.xlf index 76cc4566..135c11a3 100644 --- a/translations/messages.de_CH.xlf +++ b/translations/messages.de_CH.xlf @@ -683,7 +683,7 @@ label.my_team_projects - Team Projekte & Budgets + Meine Projekte label.my_teams diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index bb1fa776..773b4249 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -460,7 +460,7 @@ label.my_team_projects - Team projects & budgets + My projects label.progress @@ -1126,6 +1126,10 @@ export.warn_result_amount Your search leads to %count% results. If the export fails, you have to narrow down your search further. + + label.type + Type +