prepare release 1.15 (#2707)

* bump version
* fix invisible class on labels
* fail safe removal of foreign key
* check if optional form field exists before accessing it
* silently ignore stopped timesheets
* prevent colliding parameter names
* make sure decimal duration is always rendered with two decimals
* simplify translation
* fix #2751 ANSI_QUOTES
* rename composer task
* fix billable statistic rates
* added missing translation for export
This commit is contained in:
Kevin Papst
2021-09-17 00:58:25 +02:00
committed by GitHub
parent 8f832856a5
commit baff2d78d9
23 changed files with 142 additions and 117 deletions

View File

@@ -51,7 +51,7 @@ jobs:
run: vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=5 --no-progress --error-format=checkstyle | cs2pr run: vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=5 --no-progress --error-format=checkstyle | cs2pr
- name: Lint codebase - name: Lint codebase
run: composer kimai:code-lint run: composer linting
- name: Install LDAP package - name: Install LDAP package
run: composer require laminas/laminas-ldap run: composer require laminas/laminas-ldap

View File

@@ -7,8 +7,8 @@ As announced in the [README](README.md) I only support the latest available rele
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | ------------------ |
| master | :white_check_mark: | | master | :white_check_mark: |
| 1.14 | :white_check_mark: | | 1.15 | :white_check_mark: |
| < 1.14 | :x: | | < 1.15 | :x: |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@@ -146,7 +146,7 @@
"kimai:pre-commit": [ "kimai:pre-commit": [
"@kimai:codestyle", "@kimai:codestyle",
"@kimai:phpstan", "@kimai:phpstan",
"@kimai:code-lint", "@linting",
"@kimai:tests-unit" "@kimai:tests-unit"
], ],
"kimai:code-check": "@code-check", "kimai:code-check": "@code-check",
@@ -154,7 +154,8 @@
"@kimai:pre-commit", "@kimai:pre-commit",
"@kimai:tests-integration" "@kimai:tests-integration"
], ],
"kimai:code-lint": [ "kimai:code-lint": "@linting",
"linting": [
"composer validate --strict", "composer validate --strict",
"bin/console lint:container", "bin/console lint:container",
"bin/console lint:yaml config --parse-tags", "bin/console lint:yaml config --parse-tags",

View File

@@ -25,7 +25,7 @@ class Constants
/** /**
* The current release status, either "stable" or "dev" * The current release status, either "stable" or "dev"
*/ */
public const STATUS = 'dev'; public const STATUS = 'stable';
/** /**
* The software name * The software name
*/ */

View File

@@ -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')") * @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')); $this->flashUpdateException(new \Exception('Invalid CSRF token'));
return $this->redirectToRoute('admin_user_permissions'); 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')") * @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'); throw new BadRequestHttpException('Invalid CSRF token');
} }

View File

@@ -126,7 +126,7 @@ final class UserController extends AbstractController
$this->flashSuccess('action.update.success'); $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()]); return $this->redirectToRoute('user_profile_edit', ['username' => $user->getUsername()]);
} }

View File

@@ -52,11 +52,11 @@ final class Version20180715160326 extends AbstractMigration
$this->addSql('UPDATE ' . $users . ' set username_canonical = username'); $this->addSql('UPDATE ' . $users . ' set username_canonical = username');
$this->addSql('UPDATE ' . $users . ' set email_canonical = email'); $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: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: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: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: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: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_B9AC5BCE92FC23A8 ON ' . $users . ' (username_canonical)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCEA0D96FBF ON ' . $users . ' (email_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('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_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_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_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_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_CUSTOMER"]\' WHERE roles LIKE \'%ROLE_CUSTOMER%\'');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5E237E06 ON ' . $users . ' (name)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5E237E06 ON ' . $users . ' (name)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5126AC48 ON ' . $users . ' (mail)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_B9AC5BCE5126AC48 ON ' . $users . ' (mail)');

View File

@@ -28,7 +28,7 @@ final class Version20190201150324 extends AbstractMigration
$this->addSql('ALTER TABLE kimai2_timesheet ADD timezone VARCHAR(64) NOT NULL'); $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 public function down(Schema $schema): void

View File

@@ -27,7 +27,10 @@ final class Version20210802174320 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
$teams = $schema->getTable('kimai2_teams'); $teams = $schema->getTable('kimai2_teams');
// @see https://github.com/kevinpapst/kimai2/issues/2706
if ($teams->hasForeignKey('FK_3BEDDC7F8F7DE5D7')) {
$teams->removeForeignKey('FK_3BEDDC7F8F7DE5D7'); $teams->removeForeignKey('FK_3BEDDC7F8F7DE5D7');
}
$teams->dropColumn('teamlead_id'); $teams->dropColumn('teamlead_id');
} }

