diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 0b126278..f0c58e89 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -13,6 +13,7 @@ use App\Configuration\SystemConfiguration; use App\Entity\Customer; use App\Entity\Invoice; use App\Entity\InvoiceTemplate; +use App\Event\InvoiceDocumentsEvent; use App\Export\Spreadsheet\AnnotatedObjectExporter; use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; use App\Export\Spreadsheet\Writer\XlsxWriter; @@ -338,17 +339,45 @@ final class InvoiceController extends AbstractController $invoiceDir = $projectDirectory . DIRECTORY_SEPARATOR . $dir; } + $used = []; + foreach ($this->templateRepository->findAll() as $template) { + $used[$template->getRenderer()] = $template; + } + + $event = new InvoiceDocumentsEvent($this->service->getDocuments(true)); + $this->dispatcher->dispatch($event); + + $documents = []; + foreach ($event->getInvoiceDocuments() as $document) { + $isUsed = \array_key_exists($document->getId(), $used); + $template = null; + if ($isUsed) { + $template = $used[$document->getId()]; + } + $documents[] = [ + 'document' => $document, + 'template' => $template, + 'used' => $isUsed, + ]; + } + $canUpload = true; + $uploadError = null; + + if (\count($documents) >= $event->getMaximumAllowedDocuments()) { + $uploadError = 'invoice_document.max_reached'; + $canUpload = false; + } if (!file_exists($invoiceDir)) { @mkdir($invoiceDir, 0777); } if (!is_dir($invoiceDir)) { - $this->flashError(sprintf('Invoice directory "%s" is not existing and could not be created.', $dir)); + $uploadError = 'error.directory_missing'; $canUpload = false; } elseif (!is_writable($invoiceDir)) { - $this->flashError(sprintf('Invoice directory "%s" cannot be written.', $dir)); + $uploadError = 'error.directory_protected'; $canUpload = false; } @@ -369,7 +398,10 @@ final class InvoiceController extends AbstractController 'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename ); - $newFilename = $safeFilename . '.' . $uploadedFile->guessExtension(); + + $extension = $uploadedFile->guessExtension(); + + $newFilename = substr($safeFilename, 0, 20) . '.' . $extension; try { $uploadedFile->move($invoiceDir, $newFilename); @@ -383,12 +415,56 @@ final class InvoiceController extends AbstractController } return $this->render('invoice/document_upload.html.twig', [ + 'error_replacer' => ['%max%' => $event->getMaximumAllowedDocuments(), '%dir%' => $dir], + 'upload_error' => $uploadError, + 'can_upload' => $canUpload, 'form' => $form->createView(), - 'documents' => $this->service->getDocuments(true), + 'documents' => $documents, 'baseDirectory' => $projectDirectory . DIRECTORY_SEPARATOR, ]); } + /** + * @Route(path="/document/{id}/delete/{token}", name="invoice_document_delete", methods={"GET", "POST"}) + * @Security("is_granted('manage_invoice_template')") + */ + public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response + { + $document = $documentRepository->findByName($id); + if ($document === null) { + throw $this->createNotFoundException(); + } + + if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_document', $token))) { + $this->flashError('action.csrf.error'); + + return $this->redirectToRoute('admin_invoice_document_upload'); + } + + $csrfTokenManager->refreshToken('invoice.delete_document'); + + foreach ($documentRepository->findBuiltIn() as $document) { + if ($document->getId() === $id) { + throw new \Exception('Document is built-in and cannot be deleted'); + } + } + + foreach ($this->templateRepository->findAll() as $template) { + if ($template->getRenderer() === $id) { + throw new \Exception('Document is used and cannot be deleted'); + } + } + + try { + $documentRepository->remove($document); + $this->flashSuccess('action.delete.success'); + } catch (Exception $ex) { + $this->flashDeleteException($ex); + } + + return $this->redirectToRoute('admin_invoice_document_upload'); + } + /** * @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"}) * @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"}) diff --git a/src/Event/InvoiceDocumentsEvent.php b/src/Event/InvoiceDocumentsEvent.php new file mode 100644 index 00000000..116808c9 --- /dev/null +++ b/src/Event/InvoiceDocumentsEvent.php @@ -0,0 +1,65 @@ +documents = $documents; + } + + /** + * @return InvoiceDocument[] + */ + public function getInvoiceDocuments(): array + { + return $this->documents; + } + + public function addInvoiceDocuments(InvoiceDocument $document): void + { + $this->documents[] = $document; + } + + /** + * @param InvoiceDocument[] $documents + */ + public function setInvoiceDocuments(array $documents): void + { + $this->documents = $documents; + } + + public function setMaximumAllowedDocuments(int $max): void + { + $this->maximum = $max; + } + + public function getMaximumAllowedDocuments(): int + { + return $this->maximum; + } +} diff --git a/src/EventSubscriber/Actions/InvoiceDocumentSubscriber.php b/src/EventSubscriber/Actions/InvoiceDocumentSubscriber.php new file mode 100644 index 00000000..611b052d --- /dev/null +++ b/src/EventSubscriber/Actions/InvoiceDocumentSubscriber.php @@ -0,0 +1,37 @@ +getPayload(); + + /** @var InvoiceDocument|null $document */ + $document = $payload['document']; + + if ($document === null) { + return; + } + + if ($this->isGranted('manage_invoice_template')) { + $event->addDelete($this->path('invoice_document_delete', ['id' => $document->getId(), 'token' => $payload['token']]), false); + } + } +} diff --git a/src/Form/InvoiceDocumentUploadForm.php b/src/Form/InvoiceDocumentUploadForm.php index 001f0c03..66111182 100644 --- a/src/Form/InvoiceDocumentUploadForm.php +++ b/src/Form/InvoiceDocumentUploadForm.php @@ -21,9 +21,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; class InvoiceDocumentUploadForm extends AbstractType { - /** - * @var InvoiceDocumentRepository - */ private $repository; public function __construct(InvoiceDocumentRepository $repository) @@ -36,6 +33,12 @@ class InvoiceDocumentUploadForm extends AbstractType */ public function buildForm(FormBuilderInterface $builder, array $options) { + $mimetypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.spreadsheet', + ]; + $builder ->add('document', FileType::class, [ 'label' => 'label.invoice_renderer', @@ -43,13 +46,12 @@ class InvoiceDocumentUploadForm extends AbstractType 'help' => 'help.upload', 'mapped' => false, 'required' => true, + 'attr' => [ + 'accept' => implode(',', $mimetypes) + ], 'constraints' => [ new File([ - 'mimeTypes' => [ - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.oasis.opendocument.spreadsheet', - ], + 'mimeTypes' => $mimetypes, 'mimeTypesMessage' => 'This file type is not allowed', ]), new Callback([$this, 'validateDocument']) diff --git a/src/Repository/InvoiceDocumentRepository.php b/src/Repository/InvoiceDocumentRepository.php index 728a2b4a..d409e9ac 100644 --- a/src/Repository/InvoiceDocumentRepository.php +++ b/src/Repository/InvoiceDocumentRepository.php @@ -50,6 +50,14 @@ final class InvoiceDocumentRepository return $this; } + /** + * @codeCoverageIgnore + */ + public function remove(InvoiceDocument $invoiceDocument): void + { + @unlink($invoiceDocument->getFilename()); + } + /** * @deprecated since 1.10 - will be removed with 2.0 - use getUploadDirectory() instead */ diff --git a/templates/invoice/actions.html.twig b/templates/invoice/actions.html.twig index e654110c..d65e29e0 100644 --- a/templates/invoice/actions.html.twig +++ b/templates/invoice/actions.html.twig @@ -33,3 +33,9 @@ {% set event = actions(app.user, 'invoice_template', view, {'template': template, 'token': csrf_token('invoice.delete_template')}) %} {{ widgets.table_actions(event.actions) }} {% endmacro %} + +{% macro invoice_document(document, view) %} + {% import "macros/widgets.html.twig" as widgets %} + {% set event = actions(app.user, 'invoice_document', view, {'document': document, 'token': csrf_token('invoice.delete_document')}) %} + {{ widgets.table_actions(event.actions) }} +{% endmacro %} diff --git a/templates/invoice/document_upload.html.twig b/templates/invoice/document_upload.html.twig index 1358ce25..7099f5d1 100644 --- a/templates/invoice/document_upload.html.twig +++ b/templates/invoice/document_upload.html.twig @@ -1,30 +1,33 @@ -{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} +{% extends 'base.html.twig' %} {% import "invoice/actions.html.twig" as actions %} +{% import "macros/widgets.html.twig" as widgets %} -{% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %} +{% block page_title %}{{ 'label.invoice_renderer'|trans({}, 'invoice-renderer') }}{% endblock %} {% block page_actions %}{{ actions.invoice_upload('index') }}{% endblock %} {% block main %} - {% if form is not null %} + {% if can_upload and form is not null %} {% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %} - {% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %} {% set formOptions = { 'title': 'upload'|trans, 'form': form, - 'back': path('admin_invoice_template') + 'back': false, + 'reset': false } %} - {% embed formEditTemplate with formOptions %} + {% embed 'default/_form.html.twig' with formOptions %} {% block form_body %} {{ form_row(form.document) }} {{ form_widget(form) }} {% endblock %} {% endembed %} + {% elseif upload_error is not null %} + {{ widgets.callout('warning', upload_error|trans(error_replacer)) }} {% endif %} {% if documents|length > 0 %} {% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'documents': documents} %} - {% import "project/actions.html.twig" as actions %} + {% import "invoice/actions.html.twig" as actions %} {% import "macros/widgets.html.twig" as widgets %} {% block box_title %}{{ 'label.invoice_renderer'|trans({}, 'invoice-renderer') }}{% endblock %} {% block box_attributes %} @@ -33,12 +36,30 @@ {% block box_body_class %}no-padding{% endblock %} {% block box_body %} - - {% for document in documents %} + + + + + + + + + + {% for config in documents %} + {% set document = config.document %} - - + + + {% endfor %} diff --git a/tests/Event/InvoiceDocumentsEventTest.php b/tests/Event/InvoiceDocumentsEventTest.php new file mode 100644 index 00000000..86767afe --- /dev/null +++ b/tests/Event/InvoiceDocumentsEventTest.php @@ -0,0 +1,40 @@ +getInvoiceDocuments()); + self::assertEquals(99, $sut->getMaximumAllowedDocuments()); + + $sut->setMaximumAllowedDocuments(10); + self::assertEquals(10, $sut->getMaximumAllowedDocuments()); + + $file = new \SplFileInfo(__FILE__); + $document = new InvoiceDocument($file); + + $sut->setInvoiceDocuments([$document]); + self::assertEquals([$document], $sut->getInvoiceDocuments()); + + $sut->addInvoiceDocuments(new InvoiceDocument($file)); + self::assertCount(2, $sut->getInvoiceDocuments()); + } +} diff --git a/tests/EventSubscriber/Actions/AbstractActionsSubscriberTest.php b/tests/EventSubscriber/Actions/AbstractActionsSubscriberTest.php index 89afcce8..032aec44 100644 --- a/tests/EventSubscriber/Actions/AbstractActionsSubscriberTest.php +++ b/tests/EventSubscriber/Actions/AbstractActionsSubscriberTest.php @@ -9,13 +9,26 @@ namespace App\Tests\EventSubscriber\Actions; +use App\EventSubscriber\Actions\AbstractActionsSubscriber; use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; /** * @covers \App\EventSubscriber\Actions\AbstractActionsSubscriber */ abstract class AbstractActionsSubscriberTest extends TestCase { + protected function createSubscriber(string $className, ...$grants): AbstractActionsSubscriber + { + $auth = $this->createMock(AuthorizationCheckerInterface::class); + $auth->method('isGranted')->willReturnOnConsecutiveCalls(...$grants); + $router = $this->createMock(UrlGeneratorInterface::class); + $router->method('generate')->willReturnArgument(0); + + return new $className($auth, $router); + } + protected function assertGetSubscribedEvent(string $className, string $name) { $this->assertTrue(method_exists($className, 'getSubscribedEvents')); diff --git a/tests/EventSubscriber/Actions/InvoiceDocumentSubscriberTest.php b/tests/EventSubscriber/Actions/InvoiceDocumentSubscriberTest.php new file mode 100644 index 00000000..20b99f3d --- /dev/null +++ b/tests/EventSubscriber/Actions/InvoiceDocumentSubscriberTest.php @@ -0,0 +1,39 @@ +assertGetSubscribedEvent(InvoiceDocumentSubscriber::class, 'invoice_document'); + } + + public function testActions() + { + $sut = $this->createSubscriber(InvoiceDocumentSubscriber::class, true); + + $event = new PageActionsEvent(new User(), ['document' => new InvoiceDocument(new \SplFileInfo(__FILE__)), 'token' => uniqid()], 'invoice_document', 'index'); + $sut->onActions($event); + + $actions = $event->getActions(); + self::assertGreaterThanOrEqual(1, \count($actions)); + self::assertArrayHasKey('trash', $actions); + self::assertEquals('invoice_document_delete', $actions['trash']['url']); + } +} diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index eebc70fe..9ec501a7 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -250,6 +250,14 @@ error.too_many_entriesDie Anfrage konnte nicht verarbeitet werden. Es wurden zu viele Ergebnisse gefunden. + + error.directory_missing + Das Verzeichnis "%dir%" existiert nicht und konnte auch nicht erstellt werden. + + + error.directory_protected + Das Verzeichnis "%dir%" ist schreibgeschützt. + @@ -1291,6 +1299,14 @@ label.hours_2424 Stunden + + Updated at + Aktualisiert am + + + invoice_document.max_reached + Maximale Anzahl von %max% Rechnungsdokumenten erreicht. Um weitere hinzufügen müssen Sie zunächst eins löschen. + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index bd902d68..d3b0171c 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -250,6 +250,14 @@ error.too_many_entriesThe request could not be processed. Too many results were found. + + error.directory_missing + Directory "%dir%" is not existing and could not be created. + + + error.directory_protected + Directory "%dir%" is write protected. + @@ -1291,6 +1299,14 @@ label.hours_2424 hours + + Updated at + Updated at + + + invoice_document.max_reached + Reached maximum amount of %max% invoice documents. You can add more after removing one. +
{{ 'file'|trans }}{{ 'updated_at'|trans }}{{ 'label.template'|trans }}
{{ document.id }}{{ document.lastChange|date }} {{ document.name }}{{ document.lastChange|date }} + {% if config.template is not null %} + {{ config.template.name }} + {% endif %} + + {% if not config.used %} + {{ actions.invoice_document(document, 'index') }} + {% endif %} +