From 33f76f426c9e48a39089775afc4459206b2ce7f0 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Mon, 23 Mar 2020 01:51:02 +0100 Subject: [PATCH] customize html and pdf exports with twig templates (#1582) --- .gitignore | 1 + src/DependencyInjection/AppExtension.php | 4 + .../Compiler/ExportServiceCompilerPass.php | 39 +++ .../Compiler/TwigContextCompilerPass.php | 21 +- src/DependencyInjection/Configuration.php | 28 ++ src/Export/Base/HtmlRenderer.php | 44 ++- src/Export/Base/PDFRenderer.php | 38 ++- src/Export/Base/RendererTrait.php | 50 ++++ src/Export/ExportRendererInterface.php | 38 +++ src/Export/Renderer/HtmlRenderer.php | 4 +- src/Export/Renderer/HtmlRendererFactory.php | 46 +++ src/Export/Renderer/PDFRenderer.php | 4 +- src/Export/Renderer/PdfRendererFactory.php | 52 ++++ src/Export/RendererInterface.php | 26 +- src/Export/ServiceExport.php | 8 +- src/Repository/ProjectRepository.php | 17 +- templates/export/index.html.twig | 32 +- .../export/renderer/default-budget.pdf.twig | 12 + templates/export/renderer/default.html.twig | 74 ++++- templates/export/renderer/default.pdf.twig | 274 ++++++++++++++++++ templates/export/renderer/pdf.html.twig | 235 --------------- tests/Controller/ExportControllerTest.php | 10 +- .../DependencyInjection/ConfigurationTest.php | 8 + .../Export/Renderer/AbstractRendererTest.php | 5 +- .../Renderer/HtmlRendererFactoryTest.php | 38 +++ tests/Export/Renderer/HtmlRendererTest.php | 6 +- .../Renderer/PdfRendererFactoryTest.php | 41 +++ tests/Export/Renderer/PdfRendererTest.php | 6 +- tests/Export/ServiceExportTest.php | 3 +- tests/Export/Timesheet/PdfRendererTest.php | 8 +- translations/export.de.xlf | 15 + translations/export.en.xlf | 15 + 32 files changed, 895 insertions(+), 307 deletions(-) create mode 100644 src/Export/ExportRendererInterface.php create mode 100644 src/Export/Renderer/HtmlRendererFactory.php create mode 100644 src/Export/Renderer/PdfRendererFactory.php create mode 100644 templates/export/renderer/default-budget.pdf.twig create mode 100644 templates/export/renderer/default.pdf.twig delete mode 100644 templates/export/renderer/pdf.html.twig create mode 100644 tests/Export/Renderer/HtmlRendererFactoryTest.php create mode 100644 tests/Export/Renderer/PdfRendererFactoryTest.php create mode 100644 translations/export.de.xlf create mode 100644 translations/export.en.xlf diff --git a/.gitignore b/.gitignore index 6409d14f..34ac16ab 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ templates/invoice/renderer/.~lock* !var/cache/.gitkeep /var/invoices/* +/var/export/* /var/packages/* !var/packages/.gitkeep diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php index b7d3ed67..19002f45 100644 --- a/src/DependencyInjection/AppExtension.php +++ b/src/DependencyInjection/AppExtension.php @@ -49,6 +49,9 @@ class AppExtension extends Extension $config['invoice']['documents'] = array_merge($config['invoice']['documents'], $config['invoice']['defaults']); unset($config['invoice']['defaults']); + $config['export']['documents'] = array_merge($config['export']['documents'], $config['export']['defaults']); + unset($config['export']['defaults']); + // safe alternatives to %kernel.project_dir% $container->setParameter('kimai.data_dir', $config['data_dir']); $container->setParameter('kimai.plugin_dir', $config['plugin_dir']); @@ -60,6 +63,7 @@ class AppExtension extends Extension $container->setParameter('kimai.dashboard', $config['dashboard']); $container->setParameter('kimai.widgets', $config['widgets']); $container->setParameter('kimai.invoice.documents', $config['invoice']['documents']); + $container->setParameter('kimai.export.documents', $config['export']['documents']); $container->setParameter('kimai.defaults', $config['defaults']); $this->createPermissionParameter($config['permissions'], $container); diff --git a/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php b/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php index d859fbe6..44a4f271 100644 --- a/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php +++ b/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php @@ -9,10 +9,14 @@ namespace App\DependencyInjection\Compiler; +use App\Export\Renderer\HtmlRenderer; +use App\Export\Renderer\HtmlRendererFactory; +use App\Export\Renderer\PdfRendererFactory; use App\Export\ServiceExport; use App\Kernel; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; /** @@ -37,5 +41,40 @@ class ExportServiceCompilerPass implements CompilerPassInterface foreach ($taggedExporter as $id => $tags) { $definition->addMethodCall('addTimesheetExporter', [new Reference($id)]); } + + $path = dirname(dirname(dirname(__DIR__))) . DIRECTORY_SEPARATOR; + foreach ($container->getParameter('kimai.export.documents') as $exportPath) { + if (!is_dir($path . $exportPath)) { + continue; + } + + foreach (glob($path . $exportPath . '/*.html.twig') as $htmlTpl) { + $tplName = basename($htmlTpl); + + $serviceId = 'exporter_renderer.' . str_replace('.', '_', $tplName); + + $factoryDefinition = new Definition(HtmlRenderer::class); + $factoryDefinition->addArgument($tplName); + $factoryDefinition->addArgument($tplName); + $factoryDefinition->setFactory([new Reference(HtmlRendererFactory::class), 'create']); + + $container->setDefinition($serviceId, $factoryDefinition); + $definition->addMethodCall('addRenderer', [new Reference($serviceId)]); + } + + foreach (glob($path . $exportPath . '/*.pdf.twig') as $pdfHtml) { + $tplName = basename($pdfHtml); + + $serviceId = 'exporter_renderer.' . str_replace('.', '_', $tplName); + + $factoryDefinition = new Definition(HtmlRenderer::class); + $factoryDefinition->addArgument($tplName); + $factoryDefinition->addArgument($tplName); + $factoryDefinition->setFactory([new Reference(PdfRendererFactory::class), 'create']); + + $container->setDefinition($serviceId, $factoryDefinition); + $definition->addMethodCall('addRenderer', [new Reference($serviceId)]); + } + } } } diff --git a/src/DependencyInjection/Compiler/TwigContextCompilerPass.php b/src/DependencyInjection/Compiler/TwigContextCompilerPass.php index 72f7f4b5..98622ac2 100644 --- a/src/DependencyInjection/Compiler/TwigContextCompilerPass.php +++ b/src/DependencyInjection/Compiler/TwigContextCompilerPass.php @@ -32,16 +32,21 @@ class TwigContextCompilerPass implements CompilerPassInterface $saml = $container->getParameter('kimai.saml'); $twig->addMethodCall('addGlobal', ['saml', $saml]); - if ($container->hasDefinition('twig.loader.native_filesystem')) { - $definition = $container->getDefinition('twig.loader.native_filesystem'); + $definition = $container->getDefinition('twig.loader.native_filesystem'); - $path = dirname(dirname(dirname(__DIR__))) . DIRECTORY_SEPARATOR; - foreach ($container->getParameter('kimai.invoice.documents') as $invoicePath) { - if (!is_dir($path . $invoicePath)) { - continue; - } - $definition->addMethodCall('addPath', [$path . $invoicePath, 'invoice']); + $path = dirname(dirname(dirname(__DIR__))) . DIRECTORY_SEPARATOR; + foreach ($container->getParameter('kimai.invoice.documents') as $invoicePath) { + if (!is_dir($path . $invoicePath)) { + continue; } + $definition->addMethodCall('addPath', [$path . $invoicePath, 'invoice']); + } + + foreach ($container->getParameter('kimai.export.documents') as $exportPath) { + if (!is_dir($path . $exportPath)) { + continue; + } + $definition->addMethodCall('addPath', [$path . $exportPath, 'export']); } } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 09e35bf6..71c74cce 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -57,6 +57,7 @@ class Configuration implements ConfigurationInterface ->append($this->getUserNode()) ->append($this->getTimesheetNode()) ->append($this->getInvoiceNode()) + ->append($this->getExportNode()) ->append($this->getLanguagesNode()) ->append($this->getCalendarNode()) ->append($this->getThemeNode()) @@ -236,6 +237,33 @@ class Configuration implements ConfigurationInterface return $node; } + protected function getExportNode() + { + $builder = new TreeBuilder('export'); + /** @var ArrayNodeDefinition $node */ + $node = $builder->getRootNode(); + + $node + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('defaults') + ->scalarPrototype()->end() + ->defaultValue([ + 'var/export/', + 'templates/export/renderer/' + ]) + ->end() + ->arrayNode('documents') + ->requiresAtLeastOneElement() + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() + ->end() + ; + + return $node; + } + protected function getLanguagesNode() { $builder = new TreeBuilder('languages'); diff --git a/src/Export/Base/HtmlRenderer.php b/src/Export/Base/HtmlRenderer.php index 0d0b7b0b..884e18c5 100644 --- a/src/Export/Base/HtmlRenderer.php +++ b/src/Export/Base/HtmlRenderer.php @@ -17,6 +17,7 @@ use App\Event\ProjectMetaDisplayEvent; use App\Event\TimesheetMetaDisplayEvent; use App\Event\UserPreferenceDisplayEvent; use App\Export\ExportItemInterface; +use App\Repository\ProjectRepository; use App\Repository\Query\CustomerQuery; use App\Repository\Query\TimesheetQuery; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -35,11 +36,24 @@ class HtmlRenderer * @var EventDispatcherInterface */ protected $dispatcher; + /** + * @var ProjectRepository + */ + private $projectRepository; + /** + * @var string + */ + private $id = 'html'; + /** + * @var string + */ + private $template = 'default.html.twig'; - public function __construct(Environment $twig, EventDispatcherInterface $dispatcher) + public function __construct(Environment $twig, EventDispatcherInterface $dispatcher, ProjectRepository $projectRepository) { $this->twig = $twig; $this->dispatcher = $dispatcher; + $this->projectRepository = $projectRepository; } /** @@ -87,10 +101,13 @@ class HtmlRenderer $this->dispatcher->dispatch($event); $userPreferences = $event->getPreferences(); - $content = $this->twig->render('export/renderer/default.html.twig', array_merge([ + $summary = $this->calculateSummary($timesheets); + + $content = $this->twig->render($this->getTemplate(), array_merge([ 'entries' => $timesheets, 'query' => $query, - 'summaries' => $this->calculateSummary($timesheets), + 'summaries' => $summary, + 'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectRepository), 'timesheetMetaFields' => $timesheetMetaFields, 'customerMetaFields' => $customerMetaFields, 'projectMetaFields' => $projectMetaFields, @@ -104,8 +121,27 @@ class HtmlRenderer return $response; } + protected function getTemplate(): string + { + return '@export/' . $this->template; + } + + public function setTemplate(string $filename): HtmlRenderer + { + $this->template = $filename; + + return $this; + } + + public function setId(string $id): HtmlRenderer + { + $this->id = $id; + + return $this; + } + public function getId(): string { - return 'html'; + return $this->id; } } diff --git a/src/Export/Base/PDFRenderer.php b/src/Export/Base/PDFRenderer.php index a87efc4e..9de53be7 100644 --- a/src/Export/Base/PDFRenderer.php +++ b/src/Export/Base/PDFRenderer.php @@ -10,6 +10,7 @@ namespace App\Export\Base; use App\Export\ExportItemInterface; +use App\Repository\ProjectRepository; use App\Repository\Query\TimesheetQuery; use App\Timesheet\UserDateTimeFactory; use App\Utils\HtmlToPdfConverter; @@ -33,17 +34,30 @@ class PDFRenderer * @var HtmlToPdfConverter */ private $converter; + /** + * @var ProjectRepository + */ + private $projectRepository; + /** + * @var string + */ + private $id = 'pdf'; + /** + * @var string + */ + private $template = 'default.pdf.twig'; - public function __construct(Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter) + public function __construct(Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter, ProjectRepository $projectRepository) { $this->twig = $twig; $this->dateTime = $dateTime; $this->converter = $converter; + $this->projectRepository = $projectRepository; } protected function getTemplate(): string { - return 'export/renderer/pdf.html.twig'; + return '@export/' . $this->template; } protected function getOptions(TimesheetQuery $query): array @@ -68,11 +82,13 @@ class PDFRenderer */ public function render(array $timesheets, TimesheetQuery $query): Response { + $summary = $this->calculateSummary($timesheets); $content = $this->twig->render($this->getTemplate(), array_merge([ 'entries' => $timesheets, 'query' => $query, 'now' => $this->dateTime->createDateTime(), - 'summaries' => $this->calculateSummary($timesheets), + 'summaries' => $summary, + 'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectRepository), 'decimal' => false, ], $this->getOptions($query))); @@ -88,8 +104,22 @@ class PDFRenderer return $response; } + public function setTemplate(string $filename): PDFRenderer + { + $this->template = $filename; + + return $this; + } + + public function setId(string $id): PDFRenderer + { + $this->id = $id; + + return $this; + } + public function getId(): string { - return 'pdf'; + return $this->id; } } diff --git a/src/Export/Base/RendererTrait.php b/src/Export/Base/RendererTrait.php index 91dd446b..9f0ba64f 100644 --- a/src/Export/Base/RendererTrait.php +++ b/src/Export/Base/RendererTrait.php @@ -10,6 +10,8 @@ namespace App\Export\Base; use App\Export\ExportItemInterface; +use App\Repository\ProjectRepository; +use App\Repository\Query\TimesheetQuery; trait RendererTrait { @@ -80,4 +82,52 @@ trait RendererTrait return $summary; } + + /** + * @param ExportItemInterface[] $exportItems + * @param TimesheetQuery $query + * @param ProjectRepository $projectRepository + * @return array + */ + protected function calculateProjectBudget(array $exportItems, TimesheetQuery $query, ProjectRepository $projectRepository) + { + $summary = []; + + foreach ($exportItems as $exportItem) { + $customer = null; + $customerId = 'none'; + $project = null; + $projectId = 'none'; + + if (null !== ($project = $exportItem->getProject())) { + $customer = $project->getCustomer(); + $customerId = $customer->getId(); + $projectId = $project->getId(); + } + + $id = $customerId . '_' . $projectId; + + if (!isset($summary[$id])) { + $summary[$id] = [ + 'time' => $project->getTimeBudget(), + 'money' => $project->getBudget(), + 'time_left' => null, + 'money_left' => null, + ]; + + if (null !== $project && ($project->getTimeBudget() > 0 || $project->getBudget() > 0)) { + $projectStats = $projectRepository->getProjectStatistics($project, null, $query->getEnd()); + + if ($project->getTimeBudget() > 0) { + $summary[$id]['time_left'] = $project->getTimeBudget() - $projectStats->getRecordDuration(); + } + if ($project->getBudget() > 0) { + $summary[$id]['money_left'] = $project->getBudget() - $projectStats->getRecordRate(); + } + } + } + } + + return $summary; + } } diff --git a/src/Export/ExportRendererInterface.php b/src/Export/ExportRendererInterface.php new file mode 100644 index 00000000..5c8c9d64 --- /dev/null +++ b/src/Export/ExportRendererInterface.php @@ -0,0 +1,38 @@ +twig = $twig; + $this->dispatcher = $dispatcher; + $this->projectRepository = $projectRepository; + } + + public function create(string $id, string $template): HtmlRenderer + { + $renderer = new HtmlRenderer($this->twig, $this->dispatcher, $this->projectRepository); + $renderer->setId($id); + $renderer->setTemplate($template); + + return $renderer; + } +} diff --git a/src/Export/Renderer/PDFRenderer.php b/src/Export/Renderer/PDFRenderer.php index 17082057..4dc6b5c5 100644 --- a/src/Export/Renderer/PDFRenderer.php +++ b/src/Export/Renderer/PDFRenderer.php @@ -10,9 +10,9 @@ namespace App\Export\Renderer; use App\Export\Base\PDFRenderer as BasePDFRenderer; -use App\Export\RendererInterface; +use App\Export\ExportRendererInterface; -final class PDFRenderer extends BasePDFRenderer implements RendererInterface +final class PDFRenderer extends BasePDFRenderer implements ExportRendererInterface { public function getIcon(): string { diff --git a/src/Export/Renderer/PdfRendererFactory.php b/src/Export/Renderer/PdfRendererFactory.php new file mode 100644 index 00000000..1d47e563 --- /dev/null +++ b/src/Export/Renderer/PdfRendererFactory.php @@ -0,0 +1,52 @@ +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->setId($id); + $renderer->setTemplate($template); + + return $renderer; + } +} diff --git a/src/Export/RendererInterface.php b/src/Export/RendererInterface.php index 8de39c4f..bb04dd01 100644 --- a/src/Export/RendererInterface.php +++ b/src/Export/RendererInterface.php @@ -9,30 +9,6 @@ namespace App\Export; -use App\Repository\Query\TimesheetQuery; -use Symfony\Component\HttpFoundation\Response; - -interface RendererInterface +interface RendererInterface extends ExportRendererInterface { - /** - * @param ExportItemInterface[] $exportItems - * @param TimesheetQuery $query - * @return Response - */ - public function render(array $exportItems, TimesheetQuery $query): Response; - - /** - * @return string - */ - public function getId(): string; - - /** - * @return string - */ - public function getIcon(): string; - - /** - * @return string - */ - public function getTitle(): string; } diff --git a/src/Export/ServiceExport.php b/src/Export/ServiceExport.php index d1df2307..ec32b694 100644 --- a/src/Export/ServiceExport.php +++ b/src/Export/ServiceExport.php @@ -12,7 +12,7 @@ namespace App\Export; final class ServiceExport { /** - * @var RendererInterface[] + * @var ExportRendererInterface[] */ private $renderer = []; @@ -21,7 +21,7 @@ final class ServiceExport */ private $exporter = []; - public function addRenderer(RendererInterface $renderer): ServiceExport + public function addRenderer(ExportRendererInterface $renderer): ServiceExport { $this->renderer[] = $renderer; @@ -29,14 +29,14 @@ final class ServiceExport } /** - * @return RendererInterface[] + * @return ExportRendererInterface[] */ public function getRenderer(): array { return $this->renderer; } - public function getRendererById(string $id): ?RendererInterface + public function getRendererById(string $id): ?ExportRendererInterface { foreach ($this->renderer as $renderer) { if ($renderer->getId() === $id) { diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index b2197d9a..6f4ff0b2 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -73,20 +73,31 @@ class ProjectRepository extends EntityRepository return $this->count([]); } - public function getProjectStatistics(Project $project): ProjectStatistic + public function getProjectStatistics(Project $project, ?\DateTime $begin = null, ?\DateTime $end = null): ProjectStatistic { $stats = new ProjectStatistic($project); $qb = $this->getEntityManager()->createQueryBuilder(); $qb + ->from(Timesheet::class, 't') ->addSelect('COUNT(t.id) as recordAmount') ->addSelect('SUM(t.duration) as recordDuration') ->addSelect('SUM(t.rate) as recordRate') - ->from(Timesheet::class, 't') ->andWhere('t.project = :project') + ->setParameter('project', $project) ; - $timesheetResult = $qb->getQuery()->execute(['project' => $project], Query::HYDRATE_ARRAY); + + if (null !== $begin) { + $qb->andWhere($qb->expr()->gte('t.begin', ':begin')) + ->setParameter('begin', $begin); + } + if (null !== $end) { + $qb->andWhere($qb->expr()->lte('t.end', ':end')) + ->setParameter('end', $end); + } + + $timesheetResult = $qb->getQuery()->getArrayResult(); if (isset($timesheetResult[0])) { $stats->setRecordAmount($timesheetResult[0]['recordAmount']); diff --git a/templates/export/index.html.twig b/templates/export/index.html.twig index 9b4df6e3..05ec9565 100644 --- a/templates/export/index.html.twig +++ b/templates/export/index.html.twig @@ -42,11 +42,35 @@ {{ form_row(form.markAsExported) }} {% endblock %} {% block box_footer%} + {% set buttons = {} %} + {% for button in renderer %} + {% set title = button.title %} + {% set group = [] %} + {% if buttons[(title)] is defined %} + {% set group = buttons[(title)] %} + {% endif %} + {% set group = group|merge([button]) %} + {% set buttons = buttons|merge({(title): group}) %} + {% endfor %}
- {% for button in renderer %} - + {% for group in buttons %} + {% set button = group.0 %} + {% if group|length == 1 %} + + {% elseif group|length > 1 %} +
+ + +
+ {% endif %} {% endfor %}
{{ form_widget(form.preview) }} diff --git a/templates/export/renderer/default-budget.pdf.twig b/templates/export/renderer/default-budget.pdf.twig new file mode 100644 index 00000000..b0d9b139 --- /dev/null +++ b/templates/export/renderer/default-budget.pdf.twig @@ -0,0 +1,12 @@ +{% extends 'export/renderer/default.pdf.twig' %} +{% block summary %} + {% for budget in budgets %} + {% if budget.money_left > 0 %} + {% set showRateBudget = true %} + {% endif %} + {% if budget.time_left > 0 %} + {% set showTimeBudget = true %} + {% endif %} + {% endfor %} + {{ parent() }} +{% endblock %} \ No newline at end of file diff --git a/templates/export/renderer/default.html.twig b/templates/export/renderer/default.html.twig index 2125ce4c..af53e55e 100644 --- a/templates/export/renderer/default.html.twig +++ b/templates/export/renderer/default.html.twig @@ -2,6 +2,17 @@ {% import "macros/datatables.html.twig" as tables %} {% extends 'export/layout.html.twig' %} +{% set showRateBudget = false %} +{% set showTimeBudget = false %} +{% for budget in budgets %} + {% if budget.money_left > 0 %} + {% set showRateBudget = true %} + {% endif %} + {% if budget.time_left > 0 %} + {% set showTimeBudget = true %} + {% endif %} +{% endfor %} + {% set decimal = decimal|default(false) %} {% set columnTitles = {} %} {% set columns = { @@ -52,6 +63,18 @@ document.getElementById('summary-show').addEventListener('click', function(event) { document.getElementById('export-summary').style.display = event.target.checked ? 'block' : 'none'; }); + document.getElementById('summary-timeBudget').addEventListener('click', function(event) { + var cells = document.getElementsByClassName('export-timeBudget'); + for (var columnCell of cells) { + columnCell.style.display = event.target.checked ? 'table-cell' : 'none'; + } + }); + document.getElementById('summary-budget').addEventListener('click', function(event) { + var cells = document.getElementsByClassName('export-budget'); + for (var columnCell of cells) { + columnCell.style.display = event.target.checked ? 'table-cell' : 'none'; + } + }); document.getElementById('summary-duration').addEventListener('click', function(event) { if (!document.getElementById('summary-rate').checked) { return; @@ -131,6 +154,23 @@ {{ 'label.activity'|trans }} + {% if showTimeBudget %} +
+ +
+ {% endif %} + {% if showRateBudget %} +
+ +
+ {% endif %} +