View File

@@ -16,29 +16,16 @@ use DateTime;
*/ */
class TimesheetStatistic 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 * @var \DateTime|null
*/ */
protected $firstEntry; protected $firstEntry;
/** protected $durationThisMonth = 0;
* @var int protected $durationTotal = 0;
*/ protected $amountThisMonth = 0.0;
protected $amountTotal = 0.0;
protected $amountThisMonthBillable = 0.0;
protected $amountTotalBillable = 0.0;
protected $recordsTotal = 0; protected $recordsTotal = 0;
public function getDurationThisMonth(): int public function getDurationThisMonth(): int
@@ -46,12 +33,9 @@ class TimesheetStatistic
return $this->durationThisMonth; return $this->durationThisMonth;
} }
/** public function setDurationThisMonth(int $durationThisMonth): void
* @param int $durationThisMonth
*/
public function setDurationThisMonth($durationThisMonth)
{ {
$this->durationThisMonth = (int) $durationThisMonth; $this->durationThisMonth = $durationThisMonth;
} }
/** /**
@@ -69,11 +53,21 @@ class TimesheetStatistic
* *
* @param float|int $amountTotal * @param float|int $amountTotal
*/ */
public function setAmountTotal($amountTotal) public function setAmountTotal($amountTotal): void
{ {
$this->amountTotal = (float) $amountTotal; $this->amountTotal = (float) $amountTotal;
} }
public function getRateTotalBillable(): float
{
return $this->amountTotalBillable;
}
public function setRateTotalBillable(float $amountTotal): void
{
$this->amountTotalBillable = $amountTotal;
}
public function getDurationTotal(): int public function getDurationTotal(): int
{ {
return $this->durationTotal; return $this->durationTotal;
@@ -82,7 +76,7 @@ class TimesheetStatistic
/** /**
* @param int $durationTotal * @param int $durationTotal
*/ */
public function setDurationTotal($durationTotal) public function setDurationTotal($durationTotal): void
{ {
$this->durationTotal = (int) $durationTotal; $this->durationTotal = (int) $durationTotal;
} }
@@ -102,11 +96,21 @@ class TimesheetStatistic
* *
* @param float|int $amountThisMonth * @param float|int $amountThisMonth
*/ */
public function setAmountThisMonth($amountThisMonth) public function setAmountThisMonth($amountThisMonth): void
{ {
$this->amountThisMonth = (float) $amountThisMonth; $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 * @deprecated since 1.15 use TimesheetStatisticService::findFirstRecordDate() instead, will be removed with 2.0
*/ */
@@ -115,30 +119,18 @@ class TimesheetStatistic
return $this->firstEntry; return $this->firstEntry;
} }
/** public function setFirstEntry(DateTime $firstEntry): void
* @param DateTime $firstEntry
*/
public function setFirstEntry(DateTime $firstEntry)
{ {
$this->firstEntry = $firstEntry; $this->firstEntry = $firstEntry;
} }
/**
* @return int
*/
public function getRecordsTotal(): int public function getRecordsTotal(): int
{ {
return $this->recordsTotal; return $this->recordsTotal;
} }
/** public function setRecordsTotal(int $recordsTotal): void
* @param int $recordsTotal
* @return TimesheetStatistic
*/
public function setRecordsTotal(int $recordsTotal)
{ {
$this->recordsTotal = $recordsTotal; $this->recordsTotal = $recordsTotal;
return $this;
} }
} }

