diff --git a/UPGRADING.md b/UPGRADING.md index cf83427f0..c007f4732 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 9de53be70..21661c4ac 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 000000000..b569db91f --- /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 1d47e563a..1722bb32b 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 a9434d066..d99640a92 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 ae06a3b69..2bcfe6bf7 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 190fc07b3..c26d28cb3 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 b7cc11258..f0eb4d556 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 0f3221b02..7c0d20b19 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 000000000..a1175690d --- /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 828912d3e..68cbafb00 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 663f0295c..a78bf8f22 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 42ef0d33c..ff319dded 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 36ea9635c..43fcbb239 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 0089b8ca2..44edf320c 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 2174890fc..de54ebc77 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -80,7 +80,10 @@ rates.title Fees - + + sum.total + Total +