feat: rename admin screen, allow deleting a resource
This commit is contained in:
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 & 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]'));
|
||||||
|
|||||||
Reference in new issue
Block a user