diff --git a/UPGRADING.md b/UPGRADING.md index f72ae9c0..6fd99fff 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -22,6 +22,7 @@ Permission changes: - `comments_create_teamlead_project` - NEW: permission that allows to add new comments for a teamlead of the current project - `edit_teamlead_project` - removed default permission from ROLE_TEAMLEAD (if you use it: change it in the Role & Permission UI) - `edit_teamlead_customer` - removed default permission from ROLE_TEAMLEAD (if you use it: change it in the Role & Permission UI) +- `upload_invoice_template` - NEW: permission that allows to upload invoice documents from the UI ## [1.7](https://github.com/kevinpapst/kimai2/releases/tag/1.7) diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index 387414b9..c58196f8 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -111,7 +111,7 @@ kimai: SINGLE_USER: ['view_team_member','budget_team_project'] SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member'] SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member'] - SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member'] + SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template'] # link above sets to one complete set for each user role ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER'] ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD'] diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 7dd5ed29..b8a290ed 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -12,12 +12,14 @@ namespace App\Controller; use App\Entity\InvoiceTemplate; use App\Event\InvoicePostRenderEvent; use App\Event\InvoicePreRenderEvent; +use App\Form\InvoiceDocumentUploadForm; use App\Form\InvoiceTemplateForm; use App\Form\Toolbar\InvoiceToolbarForm; use App\Invoice\InvoiceFormatter; use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceModel; use App\Invoice\ServiceInvoice; +use App\Repository\InvoiceDocumentRepository; use App\Repository\InvoiceTemplateRepository; use App\Repository\Query\BaseQuery; use App\Repository\Query\InvoiceQuery; @@ -25,6 +27,7 @@ use App\Timesheet\UserDateTimeFactory; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\SubmitButton; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -289,6 +292,68 @@ final class InvoiceController extends AbstractController return $this->renderTemplateForm($template, $request); } + /** + * @Route(path="/document_upload", name="admin_invoice_document_upload", methods={"GET", "POST"}) + * @Security("is_granted('upload_invoice_template')") + */ + public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository) + { + $dir = $documentRepository->getCustomInvoiceDirectory(); + $invoiceDir = $projectDirectory . DIRECTORY_SEPARATOR . $dir; + $canUpload = true; + $form = null; + + if (!file_exists($invoiceDir)) { + @mkdir($invoiceDir); + } + if (!file_exists($invoiceDir)) { + $this->flashError(sprintf('Invoice directory is not existing and could not be created: %s', $dir)); + $canUpload = false; + } + if (!is_writable($invoiceDir)) { + $this->flashError(sprintf('Invoice directory cannot be written: %s', $dir)); + $canUpload = false; + } + + if ($canUpload) { + $form = $this->createForm(InvoiceDocumentUploadForm::class, null, [ + 'action' => $this->generateUrl('admin_invoice_document_upload', []), + 'method' => 'POST' + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var UploadedFile $uploadedFile */ + $uploadedFile = $form->get('document')->getData(); + + $originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); + $safeFilename = transliterator_transliterate( + 'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', + $originalFilename + ); + $newFilename = $safeFilename . '.' . $uploadedFile->guessExtension(); + + try { + $uploadedFile->move($invoiceDir, $newFilename); + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_invoice_document_upload'); + } catch (\Exception $e) { + $this->flashError( + sprintf('Failed uploading invoice document: %e', $e->getMessage()) + ); + } + } + } + + return $this->render('invoice/document_upload.html.twig', [ + 'form' => (null !== $form) ? $form->createView() : null, + 'documents' => $this->service->getDocuments(), + 'baseDirectory' => $projectDirectory . DIRECTORY_SEPARATOR, + ]); + } + /** * @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/Entity/InvoiceDocument.php b/src/Entity/InvoiceDocument.php index 26274b65..72e20203 100644 --- a/src/Entity/InvoiceDocument.php +++ b/src/Entity/InvoiceDocument.php @@ -42,4 +42,9 @@ final class InvoiceDocument { return $this->file->getExtension(); } + + public function getLastChange(): int + { + return $this->file->getMTime(); + } } diff --git a/src/Form/InvoiceDocumentUploadForm.php b/src/Form/InvoiceDocumentUploadForm.php new file mode 100644 index 00000000..e3b65f52 --- /dev/null +++ b/src/Form/InvoiceDocumentUploadForm.php @@ -0,0 +1,62 @@ +add('document', FileType::class, [ + 'label' => 'label.invoice_renderer', + 'translation_domain' => 'invoice-renderer', + 'help' => 'help.upload', + 'mapped' => false, + 'required' => true, + 'constraints' => [ + new File([ + 'mimeTypes' => [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.spreadsheet', + ], + 'mimeTypesMessage' => 'This file type is not allowed', + ]) + ], + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'admin_invoice_document_upload', + 'attr' => [ + 'data-form-event' => 'kimai.invoiceTemplateUpdate', + 'data-msg-success' => 'action.update.success', + 'data-msg-error' => 'action.update.error', + ], + ]); + } +} diff --git a/src/Repository/InvoiceDocumentRepository.php b/src/Repository/InvoiceDocumentRepository.php index 413eed16..77883e7b 100644 --- a/src/Repository/InvoiceDocumentRepository.php +++ b/src/Repository/InvoiceDocumentRepository.php @@ -12,26 +12,24 @@ namespace App\Repository; use App\Entity\InvoiceDocument; use Symfony\Component\Finder\Finder; -class InvoiceDocumentRepository +final class InvoiceDocumentRepository { /** * @var array */ - protected $documentDirs = []; + private $documentDirs = []; - /** - * @param array $directories - */ public function __construct(array $directories) { $this->documentDirs = $directories; } - /** - * @param string $name - * @return InvoiceDocument|null - */ - public function findByName(string $name) + public function getCustomInvoiceDirectory(): string + { + return $this->documentDirs[0]; + } + + public function findByName(string $name): ?InvoiceDocument { foreach ($this->findAll() as $document) { if ($document->getId() === $name) { diff --git a/src/Twig/IconExtension.php b/src/Twig/IconExtension.php index 9d51927a..fcf0c1f3 100644 --- a/src/Twig/IconExtension.php +++ b/src/Twig/IconExtension.php @@ -86,6 +86,7 @@ final class IconExtension extends AbstractExtension 'timesheet-team' => 'fas fa-user-clock', 'trash' => 'far fa-trash-alt', 'unlocked' => 'fas fa-unlock-alt', + 'upload' => 'fas fa-upload', 'user' => 'fas fa-user-friends', 'visibility' => 'far fa-eye', 'warning' => 'fas fa-exclamation-triangle', diff --git a/templates/invoice/actions.html.twig b/templates/invoice/actions.html.twig index 62471797..3722270d 100644 --- a/templates/invoice/actions.html.twig +++ b/templates/invoice/actions.html.twig @@ -26,12 +26,31 @@ {% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %} {% endif %} + {% if is_granted('upload_invoice_template') %} + {# File upload does not work in a modal right now #} + {% set actions = actions|merge({'upload': {'url': path('admin_invoice_document_upload')}}) %} + {% endif %} + {% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %} {% set event = trigger('actions.invoice_templates', {'actions': actions, 'view': 'index'}) %} {{ widgets.page_actions(actions) }} {% endmacro %} +{% macro invoice_upload(view) %} + {% import "macros/widgets.html.twig" as widgets %} + + {% set actions = {} %} + {% if view == 'index' and is_granted('manage_invoice_template') %} + {% set actions = actions|merge({'back': path('admin_invoice_template')}) %} + {% endif %} + + {% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %} + + {% set event = trigger('actions.invoice_upload', {'actions': actions, 'view': 'index'}) %} + {{ widgets.page_actions(actions) }} +{% endmacro %} + {% macro invoice_template(template, view) %} {% import "macros/widgets.html.twig" as widgets %} diff --git a/templates/invoice/document_upload.html.twig b/templates/invoice/document_upload.html.twig new file mode 100644 index 00000000..60154100 --- /dev/null +++ b/templates/invoice/document_upload.html.twig @@ -0,0 +1,49 @@ +{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} +{% import "invoice/actions.html.twig" as actions %} + +{% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %} +{% block page_actions %}{{ actions.invoice_upload('index') }}{% endblock %} + +{% block main %} + {% if 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') + } %} + + {% embed formEditTemplate with formOptions %} + {% block form_body %} + {{ form_row(form.document) }} + {{ form_widget(form) }} + {% endblock %} + {% endembed %} + {% endif %} + + {% if documents|length > 0 %} + {% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'documents': documents} %} + {% import "project/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 %} + id="invoice_document_list" + {% endblock %} + {% block box_body_class %}no-padding{% endblock %} + {% block box_body %} + + + {% for document in documents %} + + + + + + {% endfor %} + +
{{ document.id }}{{ document.lastChange|date }}{{ document.filename|replace({(baseDirectory): ''}) }}
+ {% endblock %} + {% endembed %} + {% endif %} +{% endblock %} diff --git a/tests/Controller/InvoiceControllerTest.php b/tests/Controller/InvoiceControllerTest.php index b36b53e5..d53205bd 100644 --- a/tests/Controller/InvoiceControllerTest.php +++ b/tests/Controller/InvoiceControllerTest.php @@ -224,4 +224,20 @@ class InvoiceControllerTest extends ControllerBaseTest $this->assertEquals(0, $em->getRepository(InvoiceTemplate::class)->count([])); } + + public function testUploadDocumentAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); + + $em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager'); + $fixture = new InvoiceFixtures(); + $this->importFixture($client, $fixture); + + $this->request($client, '/invoice/document_upload'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $node = $client->getCrawler()->filter('div.box#invoice_document_list'); + self::assertEquals(1, $node->count()); + // we do not test the upload here, just make sure that the action can be rendered properly + } } diff --git a/tests/Controller/PermissionControllerTest.php b/tests/Controller/PermissionControllerTest.php index 3de923c3..bd955327 100644 --- a/tests/Controller/PermissionControllerTest.php +++ b/tests/Controller/PermissionControllerTest.php @@ -29,7 +29,7 @@ class PermissionControllerTest extends ControllerBaseTest $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $this->assertAccessIsGranted($client, '/admin/permissions'); $this->assertHasDataTable($client); - $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 107); + $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 108); $this->assertPageActions($client, [ 'back' => $this->createUrl('/admin/user/'), 'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'), diff --git a/tests/Entity/InvoiceDocumentTest.php b/tests/Entity/InvoiceDocumentTest.php index 4a5f3fb4..b0f86ed1 100644 --- a/tests/Entity/InvoiceDocumentTest.php +++ b/tests/Entity/InvoiceDocumentTest.php @@ -26,5 +26,6 @@ class InvoiceDocumentTest extends TestCase self::assertStringContainsString('templates/invoice/renderer/default.html.twig', $sut->getFilename()); self::assertEquals('default', $sut->getId()); self::assertEquals('default.html.twig', $sut->getName()); + self::assertIsInt($sut->getLastChange()); } } diff --git a/translations/invoice-renderer.de.xlf b/translations/invoice-renderer.de.xlf index da6a521d..e38d3b8d 100644 --- a/translations/invoice-renderer.de.xlf +++ b/translations/invoice-renderer.de.xlf @@ -34,6 +34,10 @@ company Firmen-Rechnung + + help.upload + Achtung: existierende Dateien werden übschrieben. Erlaubte Dateitypen sind: DOCX, ODS, XLSX + diff --git a/translations/invoice-renderer.en.xlf b/translations/invoice-renderer.en.xlf index a76cc74b..cbfcbbf5 100644 --- a/translations/invoice-renderer.en.xlf +++ b/translations/invoice-renderer.en.xlf @@ -34,6 +34,10 @@ company Company invoice + + help.upload + Attention: existing files will be overwritten. Allowed file types are: DOCX, ODS, XLSX. + diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 252fa376..4ab47ba9 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -52,6 +52,10 @@ confirm Bestätigen + + upload + Hochladen + search Suchen diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 88870a01..33e0464f 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -52,6 +52,10 @@ confirm Confirm + + upload + Upload + search Search