diff --git a/UPGRADING.md b/UPGRADING.md index cf83427f..c007f473 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,6 +8,10 @@ you can upgrade your Kimai installation to the latest stable release. Check below if there are more version specific steps required, which need to be executed after the normal update process. Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation. +## [1.13](https://github.com/kevinpapst/kimai2/releases/tag/1.13) + +- Deprecated `now` variable in export templates: create it yourself with `{% set now = create_date('now', app.user) %}` + ## [1.12](https://github.com/kevinpapst/kimai2/releases/tag/1.12) - Export templates can now include items from plugins (eg. Expenses). diff --git a/src/Export/Base/PDFRenderer.php b/src/Export/Base/PDFRenderer.php index 9de53be7..21661c4a 100644 --- a/src/Export/Base/PDFRenderer.php +++ b/src/Export/Base/PDFRenderer.php @@ -9,10 +9,10 @@ namespace App\Export\Base; +use App\Export\ExportContext; use App\Export\ExportItemInterface; use App\Repository\ProjectRepository; use App\Repository\Query\TimesheetQuery; -use App\Timesheet\UserDateTimeFactory; use App\Utils\HtmlToPdfConverter; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; @@ -26,10 +26,6 @@ class PDFRenderer * @var Environment */ private $twig; - /** - * @var UserDateTimeFactory - */ - private $dateTime; /** * @var HtmlToPdfConverter */ @@ -46,11 +42,14 @@ class PDFRenderer * @var string */ private $template = 'default.pdf.twig'; + /** + * @var array + */ + private $pdfOptions = []; - public function __construct(Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter, ProjectRepository $projectRepository) + public function __construct(Environment $twig, HtmlToPdfConverter $converter, ProjectRepository $projectRepository) { $this->twig = $twig; - $this->dateTime = $dateTime; $this->converter = $converter; $this->projectRepository = $projectRepository; } @@ -72,6 +71,18 @@ class PDFRenderer return ['decimal' => $decimal]; } + public function getPdfOptions(): array + { + return $this->pdfOptions; + } + + public function setPdfOption(string $key, string $value): PDFRenderer + { + $this->pdfOptions[$key] = $value; + + return $this; + } + /** * @param ExportItemInterface[] $timesheets * @param TimesheetQuery $query @@ -82,21 +93,33 @@ class PDFRenderer */ public function render(array $timesheets, TimesheetQuery $query): Response { + $context = new ExportContext(); + $context->setOption('filename', 'kimai-export.pdf'); + $summary = $this->calculateSummary($timesheets); $content = $this->twig->render($this->getTemplate(), array_merge([ 'entries' => $timesheets, 'query' => $query, - 'now' => $this->dateTime->createDateTime(), + // @deprecated since 1.13 + 'now' => new \DateTime('now', new \DateTimeZone(date_default_timezone_get())), 'summaries' => $summary, 'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectRepository), 'decimal' => false, + 'pdfContext' => $context ], $this->getOptions($query))); - $content = $this->converter->convertToPdf($content); + $pdfOptions = array_merge($context->getOptions(), $this->getPdfOptions()); + + $content = $this->converter->convertToPdf($content, $pdfOptions); $response = new Response($content); - $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'kimai-export.pdf'); + $filename = $context->getOption('filename'); + if (empty($filename)) { + $filename = 'kimai-export.pdf'; + } + + $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); $response->headers->set('Content-Type', 'application/pdf'); $response->headers->set('Content-Disposition', $disposition); diff --git a/src/Export/ExportContext.php b/src/Export/ExportContext.php new file mode 100644 index 00000000..b569db91 --- /dev/null +++ b/src/Export/ExportContext.php @@ -0,0 +1,37 @@ +options[$key] = $value; + } + + public function getOptions(): array + { + return $this->options; + } + + public function getOption(string $key): ?string + { + if (\array_key_exists($key, $this->options)) { + return $this->options[$key]; + } + + return null; + } +} diff --git a/src/Export/Renderer/PdfRendererFactory.php b/src/Export/Renderer/PdfRendererFactory.php index 1d47e563..1722bb32 100644 --- a/src/Export/Renderer/PdfRendererFactory.php +++ b/src/Export/Renderer/PdfRendererFactory.php @@ -10,7 +10,6 @@ namespace App\Export\Renderer; use App\Repository\ProjectRepository; -use App\Timesheet\UserDateTimeFactory; use App\Utils\HtmlToPdfConverter; use Twig\Environment; @@ -20,10 +19,6 @@ final class PdfRendererFactory * @var Environment */ private $twig; - /** - * @var UserDateTimeFactory - */ - private $dateTime; /** * @var HtmlToPdfConverter */ @@ -33,17 +28,16 @@ final class PdfRendererFactory */ private $projectRepository; - public function __construct(Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter, ProjectRepository $projectRepository) + public function __construct(Environment $twig, HtmlToPdfConverter $converter, ProjectRepository $projectRepository) { $this->twig = $twig; - $this->dateTime = $dateTime; $this->converter = $converter; $this->projectRepository = $projectRepository; } public function create(string $id, string $template): PDFRenderer { - $renderer = new PDFRenderer($this->twig, $this->dateTime, $this->converter, $this->projectRepository); + $renderer = new PDFRenderer($this->twig, $this->converter, $this->projectRepository); $renderer->setId($id); $renderer->setTemplate($template); diff --git a/src/Twig/DateExtensions.php b/src/Twig/DateExtensions.php index a9434d06..d99640a9 100644 --- a/src/Twig/DateExtensions.php +++ b/src/Twig/DateExtensions.php @@ -11,6 +11,7 @@ namespace App\Twig; use App\Configuration\LanguageFormattings; use App\Constants; +use App\Entity\User; use App\Utils\LocaleFormats; use App\Utils\LocaleFormatter; use DateTime; @@ -89,6 +90,7 @@ class DateExtensions extends AbstractExtension { return [ new TwigFunction('get_format_duration', [$this, 'getDurationFormat']), + new TwigFunction('create_date', [$this, 'createDate']), ]; } @@ -130,6 +132,13 @@ class DateExtensions extends AbstractExtension return $this->formatter->dateTimeFull($date); } + public function createDate(string $date, ?User $user = null): \DateTime + { + $timezone = $user !== null ? $user->getTimezone() : date_default_timezone_get(); + + return new DateTime($date, new \DateTimeZone($timezone)); + } + /** * @param DateTime|string $date * @param string $format diff --git a/src/Utils/MPdfConverter.php b/src/Utils/MPdfConverter.php index ae06a3b6..2bcfe6bf 100644 --- a/src/Utils/MPdfConverter.php +++ b/src/Utils/MPdfConverter.php @@ -10,6 +10,8 @@ namespace App\Utils; use App\Constants; +use Mpdf\Config\ConfigVariables; +use Mpdf\Config\FontVariables; use Mpdf\Mpdf; use Mpdf\Output\Destination; @@ -25,6 +27,32 @@ class MPdfConverter implements HtmlToPdfConverter $this->cacheDirectory = $cacheDirectory; } + protected function sanitizeOptions(array $options): array + { + $configs = new ConfigVariables(); + $fonts = new FontVariables(); + $allowed = [ + 'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top', + 'margin_bottom', 'margin_header', 'margin_footer', 'orientation' + ]; + + $filtered = array_filter($options, function ($key) use ($allowed, $configs, $fonts) { + if (!\in_array($key, $allowed)) { + if (!\array_key_exists($key, $configs->getDefaults())) { + return \array_key_exists($key, $fonts->getDefaults()); + } + } + + return true; + }, ARRAY_FILTER_USE_KEY); + + if (\array_key_exists('tempDir', $filtered)) { + unset($filtered['tempDir']); + } + + return $filtered; + } + /** * @param string $html * @param array $options @@ -33,7 +61,11 @@ class MPdfConverter implements HtmlToPdfConverter */ public function convertToPdf(string $html, array $options = []) { - $options = array_merge($options, ['tempDir' => $this->cacheDirectory]); + $options = array_merge( + $this->sanitizeOptions($options), + ['tempDir' => $this->cacheDirectory, 'exposeVersion' => false] + ); + $mpdf = new Mpdf($options); $mpdf->creator = Constants::SOFTWARE; diff --git a/templates/export/index.html.twig b/templates/export/index.html.twig index 190fc07b..c26d28cb 100644 --- a/templates/export/index.html.twig +++ b/templates/export/index.html.twig @@ -116,7 +116,11 @@
{{ widgets.label_customer(entry.project.customer) }} - {{ widgets.label_activity(entry.activity) }} + + {% if entry.activity is not null %} + {{ widgets.label_activity(entry.activity) }} + {% endif %} + {{ entry.description|desc2html }} diff --git a/templates/export/pdf-layout.html.twig b/templates/export/pdf-layout.html.twig index b7cc1125..f0eb4d55 100644 --- a/templates/export/pdf-layout.html.twig +++ b/templates/export/pdf-layout.html.twig @@ -3,6 +3,8 @@ {% set showRateColumn = showRateColumn ?? is_granted('view_rate_other_timesheet') %} {% set showRateBudget = showRateBudget ?? false %} {% set showTimeBudget = showTimeBudget ?? false %} +{% set showTotalSummary = showTotalSummary ?? true %} +{% set now = create_date('now', app.user) %} {% set decimal = decimal ?? false %} {# this is only triggered, if a user exports from his personal timesheet screen #} {% if query.user is not null %} @@ -12,7 +14,7 @@ {% endif %} - {% block styles %} +{% block styles %} - {% endblock %} +{% endblock %} {% block pdf_footer %} @@ -132,29 +134,39 @@ mpdf--> {% set customerInternalRate = 0 %} {% set customerCurrency = null %} {% set customerCount = 0 %} + {% set multiCurrency = false %} + {% set totalDuration = 0 %} + {% set totalInternalRate = 0 %} + {% set totalRate = 0 %} {% for id, summary in summaries %} + {% set totalDuration = totalDuration + summary.duration %} + {% set totalInternalRate = totalInternalRate + summary.rate_internal %} + {% set totalRate = totalRate + summary.rate %} + {% if customerCurrency is not null and customerCurrency is not same as(summary.currency) %} + {% set multiCurrency = true %} + {% endif %} {% if customer is same as(null) %} {% set customer = summary.customer %} {% set customerCurrency = summary.currency %} {% endif %} {% if customer is not same as(summary.customer) %} - - - - {% if showTimeBudget %} - + + + + {% if showTimeBudget %} + + {% endif %} + {% if showRateBudget %} + + {% endif %} + {{ customerDuration|duration(decimal) }} + {% if showRateColumn %} + {% if showInternalRate %} + {{ customerInternalRate|money(customerCurrency) }} {% endif %} - {% if showRateBudget %} - - {% endif %} - {{ customerDuration|duration(decimal) }} - {% if showRateColumn %} - {% if showInternalRate %} - {{ customerInternalRate|money(customerCurrency) }} - {% endif %} - {{ customerRate|money(customerCurrency) }} - {% endif %} - + {{ customerRate|money(customerCurrency) }} + {% endif %} + {% set customerCurrency = summary.currency %} {% set customer = summary.customer %} {% set customerDuration = 0 %} @@ -210,6 +222,26 @@ mpdf--> {% endif %} {% endif %} + {% if showTotalSummary and not multiCurrency %} + + + {{ 'sum.total'|trans }} + + {% if showTimeBudget %} + + {% endif %} + {% if showRateBudget %} + + {% endif %} + {{ totalDuration|duration(decimal) }} + {% if showRateColumn %} + {% if showInternalRate %} + {{ totalInternalRate|money(customerCurrency) }} + {% endif %} + {{ totalRate|money(customerCurrency) }} + {% endif %} + + {% endif %} diff --git a/tests/Controller/ExportControllerTest.php b/tests/Controller/ExportControllerTest.php index 0f3221b0..7c0d20b1 100644 --- a/tests/Controller/ExportControllerTest.php +++ b/tests/Controller/ExportControllerTest.php @@ -92,14 +92,22 @@ class ExportControllerTest extends ControllerBaseTest $this->assertDataTableRowCount($client, 'datatable_export', 23); // assert export type buttons are available - $expected = ['csv', 'default.html.twig', 'default-budget.pdf.twig', 'default-internal.pdf.twig', 'default.pdf.twig', 'xlsx']; + $expected = [ + 'csv' => 'csv', + 'default.html.twig' => 'default.html.twig', + 'default-budget.pdf.twig' => 'default-budget.pdf.twig', + 'default-internal.pdf.twig' => 'default-internal.pdf.twig', + 'default.pdf.twig' => 'default.pdf.twig', + 'xlsx' => 'xlsx' + ]; $node = $client->getCrawler()->filter('#export-buttons .startExportBtn'); - $this->assertEquals(\count($expected), $node->count()); + $this->assertGreaterThanOrEqual(\count($expected), $node->count()); /** @var \DOMElement $button */ foreach ($node->getIterator() as $button) { $type = $button->getAttribute('data-type'); - $this->assertContains($type, $expected); + unset($expected[$type]); } + $this->assertEmpty($expected); } public function testIndexActionWithEntriesForTeamleadDoesNotShowUserWithoutTeam() @@ -144,14 +152,22 @@ class ExportControllerTest extends ControllerBaseTest $this->assertDataTableRowCount($client, 'datatable_export', 3); // assert export type buttons are available - $expected = ['csv', 'default.html.twig', 'default-budget.pdf.twig', 'default-internal.pdf.twig', 'default.pdf.twig', 'xlsx']; + $expected = [ + 'csv' => 'csv', + 'default.html.twig' => 'default.html.twig', + 'default-budget.pdf.twig' => 'default-budget.pdf.twig', + 'default-internal.pdf.twig' => 'default-internal.pdf.twig', + 'default.pdf.twig' => 'default.pdf.twig', + 'xlsx' => 'xlsx' + ]; $node = $client->getCrawler()->filter('#export-buttons .startExportBtn'); - $this->assertEquals(\count($expected), $node->count()); + $this->assertGreaterThanOrEqual(\count($expected), $node->count()); /** @var \DOMElement $button */ foreach ($node->getIterator() as $button) { $type = $button->getAttribute('data-type'); - $this->assertContains($type, $expected); + unset($expected[$type]); } + $this->assertEmpty($expected); } public function testExportActionWithMissingRenderer() diff --git a/tests/Export/ExportContextTest.php b/tests/Export/ExportContextTest.php new file mode 100644 index 00000000..a1175690 --- /dev/null +++ b/tests/Export/ExportContextTest.php @@ -0,0 +1,37 @@ +getOptions()); + self::assertEmpty($sut->getOptions()); + self::assertNull($sut->getOption('unknown')); + } + + public function testSetterAndGetter() + { + $sut = new ExportContext(); + + self::assertNull($sut->getOption('unknown')); + $sut->setOption('unknown', 'foo'); + self::assertEquals('foo', $sut->getOption('unknown')); + } +} diff --git a/tests/Export/Renderer/PdfRendererFactoryTest.php b/tests/Export/Renderer/PdfRendererFactoryTest.php index 828912d3..68cbafb0 100644 --- a/tests/Export/Renderer/PdfRendererFactoryTest.php +++ b/tests/Export/Renderer/PdfRendererFactoryTest.php @@ -12,7 +12,6 @@ namespace App\Tests\Export\Renderer; use App\Export\Renderer\PDFRenderer; use App\Export\Renderer\PdfRendererFactory; use App\Repository\ProjectRepository; -use App\Timesheet\UserDateTimeFactory; use App\Utils\HtmlToPdfConverter; use PHPUnit\Framework\TestCase; use Twig\Environment; @@ -26,7 +25,6 @@ class PdfRendererFactoryTest extends TestCase { $sut = new PdfRendererFactory( $this->createMock(Environment::class), - $this->createMock(UserDateTimeFactory::class), $this->createMock(HtmlToPdfConverter::class), $this->createMock(ProjectRepository::class) ); diff --git a/tests/Export/Renderer/PdfRendererTest.php b/tests/Export/Renderer/PdfRendererTest.php index 663f0295..a78bf8f2 100644 --- a/tests/Export/Renderer/PdfRendererTest.php +++ b/tests/Export/Renderer/PdfRendererTest.php @@ -11,7 +11,6 @@ namespace App\Tests\Export\Renderer; use App\Export\Renderer\PDFRenderer; use App\Repository\ProjectRepository; -use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; use App\Utils\HtmlToPdfConverter; use App\Utils\MPdfConverter; use Symfony\Component\HttpFoundation\Request; @@ -25,16 +24,10 @@ use Twig\Environment; */ class PdfRendererTest extends AbstractRendererTest { - protected function getDateTimeFactory() - { - return (new UserDateTimeFactoryFactory($this))->create(); - } - public function testConfiguration() { $sut = new PDFRenderer( $this->createMock(Environment::class), - $this->getDateTimeFactory(), $this->createMock(HtmlToPdfConverter::class), $this->createMock(ProjectRepository::class) ); @@ -42,6 +35,11 @@ class PdfRendererTest extends AbstractRendererTest $this->assertEquals('pdf', $sut->getId()); $this->assertEquals('pdf', $sut->getTitle()); $this->assertEquals('pdf', $sut->getIcon()); + $this->assertEquals([], $sut->getPdfOptions()); + + $sut->setPdfOption('foo', 'bar'); + $sut->setPdfOption('bar1', 'foo1'); + $this->assertEquals(['foo' => 'bar', 'bar1' => 'foo1'], $sut->getPdfOptions()); } public function testRender() @@ -56,7 +54,7 @@ class PdfRendererTest extends AbstractRendererTest $request->setLocale('en'); $stack->push($request); - $sut = new PDFRenderer($twig, $this->getDateTimeFactory(), $converter, $this->createMock(ProjectRepository::class)); + $sut = new PDFRenderer($twig, $converter, $this->createMock(ProjectRepository::class)); $response = $this->render($sut); diff --git a/tests/Export/Timesheet/PdfRendererTest.php b/tests/Export/Timesheet/PdfRendererTest.php index 42ef0d33..ff319dde 100644 --- a/tests/Export/Timesheet/PdfRendererTest.php +++ b/tests/Export/Timesheet/PdfRendererTest.php @@ -11,7 +11,6 @@ namespace App\Tests\Export\Timesheet; use App\Export\Timesheet\PDFRenderer; use App\Repository\ProjectRepository; -use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; use App\Utils\HtmlToPdfConverter; use App\Utils\MPdfConverter; use Symfony\Component\HttpFoundation\Request; @@ -25,16 +24,10 @@ use Twig\Environment; */ class PdfRendererTest extends AbstractRendererTest { - protected function getDateTimeFactory() - { - return (new UserDateTimeFactoryFactory($this))->create(); - } - public function testConfiguration() { $sut = new PDFRenderer( $this->createMock(Environment::class), - $this->getDateTimeFactory(), $this->createMock(HtmlToPdfConverter::class), $this->createMock(ProjectRepository::class) ); @@ -54,7 +47,7 @@ class PdfRendererTest extends AbstractRendererTest $request->setLocale('en'); $stack->push($request); - $sut = new PDFRenderer($twig, $this->getDateTimeFactory(), $converter, $this->createMock(ProjectRepository::class)); + $sut = new PDFRenderer($twig, $converter, $this->createMock(ProjectRepository::class)); $response = $this->render($sut); diff --git a/tests/Twig/DateExtensionsTest.php b/tests/Twig/DateExtensionsTest.php index 36ea9635..43fcbb23 100644 --- a/tests/Twig/DateExtensionsTest.php +++ b/tests/Twig/DateExtensionsTest.php @@ -10,6 +10,7 @@ namespace App\Tests\Twig; use App\Configuration\LanguageFormattings; +use App\Entity\User; use App\Twig\DateExtensions; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -54,7 +55,7 @@ class DateExtensionsTest extends TestCase public function testGetFunctions() { - $functions = ['get_format_duration']; + $functions = ['get_format_duration', 'create_date']; $sut = $this->getSut('de', []); $twigFunctions = $sut->getFunctions(); $this->assertCount(\count($functions), $twigFunctions); @@ -217,4 +218,21 @@ class DateExtensionsTest extends TestCase /* @phpstan-ignore-next-line */ $this->assertEquals(189.45, $sut->dateTimeFull(189.45)); } + + public function testCreateDate() + { + $user = new User(); + $user->setTimezone('Europe/Berlin'); + $sut = $this->getSut('en', []); + $date = $sut->createDate('now', $user); + $this->assertEquals('Europe/Berlin', $date->getTimezone()->getName()); + + $user->setTimezone('Asia/Dubai'); + $date = $sut->createDate('2019-08-27 16:30:45', $user); + $this->assertEquals('2019-08-27T16:30:45+0400', $date->format(DATE_ISO8601)); + $this->assertEquals('Asia/Dubai', $date->getTimezone()->getName()); + + $date = $sut->createDate('2019-08-27 16:30:45', null); + $this->assertEquals(date_default_timezone_get(), $date->getTimezone()->getName()); + } } diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 0089b8ca..44edf320 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -80,6 +80,10 @@ rates.title Gebühren + + sum.total + Gesamt + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 2174890f..de54ebc7 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -80,7 +80,10 @@ rates.title Fees - + + sum.total + Total +