feat: rename admin screen, allow deleting a resource

This commit is contained in:
fromme committed 2026-09-17 17:56:44 +02:00
1 parent dc1822b8cf
commit 5e5bf00f8f
7 files changed
+197 -9

No files matched your search

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Resource;
use App\Controller\Traits\ConfirmedActionTrait;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Htmx\HxRedirectResponse;
use App\Repository\BookingRepository;
use App\Security\Role;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Deleting a resource for good, together with every booking and series on it.
*
* Retiring (EditController) is the way to keep the history; this is for a resource that is gone
* or was created by mistake. The confirmation names how many bookings go with it.
*
* The foreign keys are ON DELETE RESTRICT, so the bookings are removed explicitly first. The
* resource row is locked like BookingManager does, so a booking made while the modal is open
* either lands before the lock -- and is deleted with the rest -- or waits and then fails.
*/
#[IsGranted(Role::ADMIN)]
final class DeleteController extends AbstractController
{
use ConfirmedActionTrait;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly BookingRepository $bookings,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/resources/{id}/delete', name: 'app_admin_resource_delete', methods: ['GET', 'POST'])]
public function index(Request $request, Resource $resource): Response
{
$tokenId = 'delete-resource-'.$resource->getId();
if (!$request->isMethod(Request::METHOD_POST)) {
$total = $this->bookings->count(['resource' => $resource]);
$upcoming = $this->bookings->countFromDate($resource, new \DateTimeImmutable('today'));
return $this->render('admin/_modal_resource_delete.html.twig', [
'resource' => $resource,
'upcoming' => $upcoming,
'past' => $total - $upcoming,
'csrf_token_id' => $tokenId,
]);
}
$this->denyUnlessCsrfValid($request, $tokenId);
$id = $resource->getId();
$label = $resource->getLabel();
$deleted = $this->em->wrapInTransaction(function () use ($resource): int {
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$deleted = $this->em->createQueryBuilder()
->delete(Booking::class, 'b')
->where('b.resource = :resource')
->setParameter('resource', $resource)
->getQuery()
->execute();
$this->em->createQueryBuilder()
->delete(BookingSeries::class, 's')
->where('s.resource = :resource')
->setParameter('resource', $resource)
->getQuery()
->execute();
$this->em->remove($resource);
$this->em->flush();
return (int) $deleted;
});
$this->logger->info('Admin deleted a resource.', [
'admin' => $this->getUser()?->getUserIdentifier(),
'resource_id' => $id,
'label' => $label,
'bookings_deleted' => $deleted,
]);
$this->addFlash('success', $deleted > 0
? \sprintf('%s und %d Buchungen gelöscht.', $label, $deleted)
: \sprintf('%s gelöscht.', $label));
return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
* A desk or parking spot. The type is not a field: it is fixed by the route that creates the * A resource. The type is not a field: it is fixed by the route that creates the
* resource and never changes afterwards, which is what lets the group choices be limited to it. * resource and never changes afterwards, which is what lets the group choices be limited to it.
*/ */
final class ResourceFormType extends AbstractType final class ResourceFormType extends AbstractType
@@ -50,7 +50,7 @@ final class ResourceFormType extends AbstractType
if ($options['with_active']) { if ($options['with_active']) {
$builder->add('active', CheckboxType::class, [ $builder->add('active', CheckboxType::class, [
'label' => 'Buchbar', 'label' => 'Buchbar',
'help' => 'Nicht buchbare Plätze erscheinen nicht mehr im Plan. Bestehende Buchungen bleiben erhalten.', 'help' => 'Nicht buchbare Ressourcen erscheinen nicht mehr im Plan. Bestehende Buchungen bleiben erhalten.',
'required' => false, 'required' => false,
]); ]);
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{# {#
Creating and editing a desk or parking spot. Same double duty as the booking modal: the form Creating and editing a resource. Same double duty as the booking modal: the form
targets the modal itself, so an invalid submit (422) replaces it with the errors showing, and a targets the modal itself, so an invalid submit (422) replaces it with the errors showing, and a
valid one answers with HX-Redirect back to the list. valid one answers with HX-Redirect back to the list.
#} #}
@@ -0,0 +1,17 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}{{ resource.type.label }} löschen?{% endblock %}
{% block content %}
<p>
{{ resource.type.label }} <em>{{ resource.label }}</em> wird endgültig gelöscht.
</p>
{% if upcoming > 0 or past > 0 %}
<p class="pt-2" data-booking-count>
Mit gelöscht werden {{ upcoming }} Buchungen ab heute und {{ past }} vergangene.
Um die Buchungen zu behalten, stattdessen „nicht buchbar“ setzen.
</p>
{% endif %}
{% endblock %}
{% block button_confirm %}Löschen{% endblock %}
+8 -4
View File
@@ -3,11 +3,12 @@
here once saved -- the same shape as the sibling apps' admin lists. here once saved -- the same shape as the sibling apps' admin lists.
Retired resources stay listed, muted, because this is the only place they can be reactivated. Retired resources stay listed, muted, because this is the only place they can be reactivated.
Only an empty room offers "Löschen"; DeleteController refuses the rest regardless. Only an empty room offers "Löschen"; DeleteController refuses the rest regardless. A resource
can always be deleted, taking its bookings with it -- the modal says how many.
#} #}
{% extends 'layout.html.twig' %} {% extends 'layout.html.twig' %}
{% block title %}Plätze & Räume · ephub{% endblock %} {% block title %}Ressourcen · ephub{% endblock %}
{% macro opener(url, label, variant = 'neutral') %} {% macro opener(url, label, variant = 'neutral') %}
<button type="button" class="button button--small button--{{ variant }}" <button type="button" class="button button--small button--{{ variant }}"
@@ -29,7 +30,10 @@
<span class="badge bg-gray-100 text-gray-600 ml-1">nicht buchbar</span> <span class="badge bg-gray-100 text-gray-600 ml-1">nicht buchbar</span>
{% endif %} {% endif %}
</span> </span>
{{ _self.opener(path('app_admin_resource_edit', { id: resource.id }), 'Bearbeiten') }} <div class="flex gap-2">
{{ _self.opener(path('app_admin_resource_edit', { id: resource.id }), 'Bearbeiten') }}
{{ _self.opener(path('app_admin_resource_delete', { id: resource.id }), 'Löschen') }}
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -39,7 +43,7 @@
{% block content %} {% block content %}
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a> <a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
<h1 class="pt-2">Plätze & Räume</h1> <h1 class="pt-2">Ressourcen</h1>
<p class="subline">Was gebucht werden kann und wo es steht.</p> <p class="subline">Was gebucht werden kann und wo es steht.</p>
{% for section in sections %} {% for section in sections %}
+2 -2
View File
@@ -68,10 +68,10 @@
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50"> class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2"> <div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:layout-grid', { class: 'h-6 w-6 shrink-0 text-primary' }) }} {{ ux_icon('lucide:layout-grid', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">Plätze &amp; Räume</span> <span class="text-lg font-bold">Ressourcen</span>
</div> </div>
<p class="text-sm text-gray-500 pb-4"> <p class="text-sm text-gray-500 pb-4">
Schreibtische, Parkplätze und Räume anlegen und bearbeiten. Buchbare Ressourcen und ihre Standorte verwalten.
</p> </p>
</a> </a>
{% endif %} {% endif %}
@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace App\Tests\Controller\Admin; namespace App\Tests\Controller\Admin;
use App\Entity\Booking; use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource; use App\Entity\Resource;
use App\Entity\ResourceGroup; use App\Entity\ResourceGroup;
use App\Entity\User; use App\Entity\User;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType; use App\Enum\ResourceType;
use App\Model\TimeRange;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -224,6 +227,67 @@ class ResourceAdminControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
} }
public function testAnEmployeeCannotDeleteAResource(): void
{
$desk = $this->resource('Schreibtisch 01');
$this->client->loginUser($this->user('[email protected]'));
$this->client->request('POST', \sprintf('/admin/resources/%d/delete', $desk->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
self::assertNotNull($this->reload($desk));
}
/**
* Unlike retiring, deleting takes the bookings and series with it -- the foreign keys would
* refuse otherwise -- and the confirmation says how many go.
*/
public function testDeletingAResourceTakesItsBookingsWithIt(): void
{
$desk = $this->resource('Schreibtisch 01');
$other = $this->resource('Schreibtisch 02');
$bea = $this->user('[email protected]');
$series = new BookingSeries($bea, $desk, RecurrenceRule::DAILY, new \DateTimeImmutable('+1 day'), new \DateTimeImmutable('+2 days'), TimeRange::wholeDay());
$this->em->persist($series);
foreach ($series->occurrences() as $day) {
$this->em->persist(new Booking($bea, $desk, $day, series: $series));
}
$this->em->persist(new Booking($bea, $desk, new \DateTimeImmutable('-3 days')));
$this->em->persist(new Booking($bea, $other, new \DateTimeImmutable('+1 day')));
$this->em->flush();
$deskId = $desk->getId();
$this->client->loginUser($this->admin());
$uri = \sprintf('/admin/resources/%d/delete', $deskId);
$crawler = $this->client->request('GET', '/admin/resources');
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="%s"]', $uri)));
$modal = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
self::assertStringContainsString('2 Buchungen ab heute und 1 vergangene', $modal->filter('[data-booking-count]')->text());
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
self::assertSame('/admin/resources', $this->client->getResponse()->headers->get('HX-Redirect'));
$this->em->clear();
self::assertNull($this->em->find(Resource::class, $deskId));
self::assertCount(0, $this->em->getRepository(BookingSeries::class)->findAll());
$remaining = $this->em->getRepository(Booking::class)->findAll();
self::assertCount(1, $remaining);
self::assertSame($other->getId(), $remaining[0]->getResource()->getId());
}
public function testDeletingAResourceNeedsAValidToken(): void
{
$desk = $this->resource('Schreibtisch 01');
$this->client->loginUser($this->admin());
$this->client->request('POST', \sprintf('/admin/resources/%d/delete', $desk->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
$this->em->clear();
self::assertNotNull($this->em->find(Resource::class, $desk->getId()));
}
public function testTheDashboardLinksHereForAdminsOnly(): void public function testTheDashboardLinksHereForAdminsOnly(): void
{ {
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));