feat: assignable managers for accommodation bookings
This commit is contained in:
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\User;
|
||||
use App\Form\Admin\Groups\AccommodationBookingCreateType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Service\AccommodationBookingService;
|
||||
@@ -31,6 +32,14 @@ class CreateController extends AbstractController
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
// Whoever creates a booking in the backoffice looks after it until someone else is
|
||||
// assigned in the edit form. The route is behind ROLE_GROUPS_MANAGER, so the creator
|
||||
// is always eligible as Betreuer.
|
||||
$user = $this->getUser();
|
||||
if ($user instanceof User) {
|
||||
$booking->setManagedBy($user);
|
||||
}
|
||||
|
||||
$form = $this->createForm(AccommodationBookingCreateType::class, $booking, [
|
||||
'hx_post' => $request->getRequestUri(),
|
||||
]);
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Form\Admin\Groups\AccommodationBookingType;
|
||||
use App\Repository\Groups\AdditionalServiceRepository;
|
||||
use App\Repository\Groups\BoardServiceRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -28,6 +29,7 @@ class EditController extends AbstractController
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly BoardServiceRepository $boardServiceRepo,
|
||||
private readonly AdditionalServiceRepository $additionalServiceRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
@@ -69,12 +71,26 @@ class EditController extends AbstractController
|
||||
));
|
||||
}
|
||||
|
||||
// ROLE_ADMIN inherits ROLE_GROUPS_ADMIN, so this single check covers both.
|
||||
$assignableManagers = [];
|
||||
if ($this->isGranted('ROLE_GROUPS_ADMIN')) {
|
||||
$assignableManagers = $this->userRepo->findGroupsStaff();
|
||||
|
||||
// A user assigned earlier may have lost the role since — keep them in the choices
|
||||
// so the field can render the current value instead of failing to transform it.
|
||||
$currentManager = $booking->getManagedBy();
|
||||
if (null !== $currentManager && !in_array($currentManager, $assignableManagers, true)) {
|
||||
$assignableManagers[] = $currentManager;
|
||||
}
|
||||
}
|
||||
|
||||
$form = $this->createForm(AccommodationBookingType::class, $booking, [
|
||||
'max_adolescent_age' => $accommodation?->getMaxAdolescentAge() ?? 0,
|
||||
'board_services' => $boardServices,
|
||||
'additional_services' => $additionalServices,
|
||||
'current_board_service' => $currentBoardService,
|
||||
'current_additional_services' => $currentAdditionalServices,
|
||||
'assignable_managers' => $assignableManagers,
|
||||
]);
|
||||
$previousStatus = $booking->getStatus();
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ class IndexController extends AbstractController
|
||||
$qb = $this
|
||||
->bookingRepository
|
||||
->createQueryBuilder('booking')
|
||||
->select('booking', 'managed_by')
|
||||
->leftJoin('booking.accommodation', 'accommodation')
|
||||
->leftJoin('booking.managedBy', 'managed_by')
|
||||
->addSelect('accommodation')
|
||||
->where('booking.dateTo >= :today')
|
||||
->setParameter('today', $today)
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Form\Admin\Groups;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
@@ -121,6 +122,20 @@ class AccommodationBookingType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
// Assigning a manager is reserved for the groups admins; the controller leaves the
|
||||
// choices empty for everyone else, so the field never enters the form tree and a
|
||||
// plain manager cannot set it by hand-crafting the request either.
|
||||
if (!empty($options['assignable_managers'])) {
|
||||
$builder->add('managedBy', EntityType::class, [
|
||||
'class' => User::class,
|
||||
'required' => false,
|
||||
'choices' => $options['assignable_managers'],
|
||||
'choice_label' => 'email',
|
||||
'placeholder' => 'keine Zuordnung',
|
||||
'label' => 'Bearbeiter:in',
|
||||
]);
|
||||
}
|
||||
|
||||
$builder
|
||||
->add('status', EnumType::class, [
|
||||
'label' => 'Status',
|
||||
@@ -176,11 +191,13 @@ class AccommodationBookingType extends AbstractType
|
||||
'additional_services' => [],
|
||||
'current_board_service' => null,
|
||||
'current_additional_services' => [],
|
||||
'assignable_managers' => [],
|
||||
]);
|
||||
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
||||
$resolver->setAllowedTypes('board_services', 'array');
|
||||
$resolver->setAllowedTypes('additional_services', 'array');
|
||||
$resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]);
|
||||
$resolver->setAllowedTypes('current_additional_services', 'array');
|
||||
$resolver->setAllowedTypes('assignable_managers', 'array');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,22 @@ class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Users who can look after a group booking. The roles are a JSON array column and no
|
||||
* JSON_CONTAINS is registered, so the needles carry their quotes to stay anchored to
|
||||
* whole array entries. Only the literal roles count — a plain ROLE_ADMIN is not offered.
|
||||
*
|
||||
* @return User[]
|
||||
*/
|
||||
public function findGroupsStaff(): array
|
||||
{
|
||||
return $this->createQueryBuilder('u')
|
||||
->andWhere('u.roles LIKE :groupsAdmin OR u.roles LIKE :groupsManager')
|
||||
->setParameter('groupsAdmin', '%"ROLE_GROUPS_ADMIN"%')
|
||||
->setParameter('groupsManager', '%"ROLE_GROUPS_MANAGER"%')
|
||||
->orderBy('u.email', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if form.managedBy is defined %}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.managedBy) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_row(form.accommodationDiscount) }}
|
||||
{{ form_row(form.boardServiceDiscount) }}
|
||||
{{ form_row(form.additionalServicesDiscount) }}
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
<th>
|
||||
Personen
|
||||
</th>
|
||||
<th>
|
||||
Betreuer:in
|
||||
</th>
|
||||
<th>
|
||||
Status
|
||||
</th>
|
||||
@@ -49,6 +52,13 @@
|
||||
<td>
|
||||
{{ booking.paxCount }}
|
||||
</td>
|
||||
<td>
|
||||
{% if booking.managedBy is not null %}
|
||||
{{ booking.managedBy.email }}
|
||||
{% else %}
|
||||
keine Zuordnung
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ booking.status.label }}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Admin\AccommodationBooking\CreateController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\User;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Covers the Betreuer default only — the form itself is exercised through
|
||||
* AccommodationBookingCreateType.
|
||||
*/
|
||||
class CreateControllerTest extends TestCase
|
||||
{
|
||||
public function testTheCreatingUserBecomesTheBetreuer(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
|
||||
$controller = $this->buildController($user);
|
||||
$controller->index(Request::create('/admin/accommodation-booking/create'));
|
||||
|
||||
self::assertSame($user, $controller->capturedBooking?->getManagedBy());
|
||||
}
|
||||
|
||||
private function buildController(?UserInterface $user): TestableCreateController
|
||||
{
|
||||
$form = $this->createMock(FormInterface::class);
|
||||
$form->method('handleRequest')->willReturn($form);
|
||||
$form->method('isSubmitted')->willReturn(false);
|
||||
|
||||
return new TestableCreateController(
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
$this->createMock(LoggerInterface::class),
|
||||
$this->createMock(AccommodationBookingService::class),
|
||||
$form,
|
||||
$user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableCreateController extends CreateController
|
||||
{
|
||||
public ?AccommodationBooking $capturedBooking = null;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $entityManager,
|
||||
LoggerInterface $logger,
|
||||
AccommodationBookingService $bookingService,
|
||||
private readonly FormInterface $form,
|
||||
private readonly ?UserInterface $user,
|
||||
) {
|
||||
parent::__construct($entityManager, $logger, $bookingService);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
if ($data instanceof AccommodationBooking) {
|
||||
$this->capturedBooking = $data;
|
||||
}
|
||||
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
protected function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
return new Response();
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
use App\Controller\Admin\AccommodationBooking\EditController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Groups\AdditionalServiceRepository;
|
||||
use App\Repository\Groups\BoardServiceRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -101,6 +103,7 @@ class EditControllerTest extends TestCase
|
||||
$this->createMock(LoggerInterface::class),
|
||||
$this->createMock(BoardServiceRepository::class),
|
||||
$this->createMock(AdditionalServiceRepository::class),
|
||||
$this->createMock(UserRepository::class),
|
||||
$bookingService,
|
||||
$form,
|
||||
);
|
||||
@@ -109,26 +112,106 @@ class EditControllerTest extends TestCase
|
||||
|
||||
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testManagerWithoutAdminRoleGetsNoBetreuerChoices(): void
|
||||
{
|
||||
$userRepo = $this->createMock(UserRepository::class);
|
||||
$userRepo->expects(self::never())->method('findGroupsStaff');
|
||||
|
||||
$controller = $this->buildController($userRepo, granted: false);
|
||||
$controller->index(new AccommodationBooking(), Request::create('/admin/accommodation-booking/1/edit'));
|
||||
|
||||
self::assertSame([], $controller->capturedOptions['assignable_managers']);
|
||||
}
|
||||
|
||||
public function testAdminGetsTheGroupsStaffAsBetreuerChoices(): void
|
||||
{
|
||||
$staff = [new User('[email protected]')];
|
||||
$userRepo = $this->createMock(UserRepository::class);
|
||||
$userRepo->method('findGroupsStaff')->willReturn($staff);
|
||||
|
||||
$controller = $this->buildController($userRepo, granted: true);
|
||||
$controller->index(new AccommodationBooking(), Request::create('/admin/accommodation-booking/1/edit'));
|
||||
|
||||
self::assertSame($staff, $controller->capturedOptions['assignable_managers']);
|
||||
}
|
||||
|
||||
public function testAnAssignedUserWhoLostTheRoleStaysSelectable(): void
|
||||
{
|
||||
$formerManager = new User('[email protected]');
|
||||
$staff = [new User('[email protected]')];
|
||||
|
||||
$userRepo = $this->createMock(UserRepository::class);
|
||||
$userRepo->method('findGroupsStaff')->willReturn($staff);
|
||||
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setManagedBy($formerManager);
|
||||
|
||||
$controller = $this->buildController($userRepo, granted: true);
|
||||
$controller->index($booking, Request::create('/admin/accommodation-booking/1/edit'));
|
||||
|
||||
self::assertSame([$staff[0], $formerManager], $controller->capturedOptions['assignable_managers']);
|
||||
}
|
||||
|
||||
private function buildController(UserRepository $userRepo, bool $granted): TestableEditController
|
||||
{
|
||||
// An unsubmitted form: index() falls through to render() and only the options
|
||||
// handed to createForm() are of interest here.
|
||||
$form = $this->createMock(FormInterface::class);
|
||||
$form->method('handleRequest')->willReturn($form);
|
||||
$form->method('isSubmitted')->willReturn(false);
|
||||
|
||||
return new TestableEditController(
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
$this->createMock(LoggerInterface::class),
|
||||
$this->createMock(BoardServiceRepository::class),
|
||||
$this->createMock(AdditionalServiceRepository::class),
|
||||
$userRepo,
|
||||
$this->createMock(AccommodationBookingService::class),
|
||||
$form,
|
||||
$granted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableEditController extends EditController
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $capturedOptions = [];
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $entityManager,
|
||||
LoggerInterface $logger,
|
||||
BoardServiceRepository $boardServiceRepo,
|
||||
AdditionalServiceRepository $additionalServiceRepo,
|
||||
UserRepository $userRepo,
|
||||
AccommodationBookingService $bookingService,
|
||||
private readonly FormInterface $form,
|
||||
private readonly bool $granted = false,
|
||||
) {
|
||||
parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $bookingService);
|
||||
parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $userRepo, $bookingService);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
$this->capturedOptions = $options;
|
||||
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
protected function isGranted(mixed $attribute, mixed $subject = null): bool
|
||||
{
|
||||
return $this->granted;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user