Upload invoice documents via UI (#1495)

This commit is contained in:
Kevin Papst
2020-02-27 23:19:19 +01:00
committed by GitHub
parent 34d2228d5d
commit bf11de82fc
16 changed files with 245 additions and 12 deletions

View File

@@ -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 - `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_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) - `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) ## [1.7](https://github.com/kevinpapst/kimai2/releases/tag/1.7)

View File

@@ -111,7 +111,7 @@ kimai:
SINGLE_USER: ['view_team_member','budget_team_project'] 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_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_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 # link above sets to one complete set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER'] ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD'] ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']

View File

@@ -12,12 +12,14 @@ namespace App\Controller;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Event\InvoicePostRenderEvent; use App\Event\InvoicePostRenderEvent;
use App\Event\InvoicePreRenderEvent; use App\Event\InvoicePreRenderEvent;
use App\Form\InvoiceDocumentUploadForm;
use App\Form\InvoiceTemplateForm; use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceToolbarForm; use App\Form\Toolbar\InvoiceToolbarForm;
use App\Invoice\InvoiceFormatter; use App\Invoice\InvoiceFormatter;
use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Invoice\ServiceInvoice; use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceTemplateRepository; use App\Repository\InvoiceTemplateRepository;
use App\Repository\Query\BaseQuery; use App\Repository\Query\BaseQuery;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
@@ -25,6 +27,7 @@ use App\Timesheet\UserDateTimeFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton; use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
@@ -289,6 +292,68 @@ final class InvoiceController extends AbstractController
return $this->renderTemplateForm($template, $request); 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", name="admin_invoice_template_create", methods={"GET", "POST"})
* @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"}) * @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"})

View File

@@ -42,4 +42,9 @@ final class InvoiceDocument
{ {
return $this->file->getExtension(); return $this->file->getExtension();
} }
public function getLastChange(): int
{
return $this->file->getMTime();
}
} }

View File

@@ -0,0 +1,62 @@
<?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\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File;
class InvoiceDocumentUploadForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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',
],
]);
}
}

View File

@@ -12,26 +12,24 @@ namespace App\Repository;
use App\Entity\InvoiceDocument; use App\Entity\InvoiceDocument;
use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Finder;
class InvoiceDocumentRepository final class InvoiceDocumentRepository
{ {
/** /**
* @var array * @var array
*/ */
protected $documentDirs = []; private $documentDirs = [];
/**
* @param array $directories
*/
public function __construct(array $directories) public function __construct(array $directories)
{ {
$this->documentDirs = $directories; $this->documentDirs = $directories;
} }
/** public function getCustomInvoiceDirectory(): string
* @param string $name {
* @return InvoiceDocument|null return $this->documentDirs[0];
*/ }
public function findByName(string $name)
public function findByName(string $name): ?InvoiceDocument
{ {
foreach ($this->findAll() as $document) { foreach ($this->findAll() as $document) {
if ($document->getId() === $name) { if ($document->getId() === $name) {

View File

@@ -86,6 +86,7 @@ final class IconExtension extends AbstractExtension
'timesheet-team' => 'fas fa-user-clock', 'timesheet-team' => 'fas fa-user-clock',
'trash' => 'far fa-trash-alt', 'trash' => 'far fa-trash-alt',
'unlocked' => 'fas fa-unlock-alt', 'unlocked' => 'fas fa-unlock-alt',
'upload' => 'fas fa-upload',
'user' => 'fas fa-user-friends', 'user' => 'fas fa-user-friends',
'visibility' => 'far fa-eye', 'visibility' => 'far fa-eye',
'warning' => 'fas fa-exclamation-triangle', 'warning' => 'fas fa-exclamation-triangle',

View File

@@ -26,12 +26,31 @@
{% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %} {% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %}
{% endif %} {% 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 actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.invoice_templates', {'actions': actions, 'view': 'index'}) %} {% set event = trigger('actions.invoice_templates', {'actions': actions, 'view': 'index'}) %}
{{ widgets.page_actions(actions) }} {{ widgets.page_actions(actions) }}
{% endmacro %} {% 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) %} {% macro invoice_template(template, view) %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}

View File

@@ -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 %}
<table class="table table-hover dataTable">
<tbody>
{% for document in documents %}
<tr>
<td>{{ document.id }}</td>
<td>{{ document.lastChange|date }}</td>
<td>{{ document.filename|replace({(baseDirectory): ''}) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% endembed %}
{% endif %}
{% endblock %}

View File

@@ -224,4 +224,20 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertEquals(0, $em->getRepository(InvoiceTemplate::class)->count([])); $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
}
} }

View File

@@ -29,7 +29,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions'); $this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 107); $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 108);
$this->assertPageActions($client, [ $this->assertPageActions($client, [
'back' => $this->createUrl('/admin/user/'), 'back' => $this->createUrl('/admin/user/'),
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'), 'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),

View File

@@ -26,5 +26,6 @@ class InvoiceDocumentTest extends TestCase
self::assertStringContainsString('templates/invoice/renderer/default.html.twig', $sut->getFilename()); self::assertStringContainsString('templates/invoice/renderer/default.html.twig', $sut->getFilename());
self::assertEquals('default', $sut->getId()); self::assertEquals('default', $sut->getId());
self::assertEquals('default.html.twig', $sut->getName()); self::assertEquals('default.html.twig', $sut->getName());
self::assertIsInt($sut->getLastChange());
} }
} }

View File

@@ -34,6 +34,10 @@
<source>company</source> <source>company</source>
<target>Firmen-Rechnung</target> <target>Firmen-Rechnung</target>
</trans-unit> </trans-unit>
<trans-unit id="help.upload">
<source>help.upload</source>
<target>Achtung: existierende Dateien werden übschrieben. Erlaubte Dateitypen sind: DOCX, ODS, XLSX</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -34,6 +34,10 @@
<source>company</source> <source>company</source>
<target>Company invoice</target> <target>Company invoice</target>
</trans-unit> </trans-unit>
<trans-unit id="help.upload">
<source>help.upload</source>
<target>Attention: existing files will be overwritten. Allowed file types are: DOCX, ODS, XLSX.</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -52,6 +52,10 @@
<source>confirm</source> <source>confirm</source>
<target>Bestätigen</target> <target>Bestätigen</target>
</trans-unit> </trans-unit>
<trans-unit id="upload">
<source>upload</source>
<target>Hochladen</target>
</trans-unit>
<trans-unit id="search"> <trans-unit id="search">
<source>search</source> <source>search</source>
<target>Suchen</target> <target>Suchen</target>

View File

@@ -52,6 +52,10 @@
<source>confirm</source> <source>confirm</source>
<target>Confirm</target> <target>Confirm</target>
</trans-unit> </trans-unit>
<trans-unit id="upload">
<source>upload</source>
<target>Upload</target>
</trans-unit>
<trans-unit id="search"> <trans-unit id="search">
<source>search</source> <source>search</source>
<target>Search</target> <target>Search</target>