beta 3 (#3780)
* merge master - allow to upload twig invoice templates via UI * support adding existing teams with same name * permissions cannot be set right after role was created - fixes #3777 * allow to deactivate unique customer number validation - fixes #3762 * invalid message when trying to edit locked or exported timesheets in calendar - fixes #3766 * updated icons and manifest - fixes #3761
This commit is contained in:
@@ -56,7 +56,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_activity', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_activity_paginated', methods: ['GET'])]
|
||||
public function indexAction($page, Request $request)
|
||||
public function indexAction(int $page, Request $request): Response
|
||||
{
|
||||
$query = new ActivityQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -124,7 +124,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/details', name: 'activity_details', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'activity')]
|
||||
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService)
|
||||
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService): Response
|
||||
{
|
||||
$event = new ActivityMetaDefinitionEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -297,13 +297,11 @@ final class ActivityController extends AbstractController
|
||||
public function createDefaultTeamAction(Activity $activity, TeamRepository $teamRepository): Response
|
||||
{
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]);
|
||||
if (null !== $defaultTeam) {
|
||||
$this->flashError('action.update.error', 'Team already existing');
|
||||
|
||||
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = new Team($activity->getName());
|
||||
}
|
||||
|
||||
$defaultTeam = new Team($activity->getName());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addActivity($activity);
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_customer', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_customer_paginated', methods: ['GET'])]
|
||||
public function indexAction($page, Request $request)
|
||||
public function indexAction(int $page, Request $request): Response
|
||||
{
|
||||
$query = new CustomerQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -141,7 +141,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/create', name: 'admin_customer_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_customer')]
|
||||
public function createAction(Request $request, CustomerService $customerService)
|
||||
public function createAction(Request $request, CustomerService $customerService): Response
|
||||
{
|
||||
$customer = $customerService->createNewCustomer('');
|
||||
|
||||
@@ -150,7 +150,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/permissions', name: 'admin_customer_permissions', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('permissions', 'customer')]
|
||||
public function teamPermissionsAction(Customer $customer, Request $request)
|
||||
public function teamPermissionsAction(Customer $customer, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
|
||||
'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]),
|
||||
@@ -183,7 +183,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/comment_delete/{token}', name: 'customer_comment_delete', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('edit', subject.getCustomer()) and is_granted('comments', subject.getCustomer())"), 'comment')]
|
||||
public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$customerId = $comment->getCustomer()->getId();
|
||||
|
||||
@@ -206,7 +206,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/comment_add', name: 'customer_comment_add', methods: ['POST'])]
|
||||
#[IsGranted('comments', 'customer')]
|
||||
public function addCommentAction(Customer $customer, Request $request)
|
||||
public function addCommentAction(Customer $customer, Request $request): Response
|
||||
{
|
||||
$comment = new CustomerComment($customer);
|
||||
$form = $this->getCommentForm($comment);
|
||||
@@ -226,7 +226,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/comment_pin/{token}', name: 'customer_comment_pin', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('edit', subject.getCustomer()) and is_granted('comments', subject.getCustomer())"), 'comment')]
|
||||
public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$customerId = $comment->getCustomer()->getId();
|
||||
|
||||
@@ -251,16 +251,14 @@ final class CustomerController extends AbstractController
|
||||
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'customer')]
|
||||
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository)
|
||||
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository): Response
|
||||
{
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
|
||||
if (null !== $defaultTeam) {
|
||||
$this->flashError('action.update.error', 'Team already existing');
|
||||
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = new Team($customer->getName());
|
||||
}
|
||||
|
||||
$defaultTeam = new Team($customer->getName());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addCustomer($customer);
|
||||
|
||||
@@ -275,7 +273,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/projects/{page}', defaults: ['page' => 1], name: 'customer_projects', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'customer')]
|
||||
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository)
|
||||
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository): Response
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -298,7 +296,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/details', name: 'customer_details', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'customer')]
|
||||
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService)
|
||||
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService): Response
|
||||
{
|
||||
$event = new CustomerMetaDefinitionEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -462,14 +460,14 @@ final class CustomerController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'admin_customer_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('edit', 'customer')]
|
||||
public function editAction(Customer $customer, Request $request)
|
||||
public function editAction(Customer $customer, Request $request): Response
|
||||
{
|
||||
return $this->renderCustomerForm($customer, $request);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'admin_customer_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('delete', 'customer')]
|
||||
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService)
|
||||
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService): Response
|
||||
{
|
||||
$stats = $statisticService->getCustomerStatistics($customer);
|
||||
|
||||
@@ -511,7 +509,7 @@ final class CustomerController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/export', name: 'customer_export', methods: ['GET'])]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
|
||||
{
|
||||
$query = new CustomerQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
@@ -48,6 +49,7 @@ use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Controller used to create invoices and manage invoice templates.
|
||||
@@ -447,7 +449,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('upload_invoice_template')]
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository)
|
||||
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response
|
||||
{
|
||||
$dir = $documentRepository->getUploadDirectory();
|
||||
$invoiceDir = $dir;
|
||||
@@ -456,6 +458,7 @@ final class InvoiceController extends AbstractController
|
||||
if ($invoiceDir[0] !== '/') {
|
||||
$invoiceDir = $projectDirectory . DIRECTORY_SEPARATOR . $dir;
|
||||
}
|
||||
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
|
||||
$used = [];
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
@@ -511,23 +514,56 @@ final class InvoiceController extends AbstractController
|
||||
/** @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
|
||||
);
|
||||
$originalName = $uploadedFile->getClientOriginalName();
|
||||
$safeFilename = null;
|
||||
$extension = null;
|
||||
$success = true;
|
||||
|
||||
$extension = $uploadedFile->guessExtension();
|
||||
$allowed = InvoiceDocumentUploadForm::EXTENSIONS_NO_TWIG;
|
||||
if ((bool) $systemConfiguration->find('invoice.upload_twig') === true) {
|
||||
$allowed = InvoiceDocumentUploadForm::EXTENSIONS;
|
||||
}
|
||||
|
||||
$newFilename = substr($safeFilename, 0, 20) . '.' . $extension;
|
||||
foreach ($allowed as $ext) {
|
||||
$len = \strlen($ext);
|
||||
if (substr_compare($originalName, $ext, -$len) === 0) {
|
||||
$extension = $ext;
|
||||
$withoutExtension = str_replace($ext, '', $originalName);
|
||||
$safeFilename = transliterator_transliterate(InvoiceDocumentUploadForm::FILENAME_RULE, $withoutExtension);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$uploadedFile->move($invoiceDir, $newFilename);
|
||||
if ($safeFilename === null || $extension === null) {
|
||||
$success = false;
|
||||
$this->flashError('Invalid file given');
|
||||
} else {
|
||||
$newFilename = substr($safeFilename, 0, 20) . $extension;
|
||||
|
||||
try {
|
||||
$uploadedFile->move($invoiceDir, $newFilename);
|
||||
|
||||
// if this is a twig file, we directly try to compile the template
|
||||
if (stripos($newFilename, '.twig') !== false) {
|
||||
try {
|
||||
$twig->enableAutoReload();
|
||||
$twig->load('@invoice/' . $newFilename);
|
||||
$twig->disableAutoReload();
|
||||
} catch (Exception $ex) {
|
||||
unlink($invoiceDir . $newFilename);
|
||||
$success = false;
|
||||
$this->flashException($ex, 'File was deleted, as Twig template is broken: ' . $ex->getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
$this->flashException($ex, 'action.upload.error');
|
||||
}
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_document_upload');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashException($ex, 'action.upload.error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_project', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_project_paginated', methods: ['GET'])]
|
||||
public function indexAction($page, Request $request)
|
||||
public function indexAction(int $page, Request $request): Response
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -134,7 +134,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/permissions', name: 'admin_project_permissions', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('permissions', 'project')]
|
||||
public function teamPermissions(Project $project, Request $request)
|
||||
public function teamPermissions(Project $project, Request $request): Response
|
||||
{
|
||||
$form = $this->createForm(ProjectTeamPermissionForm::class, $project, [
|
||||
'action' => $this->generateUrl('admin_project_permissions', ['id' => $project->getId()]),
|
||||
@@ -167,19 +167,19 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/create/{customer}', name: 'admin_project_create_with_customer', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_project')]
|
||||
public function createWithCustomerAction(Request $request, Customer $customer)
|
||||
public function createWithCustomerAction(Request $request, Customer $customer): Response
|
||||
{
|
||||
return $this->createProject($request, $customer);
|
||||
}
|
||||
|
||||
#[Route(path: '/create', name: 'admin_project_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_project')]
|
||||
public function createAction(Request $request)
|
||||
public function createAction(Request $request): Response
|
||||
{
|
||||
return $this->createProject($request, null);
|
||||
}
|
||||
|
||||
private function createProject(Request $request, ?Customer $customer = null)
|
||||
private function createProject(Request $request, ?Customer $customer = null): Response
|
||||
{
|
||||
$project = $this->projectService->createNewProject($customer);
|
||||
|
||||
@@ -206,7 +206,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/comment_delete/{token}', name: 'project_comment_delete', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('edit', subject.getProject()) and is_granted('comments', subject.getProject())"), 'comment')]
|
||||
public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$projectId = $comment->getProject()->getId();
|
||||
|
||||
@@ -229,7 +229,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/comment_add', name: 'project_comment_add', methods: ['POST'])]
|
||||
#[IsGranted('comments', 'project')]
|
||||
public function addCommentAction(Project $project, Request $request)
|
||||
public function addCommentAction(Project $project, Request $request): Response
|
||||
{
|
||||
$comment = new ProjectComment($project);
|
||||
$form = $this->getCommentForm($comment);
|
||||
@@ -249,7 +249,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/comment_pin/{token}', name: 'project_comment_pin', methods: ['GET'])]
|
||||
#[IsGranted(new Expression("is_granted('edit', subject.getProject()) and is_granted('comments', subject.getProject())"), 'comment')]
|
||||
public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$projectId = $comment->getProject()->getId();
|
||||
|
||||
@@ -274,16 +274,14 @@ final class ProjectController extends AbstractController
|
||||
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
|
||||
#[IsGranted('create_team')]
|
||||
#[IsGranted('permissions', 'project')]
|
||||
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository)
|
||||
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository): Response
|
||||
{
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
|
||||
if (null !== $defaultTeam) {
|
||||
$this->flashError('action.update.error', 'Team already existing');
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
if (null === $defaultTeam) {
|
||||
$defaultTeam = new Team($project->getName());
|
||||
}
|
||||
|
||||
$defaultTeam = new Team($project->getName());
|
||||
$defaultTeam->addTeamlead($this->getUser());
|
||||
$defaultTeam->addProject($project);
|
||||
|
||||
@@ -298,7 +296,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/activities/{page}', defaults: ['page' => 1], name: 'project_activities', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'project')]
|
||||
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository)
|
||||
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository): Response
|
||||
{
|
||||
$query = new ActivityQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
@@ -322,7 +320,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/details', name: 'project_details', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('view', 'project')]
|
||||
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
$event = new ProjectMetaDefinitionEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
@@ -427,7 +425,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'admin_project_edit', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('edit', 'project')]
|
||||
public function editAction(Project $project, Request $request)
|
||||
public function editAction(Project $project, Request $request): Response
|
||||
{
|
||||
$editForm = $this->createEditForm($project);
|
||||
$editForm->handleRequest($request);
|
||||
@@ -452,7 +450,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/duplicate/{token}', name: 'admin_project_duplicate', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('edit', 'project')]
|
||||
public function duplicateAction(Project $project, string $token, ProjectDuplicationService $projectDuplicationService, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
public function duplicateAction(Project $project, string $token, ProjectDuplicationService $projectDuplicationService, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.duplicate', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
@@ -471,7 +469,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'admin_project_delete', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('delete', 'project')]
|
||||
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService)
|
||||
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService): Response
|
||||
{
|
||||
$stats = $statisticService->getProjectStatistics($project);
|
||||
|
||||
@@ -514,7 +512,7 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/export', name: 'project_export', methods: ['GET'])]
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
|
||||
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
|
||||
{
|
||||
$query = new ProjectQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
@@ -432,6 +432,7 @@ final class SystemConfigurationController extends AbstractController
|
||||
])
|
||||
->setRequired(true)
|
||||
->setType(TextType::class)
|
||||
->setConstraints([new NotBlank()])
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
$authentication,
|
||||
@@ -459,6 +460,10 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setRequired(true)
|
||||
->setType(TextType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
(new Configuration('customer.rules.allow_duplicate_number'))
|
||||
->setLabel('customer.allow_duplicate_number')
|
||||
->setType(YesNoType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel('project'))
|
||||
->setConfiguration([
|
||||
|
||||
@@ -47,7 +47,7 @@ final class UserController extends AbstractController
|
||||
|
||||
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_user', methods: ['GET'])]
|
||||
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_user_paginated', methods: ['GET'])]
|
||||
public function indexAction($page, Request $request): Response
|
||||
public function indexAction(int $page, Request $request): Response
|
||||
{
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
@@ -322,6 +322,9 @@ final class Configuration implements ConfigurationInterface
|
||||
->scalarNode('number_format')
|
||||
->defaultValue('{Y}/{cy,3}')
|
||||
->end()
|
||||
->booleanNode('upload_twig')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
@@ -531,6 +534,14 @@ final class Configuration implements ConfigurationInterface
|
||||
->scalarNode('number_format')
|
||||
->defaultValue('{cc,4}')
|
||||
->end()
|
||||
->arrayNode('rules')
|
||||
->addDefaultsIfNotSet()
|
||||
->children()
|
||||
->booleanNode('allow_duplicate_number')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
|
||||
@@ -10,21 +10,21 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_customers')]
|
||||
#[ORM\Index(columns: ['visible'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('number')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'teams', 'comment', 'billable'])]
|
||||
#[Constraints\Customer]
|
||||
class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
{
|
||||
public const DEFAULT_CURRENCY = 'EUR';
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
@@ -19,6 +21,10 @@ use Symfony\Component\Security\Core\Exception\AuthenticationExpiredException;
|
||||
|
||||
final class AjaxAuthenticationSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
@@ -29,6 +35,12 @@ final class AjaxAuthenticationSubscriber implements EventSubscriberInterface
|
||||
public function onCoreException(ExceptionEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
|
||||
// do not act upon requests which were triggered by fully logged-in users
|
||||
if ($this->security->getUser() instanceof User && $this->security->isGranted('IS_AUTHENTICATED_FULLY')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$header = $request->headers->get('X-Requested-With');
|
||||
|
||||
if ($request->isXmlHttpRequest() || ($header !== null && str_contains(strtolower($header), 'kimai'))) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
@@ -21,28 +22,45 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
final class InvoiceDocumentUploadForm extends AbstractType
|
||||
{
|
||||
public function __construct(private InvoiceDocumentRepository $repository)
|
||||
public const EXTENSIONS = ['.html.twig', '.pdf.twig', '.docx', '.xlsx', '.ods'];
|
||||
public const EXTENSIONS_NO_TWIG = ['.docx', '.xlsx', '.ods'];
|
||||
public const FILENAME_RULE = 'Any-Latin; Latin-ASCII; [^A-Za-z0-9_\-] remove; Lower()';
|
||||
|
||||
/** @var array<string> */
|
||||
private array $extensions = [];
|
||||
|
||||
public function __construct(private InvoiceDocumentRepository $repository, private SystemConfiguration $systemConfiguration)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$this->extensions = self::EXTENSIONS_NO_TWIG;
|
||||
$extensions = 'DOCX, ODS, XLSX';
|
||||
$mimetypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.oasis.opendocument.spreadsheet',
|
||||
];
|
||||
|
||||
if ((bool) $this->systemConfiguration->find('invoice.upload_twig') === true) {
|
||||
$this->extensions = self::EXTENSIONS;
|
||||
$extensions = 'DOCX, ODS, XLSX, TWIG (PDF & HTML)';
|
||||
$mimetypes = array_merge($mimetypes, [
|
||||
'application/octet-stream', // needed for twig templates
|
||||
'text/html', // needed for twig templates
|
||||
'text/plain', // needed for twig templates
|
||||
]);
|
||||
}
|
||||
|
||||
$builder
|
||||
->add('document', FileType::class, [
|
||||
'label' => 'invoice_renderer',
|
||||
'translation_domain' => 'invoice-renderer',
|
||||
'help' => 'help.upload',
|
||||
'help_translation_parameters' => ['%extensions%' => $extensions],
|
||||
'mapped' => false,
|
||||
'required' => true,
|
||||
'attr' => [
|
||||
'accept' => implode(',', $mimetypes)
|
||||
],
|
||||
'constraints' => [
|
||||
new File([
|
||||
'mimeTypes' => $mimetypes,
|
||||
@@ -54,7 +72,7 @@ final class InvoiceDocumentUploadForm extends AbstractType
|
||||
;
|
||||
}
|
||||
|
||||
public function validateDocument($value, ExecutionContextInterface $context)
|
||||
public function validateDocument($value, ExecutionContextInterface $context): void
|
||||
{
|
||||
if (!($value instanceof UploadedFile)) {
|
||||
return;
|
||||
@@ -71,6 +89,48 @@ final class InvoiceDocumentUploadForm extends AbstractType
|
||||
->setTranslationDomain('validators')
|
||||
->setCode('kimai-invoice-document-upload-01')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$extension = null;
|
||||
$nameWithoutExtension = null;
|
||||
|
||||
foreach ($this->extensions as $ext) {
|
||||
$len = \strlen($ext);
|
||||
if (substr_compare($name, $ext, -$len) === 0) {
|
||||
$extension = $ext;
|
||||
$nameWithoutExtension = str_replace($ext, '', $name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($extension === null || $nameWithoutExtension === null) {
|
||||
$context->buildViolation('This invoice document cannot be used, allowed file extensions are: %extensions%')
|
||||
->setParameters(['%extensions%' => implode(', ', $this->extensions)])
|
||||
->setTranslationDomain('validators')
|
||||
->setCode('kimai-invoice-document-upload-02')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$safeFilename = transliterator_transliterate(self::FILENAME_RULE, $nameWithoutExtension);
|
||||
|
||||
if ($safeFilename !== $nameWithoutExtension) {
|
||||
$context->buildViolation('This invoice document cannot be used, filename may only contain the following ascii character: %character%')
|
||||
->setParameters(['%character%' => 'A-Z a-z 0-9 _ -'])
|
||||
->setTranslationDomain('validators')
|
||||
->setCode('kimai-invoice-document-upload-03')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
if (mb_strlen($nameWithoutExtension) > 20) {
|
||||
$context->buildViolation('This invoice document cannot be used, allowed filename length without extension is %character% character.')
|
||||
->setParameters(['%character%' => 20])
|
||||
->setTranslationDomain('validators')
|
||||
->setCode('kimai-invoice-document-upload-04')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
src/Validator/Constraints/Customer.php
Normal file
29
src/Validator/Constraints/Customer.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
final class Customer extends Constraint
|
||||
{
|
||||
public const CUSTOMER_NUMBER_EXISTING = 'kimai-customer-00';
|
||||
|
||||
protected const ERROR_NAMES = [
|
||||
self::CUSTOMER_NUMBER_EXISTING => 'This account number is already used.',
|
||||
];
|
||||
|
||||
public string $message = 'This customer has invalid settings.';
|
||||
|
||||
public function getTargets(): string|array
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
||||
50
src/Validator/Constraints/CustomerValidator.php
Normal file
50
src/Validator/Constraints/CustomerValidator.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Customer as CustomerEntity;
|
||||
use App\Repository\CustomerRepository;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class CustomerValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(private SystemConfiguration $systemConfiguration, private CustomerRepository $customerRepository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param CustomerEntity|mixed $value
|
||||
* @param Constraint $constraint
|
||||
*/
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof Customer)) {
|
||||
throw new UnexpectedTypeException($constraint, Customer::class);
|
||||
}
|
||||
|
||||
if (!($value instanceof CustomerEntity)) {
|
||||
throw new UnexpectedTypeException($value, CustomerEntity::class);
|
||||
}
|
||||
|
||||
if ((bool) $this->systemConfiguration->find('customer.rules.allow_duplicate_number') === false && (($number = $value->getNumber()) !== null)) {
|
||||
$tmp = $this->customerRepository->findOneBy(['number' => $number]);
|
||||
if ($tmp !== null && $tmp->getId() !== $value->getId()) {
|
||||
$this->context->buildViolation(Customer::getErrorName(Customer::CUSTOMER_NUMBER_EXISTING))
|
||||
->atPath('number')
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(Customer::CUSTOMER_NUMBER_EXISTING)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user