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

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

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')")
*/
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');
}

View File

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

View File

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

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('UPDATE kimai2_timesheet SET timezone = "' . $timezone . '"');
$this->addSql("UPDATE kimai2_timesheet SET timezone = '" . $timezone . "'");
}
public function down(Schema $schema): void

View File

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

View File

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

View File

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

View File

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

View File

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