added export context (#2216)

This commit is contained in:
Kevin Papst
2020-12-22 18:41:00 +01:00
committed by GitHub
parent 74b3917e69
commit 40eda01c22
16 changed files with 266 additions and 64 deletions

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export;
/**
* A simple class that is available in twig renderer context, which can be used to define global renderer options.
*/
final class ExportContext
{
private $options = [];
public function setOption(string $key, string $value): void
{
$this->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;
}
}

View File

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

View File

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

View File

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

View File

@@ -116,7 +116,11 @@
<br>
<small>{{ widgets.label_customer(entry.project.customer) }}</small>
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">
{% if entry.activity is not null %}
{{ widgets.label_activity(entry.activity) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}">
{{ entry.description|desc2html }}
</td>

View File

@@ -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 %}
@@ -132,7 +134,17 @@ 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 %}
@@ -210,6 +222,26 @@ mpdf-->
{% endif %}
</tr>
{% endif %}
{% if showTotalSummary and not multiCurrency %}
<tr class="summary-total">
<td class="totals" colspan="2">
{{ 'sum.total'|trans }}
</td>
{% if showTimeBudget %}
<td></td>
{% endif %}
{% if showRateBudget %}
<td></td>
{% endif %}
<td class="totals duration">{{ totalDuration|duration(decimal) }}</td>
{% if showRateColumn %}
{% if showInternalRate %}
<td class="totals cost">{{ totalInternalRate|money(customerCurrency) }}</td>
{% endif %}
<td class="totals cost">{{ totalRate|money(customerCurrency) }}</td>
{% endif %}
</tr>
{% endif %}
</tbody>
</table>

View File

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

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Export;
use App\Export\ExportContext;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\ExportContext
*/
class ExportContextTest extends TestCase
{
public function testEmptyObject()
{
$sut = new ExportContext();
self::assertIsArray($sut->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'));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,6 +80,10 @@
<source>rates.title</source>
<target>Gebühren</target>
</trans-unit>
<trans-unit id="sum.total">
<source>sum.total</source>
<target>Gesamt</target>
</trans-unit>
<!--
Login / Security
-->

View File

@@ -80,7 +80,10 @@
<source>rates.title</source>
<target>Fees</target>
</trans-unit>
<trans-unit id="sum.total">
<source>sum.total</source>
<target>Total</target>
</trans-unit>
<!--
Login / Security
-->