fix: refuse deleting a document assigned to a season task
This commit is contained in:
1 parent
4aa2d7a5e6
commit
3116d23199
7 files changed
+271
-14
No files matched your search
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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<int, SeasonTaskDocument>
|
||||
*/
|
||||
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<int, array<int, SeasonTaskDocument>>
|
||||
*/
|
||||
public function findGroupedByDocumentId(): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($this->findAll() as $seasonTaskDocument) {
|
||||
$grouped[$seasonTaskDocument->getDocument()->getId()][] = $seasonTaskDocument;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for document in pagination %}
|
||||
{% set assignments = seasonTaskDocuments[document.id] ?? [] %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ document.displayName ? document.displayName : document.originalFilename }}
|
||||
@@ -38,7 +39,10 @@
|
||||
<td>
|
||||
{# 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(', ') }}
|
||||
</td>
|
||||
<td>
|
||||
{{ document.size|file_size }}
|
||||
@@ -51,13 +55,21 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center space-x-2 justify-end">
|
||||
<button type="button"
|
||||
class="text-red-500"
|
||||
hx-get="{{ path('app_administrative_system_document_delete', { 'uuid': document.uuid }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('delete') }}
|
||||
</button>
|
||||
{% if assignments is empty %}
|
||||
<button type="button"
|
||||
class="text-red-500"
|
||||
hx-get="{{ path('app_administrative_system_document_delete', { 'uuid': document.uuid }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
{{ icon('delete') }}
|
||||
</button>
|
||||
{% else %}
|
||||
{# Held by a NOT NULL foreign key; replace it under Saisonteam-Dokumente first. #}
|
||||
<span class="text-gray-300 cursor-not-allowed"
|
||||
title="Als Saisonteam-Dokument hinterlegt, kann nicht gelöscht werden">
|
||||
{{ icon('delete') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<button type="button"
|
||||
hx-get="{{ path('app_administrative_system_document_replace', { 'uuid': document.uuid }) }}"
|
||||
hx-target="body"
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||
|
||||
{% block title %}{{ seasonTaskDocuments is empty ? 'Bist du sicher?' : 'Löschen nicht möglich' }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
Möchtest du das Dokument <em>{{ document }}</em> wirklich löschen?
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% if seasonTaskDocuments is empty %}
|
||||
<div>
|
||||
Möchtest du das Dokument <em>{{ document }}</em> wirklich löschen?
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
Das Dokument <em>{{ document }}</em> 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
|
||||
<a class="underline" href="{{ path('app_administrative_system_season_task_document_index') }}">Saisonteam-Dokumente</a>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block confirm %}
|
||||
{% if seasonTaskDocuments is empty %}{{ parent() }}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block button_cancel %}{{ seasonTaskDocuments is empty ? 'Abbrechen' : 'Schließen' }}{% endblock %}
|
||||
@@ -14,9 +14,11 @@
|
||||
</div>
|
||||
{% block content %}{% endblock %}
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
{% block confirm %}
|
||||
<button type="button" class="btn bg-red-500" {% block button_attribute %}hx-post="{{ app.request.uri }}"{% endblock %}>
|
||||
{% block button_confirm %}Ja{% endblock %}
|
||||
</button>
|
||||
{% endblock %}
|
||||
<button type="button" class="btn" {{ stimulus_action('modal', 'close') }}>
|
||||
{% block button_cancel %}Abbrechen{% endblock %}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Administrative\System\Document;
|
||||
|
||||
use App\Config\SeasonTask;
|
||||
use App\Entity\SeasonTaskDocument;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* A library document handed out for a season task is held by a NOT NULL foreign key. Deleting it
|
||||
* used to end in a 500 that htmx swallowed, so the modal just stayed open; it now has to be
|
||||
* refused before it reaches the database.
|
||||
*/
|
||||
class DeleteControllerTest extends WebTestCase
|
||||
{
|
||||
private const LIST_URL = '/administrative/system/document';
|
||||
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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('[email protected]')
|
||||
->setRoles(['ROLE_ADMINISTRATIVE'])
|
||||
->setBusProAddressId(1)
|
||||
->setBusProPersonId(1)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user