diff --git a/src/Controller/Administrative/System/Document/DeleteController.php b/src/Controller/Administrative/System/Document/DeleteController.php index 319b012..9228123 100644 --- a/src/Controller/Administrative/System/Document/DeleteController.php +++ b/src/Controller/Administrative/System/Document/DeleteController.php @@ -4,8 +4,10 @@ namespace App\Controller\Administrative\System\Document; use App\Entity\Upload; use App\Htmx\HxRedirectResponse; +use App\Repository\SeasonTaskDocumentRepository; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; +use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -17,14 +19,32 @@ class DeleteController extends AbstractController public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, + private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository, ) { } #[Route('/administrative/system/document/delete/{uuid}', name: 'app_administrative_system_document_delete')] #[IsGranted('ROLE_ADMINISTRATIVE')] #[IsGranted('DELETE', subject: 'document')] - public function index(Upload $document, Request $request): Response - { + public function index( + #[MapEntity(mapping: ['uuid' => 'uuid'])] + Upload $document, + Request $request, + ): Response { + // A document handed out for a season task is referenced by a NOT NULL foreign key, so + // deleting it fails in the database. It has to be replaced in that mapping first. + $seasonTaskDocuments = $this->seasonTaskDocumentRepository->findByDocument($document); + + if (0 < count($seasonTaskDocuments) && true === $request->isMethod('POST')) { + $this->addFlash('error', 'Das Dokument ist als Saisonteam-Dokument hinterlegt und kann nicht gelöscht werden'); + $this->logger->info('Refuse deleting document assigned to a season task', [ + 'document_id' => $document->getId(), + 'document_filename' => $document->getOriginalFilename(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_administrative_system_document_index')); + } + if (true === $request->isMethod('POST')) { $this->entityManager->remove($document); $this->entityManager->flush(); @@ -40,6 +60,7 @@ class DeleteController extends AbstractController return $this->render('administrative/system/document/modal_delete.html.twig', [ 'document' => $document, + 'seasonTaskDocuments' => $seasonTaskDocuments, ]); } } diff --git a/src/Controller/Administrative/System/Document/IndexController.php b/src/Controller/Administrative/System/Document/IndexController.php index 51e8d05..1b7e173 100644 --- a/src/Controller/Administrative/System/Document/IndexController.php +++ b/src/Controller/Administrative/System/Document/IndexController.php @@ -2,6 +2,7 @@ namespace App\Controller\Administrative\System\Document; +use App\Repository\SeasonTaskDocumentRepository; use App\Repository\UploadRepository; use Knp\Component\Pager\PaginatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -15,6 +16,7 @@ class IndexController extends AbstractController public function __construct( private readonly UploadRepository $uploadRepository, private readonly PaginatorInterface $paginator, + private readonly SeasonTaskDocumentRepository $seasonTaskDocumentRepository, ) { } @@ -36,6 +38,7 @@ class IndexController extends AbstractController return $this->render('administrative/system/document/index.html.twig', [ 'pagination' => $pagination, + 'seasonTaskDocuments' => $this->seasonTaskDocumentRepository->findGroupedByDocumentId(), ]); } } diff --git a/src/Repository/SeasonTaskDocumentRepository.php b/src/Repository/SeasonTaskDocumentRepository.php index 972bfa4..a0e21dd 100644 --- a/src/Repository/SeasonTaskDocumentRepository.php +++ b/src/Repository/SeasonTaskDocumentRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; use App\Config\SeasonTask; use App\Entity\SeasonTaskDocument; +use App\Entity\Upload; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -43,4 +44,31 @@ class SeasonTaskDocumentRepository extends ServiceEntityRepository { return $this->findOneBy(['taskKey' => $task->value]); } + + /** + * Every mapping that points at the document, including rows whose task key no longer + * resolves: those still hold the foreign key that blocks deleting the upload. + * + * @return array + */ + public function findByDocument(Upload $document): array + { + return $this->findBy(['document' => $document]); + } + + /** + * The mappings grouped by the id of the document they point at, for the document list. + * + * @return array> + */ + public function findGroupedByDocumentId(): array + { + $grouped = []; + + foreach ($this->findAll() as $seasonTaskDocument) { + $grouped[$seasonTaskDocument->getDocument()->getId()][] = $seasonTaskDocument; + } + + return $grouped; + } } diff --git a/templates/administrative/system/document/index.html.twig b/templates/administrative/system/document/index.html.twig index d3e0556..c9b744c 100644 --- a/templates/administrative/system/document/index.html.twig +++ b/templates/administrative/system/document/index.html.twig @@ -31,6 +31,7 @@ {% for document in pagination %} + {% set assignments = seasonTaskDocuments[document.id] ?? [] %} {{ document.displayName ? document.displayName : document.originalFilename }} @@ -38,7 +39,10 @@ {# A slot belongs to exactly one document, so this is what tells staff which file a feature actually picks up. #} - {{ document.token ? document.token.label() : '-' }} + {% set usages = (document.token ? [document.token.label()] : [])|merge( + assignments|filter(a => a.task)|map(a => 'Saisonteam: ' ~ a.task.label()) + ) %} + {{ usages is empty ? '-' : usages|join(', ') }} {{ document.size|file_size }} @@ -51,13 +55,21 @@
- + {% if assignments is empty %} + + {% else %} + {# Held by a NOT NULL foreign key; replace it under Saisonteam-Dokumente first. #} + + {{ icon('delete') }} + + {% endif %}
-{% endblock %} \ No newline at end of file + {% if seasonTaskDocuments is empty %} +
+ Möchtest du das Dokument {{ document }} wirklich löschen? +
+ {% else %} +
+ Das Dokument {{ document }} ist als Saisonteam-Dokument hinterlegt + {%- set labels = seasonTaskDocuments|map(m => m.task ? m.task.label() : null)|filter(l => l is not null) -%} + {%- if labels is not empty %} ({{ labels|join(', ') }}){% endif %} + und kann deshalb nicht gelöscht werden. Ersetze es zuerst unter + Saisonteam-Dokumente. +
+ {% endif %} +{% endblock %} + +{% block confirm %} + {% if seasonTaskDocuments is empty %}{{ parent() }}{% endif %} +{% endblock %} + +{% block button_cancel %}{{ seasonTaskDocuments is empty ? 'Abbrechen' : 'Schließen' }}{% endblock %} diff --git a/templates/htmx_confirmation_modal.html.twig b/templates/htmx_confirmation_modal.html.twig index 64baf23..f3744c6 100644 --- a/templates/htmx_confirmation_modal.html.twig +++ b/templates/htmx_confirmation_modal.html.twig @@ -14,9 +14,11 @@ {% block content %}{% endblock %}
+ {% block confirm %} + {% endblock %} diff --git a/tests/Controller/Administrative/System/Document/DeleteControllerTest.php b/tests/Controller/Administrative/System/Document/DeleteControllerTest.php new file mode 100644 index 0000000..cd8197d --- /dev/null +++ b/tests/Controller/Administrative/System/Document/DeleteControllerTest.php @@ -0,0 +1,173 @@ +client = static::createClient(server: ['HTTPS' => true]); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $this->entityManager->createQuery(sprintf('DELETE FROM %s', SeasonTaskDocument::class))->execute(); + $this->entityManager->createQuery(sprintf('DELETE FROM %s', Upload::class))->execute(); + $this->entityManager->createQuery(sprintf('DELETE FROM %s', User::class))->execute(); + + $this->client->loginUser($this->administrative()); + } + + /** + * The mapping holds a foreign key on upload; left behind, it breaks every later test that + * clears the upload table. + */ + protected function tearDown(): void + { + $this->entityManager->createQuery(sprintf('DELETE FROM %s', SeasonTaskDocument::class))->execute(); + + parent::tearDown(); + } + + public function testTheModalOffersNoConfirmationForAnAssignedDocument(): void + { + $document = $this->assigned($this->document('Vertrag.pdf'), SeasonTask::CONTRACT); + + $crawler = $this->client->request('GET', $this->deleteUrl($document)); + + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode()); + $this->assertStringContainsString('kann deshalb nicht gelöscht werden', $crawler->text()); + $this->assertStringContainsString('Vertrag', $crawler->text()); + $this->assertCount(0, $crawler->filter('button[hx-post]')); + } + + public function testDeletingAnAssignedDocumentIsRefused(): void + { + $document = $this->assigned($this->document('Vertrag.pdf'), SeasonTask::CONTRACT); + $id = $document->getId(); + + $this->client->request('POST', $this->deleteUrl($document)); + + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode()); + $this->assertSame(self::LIST_URL, $this->client->getResponse()->headers->get('HX-Redirect')); + + $this->entityManager->clear(); + $this->assertNotNull($this->entityManager->find(Upload::class, $id)); + } + + /** + * A row whose task key no longer resolves still holds the foreign key. + */ + public function testAnAssignmentToARemovedTaskStillBlocksDeletion(): void + { + $document = $this->document('Alt.pdf'); + $this->entityManager->persist((new SeasonTaskDocument())->setTaskKey('removed_task')->setDocument($document)); + $this->entityManager->flush(); + $id = $document->getId(); + + $this->client->request('POST', $this->deleteUrl($document)); + + // Refused by the guard, not by the database: that would be the 500. + $this->assertSame(self::LIST_URL, $this->client->getResponse()->headers->get('HX-Redirect')); + + $this->entityManager->clear(); + $this->assertNotNull($this->entityManager->find(Upload::class, $id)); + } + + public function testAnUnassignedDocumentIsDeleted(): void + { + $document = $this->document('Handbuch.pdf'); + $id = $document->getId(); + + $crawler = $this->client->request('GET', $this->deleteUrl($document)); + $this->assertCount(1, $crawler->filter('button[hx-post]')); + + $this->client->request('POST', $this->deleteUrl($document)); + + $this->assertSame(self::LIST_URL, $this->client->getResponse()->headers->get('HX-Redirect')); + + $this->entityManager->clear(); + $this->assertNull($this->entityManager->find(Upload::class, $id)); + } + + public function testTheListMarksAnAssignedDocumentAndOffersNoDelete(): void + { + $assigned = $this->assigned($this->document('Vertrag.pdf'), SeasonTask::CONTRACT); + $free = $this->document('Handbuch.pdf'); + + $crawler = $this->client->request('GET', self::LIST_URL); + + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode()); + $this->assertStringContainsString('Saisonteam: Vertrag', $crawler->text()); + $this->assertCount(0, $crawler->filter(sprintf('button[hx-get="%s"]', $this->deleteUrl($assigned)))); + $this->assertCount(1, $crawler->filter(sprintf('button[hx-get="%s"]', $this->deleteUrl($free)))); + } + + private function deleteUrl(Upload $document): string + { + return sprintf('/administrative/system/document/delete/%s', $document->getUuid()); + } + + private function assigned(Upload $document, SeasonTask $task): Upload + { + $this->entityManager->persist((new SeasonTaskDocument())->setTaskKey($task->value)->setDocument($document)); + $this->entityManager->flush(); + + return $document; + } + + private function document(string $filename): Upload + { + // No file on disk: neither the guard nor the list reads it, and deleting a missing file + // is a no-op for UploadHandler::removeUploadedFile(). + $upload = (new Upload()) + ->setType(Upload::TYPE_DOCUMENT) + ->setFilename(sprintf('test-%s-%s', bin2hex(random_bytes(4)), $filename)) + ->setOriginalFilename($filename) + ->setMimeType('application/pdf') + ->setSize(1024) + ; + + $this->entityManager->persist($upload); + $this->entityManager->flush(); + + return $upload; + } + + private function administrative(): User + { + $user = (new User()) + ->setFirstName('Anna') + ->setLastName('Administrative') + ->setEmail('anna.administrative@example.com') + ->setRoles(['ROLE_ADMINISTRATIVE']) + ->setBusProAddressId(1) + ->setBusProPersonId(1) + ; + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +}