feat: mark a library document for a specific purpose
This commit is contained in:
1 parent
cb2d64f2cd
commit
19364df2db
8 files changed
+249
-6
No files matched your search
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* A slot marker on the document library, so a feature can look a file up by what it is for
|
||||
* instead of by id (App\Config\UploadToken). Unique: one document per slot.
|
||||
*
|
||||
* Reduced to these two statements by hand - the generated diff also wanted to drop another
|
||||
* branch's upload columns and restate the long-standing datetime drift on this table.
|
||||
*/
|
||||
final class Version20260924140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add the token slot marker to upload';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE upload ADD token VARCHAR(64) DEFAULT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_17BDE61F5F37A13B ON upload (token)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX UNIQ_17BDE61F5F37A13B ON upload');
|
||||
$this->addSql('ALTER TABLE upload DROP token');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* The slots in the staff-wide document library (Upload::TYPE_DOCUMENT) that code looks a file up
|
||||
* by, rather than by id or name.
|
||||
*
|
||||
* A fixed list and not free text on purpose: something in the application has to consume a slot
|
||||
* for it to mean anything, so inventing one without a release would only produce a token nothing
|
||||
* reads - and a typo would look exactly the same as a document nobody has assigned yet, while
|
||||
* silently dropping an attachment.
|
||||
*
|
||||
* A case's value is what ends up in upload.token, so renaming one orphans the assignment and the
|
||||
* mail quietly loses its attachment. The column is unique: one document per slot.
|
||||
*/
|
||||
enum UploadToken: string
|
||||
{
|
||||
case APPLICATION_FORM_HANDOUT = 'application_form_handout';
|
||||
|
||||
/**
|
||||
* What the document library offers in its dropdown.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::APPLICATION_FORM_HANDOUT => 'Handout für die 2. Mail an Bewerber:innen',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Controller\Administrative\System\Document;
|
||||
|
||||
use App\Config\UploadToken;
|
||||
use App\Entity\Upload;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\UploadRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -18,6 +21,7 @@ class EditController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly UploadRepository $uploadRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -26,6 +30,7 @@ class EditController extends AbstractController
|
||||
public function index(Upload $upload, Request $request): Response
|
||||
{
|
||||
$nameBefore = $upload->getDisplayName();
|
||||
$tokenBefore = $upload->getToken();
|
||||
|
||||
$form = $this
|
||||
->createFormBuilder($upload)
|
||||
@@ -35,19 +40,37 @@ class EditController extends AbstractController
|
||||
'placeholder' => $upload->getOriginalFilename(),
|
||||
],
|
||||
])
|
||||
->add('token', EnumType::class, [
|
||||
'label' => 'Verwendung',
|
||||
'class' => UploadToken::class,
|
||||
'required' => false,
|
||||
'placeholder' => 'keine besondere Verwendung',
|
||||
'choice_label' => fn (UploadToken $token) => $token->label(),
|
||||
'help' => 'Eine Verwendung gehört immer zu genau einem Dokument. Wird sie hier gesetzt, verliert das bisherige Dokument sie.',
|
||||
])
|
||||
->getForm()
|
||||
;
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'Das Dokument wurden umbenannt');
|
||||
// The column is unique, so taking a slot has to free it first - otherwise the flush
|
||||
// dies on the index. Moving rather than refusing is what staff mean by assigning it:
|
||||
// "this is the handout now".
|
||||
$previousHolderId = null === $upload->getToken()
|
||||
? null
|
||||
: $this->uploadRepository->releaseToken($upload->getToken(), $upload);
|
||||
|
||||
$this->logger->info('Rename document', [
|
||||
$this->entityManager->flush();
|
||||
$this->addFlash('success', 'Das Dokument wurde aktualisiert');
|
||||
|
||||
$this->logger->info('Edit document', [
|
||||
'document_id' => $upload->getId(),
|
||||
'document_filename' => $upload->getOriginalFilename(),
|
||||
'document_name_before' => $nameBefore,
|
||||
'document_name_after' => $upload->getDisplayName(),
|
||||
'document_token_before' => $tokenBefore?->value,
|
||||
'document_token_after' => $upload->getToken()?->value,
|
||||
'document_token_taken_from' => $previousHolderId,
|
||||
]);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_system_document_index'));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Config\UploadToken;
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Model\UploadDto;
|
||||
@@ -53,6 +54,14 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $displayName = null;
|
||||
|
||||
/**
|
||||
* Marks this document as the one a specific feature looks up - the handout a mail attaches,
|
||||
* and whatever else earns a slot later. Unique, so assigning a token that another document
|
||||
* holds moves it rather than duplicating it (see the document library's edit action).
|
||||
*/
|
||||
#[ORM\Column(length: 64, nullable: true, unique: true, enumType: UploadToken::class)]
|
||||
private ?UploadToken $token = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?int $size = null;
|
||||
|
||||
@@ -163,6 +172,18 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
return static::STATUS_REJECTED === $this->getStatus();
|
||||
}
|
||||
|
||||
public function getToken(): ?UploadToken
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function setToken(?UploadToken $token): static
|
||||
{
|
||||
$this->token = $token;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFilename(): ?string
|
||||
{
|
||||
return $this->filename;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Config\UploadToken;
|
||||
use App\Entity\Upload;
|
||||
use App\Model\DocumentFilterDto;
|
||||
use App\Repository\Traits\QueryHelperTrait;
|
||||
@@ -231,4 +232,38 @@ class UploadRepository extends ServiceEntityRepository
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function findByToken(UploadToken $token): ?Upload
|
||||
{
|
||||
return $this->findOneBy(['token' => $token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees a token slot, so the document taking it can be flushed without tripping the unique
|
||||
* index. The update runs immediately rather than through the unit of work on purpose: both
|
||||
* rows are Uploads, and nothing guarantees Doctrine writes the release before it writes the
|
||||
* row that takes the token.
|
||||
*
|
||||
* Returns the id of the document that lost the slot, for the log.
|
||||
*/
|
||||
public function releaseToken(UploadToken $token, Upload $takenBy): ?int
|
||||
{
|
||||
$holder = $this->findByToken($token);
|
||||
|
||||
if (null === $holder || $holder === $takenBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this
|
||||
->getEntityManager()
|
||||
->createQuery('UPDATE App\Entity\Upload u SET u.token = NULL WHERE u.id = :id')
|
||||
->setParameter('id', $holder->getId())
|
||||
->execute()
|
||||
;
|
||||
|
||||
// Keeps the loaded entity in step with the row that was just written.
|
||||
$holder->setToken(null);
|
||||
|
||||
return $holder->getId();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Dateiname', 'upload.originalFilename') }}
|
||||
</th>
|
||||
<th>
|
||||
Verwendung
|
||||
</th>
|
||||
<th>
|
||||
Dateigröße
|
||||
</th>
|
||||
@@ -32,6 +35,11 @@
|
||||
<td>
|
||||
{{ document.displayName ? document.displayName : document.originalFilename }}
|
||||
</td>
|
||||
<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() : '-' }}
|
||||
</td>
|
||||
<td>
|
||||
{{ document.size|file_size }}
|
||||
</td>
|
||||
@@ -71,7 +79,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<td colspan="6">
|
||||
Keine Daten...
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Dokument umbenennen{% endblock %}
|
||||
{% block title %}Dokument bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' } }) }}
|
||||
<div class="pb-4">
|
||||
<div class="flex flex-col space-y-2 pb-4">
|
||||
{{ form_row(form.displayName) }}
|
||||
{{ form_row(form.token) }}
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
aktualisieren
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Config\UploadToken;
|
||||
use App\Entity\Upload;
|
||||
use App\Repository\UploadRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* The token column is unique, so taking a slot from another document has to free it first - and
|
||||
* both rows are Uploads, which means nothing guarantees Doctrine writes the release before the
|
||||
* row that takes it. A single flush would trip the index; these tests pin that it does not.
|
||||
*/
|
||||
class UploadRepositoryTokenTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
private UploadRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->repository = self::getContainer()->get(UploadRepository::class);
|
||||
|
||||
$this->entityManager->createQuery(sprintf('DELETE FROM %s', Upload::class))->execute();
|
||||
}
|
||||
|
||||
public function testADocumentIsFoundByItsToken(): void
|
||||
{
|
||||
$document = $this->document('handout.pdf')->setToken(UploadToken::APPLICATION_FORM_HANDOUT);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->assertSame($document, $this->repository->findByToken(UploadToken::APPLICATION_FORM_HANDOUT));
|
||||
}
|
||||
|
||||
public function testNothingIsFoundWhileNoDocumentHoldsTheToken(): void
|
||||
{
|
||||
$this->document('handout.pdf');
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->assertNull($this->repository->findByToken(UploadToken::APPLICATION_FORM_HANDOUT));
|
||||
}
|
||||
|
||||
public function testTakingTheTokenMovesItOffThePreviousDocument(): void
|
||||
{
|
||||
$old = $this->document('handout-2025.pdf')->setToken(UploadToken::APPLICATION_FORM_HANDOUT);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$new = $this->document('handout-2026.pdf')->setToken(UploadToken::APPLICATION_FORM_HANDOUT);
|
||||
$releasedFrom = $this->repository->releaseToken(UploadToken::APPLICATION_FORM_HANDOUT, $new);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->assertSame($old->getId(), $releasedFrom);
|
||||
$this->assertNull($old->getToken());
|
||||
$this->assertSame($new, $this->repository->findByToken(UploadToken::APPLICATION_FORM_HANDOUT));
|
||||
}
|
||||
|
||||
public function testReleasingForTheDocumentThatAlreadyHoldsItChangesNothing(): void
|
||||
{
|
||||
$document = $this->document('handout.pdf')->setToken(UploadToken::APPLICATION_FORM_HANDOUT);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->assertNull($this->repository->releaseToken(UploadToken::APPLICATION_FORM_HANDOUT, $document));
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->assertSame($document, $this->repository->findByToken(UploadToken::APPLICATION_FORM_HANDOUT));
|
||||
}
|
||||
|
||||
private function document(string $filename): Upload
|
||||
{
|
||||
$upload = (new Upload())
|
||||
->setType(Upload::TYPE_DOCUMENT)
|
||||
->setFilename($filename)
|
||||
->setOriginalFilename($filename)
|
||||
->setMimeType('application/pdf')
|
||||
->setSize(1024)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($upload);
|
||||
|
||||
return $upload;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user