View File

@@ -245,7 +245,7 @@ class TimesheetRepository extends EntityRepository
* @return int|mixed * @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException * @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) { switch ($type) {
case self::STATS_QUERY_ACTIVE: case self::STATS_QUERY_ACTIVE:
@@ -262,6 +262,7 @@ class TimesheetRepository extends EntityRepository
break; break;
case self::STATS_QUERY_RATE: case self::STATS_QUERY_RATE:
$what = 'COALESCE(SUM(t.rate), 0)'; $what = 'COALESCE(SUM(t.rate), 0)';
$billable = true;
break; break;
case self::STATS_QUERY_USER: case self::STATS_QUERY_USER:
$what = 'COUNT(DISTINCT(t.user))'; $what = 'COUNT(DISTINCT(t.user))';
@@ -273,7 +274,7 @@ class TimesheetRepository extends EntityRepository
throw new InvalidArgumentException('Invalid query type: ' . $type); 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 $begin
* @param DateTime|null $end * @param DateTime|null $end
* @param User|null $user * @param User|null $user
* @param bool|null $billable
* @return int|mixed * @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException * @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; $selects = $select;
if (!\is_array($select)) { if (!\is_array($select)) {
@@ -315,6 +317,11 @@ class TimesheetRepository extends EntityRepository
->setParameter('user', $user); ->setParameter('user', $user);
} }
if (null !== $billable) {
$qb->andWhere('t.billable = :billable')
->setParameter('billable', $billable);
}
if (\is_array($select)) { if (\is_array($select)) {
return $qb->getQuery()->getOneOrNullResult(); return $qb->getQuery()->getOneOrNullResult();
} }
@@ -331,12 +338,21 @@ class TimesheetRepository extends EntityRepository
*/ */
public function getUserStatistics(User $user, bool $bcSafe = true): TimesheetStatistic public function getUserStatistics(User $user, bool $bcSafe = true): TimesheetStatistic
{ {
$stats = new TimesheetStatistic();
$allTimeData = $this->queryTimeRange([ $allTimeData = $this->queryTimeRange([
'COALESCE(SUM(t.duration), 0) as duration', 'COALESCE(SUM(t.duration), 0) as duration',
'COALESCE(SUM(t.rate), 0) as rate', 'COALESCE(SUM(t.rate), 0) as rate',
'COUNT(t.id) as amount' 'COUNT(t.id) as amount'
], null, null, $user); ], 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()); $timezone = new \DateTimeZone($user->getTimezone());
$begin = new DateTime('first day of this month 00:00:00', $timezone); $begin = new DateTime('first day of this month 00:00:00', $timezone);
$end = new DateTime('last day of this month 23:59:59', $timezone); $end = new DateTime('last day of this month 23:59:59', $timezone);
@@ -351,11 +367,15 @@ class TimesheetRepository extends EntityRepository
$user $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) { if ($bcSafe) {
$firstEntry = $this->getEntityManager() $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) ->setParameter('user', $user)
->getSingleScalarResult(); ->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; return $stats;
} }

View File

@@ -226,7 +226,11 @@ final class TimesheetService
public function stopTimesheet(Timesheet $timesheet): void public function stopTimesheet(Timesheet $timesheet): void
{ {
if (null !== $timesheet->getEnd()) { 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(); $begin = clone $timesheet->getBegin();

View File

@@ -24,6 +24,10 @@ final class LocaleHelper
* @var NumberFormatter * @var NumberFormatter
*/ */
private $numberFormatter; private $numberFormatter;
/**
* @var NumberFormatter
*/
private $durationFormatter;
/** /**
* @var NumberFormatter * @var NumberFormatter
*/ */
@@ -51,10 +55,12 @@ final class LocaleHelper
$value = 0; $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 * @param string|float $amount
* @return bool|false|string * @return bool|false|string
*/ */
@@ -133,6 +139,16 @@ final class LocaleHelper
return $this->numberFormatter; 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 private function getMoneyFormatter(bool $withCurrency = true): NumberFormatter
{ {
if ($withCurrency) { if ($withCurrency) {

View File

@@ -155,7 +155,7 @@
{# for @internal use only #} {# for @internal use only #}
{% macro label_color_dot(type, isVisible, name, url, color) %} {% macro label_color_dot(type, isVisible, name, url, color) %}
{% apply spaceless %} {% apply spaceless %}
<span class="label-{{ type }}{{ isVisible ? '' : ' label-invisible' }}"> <span class="label-{{ type }}{{ isVisible is same as (false) ? ' label-invisible' : '' }}">
<span class="name"> <span class="name">
<i class="dot {{ 'dot'|icon }} fa-fw"{% if color is not empty %} style="color:{{ color }}"{% endif %}></i> <i class="dot {{ 'dot'|icon }} fa-fw"{% if color is not empty %} style="color:{{ color }}"{% endif %}></i>
<span class="name-inner"> <span class="name-inner">

View File

@@ -12,7 +12,7 @@
{% set options = {'class': 'alwaysVisible text-center'} %} {% set options = {'class': 'alwaysVisible text-center'} %}
{% if canEditPermissions and role.name not in system_roles|keys %} {% if canEditPermissions and role.name not in system_roles|keys %}
{% set widget %} {% set widget %}
&nbsp;<a href="{{ path('admin_user_role_delete', {'id': role.id, 'token': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ widgets.icon('trash') }}</a> &nbsp;<a href="{{ path('admin_user_role_delete', {'id': role.id, 'csrfToken': token}) }}" class="confirmation-link" data-question="confirm.delete">{{ widgets.icon('trash') }}</a>
{% endset %} {% endset %}
{% set options = options|merge({'html_after': widget}) %} {% set options = options|merge({'html_after': widget}) %}
{% endif %} {% endif %}
@@ -47,10 +47,10 @@
{% if value %} {% if value %}
{{ widgets.label('yes'|trans, 'warning') }} {{ widgets.label('yes'|trans, 'warning') }}
{% else %} {% else %}
<a class="togglePerm permOff" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '1', 'token': '__TOKEN__'}) }}">{{ widgets.label('no'|trans, 'danger') }}</a> <a class="togglePerm permOff" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '1', 'csrfToken': '__TOKEN__'}) }}">{{ widgets.label('no'|trans, 'danger') }}</a>
{% endif %} {% endif %}
{% else %} {% else %}
<a class="togglePerm {{ value ? 'permOn' : 'permOff' }}" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '__VALUE__', 'token': '__TOKEN__'}) }}">{{ widgets.label_boolean(value) }}</a> <a class="togglePerm {{ value ? 'permOn' : 'permOff' }}" href="#" data-href="{{ path('admin_user_permission_save', {'id': role.id, 'name': permission, 'value': '__VALUE__', 'csrfToken': '__TOKEN__'}) }}">{{ widgets.label_boolean(value) }}</a>
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% endfor %}

View File

@@ -130,8 +130,7 @@
{% if seeOwnRate %} {% if seeOwnRate %}
<tr> <tr>
<th>{{ 'stats.amountMonth'|trans }}</th> <th>{{ 'stats.amountMonth'|trans }}</th>
{# TODO which currency shall we use here? #} <td class="text-nowrap pull-right">{{ stats.rateThisMonthBillable|money }}</td>
<td class="text-nowrap pull-right">{{ stats.amountThisMonth|money }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
@@ -141,8 +140,7 @@
{% if seeOwnRate %} {% if seeOwnRate %}
<tr> <tr>
<th>{{ 'stats.amountTotal'|trans }}</th> <th>{{ 'stats.amountTotal'|trans }}</th>
{# TODO which currency shall we use here? #} <td class="text-nowrap pull-right">{{ stats.rateTotalBillable|money }}</td>
<td class="text-nowrap pull-right">{{ stats.amountTotal|money }}</td>
</tr> </tr>
{% endif %} {% endif %}
</table> </table>
@@ -185,7 +183,7 @@
{% if seeOwnRate %} {% if seeOwnRate %}
<div class="col-sm-{{ columnLength }} border-right"> <div class="col-sm-{{ columnLength }} border-right">
<div class="description-block"> <div class="description-block">
<h5 class="description-header">{{ stats.amountThisMonth|money }}</h5> <h5 class="description-header">{{ stats.rateThisMonthBillable|money }}</h5>
<span class="description-text">{{ 'stats.amountMonth'|trans }}</span> <span class="description-text">{{ 'stats.amountMonth'|trans }}</span>
</div> </div>
</div> </div>
@@ -199,7 +197,7 @@
{% if seeOwnRate %} {% if seeOwnRate %}
<div class="col-sm-{{ columnLength }} border-right"> <div class="col-sm-{{ columnLength }} border-right">
<div class="description-block"> <div class="description-block">
<h5 class="description-header">{{ stats.amountTotal|money }}</h5> <h5 class="description-header">{{ stats.rateTotalBillable|money }}</h5>
<span class="description-text">{{ 'stats.amountTotal'|trans }}</span> <span class="description-text">{{ 'stats.amountTotal'|trans }}</span>
</div> </div>
</div> </div>

View File

@@ -796,16 +796,6 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiCallValidationError($client->getResponse(), ['duration' => 'Maximum 12:30 hours allowed.']); $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() public function testStopThrowsNotFound()
{ {
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/11/stop', [], 'App\\Entity\\Timesheet object not found by the @ParamConverter annotation.'); $this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/11/stop', [], 'App\\Entity\\Timesheet object not found by the @ParamConverter annotation.');

View File

@@ -21,7 +21,6 @@ use App\Event\TimesheetRestartPreEvent;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetService; use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingModeService; use App\Timesheet\TrackingModeService;
use App\Validator\ValidationException;
use App\Validator\ValidationFailedException; use App\Validator\ValidationFailedException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -246,15 +245,15 @@ class TimesheetServiceTest extends TestCase
public function testStoppedEntriesCannotBeStoppedAgain() public function testStoppedEntriesCannotBeStoppedAgain()
{ {
$dateTime = new \DateTime('-2 hours');
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet->setEnd(new \DateTime()); $timesheet->setEnd($dateTime);
$sut = $this->getSut(); $sut = $this->getSut();
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Timesheet entry already stopped');
$sut->stopTimesheet($timesheet); $sut->stopTimesheet($timesheet);
self::assertSame($dateTime->getTimestamp(), $timesheet->getEnd()->getTimestamp());
} }
public function testDeleteDispatchesEvent() public function testDeleteDispatchesEvent()

View File

@@ -455,7 +455,7 @@ class LocaleFormatExtensionsTest extends TestCase
$sut = $this->getSut($this->localeEn, 'en'); $sut = $this->getSut($this->localeEn, 'en');
$this->assertEquals('00:00 h', $sut->duration(null)); $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() public function testDurationChart()
@@ -483,15 +483,15 @@ class LocaleFormatExtensionsTest extends TestCase
// test negative duration // test negative duration
$sut = $this->getSut($this->localeEn, 'en'); $sut = $this->getSut($this->localeEn, 'en');
$this->assertEquals('0', $sut->durationDecimal(-1)); $this->assertEquals('0.00', $sut->durationDecimal(-1));
// test zero duration // test zero duration
$sut = $this->getSut($this->localeEn, 'en'); $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'); $sut = $this->getSut($this->localeEn, 'en');
$this->assertEquals('0', $sut->durationDecimal(null)); $this->assertEquals('0.00', $sut->durationDecimal(null));
} }
protected function getTimesheet($seconds) protected function getTimesheet($seconds)

View File

@@ -199,18 +199,18 @@ class LocaleHelperTest extends TestCase
$sut = $this->getSut('de'); $sut = $this->getSut('de');
$this->assertEquals('2,62', $sut->durationDecimal($record->getDuration())); $this->assertEquals('2,62', $sut->durationDecimal($record->getDuration()));
$this->assertEquals('6.328,89', $sut->durationDecimal(22784012)); $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,01', $sut->durationDecimal(3630));
$this->assertEquals('1,02', $sut->durationDecimal(3661)); $this->assertEquals('1,02', $sut->durationDecimal(3661));
$this->assertEquals('1,1', $sut->durationDecimal(3960)); $this->assertEquals('1,10', $sut->durationDecimal(3960));
// test negative duration // test negative duration
$sut = $this->getSut('en'); $sut = $this->getSut('en');
$this->assertEquals('0', $sut->durationDecimal(-1)); $this->assertEquals('0.00', $sut->durationDecimal(-1));
// test zero duration // test zero duration
$sut = $this->getSut('en'); $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)); $this->assertEquals('6,328.89', $sut->durationDecimal(22784012));
} }

View File

@@ -460,7 +460,7 @@
</trans-unit> </trans-unit>
<trans-unit id="label.my_team_projects"> <trans-unit id="label.my_team_projects">
<source>label.my_team_projects</source> <source>label.my_team_projects</source>
<target>Team Projekte &amp; Budgets</target> <target>Meine Projekte</target>
</trans-unit> </trans-unit>
<trans-unit id="label.progress"> <trans-unit id="label.progress">
<source>label.progress</source> <source>label.progress</source>
@@ -1126,6 +1126,10 @@
<source>export.warn_result_amount</source> <source>export.warn_result_amount</source>
<target>Ihre Suche führt zu %count% Ergebnissen. Sollte der Export fehlschlagen, müssen Sie die Suche weiter eingrenzen.</target> <target>Ihre Suche führt zu %count% Ergebnissen. Sollte der Export fehlschlagen, müssen Sie die Suche weiter eingrenzen.</target>
</trans-unit> </trans-unit>
<trans-unit id="label.type">
<source>label.type</source>
<target>Typ</target>
</trans-unit>
<!-- <!--
Navbar - recent entries and activities Navbar - recent entries and activities
--> -->

View File

@@ -683,7 +683,7 @@
</trans-unit> </trans-unit>
<trans-unit id="label.my_team_projects"> <trans-unit id="label.my_team_projects">
<source>label.my_team_projects</source> <source>label.my_team_projects</source>
<target>Team Projekte &amp; Budgets</target> <target>Meine Projekte</target>
</trans-unit> </trans-unit>
<trans-unit id="label.my_teams"> <trans-unit id="label.my_teams">
<source>label.my_teams</source> <source>label.my_teams</source>

View File

@@ -460,7 +460,7 @@
</trans-unit> </trans-unit>
<trans-unit id="label.my_team_projects"> <trans-unit id="label.my_team_projects">
<source>label.my_team_projects</source> <source>label.my_team_projects</source>
<target>Team projects &amp; budgets</target> <target>My projects</target>
</trans-unit> </trans-unit>
<trans-unit id="label.progress"> <trans-unit id="label.progress">
<source>label.progress</source> <source>label.progress</source>
@@ -1126,6 +1126,10 @@
<source>export.warn_result_amount</source> <source>export.warn_result_amount</source>
<target>Your search leads to %count% results. If the export fails, you have to narrow down your search further.</target> <target>Your search leads to %count% results. If the export fails, you have to narrow down your search further.</target>
</trans-unit> </trans-unit>
<trans-unit id="label.type">
<source>label.type</source>
<target>Type</target>
</trans-unit>
<!-- <!--
Navbar - recent entries and activities Navbar - recent entries and activities
--> -->