Compare commits
8
Commits
dcd85bb911
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be349c6fde | ||
|
|
b1fe2fcd26 | ||
|
|
0ab39d810b | ||
|
|
d68b6b156c | ||
|
|
a957e0d662 | ||
|
|
813aaf2cf6 | ||
|
|
5d1d7fa19e | ||
|
|
3af326251d |
No files matched your search
@@ -87,10 +87,5 @@ EP_OAUTH2_SCOPES=email,profile,roles
|
|||||||
###> app/features ###
|
###> app/features ###
|
||||||
# Feature flags, read by config/packages/flagception.yaml. Off here and switched on per
|
# Feature flags, read by config/packages/flagception.yaml. Off here and switched on per
|
||||||
# deployment in .env.local, so a feature reaches production when its environment says so rather
|
# deployment in .env.local, so a feature reaches production when its environment says so rather
|
||||||
# than when it is merged.
|
# than when it is merged. None at the moment.
|
||||||
#
|
|
||||||
# Door, lift and barrier codes under /access-codes and their admin table. Off until the codes
|
|
||||||
# have actually been entered: an empty list of codes is worse than no menu entry, because it
|
|
||||||
# reads as "there are none" to somebody standing at the door.
|
|
||||||
FEATURE_ACCESS_CODES=false
|
|
||||||
###< app/features ###
|
###< app/features ###
|
||||||
@@ -8,6 +8,3 @@ APP_SECRET=44660e8fcd421376ebe0f7a7d55e179c
|
|||||||
|
|
||||||
# ddev serves the application under this name; anything else is refused.
|
# ddev serves the application under this name; anything else is refused.
|
||||||
SYMFONY_TRUSTED_HOSTS='^ephub\.ddev\.site$'
|
SYMFONY_TRUSTED_HOSTS='^ephub\.ddev\.site$'
|
||||||
|
|
||||||
# On for local development, so a fresh checkout sees the feature the branch is about.
|
|
||||||
FEATURE_ACCESS_CODES=true
|
|
||||||
@@ -3,7 +3,3 @@ KERNEL_CLASS='App\Kernel'
|
|||||||
# Fixed and committed so the test suite is reproducible, and never served: .env.test is excluded
|
# Fixed and committed so the test suite is reproducible, and never served: .env.test is excluded
|
||||||
# from the deploy rsync and production takes APP_SECRET from its own uncommitted .env.local.
|
# from the deploy rsync and production takes APP_SECRET from its own uncommitted .env.local.
|
||||||
APP_SECRET='$ecretf0rt3st'
|
APP_SECRET='$ecretf0rt3st'
|
||||||
|
|
||||||
# The access-code suite tests the feature, not the gate in front of it, so it runs with the flag
|
|
||||||
# on.
|
|
||||||
FEATURE_ACCESS_CODES=true
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5m4-1v5l4 2"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 262 B |
+6
-6
@@ -7,14 +7,14 @@
|
|||||||
"php": ">=8.5",
|
"php": ">=8.5",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"doctrine/doctrine-bundle": "^3.3",
|
"doctrine/doctrine-bundle": "^3.3.4",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
"doctrine/orm": "^3.7.2",
|
"doctrine/orm": "^3.7.5",
|
||||||
"flagception/flagception-bundle": "^6.0",
|
"flagception/flagception-bundle": "^6.0",
|
||||||
"knplabs/knp-paginator-bundle": "^6.10",
|
"knplabs/knp-paginator-bundle": "^6.10",
|
||||||
"league/oauth2-client": "^2.9.1",
|
"league/oauth2-client": "^2.9.1",
|
||||||
"phpdocumentor/reflection-docblock": "^6.0",
|
"phpdocumentor/reflection-docblock": "^6.0",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.4.0",
|
||||||
"symfony/apache-pack": "*",
|
"symfony/apache-pack": "*",
|
||||||
"symfony/asset": "7.4.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/asset-mapper": "7.4.*",
|
"symfony/asset-mapper": "7.4.*",
|
||||||
@@ -37,11 +37,11 @@
|
|||||||
"symfony/runtime": "7.4.*",
|
"symfony/runtime": "7.4.*",
|
||||||
"symfony/security-bundle": "7.4.*",
|
"symfony/security-bundle": "7.4.*",
|
||||||
"symfony/serializer": "7.4.*",
|
"symfony/serializer": "7.4.*",
|
||||||
"symfony/stimulus-bundle": "^3.5.1",
|
"symfony/stimulus-bundle": "^3.6.0",
|
||||||
"symfony/string": "7.4.*",
|
"symfony/string": "7.4.*",
|
||||||
"symfony/translation": "7.4.*",
|
"symfony/translation": "7.4.*",
|
||||||
"symfony/twig-bundle": "7.4.*",
|
"symfony/twig-bundle": "7.4.*",
|
||||||
"symfony/ux-icons": "^3.5.1",
|
"symfony/ux-icons": "^3.6.0",
|
||||||
"symfony/validator": "7.4.*",
|
"symfony/validator": "7.4.*",
|
||||||
"symfony/web-link": "7.4.*",
|
"symfony/web-link": "7.4.*",
|
||||||
"symfony/yaml": "7.4.*",
|
"symfony/yaml": "7.4.*",
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^13.3.5",
|
"phpunit/phpunit": "^13.4.5",
|
||||||
"symfony/browser-kit": "7.4.*",
|
"symfony/browser-kit": "7.4.*",
|
||||||
"symfony/css-selector": "7.4.*",
|
"symfony/css-selector": "7.4.*",
|
||||||
"symfony/debug-bundle": "7.4.*",
|
"symfony/debug-bundle": "7.4.*",
|
||||||
|
|||||||
Generated
+519
-509
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,6 @@
|
|||||||
# flag gets the state the feature had before it existed. Switching one on is an .env change, not
|
# flag gets the state the feature had before it existed. Switching one on is an .env change, not
|
||||||
# a release.
|
# a release.
|
||||||
flagception:
|
flagception:
|
||||||
features:
|
# None at the moment. A new one is an entry here, with `default: false` and an `env` variable
|
||||||
access_codes:
|
# declared in .env.
|
||||||
default: false
|
features: {}
|
||||||
env: FEATURE_ACCESS_CODES
|
|
||||||
@@ -21,7 +21,7 @@ framework:
|
|||||||
# Mail is deliberately NOT routed to async. Nothing starts a worker on the server --
|
# Mail is deliberately NOT routed to async. Nothing starts a worker on the server --
|
||||||
# deploy.php only stops them -- so a queued SendEmailMessage would be written to
|
# deploy.php only stops them -- so a queued SendEmailMessage would be written to
|
||||||
# messenger_messages and stay there, with no error anywhere and nobody notified.
|
# messenger_messages and stay there, with no error anywhere and nobody notified.
|
||||||
# Sending happens in the request instead; BookingNotifier catches a transport
|
# Sending happens in the request instead; App\Email\Mailer catches a transport
|
||||||
# failure so it can never fail the booking that triggered it. Route it back here
|
# failure so it can never fail the booking that triggered it. Route it back here
|
||||||
# once a `messenger:consume async` worker actually runs.
|
# once a `messenger:consume async` worker actually runs.
|
||||||
# Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
# Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ security:
|
|||||||
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep,
|
# No password_hashers: there are no passwords here. Authentication is OAuth2 against myep,
|
||||||
# which owns the credentials; ephub only ever sees a userinfo response.
|
# which owns the credentials; ephub only ever sees a userinfo response.
|
||||||
|
|
||||||
|
# An administrator can do everything a supervisor can, so the "may act on other people's
|
||||||
|
# bookings" checks ask for ROLE_SUPERVISOR alone. Only isGranted() sees this:
|
||||||
|
# User::getRoles() still returns the stored roles, which is why CodeVisibility names
|
||||||
|
# ROLE_ADMIN explicitly.
|
||||||
|
role_hierarchy:
|
||||||
|
ROLE_ADMIN: [ROLE_SUPERVISOR]
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
app_user_provider:
|
app_user_provider:
|
||||||
entity:
|
entity:
|
||||||
@@ -46,8 +53,11 @@ security:
|
|||||||
# Before the catch-all, or ROLE_EMPLOYEE would match ^/ first and the admin area would be
|
# Before the catch-all, or ROLE_EMPLOYEE would match ^/ first and the admin area would be
|
||||||
# open to everybody. Order is the whole guard here.
|
# open to everybody. Order is the whole guard here.
|
||||||
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
|
- { path: ^/admin, roles: ROLE_ADMIN, requires_channel: https }
|
||||||
# Either role opens the app -- access_control treats a list of roles as an OR. Not
|
# Everybody's bookings, for supervisors and (through role_hierarchy) administrators. Kept
|
||||||
|
# out of ^/admin so that everything under it stays administrator-only.
|
||||||
|
- { path: ^/manage, roles: ROLE_SUPERVISOR, requires_channel: https }
|
||||||
|
# Any of these opens the app -- access_control treats a list of roles as an OR. Not
|
||||||
# ROLE_USER: myep authenticates far more people than work here, and someone who holds
|
# ROLE_USER: myep authenticates far more people than work here, and someone who holds
|
||||||
# neither of these is refused at login with an explanation rather than let in to an
|
# none of these is refused at login with an explanation rather than let in to an empty
|
||||||
# empty screen.
|
# screen. Keep in step with Role::ELIGIBLE.
|
||||||
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_ADMIN], requires_channel: https }
|
- { path: ^/, roles: [ROLE_EMPLOYEE, ROLE_SUPERVISOR, ROLE_ADMIN], requires_channel: https }
|
||||||
@@ -680,7 +680,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
|
||||||
* routing?: array<string, array{ // Default: []
|
* routing?: array<string, array{ // Default: []
|
||||||
* service?: scalar|Param|null,
|
* service?: scalar|Param|null,
|
||||||
* secret?: scalar|Param|null, // The secret used to verify incoming request signatures. It must be set in production: with an empty value, requests from any sender are accepted. // Default: ""
|
* secret?: scalar|Param|null, // The secret used to verify incoming request signatures. It must be set in production: with an empty value, depending on the parser, requests from any sender are accepted or every request is rejected. // Default: ""
|
||||||
* }>,
|
* }>,
|
||||||
* },
|
* },
|
||||||
* remote_event?: bool|array{ // RemoteEvent configuration
|
* remote_event?: bool|array{ // RemoteEvent configuration
|
||||||
@@ -1224,7 +1224,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* token_handler?: Param|string|array{
|
* token_handler?: Param|string|array{
|
||||||
* id?: scalar|Param|null,
|
* id?: scalar|Param|null,
|
||||||
* oidc_user_info?: Param|string|array{
|
* oidc_user_info?: Param|string|array{
|
||||||
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
|
* base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). The latter must end with a slash when it has a path, as the discovery path is resolved against it as a relative URL.
|
||||||
* discovery?: array{ // Enable the OIDC discovery.
|
* discovery?: array{ // Enable the OIDC discovery.
|
||||||
* cache?: array{
|
* cache?: array{
|
||||||
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
* id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
|
||||||
@@ -1293,9 +1293,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* lifetime?: int|Param, // Default: 31536000
|
* lifetime?: int|Param, // Default: 31536000
|
||||||
* path?: scalar|Param|null, // Default: "/"
|
* path?: scalar|Param|null, // Default: "/"
|
||||||
* domain?: scalar|Param|null, // Default: null
|
* domain?: scalar|Param|null, // Default: null
|
||||||
* secure?: true|false|"auto"|Param, // Default: true
|
* secure?: true|false|"auto"|Param, // Defaults to the value of "framework.session.cookie_secure", or to "auto".
|
||||||
* httponly?: bool|Param, // Default: true
|
* httponly?: bool|Param, // Default: true
|
||||||
* samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax"
|
* samesite?: null|"lax"|"strict"|"none"|Param, // Defaults to the value of "framework.session.cookie_samesite", or to "lax".
|
||||||
* always_remember_me?: bool|Param, // Default: false
|
* always_remember_me?: bool|Param, // Default: false
|
||||||
* remember_me_parameter?: scalar|Param|null, // Default: "_remember_me"
|
* remember_me_parameter?: scalar|Param|null, // Default: "_remember_me"
|
||||||
* },
|
* },
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$logger: '@monolog.logger.audit'
|
$logger: '@monolog.logger.audit'
|
||||||
|
|
||||||
# Booking notifications. The sender is also the To of every notification -- the actual
|
# Every mail goes out from this address, one per recipient -- see App\Email\Mailer.
|
||||||
# recipients are Bcc'd -- so it has to be an address that may both send and receive.
|
App\Email\Mailer:
|
||||||
App\Service\BookingNotifier:
|
|
||||||
arguments:
|
arguments:
|
||||||
$sender: '%env(string:MAILER_FROM)%'
|
$sender: '%env(string:MAILER_FROM)%'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event history behind /admin/history. Starts empty: the audit log file covers the time
|
||||||
|
* before it, and is not imported.
|
||||||
|
*/
|
||||||
|
final class Version20261016135717 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds the booking event history';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE booking_event (id INT AUTO_INCREMENT NOT NULL, occurred_at DATETIME NOT NULL, type VARCHAR(32) NOT NULL, actor_name VARCHAR(100) NOT NULL, holder_name VARCHAR(100) DEFAULT NULL, resource_id INT DEFAULT NULL, resource_label VARCHAR(50) NOT NULL, resource_type VARCHAR(32) NOT NULL, date DATE DEFAULT NULL, end_date DATE DEFAULT NULL, start_minute INT DEFAULT NULL, end_minute INT DEFAULT NULL, previous_start_minute INT DEFAULT NULL, previous_end_minute INT DEFAULT NULL, booking_id INT DEFAULT NULL, series_id INT DEFAULT NULL, details JSON NOT NULL, actor_id INT DEFAULT NULL, holder_id INT DEFAULT NULL, INDEX idx_booking_event_occurred_at (occurred_at), INDEX idx_booking_event_actor_occurred_at (actor_id, occurred_at), INDEX IDX_AAD2F71210DAF24A (actor_id), INDEX IDX_AAD2F712DEEE62D0 (holder_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
$this->addSql('ALTER TABLE booking_event ADD CONSTRAINT FK_AAD2F71210DAF24A FOREIGN KEY (actor_id) REFERENCES `user` (id) ON DELETE SET NULL');
|
||||||
|
$this->addSql('ALTER TABLE booking_event ADD CONSTRAINT FK_AAD2F712DEEE62D0 FOREIGN KEY (holder_id) REFERENCES `user` (id) ON DELETE SET NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE booking_event DROP FOREIGN KEY FK_AAD2F71210DAF24A');
|
||||||
|
$this->addSql('ALTER TABLE booking_event DROP FOREIGN KEY FK_AAD2F712DEEE62D0');
|
||||||
|
$this->addSql('DROP TABLE booking_event');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\BookingEvent;
|
||||||
|
use App\Repository\BookingEventRepository;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops event history older than the retention period. Meant to run daily from cron.
|
||||||
|
*
|
||||||
|
* The history names who booked what for a year; keeping it longer than that would turn it into a
|
||||||
|
* record of people's attendance that nobody asked for. The audit log file is rotated separately
|
||||||
|
* by Monolog and is not touched here.
|
||||||
|
*/
|
||||||
|
#[AsCommand(name: 'app:booking-events:prune', description: 'Deletes booking events older than the retention period')]
|
||||||
|
final class PruneBookingEventsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BookingEventRepository $events)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'months',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'How many months of history to keep',
|
||||||
|
(string) BookingEvent::RETENTION_MONTHS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$months = filter_var($input->getOption('months'), \FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||||
|
|
||||||
|
if (false === $months) {
|
||||||
|
$io->error('--months must be a positive whole number.');
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = new \DateTimeImmutable(\sprintf('today -%d months', $months));
|
||||||
|
$deleted = $this->events->deleteOlderThan($cutoff);
|
||||||
|
|
||||||
|
$io->success(\sprintf('Deleted %d booking events from before %s.', $deleted, $cutoff->format('Y-m-d')));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ namespace App\Controller\AccessCode;
|
|||||||
use App\Controller\Traits\CurrentUserTrait;
|
use App\Controller\Traits\CurrentUserTrait;
|
||||||
use App\Entity\AccessCode;
|
use App\Entity\AccessCode;
|
||||||
use App\Repository\AccessCodeRepository;
|
use App\Repository\AccessCodeRepository;
|
||||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -24,7 +23,6 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
* has been replaced is gone the day its successor takes over. Both would only be one more number
|
* has been replaced is gone the day its successor takes over. Both would only be one more number
|
||||||
* on the screen for somebody to mistype.
|
* on the screen for somebody to mistype.
|
||||||
*/
|
*/
|
||||||
#[Feature('access_codes')]
|
|
||||||
final class IndexController extends AbstractController
|
final class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
use CurrentUserTrait;
|
use CurrentUserTrait;
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ use App\Htmx\HxRedirectResponse;
|
|||||||
use App\Security\Role;
|
use App\Security\Role;
|
||||||
use App\Service\AuditLogger;
|
use App\Service\AuditLogger;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[Feature('access_codes')]
|
|
||||||
#[IsGranted(Role::ADMIN)]
|
#[IsGranted(Role::ADMIN)]
|
||||||
final class CreateController extends AbstractController
|
final class CreateController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use App\Htmx\HxRedirectResponse;
|
|||||||
use App\Security\Role;
|
use App\Security\Role;
|
||||||
use App\Service\AuditLogger;
|
use App\Service\AuditLogger;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -22,7 +21,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
* confirmation, which is there because a deleted code is not recoverable from the screen that
|
* confirmation, which is there because a deleted code is not recoverable from the screen that
|
||||||
* deleted it.
|
* deleted it.
|
||||||
*/
|
*/
|
||||||
#[Feature('access_codes')]
|
|
||||||
#[IsGranted(Role::ADMIN)]
|
#[IsGranted(Role::ADMIN)]
|
||||||
final class DeleteController extends AbstractController
|
final class DeleteController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ use App\Htmx\HxRedirectResponse;
|
|||||||
use App\Security\Role;
|
use App\Security\Role;
|
||||||
use App\Service\AuditLogger;
|
use App\Service\AuditLogger;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
#[Feature('access_codes')]
|
|
||||||
#[IsGranted(Role::ADMIN)]
|
#[IsGranted(Role::ADMIN)]
|
||||||
final class EditController extends AbstractController
|
final class EditController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Controller\Traits\CurrentUserTrait;
|
|||||||
use App\Entity\AccessCode;
|
use App\Entity\AccessCode;
|
||||||
use App\Repository\AccessCodeRepository;
|
use App\Repository\AccessCodeRepository;
|
||||||
use App\Security\Role;
|
use App\Security\Role;
|
||||||
use Flagception\Bundle\FlagceptionBundle\Attribute\Feature;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -21,7 +20,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
* Which is which is a question about today rather than a stored flag, and it is answered here
|
* Which is which is a question about today rather than a stored flag, and it is answered here
|
||||||
* because it is a question about the whole list: a template cannot look back over its own rows.
|
* because it is a question about the whole list: a template cannot look back over its own rows.
|
||||||
*/
|
*/
|
||||||
#[Feature('access_codes')]
|
|
||||||
#[IsGranted(Role::ADMIN)]
|
#[IsGranted(Role::ADMIN)]
|
||||||
final class IndexController extends AbstractController
|
final class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\AccessCode;
|
||||||
|
|
||||||
|
use App\Controller\Traits\ConfirmedActionTrait;
|
||||||
|
use App\Htmx\HxRedirectResponse;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use App\Service\AccessCodeNotifier;
|
||||||
|
use App\Service\AuditLogger;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the whole staff that the codes changed. Confirmed first, because a mail to everybody
|
||||||
|
* cannot be taken back -- and the confirmation says how many people that is.
|
||||||
|
*/
|
||||||
|
#[IsGranted(Role::ADMIN)]
|
||||||
|
final class NotifyController extends AbstractController
|
||||||
|
{
|
||||||
|
use ConfirmedActionTrait;
|
||||||
|
|
||||||
|
private const CSRF_TOKEN_ID = 'notify-access-codes';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccessCodeNotifier $notifier,
|
||||||
|
private readonly UserRepository $users,
|
||||||
|
private readonly AuditLogger $audit,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/access-codes/notify', name: 'app_admin_access_code_notify', methods: ['GET', 'POST'])]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!$request->isMethod(Request::METHOD_POST)) {
|
||||||
|
return $this->render('admin/_modal_access_code_notify.html.twig', [
|
||||||
|
'recipients' => $this->users->countActive(),
|
||||||
|
'csrf_token_id' => self::CSRF_TOKEN_ID,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->denyUnlessCsrfValid($request, self::CSRF_TOKEN_ID);
|
||||||
|
|
||||||
|
$report = $this->notifier->codesUpdated();
|
||||||
|
$this->audit->accessCodesAnnounced($report->sent, $report->failed);
|
||||||
|
|
||||||
|
if (0 === $report->failed) {
|
||||||
|
$this->addFlash('success', \sprintf('Benachrichtigung an %d Mitarbeitende versendet.', $report->sent));
|
||||||
|
} else {
|
||||||
|
$this->addFlash('warning', \sprintf(
|
||||||
|
'Benachrichtigung an %d Mitarbeitende versendet, %d konnten nicht erreicht werden.',
|
||||||
|
$report->sent,
|
||||||
|
$report->failed,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Admin\BookingEvent;
|
||||||
|
|
||||||
|
use App\Form\Admin\BookingEventFilter;
|
||||||
|
use App\Form\Admin\BookingEventFilterType;
|
||||||
|
use App\Repository\BookingEventRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Knp\Component\Pager\PaginatorInterface;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event history: who booked, moved or freed what, and for whom.
|
||||||
|
*
|
||||||
|
* Administrators only, under /admin, although supervisors can free bookings themselves: the
|
||||||
|
* history names who did what to whom across the whole staff, which is more than a supervisor
|
||||||
|
* needs to do their part.
|
||||||
|
*
|
||||||
|
* Read-only. The rows are written by BookingEventRecorder and removed after a year by
|
||||||
|
* app:booking-events:prune.
|
||||||
|
*/
|
||||||
|
#[IsGranted(Role::ADMIN)]
|
||||||
|
final class IndexController extends AbstractController
|
||||||
|
{
|
||||||
|
private const PER_PAGE = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns a header may sort by. Spelled out because the field name comes from the query
|
||||||
|
* string and the paginator would otherwise accept any path into the entity.
|
||||||
|
*/
|
||||||
|
private const SORTABLE = [
|
||||||
|
'e.occurredAt',
|
||||||
|
'e.type',
|
||||||
|
'e.resourceLabel',
|
||||||
|
'e.date',
|
||||||
|
'e.holderName',
|
||||||
|
'e.actorName',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly BookingEventRepository $events,
|
||||||
|
private readonly PaginatorInterface $paginator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/history', name: 'app_admin_booking_event', methods: ['GET'])]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$filter = new BookingEventFilter();
|
||||||
|
$form = $this->createForm(BookingEventFilterType::class, $filter);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
$pagination = $this->paginator->paginate(
|
||||||
|
$this->events->createFilteredQueryBuilder($filter),
|
||||||
|
$request->query->getInt('page', 1),
|
||||||
|
self::PER_PAGE,
|
||||||
|
[
|
||||||
|
'defaultSortFieldName' => 'e.occurredAt',
|
||||||
|
'defaultSortDirection' => 'desc',
|
||||||
|
'sortFieldAllowList' => self::SORTABLE,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->render('admin/booking_events.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
'filter' => $filter,
|
||||||
|
'pagination' => $pagination,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Repository\BookingRepository;
|
|||||||
use App\Security\Role;
|
use App\Security\Role;
|
||||||
use Doctrine\DBAL\LockMode;
|
use Doctrine\DBAL\LockMode;
|
||||||
use App\Service\AuditLogger;
|
use App\Service\AuditLogger;
|
||||||
|
use App\Service\BookingEventRecorder;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -39,6 +40,7 @@ final class DeleteController extends AbstractController
|
|||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly BookingRepository $bookings,
|
private readonly BookingRepository $bookings,
|
||||||
private readonly AuditLogger $audit,
|
private readonly AuditLogger $audit,
|
||||||
|
private readonly BookingEventRecorder $events,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ final class DeleteController extends AbstractController
|
|||||||
$label = $resource->getLabel();
|
$label = $resource->getLabel();
|
||||||
$type = $resource->getType();
|
$type = $resource->getType();
|
||||||
|
|
||||||
$deleted = $this->em->wrapInTransaction(function () use ($resource): int {
|
$deleted = $this->em->wrapInTransaction(function () use ($resource, $id, $label, $type): int {
|
||||||
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
|
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
|
||||||
|
|
||||||
$deleted = $this->em->createQueryBuilder()
|
$deleted = $this->em->createQueryBuilder()
|
||||||
@@ -84,6 +86,7 @@ final class DeleteController extends AbstractController
|
|||||||
->execute();
|
->execute();
|
||||||
|
|
||||||
$this->em->remove($resource);
|
$this->em->remove($resource);
|
||||||
|
$this->events->resourceDeleted($id, $label, $type, (int) $deleted);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return (int) $deleted;
|
return (int) $deleted;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
* Retiring is a checkbox here rather than a confirmation modal of its own: it deletes nothing and
|
* Retiring is a checkbox here rather than a confirmation modal of its own: it deletes nothing and
|
||||||
* is undone by ticking the box again. What it does leave behind -- bookings that still point at
|
* is undone by ticking the box again. What it does leave behind -- bookings that still point at
|
||||||
* the resource -- is named in the flash, so the administrator can release them from
|
* the resource -- is named in the flash, so the administrator can release them from
|
||||||
* /admin/bookings if they should not stand.
|
* /manage/bookings if they should not stand.
|
||||||
*/
|
*/
|
||||||
#[IsGranted(Role::ADMIN)]
|
#[IsGranted(Role::ADMIN)]
|
||||||
final class EditController extends AbstractController
|
final class EditController extends AbstractController
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ final class ReleaseController extends AbstractController
|
|||||||
$onBehalf = !$booking->belongsTo($this->currentUser());
|
$onBehalf = !$booking->belongsTo($this->currentUser());
|
||||||
|
|
||||||
// The release itself is audited from BookingManager, which is also where the row still
|
// The release itself is audited from BookingManager, which is also where the row still
|
||||||
// exists to be described. An administrator freeing somebody else's desk needs nothing
|
// exists to be described. A supervisor freeing somebody else's desk needs nothing
|
||||||
// extra here: the record carries the holder, and the actor comes from the token.
|
// extra here: the record carries the holder, and the actor comes from the token.
|
||||||
$this->manager->release($booking);
|
$this->manager->release($booking);
|
||||||
|
|
||||||
@@ -78,13 +78,13 @@ final class ReleaseController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releasing is the one thing an administrator may do to a booking that is not theirs -- a
|
* Releasing is the one thing a supervisor may do to a booking that is not theirs -- a colleague
|
||||||
* colleague calls in sick and their desk has to go back into the pool.
|
* calls in sick and their desk has to go back into the pool. Administrators inherit it.
|
||||||
*/
|
*/
|
||||||
private function denyUnlessMayManage(Booking $booking): void
|
private function denyUnlessMayManage(Booking $booking): void
|
||||||
{
|
{
|
||||||
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) {
|
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::SUPERVISOR)) {
|
||||||
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by an administrator.');
|
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by a supervisor.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,8 +14,8 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The landing screen: one tile per bookable kind, plus tiles for the bookings list and, for an
|
* The landing screen: one tile per bookable kind, plus tiles for the bookings list, for a
|
||||||
* administrator, the admin screens.
|
* supervisor the overview of everybody's bookings and, for an administrator, the admin screens.
|
||||||
*
|
*
|
||||||
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
|
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
|
||||||
* bookings list -- which is why the tiles are generated from ResourceType rather than written
|
* bookings list -- which is why the tiles are generated from ResourceType rather than written
|
||||||
|
|||||||
+16
-12
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller\Admin\Booking;
|
namespace App\Controller\Manage\Booking;
|
||||||
|
|
||||||
use App\Form\Admin\BookingFilter;
|
use App\Form\Manage\BookingFilter;
|
||||||
use App\Form\Admin\BookingFilterType;
|
use App\Form\Manage\BookingFilterType;
|
||||||
use App\Repository\BookingRepository;
|
use App\Repository\BookingRepository;
|
||||||
use App\Repository\BookingSeriesRepository;
|
use App\Repository\BookingSeriesRepository;
|
||||||
use App\Security\Role;
|
use App\Security\Role;
|
||||||
@@ -17,18 +17,22 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What ROLE_ADMIN can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a resource
|
* What a supervisor can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a
|
||||||
* on somebody's behalf -- a colleague calls in sick and their desk should go back into the pool.
|
* resource on somebody's behalf -- a colleague calls in sick and their desk should go back into
|
||||||
|
* the pool. Administrators reach it through role_hierarchy.
|
||||||
*
|
*
|
||||||
* The admin controllers are read-only. The two actions offered here post to the ordinary
|
* Not under /admin, which is administrator-only throughout: supervisors may free bookings but not
|
||||||
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit an
|
* configure resources or codes.
|
||||||
* administrator, so there is exactly one code path that removes a booking and it still goes
|
*
|
||||||
|
* The manage controllers are read-only. The two actions offered here post to the ordinary
|
||||||
|
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit
|
||||||
|
* supervisors, so there is exactly one code path that removes a booking and it still goes
|
||||||
* through BookingManager.
|
* through BookingManager.
|
||||||
*
|
*
|
||||||
* The access_control rule on ^/admin is the real gate; the attribute here is so the guard
|
* The access_control rule on ^/manage is the real gate; the attribute here is so the guard
|
||||||
* survives a change to the route path.
|
* survives a change to the route path.
|
||||||
*/
|
*/
|
||||||
#[IsGranted(Role::ADMIN)]
|
#[IsGranted(Role::SUPERVISOR)]
|
||||||
final class IndexController extends AbstractController
|
final class IndexController extends AbstractController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +48,7 @@ final class IndexController extends AbstractController
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/admin/bookings', name: 'app_admin_booking', methods: ['GET'])]
|
#[Route('/manage/bookings', name: 'app_manage_booking', methods: ['GET'])]
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$filter = new BookingFilter();
|
$filter = new BookingFilter();
|
||||||
@@ -62,7 +66,7 @@ final class IndexController extends AbstractController
|
|||||||
self::PER_PAGE,
|
self::PER_PAGE,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->render('admin/bookings.html.twig', [
|
return $this->render('manage/bookings.html.twig', [
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
'filter' => $filter,
|
'filter' => $filter,
|
||||||
'pagination' => $pagination,
|
'pagination' => $pagination,
|
||||||
+6
-6
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller\Admin\Booking;
|
namespace App\Controller\Manage\Booking;
|
||||||
|
|
||||||
use App\Controller\Traits\BoardResponseTrait;
|
use App\Controller\Traits\BoardResponseTrait;
|
||||||
use App\Entity\Resource;
|
use App\Entity\Resource;
|
||||||
@@ -16,13 +16,13 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The modal behind an occupied shape on the floor plan, for an administrator.
|
* The modal behind an occupied shape on the floor plan, for supervisors and administrators.
|
||||||
*
|
*
|
||||||
* Not app_booking_edit: a shape can hold several bookings on one day once part-day spans exist,
|
* Not app_booking_edit: a shape can hold several bookings on one day once part-day spans exist,
|
||||||
* so "the booking" has no single referent -- and editing reschedules, which an administrator
|
* so "the booking" has no single referent -- and editing reschedules, which a supervisor
|
||||||
* deliberately may not do to somebody else's booking.
|
* deliberately may not do to somebody else's booking.
|
||||||
*/
|
*/
|
||||||
#[IsGranted(Role::ADMIN)]
|
#[IsGranted(Role::SUPERVISOR)]
|
||||||
final class SpotController extends AbstractController
|
final class SpotController extends AbstractController
|
||||||
{
|
{
|
||||||
use BoardResponseTrait;
|
use BoardResponseTrait;
|
||||||
@@ -31,12 +31,12 @@ final class SpotController extends AbstractController
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/admin/spot/{id}/{date}', name: 'app_admin_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
|
#[Route('/manage/spot/{id}/{date}', name: 'app_manage_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
|
||||||
public function index(Request $request, Resource $resource, string $date): Response
|
public function index(Request $request, Resource $resource, string $date): Response
|
||||||
{
|
{
|
||||||
$selected = DateParam::parseOrToday($date);
|
$selected = DateParam::parseOrToday($date);
|
||||||
|
|
||||||
return $this->render('admin/_modal_spot.html.twig', [
|
return $this->render('manage/_modal_spot.html.twig', [
|
||||||
'resource' => $resource,
|
'resource' => $resource,
|
||||||
'date' => $selected,
|
'date' => $selected,
|
||||||
// The board's span, so the modal lists exactly what the shape the user clicked shows.
|
// The board's span, so the modal lists exactly what the shape the user clicked shows.
|
||||||
@@ -59,8 +59,8 @@ final class EndController extends AbstractController
|
|||||||
|
|
||||||
private function denyUnlessMayManageSeries(BookingSeries $series): void
|
private function denyUnlessMayManageSeries(BookingSeries $series): void
|
||||||
{
|
{
|
||||||
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) {
|
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::SUPERVISOR)) {
|
||||||
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by an administrator.');
|
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by a supervisor.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,14 +13,14 @@ trait ConfirmedActionTrait
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Where to navigate after a confirmed action, or null when the caller is the board and wants
|
* Where to navigate after a confirmed action, or null when the caller is the board and wants
|
||||||
* its fragments swapped instead. The opener says which -- the admin screen sends
|
* its fragments swapped instead. The opener says which -- the overview of everybody's
|
||||||
* ?return=admin so freeing somebody else's desk does not drop the administrator on their own
|
* bookings sends ?return=manage so freeing somebody else's desk does not drop a supervisor on
|
||||||
* bookings.
|
* their own bookings.
|
||||||
*/
|
*/
|
||||||
private function returnRoute(Request $request): ?string
|
private function returnRoute(Request $request): ?string
|
||||||
{
|
{
|
||||||
return match ($request->query->get('return')) {
|
return match ($request->query->get('return')) {
|
||||||
'admin' => 'app_admin_booking',
|
'manage' => 'app_manage_booking',
|
||||||
'bookings' => 'app_booking',
|
'bookings' => 'app_booking',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Email;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Component\Mime\Address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every mail this application sends goes through here.
|
||||||
|
*
|
||||||
|
* The counterpart of myep's App\Email\Mailer, with three deliberate differences. Arguments are
|
||||||
|
* typed rather than an OptionsResolver array, since there are only four of them and all are
|
||||||
|
* required. Every mail is multipart, with a hand-written text part beside the MyE&P layout. And a
|
||||||
|
* failure is logged and counted rather than rethrown -- see below.
|
||||||
|
*
|
||||||
|
* **Sending never throws.** Mail here is always the side effect of something that has already
|
||||||
|
* happened -- a booking committed, a code rotated -- and the person who caused it should not be
|
||||||
|
* shown an error about a mail server. The caller gets a SendReport instead and decides whether
|
||||||
|
* the failures are worth mentioning.
|
||||||
|
*
|
||||||
|
* Every recipient gets their own mail, addressed only to them. Nobody sees who else is on the
|
||||||
|
* list, and a single bad address loses its own mail rather than everybody's.
|
||||||
|
*
|
||||||
|
* Sending is synchronous, inside the request: no messenger worker runs on the server (see
|
||||||
|
* messenger.yaml). At the fifty-odd accounts this application has, that is a few seconds at
|
||||||
|
* most. Routing SendEmailMessage to `async` once a worker runs needs no change here.
|
||||||
|
*/
|
||||||
|
final readonly class Mailer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MailerInterface $mailer,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private string $sender,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<Address|string> $recipients
|
||||||
|
* @param string $template the template pair's basename; both .html.twig
|
||||||
|
* and .txt.twig of it are rendered
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
public function send(iterable $recipients, string $subject, string $template, array $context = []): SendReport
|
||||||
|
{
|
||||||
|
$sent = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
$email = (new TemplatedEmail())
|
||||||
|
->from(new Address($this->sender, 'E&P Platz'))
|
||||||
|
->to($recipient)
|
||||||
|
->subject($subject)
|
||||||
|
->htmlTemplate($template.'.html.twig')
|
||||||
|
->textTemplate($template.'.txt.twig')
|
||||||
|
->context($context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->mailer->send($email);
|
||||||
|
++$sent;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Caught per recipient, so one unreachable address does not cost the others theirs.
|
||||||
|
// \Throwable rather than TransportExceptionInterface because the templates are
|
||||||
|
// rendered inside send(): a typo in one of them would otherwise surface as an
|
||||||
|
// error in whatever action triggered the mail.
|
||||||
|
++$failed;
|
||||||
|
$this->logger->error('Email could not be sent.', [
|
||||||
|
'subject' => $subject,
|
||||||
|
'recipient' => $recipient instanceof Address ? $recipient->getAddress() : $recipient,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SendReport($sent, $failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What came of a Mailer::send(): how many recipients the mail reached the transport for, and how
|
||||||
|
* many it did not. The failures themselves are in the log.
|
||||||
|
*/
|
||||||
|
final readonly class SendReport
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $sent,
|
||||||
|
public int $failed,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Enum\BookingEventType;
|
||||||
|
use App\Enum\RecurrenceRule;
|
||||||
|
use App\Enum\ResourceType;
|
||||||
|
use App\Model\TimeRange;
|
||||||
|
use App\Repository\BookingEventRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line of the event history: something that happened to a booking, a series, or the bookings
|
||||||
|
* on a deleted resource.
|
||||||
|
*
|
||||||
|
* **A snapshot, not a reference.** Releasing a booking deletes its row, ending a series can delete
|
||||||
|
* the series, and deleting a resource deletes both -- so a foreign key to any of them would point
|
||||||
|
* at nothing on exactly the records worth reading. What the history shows is therefore copied in
|
||||||
|
* at the time: labels, names, dates and times. The ids are kept as plain integers, so a record can
|
||||||
|
* still be matched to the audit log file, which carries the same ones.
|
||||||
|
*
|
||||||
|
* The two people are the exception: they are kept as nullable foreign keys *and* as names. The key
|
||||||
|
* is what the filter matches on, so a renamed account still finds its own history; the name is
|
||||||
|
* what is shown, and survives the account itself going away (ON DELETE SET NULL).
|
||||||
|
*
|
||||||
|
* Written by BookingEventRecorder alone, inside the same transaction as the change it describes.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: BookingEventRepository::class)]
|
||||||
|
#[ORM\Index(name: 'idx_booking_event_occurred_at', columns: ['occurred_at'])]
|
||||||
|
#[ORM\Index(name: 'idx_booking_event_actor_occurred_at', columns: ['actor_id', 'occurred_at'])]
|
||||||
|
class BookingEvent
|
||||||
|
{
|
||||||
|
/** How long the history reaches back -- see app:booking-events:prune. */
|
||||||
|
public const RETENTION_MONTHS = 12;
|
||||||
|
|
||||||
|
/** The actor name when nobody was signed in -- a console command, the worker. */
|
||||||
|
public const SYSTEM_ACTOR = 'System';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $occurredAt;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 32, enumType: BookingEventType::class)]
|
||||||
|
private BookingEventType $type;
|
||||||
|
|
||||||
|
/** Who did it; null for a console command or the message worker, which have no user. */
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $actor;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100)]
|
||||||
|
private string $actorName;
|
||||||
|
|
||||||
|
/** Whose booking it was; null only for a resource deletion, which affects many people. */
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $holder = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $holderName = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $resourceId;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
private string $resourceLabel;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 32, enumType: ResourceType::class)]
|
||||||
|
private ResourceType $resourceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The booked day; for a series its first day, and for an ended series the day it ends from.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $date = null;
|
||||||
|
|
||||||
|
/** A series' last day. */
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $endDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $startMinute = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $endMinute = null;
|
||||||
|
|
||||||
|
/** The times a rescheduled booking had before. */
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $previousStartMinute = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $previousEndMinute = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $bookingId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $seriesId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What only one kind of event has: a series' rule and its created/skipped counts, the number
|
||||||
|
* of bookings a series end or a resource deletion removed.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::JSON)]
|
||||||
|
private array $details = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
BookingEventType $type,
|
||||||
|
?User $actor,
|
||||||
|
string $actorName,
|
||||||
|
?int $resourceId,
|
||||||
|
string $resourceLabel,
|
||||||
|
ResourceType $resourceType,
|
||||||
|
) {
|
||||||
|
$this->occurredAt = new \DateTimeImmutable();
|
||||||
|
$this->type = $type;
|
||||||
|
$this->actor = $actor;
|
||||||
|
$this->actorName = $actorName;
|
||||||
|
$this->resourceId = $resourceId;
|
||||||
|
$this->resourceLabel = $resourceLabel;
|
||||||
|
$this->resourceType = $resourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOccurredAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): BookingEventType
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActor(): ?User
|
||||||
|
{
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActorName(): string
|
||||||
|
{
|
||||||
|
return $this->actorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHolder(): ?User
|
||||||
|
{
|
||||||
|
return $this->holder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHolderName(): ?string
|
||||||
|
{
|
||||||
|
return $this->holderName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHolder(User $holder): self
|
||||||
|
{
|
||||||
|
$this->holder = $holder;
|
||||||
|
$this->holderName = $holder->getName();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Somebody acting on another person's booking -- a supervisor freeing a desk, say. Compared by
|
||||||
|
* id while both accounts exist and by name once one has gone, which is the best left to go on.
|
||||||
|
* The system acts for nobody: it has no booking of its own to tell apart from someone else's.
|
||||||
|
*/
|
||||||
|
public function isOnBehalf(): bool
|
||||||
|
{
|
||||||
|
if (null === $this->holderName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->actor && self::SYSTEM_ACTOR === $this->actorName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $this->actor && null !== $this->holder) {
|
||||||
|
return $this->actor->getId() !== $this->holder->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->actorName !== $this->holderName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResourceId(): ?int
|
||||||
|
{
|
||||||
|
return $this->resourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResourceLabel(): string
|
||||||
|
{
|
||||||
|
return $this->resourceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResourceType(): ResourceType
|
||||||
|
{
|
||||||
|
return $this->resourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDate(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDate(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDates(\DateTimeImmutable $date, ?\DateTimeImmutable $endDate = null): self
|
||||||
|
{
|
||||||
|
$this->date = $date;
|
||||||
|
$this->endDate = $endDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimeRange(): ?TimeRange
|
||||||
|
{
|
||||||
|
return null === $this->startMinute || null === $this->endMinute
|
||||||
|
? null
|
||||||
|
: TimeRange::fromMinutes($this->startMinute, $this->endMinute);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPreviousTimeRange(): ?TimeRange
|
||||||
|
{
|
||||||
|
return null === $this->previousStartMinute || null === $this->previousEndMinute
|
||||||
|
? null
|
||||||
|
: TimeRange::fromMinutes($this->previousStartMinute, $this->previousEndMinute);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTimeRange(TimeRange $time, ?TimeRange $previous = null): self
|
||||||
|
{
|
||||||
|
$this->startMinute = $time->startMinute;
|
||||||
|
$this->endMinute = $time->endMinute;
|
||||||
|
$this->previousStartMinute = $previous?->startMinute;
|
||||||
|
$this->previousEndMinute = $previous?->endMinute;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBookingId(): ?int
|
||||||
|
{
|
||||||
|
return $this->bookingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeriesId(): ?int
|
||||||
|
{
|
||||||
|
return $this->seriesId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIds(?int $bookingId, ?int $seriesId): self
|
||||||
|
{
|
||||||
|
$this->bookingId = $bookingId;
|
||||||
|
$this->seriesId = $seriesId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getDetails(): array
|
||||||
|
{
|
||||||
|
return $this->details;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $details
|
||||||
|
*/
|
||||||
|
public function setDetails(array $details): self
|
||||||
|
{
|
||||||
|
$this->details = $details;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRule(): ?RecurrenceRule
|
||||||
|
{
|
||||||
|
return isset($this->details['rule']) ? RecurrenceRule::tryFrom($this->details['rule']) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What can happen to a booking, as the event history at /admin/history lists it.
|
||||||
|
*
|
||||||
|
* Narrower than the audit trail on purpose: sign-ins, resources and access codes stay in the log
|
||||||
|
* file, and this is the part of it an administrator wants to search by hand -- who booked, moved
|
||||||
|
* or freed what, and for whom.
|
||||||
|
*
|
||||||
|
* The backing value is what is stored and what the filter puts in the URL. The label and the
|
||||||
|
* badge are the only things anyone sees.
|
||||||
|
*/
|
||||||
|
enum BookingEventType: string
|
||||||
|
{
|
||||||
|
case BOOKING_CREATED = 'booking_created';
|
||||||
|
case BOOKING_RESCHEDULED = 'booking_rescheduled';
|
||||||
|
case BOOKING_RELEASED = 'booking_released';
|
||||||
|
case SERIES_CREATED = 'series_created';
|
||||||
|
case SERIES_ENDED = 'series_ended';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a resource takes every booking on it along, without going through BookingManager.
|
||||||
|
* One event for the lot rather than one per booking, like a series: it is a single act.
|
||||||
|
*/
|
||||||
|
case RESOURCE_DELETED = 'resource_deleted';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::BOOKING_CREATED => 'Buchung angelegt',
|
||||||
|
self::BOOKING_RESCHEDULED => 'Buchung geändert',
|
||||||
|
self::BOOKING_RELEASED => 'Buchung freigegeben',
|
||||||
|
self::SERIES_CREATED => 'Serie angelegt',
|
||||||
|
self::SERIES_ENDED => 'Serie beendet',
|
||||||
|
self::RESOURCE_DELETED => 'Ressource gelöscht',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge colours, spelled out in full so Tailwind finds them in src/: green for something
|
||||||
|
* coming into existence, amber for a change, red for something going away.
|
||||||
|
*/
|
||||||
|
public function badgeClass(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::BOOKING_CREATED, self::SERIES_CREATED => 'bg-green-100 text-green-800',
|
||||||
|
self::BOOKING_RESCHEDULED => 'bg-amber-100 text-amber-800',
|
||||||
|
self::BOOKING_RELEASED, self::SERIES_ENDED, self::RESOURCE_DELETED => 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,10 @@ use App\Security\Role;
|
|||||||
/**
|
/**
|
||||||
* Who may read an access code.
|
* Who may read an access code.
|
||||||
*
|
*
|
||||||
* Deliberately an enum with two cases rather than a boolean column: "admin only" is one policy
|
* Deliberately an enum rather than a boolean column: "admin only" was one policy among several
|
||||||
* among several plausible ones -- a code for the reception desk or for the cleaning company is
|
* plausible ones -- supervisors were the next, a code for the reception desk or for the cleaning
|
||||||
* the obvious next case -- and a third case here costs a case in the match below, where a third
|
* company the obvious ones after that -- and another case here costs a case in the match below,
|
||||||
* value of a boolean costs a migration.
|
* where another value of a boolean costs a migration.
|
||||||
*
|
*
|
||||||
* This is also what the list shows under "Für wen": the access decision and the caption are one
|
* This is also what the list shows under "Für wen": the access decision and the caption are one
|
||||||
* value, so there is no second, prose field describing an audience that the filter does not
|
* value, so there is no second, prose field describing an audience that the filter does not
|
||||||
@@ -23,6 +23,9 @@ enum CodeVisibility: string
|
|||||||
/** Every signed-in user, which in this application means everyone who works here. */
|
/** Every signed-in user, which in this application means everyone who works here. */
|
||||||
case EVERYONE = 'everyone';
|
case EVERYONE = 'everyone';
|
||||||
|
|
||||||
|
/** Supervisors, and administrators with them -- e.g. the office of the management. */
|
||||||
|
case SUPERVISOR = 'supervisor';
|
||||||
|
|
||||||
/** Administrators only -- a code the whole staff has no business reading. */
|
/** Administrators only -- a code the whole staff has no business reading. */
|
||||||
case ADMIN = 'admin';
|
case ADMIN = 'admin';
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ enum CodeVisibility: string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::EVERYONE => 'Alle Mitarbeitenden',
|
self::EVERYONE => 'Alle Mitarbeitenden',
|
||||||
|
self::SUPERVISOR => 'Leitung',
|
||||||
self::ADMIN => 'Administration',
|
self::ADMIN => 'Administration',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,12 +48,16 @@ enum CodeVisibility: string
|
|||||||
* The roles this application knows are the ones in Role::ELIGIBLE, so "everyone" needs no
|
* The roles this application knows are the ones in Role::ELIGIBLE, so "everyone" needs no
|
||||||
* role of its own: reaching the page at all already required one.
|
* role of its own: reaching the page at all already required one.
|
||||||
*
|
*
|
||||||
|
* These are the stored roles, which role_hierarchy does not expand -- so an administrator's
|
||||||
|
* claim to the supervisor codes is spelt out here rather than inherited.
|
||||||
|
*
|
||||||
* @param list<string> $roles
|
* @param list<string> $roles
|
||||||
*/
|
*/
|
||||||
public function isReadableBy(array $roles): bool
|
public function isReadableBy(array $roles): bool
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::EVERYONE => true,
|
self::EVERYONE => true,
|
||||||
|
self::SUPERVISOR => \in_array(Role::SUPERVISOR, $roles, true) || \in_array(Role::ADMIN, $roles, true),
|
||||||
self::ADMIN => \in_array(Role::ADMIN, $roles, true),
|
self::ADMIN => \in_array(Role::ADMIN, $roles, true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Admin;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Enum\BookingEventType;
|
||||||
|
use App\Enum\ResourceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the event history is narrowed by. A plain DTO bound to a GET form, like the manage
|
||||||
|
* overview's BookingFilter, so a filtered history can be linked to.
|
||||||
|
*
|
||||||
|
* The window is about when something *happened*, not about the day that was booked: the
|
||||||
|
* question this screen answers is "what went on recently", and a booking for next month made
|
||||||
|
* this morning belongs to this morning.
|
||||||
|
*/
|
||||||
|
final class BookingEventFilter
|
||||||
|
{
|
||||||
|
public ?\DateTimeImmutable $from = null;
|
||||||
|
|
||||||
|
public ?\DateTimeImmutable $to = null;
|
||||||
|
|
||||||
|
/** @var list<BookingEventType> empty means every kind */
|
||||||
|
public array $types = [];
|
||||||
|
|
||||||
|
/** Who carried the change out -- not whose booking it was. */
|
||||||
|
public ?User $actor = null;
|
||||||
|
|
||||||
|
public ?ResourceType $resourceType = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->from = new \DateTimeImmutable('today -30 days');
|
||||||
|
$this->to = new \DateTimeImmutable('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fromDate(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return ($this->from ?? new \DateTimeImmutable('today -30 days'))->setTime(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last day shown, inclusive -- the query compares against the midnight after it.
|
||||||
|
*/
|
||||||
|
public function toDate(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return ($this->to ?? new \DateTimeImmutable('today'))->setTime(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Admin;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Enum\BookingEventType;
|
||||||
|
use App\Enum\ResourceType;
|
||||||
|
use Doctrine\ORM\EntityRepository;
|
||||||
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GET form, like the manage overview's BookingFilterType: navigation, not a fragment swap, and
|
||||||
|
* no CSRF token since nothing is written.
|
||||||
|
*/
|
||||||
|
final class BookingEventFilterType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('from', DateType::class, [
|
||||||
|
'label' => 'Von',
|
||||||
|
'widget' => 'single_text',
|
||||||
|
'input' => 'datetime_immutable',
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
|
->add('to', DateType::class, [
|
||||||
|
'label' => 'Bis',
|
||||||
|
'widget' => 'single_text',
|
||||||
|
'input' => 'datetime_immutable',
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
|
->add('actor', EntityType::class, [
|
||||||
|
'label' => 'Durchgeführt von',
|
||||||
|
'class' => User::class,
|
||||||
|
'required' => false,
|
||||||
|
'placeholder' => 'Alle Personen',
|
||||||
|
'choice_label' => 'name',
|
||||||
|
'query_builder' => static fn (EntityRepository $repository) => $repository
|
||||||
|
->createQueryBuilder('u')
|
||||||
|
->orderBy('u.name', 'ASC'),
|
||||||
|
])
|
||||||
|
->add('resourceType', EnumType::class, [
|
||||||
|
'label' => 'Art',
|
||||||
|
'class' => ResourceType::class,
|
||||||
|
'required' => false,
|
||||||
|
'placeholder' => 'Alle Arten',
|
||||||
|
'choice_label' => static fn (ResourceType $type): string => $type->plural(),
|
||||||
|
])
|
||||||
|
// Checkboxes rather than a multi-select: there are few kinds, and a multi-select needs
|
||||||
|
// a modifier key nobody remembers.
|
||||||
|
->add('types', EnumType::class, [
|
||||||
|
'label' => 'Ereignis',
|
||||||
|
'class' => BookingEventType::class,
|
||||||
|
'multiple' => true,
|
||||||
|
'expanded' => true,
|
||||||
|
'required' => false,
|
||||||
|
'choice_label' => static fn (BookingEventType $type): string => $type->label(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => BookingEventFilter::class,
|
||||||
|
'method' => 'GET',
|
||||||
|
'csrf_protection' => false,
|
||||||
|
// page/sort/direction share the query string; see BookingFilterType.
|
||||||
|
'allow_extra_fields' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No prefix, so the query string reads ?from=&types[]=&actor= rather than nested.
|
||||||
|
*/
|
||||||
|
public function getBlockPrefix(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Form\Admin;
|
namespace App\Form\Manage;
|
||||||
|
|
||||||
use App\Config\BookingHorizon;
|
use App\Config\BookingHorizon;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Enum\ResourceType;
|
use App\Enum\ResourceType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the admin booking list is narrowed by. A plain DTO bound to a GET form, so the filters
|
* What the overview of all bookings is narrowed by. A plain DTO bound to a GET form, so the filters
|
||||||
* live in the URL and a filtered view can be linked to or bookmarked.
|
* live in the URL and a filtered view can be linked to or bookmarked.
|
||||||
*/
|
*/
|
||||||
final class BookingFilter
|
final class BookingFilter
|
||||||
@@ -24,7 +24,7 @@ final class BookingFilter
|
|||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
// Today through the booking horizon: what an administrator can still do something about.
|
// Today through the booking horizon: what a supervisor can still do something about.
|
||||||
// Past bookings are history and are not actionable, so they are not the default view.
|
// Past bookings are history and are not actionable, so they are not the default view.
|
||||||
$this->from = new \DateTimeImmutable('today');
|
$this->from = new \DateTimeImmutable('today');
|
||||||
$this->to = BookingHorizon::latest();
|
$this->to = BookingHorizon::latest();
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Form\Admin;
|
namespace App\Form\Manage;
|
||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Enum\ResourceType;
|
use App\Enum\ResourceType;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\BookingEvent;
|
||||||
|
use App\Form\Admin\BookingEventFilter;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<BookingEvent>
|
||||||
|
*/
|
||||||
|
class BookingEventRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, BookingEvent::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest first. The id breaks ties, since a series and the booking released right after it can
|
||||||
|
* share a second; the paginator puts a clicked column in front of both.
|
||||||
|
*/
|
||||||
|
public function createFilteredQueryBuilder(BookingEventFilter $filter): QueryBuilder
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('e')
|
||||||
|
->andWhere('e.occurredAt >= :from')
|
||||||
|
->andWhere('e.occurredAt < :until')
|
||||||
|
->setParameter('from', $filter->fromDate())
|
||||||
|
->setParameter('until', $filter->toDate()->modify('+1 day'))
|
||||||
|
->orderBy('e.occurredAt', 'DESC')
|
||||||
|
->addOrderBy('e.id', 'DESC');
|
||||||
|
|
||||||
|
if ([] !== $filter->types) {
|
||||||
|
$qb->andWhere('e.type IN (:types)')->setParameter('types', $filter->types);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $filter->actor) {
|
||||||
|
$qb->andWhere('e.actor = :actor')->setParameter('actor', $filter->actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $filter->resourceType) {
|
||||||
|
$qb->andWhere('e.resourceType = :resourceType')->setParameter('resourceType', $filter->resourceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int the number of events removed
|
||||||
|
*/
|
||||||
|
public function deleteOlderThan(\DateTimeImmutable $cutoff): int
|
||||||
|
{
|
||||||
|
return (int) $this->createQueryBuilder('e')
|
||||||
|
->delete()
|
||||||
|
->andWhere('e.occurredAt < :cutoff')
|
||||||
|
->setParameter('cutoff', $cutoff)
|
||||||
|
->getQuery()
|
||||||
|
->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use App\Entity\BookingSeries;
|
|||||||
use App\Entity\Resource;
|
use App\Entity\Resource;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Enum\ResourceType;
|
use App\Enum\ResourceType;
|
||||||
use App\Form\Admin\BookingFilter;
|
use App\Form\Manage\BookingFilter;
|
||||||
use App\Model\TimeRange;
|
use App\Model\TimeRange;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
@@ -210,10 +210,10 @@ class BookingRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every booking in a window, across everybody -- what the admin list renders.
|
* Every booking in a window, across everybody -- what /manage/bookings renders.
|
||||||
*
|
*
|
||||||
* findForUserInRange() is the same query with a mandatory user; this one keeps it optional
|
* findForUserInRange() is the same query with a mandatory user; this one keeps it optional
|
||||||
* and eager-selects the holder as well, because the admin list names a different person on
|
* and eager-selects the holder as well, because the overview names a different person on
|
||||||
* every row and would otherwise fire a query per booking.
|
* every row and would otherwise fire a query per booking.
|
||||||
*
|
*
|
||||||
* Unexecuted, because the paginator has to add the LIMIT and the ORDER BY of a clicked column
|
* Unexecuted, because the paginator has to add the LIMIT and the ORDER BY of a clicked column
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ class BookingSeriesRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Every series still running on the given day, optionally narrowed to one person or one kind
|
* Every series still running on the given day, optionally narrowed to one person or one kind
|
||||||
* of resource. The admin list is the caller that needs it across everybody.
|
* of resource. The overview at /manage/bookings is the caller that needs it across everybody.
|
||||||
*
|
*
|
||||||
* The user is eager-selected because the admin list names the holder of every row -- without
|
* The user is eager-selected because the overview names the holder of every row -- without
|
||||||
* it the page fires a query per series.
|
* it the page fires a query per series.
|
||||||
*
|
*
|
||||||
* @return list<BookingSeries>
|
* @return list<BookingSeries>
|
||||||
|
|||||||
@@ -30,4 +30,20 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
return $this->findOneBy(['sub' => $sub]);
|
return $this->findOneBy(['sub' => $sub]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everybody who can currently sign in -- the audience of a mail meant for the whole staff.
|
||||||
|
* A locked account is left out: its holder could not follow the link in it anyway.
|
||||||
|
*
|
||||||
|
* @return list<User>
|
||||||
|
*/
|
||||||
|
public function findActive(): array
|
||||||
|
{
|
||||||
|
return $this->findBy(['disabledAt' => null], ['name' => 'ASC']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countActive(): int
|
||||||
|
{
|
||||||
|
return $this->count(['disabledAt' => null]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+12
-3
@@ -20,13 +20,21 @@ final class Role
|
|||||||
/** Derived on myep from the account's email domain: "works here". */
|
/** Derived on myep from the account's email domain: "works here". */
|
||||||
public const EMPLOYEE = 'ROLE_EMPLOYEE';
|
public const EMPLOYEE = 'ROLE_EMPLOYEE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frees other people's bookings and reads the codes meant for supervisors, but does not
|
||||||
|
* configure anything -- the admin area stays ROLE_ADMIN's. Nominated by the CRM and approved
|
||||||
|
* on myep like ROLE_ADMIN. ROLE_ADMIN inherits it through role_hierarchy, so a check for this
|
||||||
|
* role is a check for "supervisor or above".
|
||||||
|
*/
|
||||||
|
public const SUPERVISOR = 'ROLE_SUPERVISOR';
|
||||||
|
|
||||||
/** Administrative on myep -- nominated by the CRM, but only exported once approved there. */
|
/** Administrative on myep -- nominated by the CRM, but only exported once approved there. */
|
||||||
public const ADMIN = 'ROLE_ADMIN';
|
public const ADMIN = 'ROLE_ADMIN';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The roles that entitle someone to use ephub at all. Someone who holds neither is refused
|
* The roles that entitle someone to use ephub at all. Someone who holds none of them is
|
||||||
* at login rather than let in without access, so they get an explanation instead of an
|
* refused at login rather than let in without access, so they get an explanation instead of
|
||||||
* empty screen they cannot act on.
|
* an empty screen they cannot act on.
|
||||||
*
|
*
|
||||||
* **Keep this in step with `required_roles` on ephub's `oauth2_client` row on myep.** myep
|
* **Keep this in step with `required_roles` on ephub's `oauth2_client` row on myep.** myep
|
||||||
* gates its own authorization endpoint on the same list and shows an explanation page
|
* gates its own authorization endpoint on the same list and shows an explanation page
|
||||||
@@ -39,6 +47,7 @@ final class Role
|
|||||||
*/
|
*/
|
||||||
public const ELIGIBLE = [
|
public const ELIGIBLE = [
|
||||||
self::EMPLOYEE,
|
self::EMPLOYEE,
|
||||||
|
self::SUPERVISOR,
|
||||||
self::ADMIN,
|
self::ADMIN,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Email\Mailer;
|
||||||
|
use App\Email\SendReport;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Symfony\Component\Mime\Address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the whole staff that the access codes have changed -- and nothing more.
|
||||||
|
*
|
||||||
|
* **The mail carries no codes, and no door names either.** A mailbox is not a place a door code
|
||||||
|
* should end up: it is synced to phones, forwarded, and kept long after the code is rotated away.
|
||||||
|
* The door names are left out too, because some of them belong to codes only supervisors or
|
||||||
|
* administrators may read, and naming them to everybody would leak what this application
|
||||||
|
* otherwise keeps from them. So every recipient gets the same mail, and the link in it leads to
|
||||||
|
* /access-codes, which shows each of them exactly what they may see.
|
||||||
|
*
|
||||||
|
* Sent on an administrator's explicit request rather than on every save: a rotation is usually
|
||||||
|
* several edits in a row, and a corrected description or an admin-only code is no news to the
|
||||||
|
* staff.
|
||||||
|
*/
|
||||||
|
final readonly class AccessCodeNotifier
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Mailer $mailer,
|
||||||
|
private UserRepository $users,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function codesUpdated(): SendReport
|
||||||
|
{
|
||||||
|
return $this->mailer->send(
|
||||||
|
array_map(
|
||||||
|
static fn (User $user): Address => new Address($user->getEmail(), $user->getName()),
|
||||||
|
$this->users->findActive(),
|
||||||
|
),
|
||||||
|
'Neue Zugangscodes bei E&P',
|
||||||
|
'email/access_codes_updated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ use Psr\Log\LoggerInterface;
|
|||||||
* The acting user is NOT passed in. AuditActorProcessor takes it from the security token and
|
* The acting user is NOT passed in. AuditActorProcessor takes it from the security token and
|
||||||
* puts it in `extra.actor`, so no call site can log the wrong one. What the context carries is
|
* puts it in `extra.actor`, so no call site can log the wrong one. What the context carries is
|
||||||
* the subject -- `holder` for a booking -- and a record whose holder is not its actor is an
|
* the subject -- `holder` for a booking -- and a record whose holder is not its actor is an
|
||||||
* administrator having acted for somebody else.
|
* supervisor or administrator having acted for somebody else.
|
||||||
*
|
*
|
||||||
* Level is info throughout: an audit record is a fact, not a problem. Something that *is* a
|
* Level is info throughout: an audit record is a fact, not a problem. Something that *is* a
|
||||||
* problem belongs in the framework log, which is what the default channel is for -- a refused
|
* problem belongs in the framework log, which is what the default channel is for -- a refused
|
||||||
@@ -295,6 +295,18 @@ final readonly class AuditLogger
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The staff told that the codes changed. Counts only: the recipients are everybody, and
|
||||||
|
* which addresses failed is in the framework log, where a failure belongs.
|
||||||
|
*/
|
||||||
|
public function accessCodesAnnounced(int $sent, int $failed): void
|
||||||
|
{
|
||||||
|
$this->logger->info('Access code update announced.', [
|
||||||
|
'sent' => $sent,
|
||||||
|
'failed' => $failed,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Booking;
|
||||||
|
use App\Entity\BookingEvent;
|
||||||
|
use App\Entity\BookingSeries;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Enum\BookingEventType;
|
||||||
|
use App\Enum\ResourceType;
|
||||||
|
use App\Model\SeriesResult;
|
||||||
|
use App\Model\TimeRange;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the event history that /admin/history lists.
|
||||||
|
*
|
||||||
|
* The sibling of AuditLogger, and deliberately not merged into it: the log file is the complete
|
||||||
|
* trail and is written after a commit, while these rows are a queryable subset that has to agree
|
||||||
|
* with the bookings table. So every method here only persists -- the caller flushes, inside the
|
||||||
|
* same transaction as the change itself. A booking that was rolled back leaves no event, and an
|
||||||
|
* event that failed to write takes the booking with it.
|
||||||
|
*
|
||||||
|
* The actor is read from the token, as AuditActorProcessor does, so no call site can name the
|
||||||
|
* wrong one.
|
||||||
|
*/
|
||||||
|
final readonly class BookingEventRecorder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private Security $security,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Needs the booking's id, so call it after the booking has been flushed.
|
||||||
|
*/
|
||||||
|
public function bookingCreated(Booking $booking): void
|
||||||
|
{
|
||||||
|
$this->persist($this->forBooking(BookingEventType::BOOKING_CREATED, $booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bookingRescheduled(Booking $booking, TimeRange $previous): void
|
||||||
|
{
|
||||||
|
$event = $this->forBooking(BookingEventType::BOOKING_RESCHEDULED, $booking)
|
||||||
|
->setTimeRange($booking->getTimeRange(), $previous);
|
||||||
|
|
||||||
|
$this->persist($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before the row is removed, while the booking still knows its own id.
|
||||||
|
*/
|
||||||
|
public function bookingReleased(Booking $booking): void
|
||||||
|
{
|
||||||
|
$this->persist($this->forBooking(BookingEventType::BOOKING_RELEASED, $booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event for the whole series, as in the audit log. A series that booked nothing was never
|
||||||
|
* stored and has no id, but the attempt is still recorded with its count of zero.
|
||||||
|
*/
|
||||||
|
public function seriesCreated(SeriesResult $result): void
|
||||||
|
{
|
||||||
|
$series = $result->series;
|
||||||
|
|
||||||
|
$event = $this->forSeries(BookingEventType::SERIES_CREATED, $series, $series->getId())
|
||||||
|
->setDates($series->getStartDate(), $series->getEndDate())
|
||||||
|
->setDetails([
|
||||||
|
'rule' => $series->getRule()->value,
|
||||||
|
'created' => $result->createdCount(),
|
||||||
|
'skipped' => array_keys($result->skipped),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->persist($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ?int $seriesId read by the caller before the transaction, since ending a series can
|
||||||
|
* delete it and a deleted entity no longer has an id
|
||||||
|
*/
|
||||||
|
public function seriesEnded(BookingSeries $series, ?int $seriesId, \DateTimeImmutable $from, int $removed): void
|
||||||
|
{
|
||||||
|
$event = $this->forSeries(BookingEventType::SERIES_ENDED, $series, $seriesId)
|
||||||
|
->setDates($from)
|
||||||
|
->setDetails([
|
||||||
|
'rule' => $series->getRule()->value,
|
||||||
|
'removed' => $removed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->persist($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only worth a line when bookings went with it -- an unused resource deleted is configuration,
|
||||||
|
* not booking history, and stays in the audit log alone.
|
||||||
|
*/
|
||||||
|
public function resourceDeleted(int $id, string $label, ResourceType $type, int $bookingsDeleted): void
|
||||||
|
{
|
||||||
|
if ($bookingsDeleted < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->persist($this->event(BookingEventType::RESOURCE_DELETED, $id, $label, $type)
|
||||||
|
->setDetails(['removed' => $bookingsDeleted]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function forBooking(BookingEventType $type, Booking $booking): BookingEvent
|
||||||
|
{
|
||||||
|
$resource = $booking->getResource();
|
||||||
|
|
||||||
|
return $this->event($type, $resource->getId(), $resource->getLabel(), $resource->getType())
|
||||||
|
->setHolder($booking->getUser())
|
||||||
|
->setDates($booking->getDate())
|
||||||
|
->setTimeRange($booking->getTimeRange())
|
||||||
|
->setIds($booking->getId(), $booking->getSeries()?->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function forSeries(BookingEventType $type, BookingSeries $series, ?int $seriesId): BookingEvent
|
||||||
|
{
|
||||||
|
$resource = $series->getResource();
|
||||||
|
|
||||||
|
return $this->event($type, $resource->getId(), $resource->getLabel(), $resource->getType())
|
||||||
|
->setHolder($series->getUser())
|
||||||
|
->setTimeRange($series->getTimeRange())
|
||||||
|
->setIds(null, $seriesId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function event(BookingEventType $type, ?int $resourceId, string $resourceLabel, ResourceType $resourceType): BookingEvent
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
$actor = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
|
return new BookingEvent(
|
||||||
|
$type,
|
||||||
|
$actor,
|
||||||
|
$actor?->getName() ?? $user?->getUserIdentifier() ?? BookingEvent::SYSTEM_ACTOR,
|
||||||
|
$resourceId,
|
||||||
|
$resourceLabel,
|
||||||
|
$resourceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persist(BookingEvent $event): void
|
||||||
|
{
|
||||||
|
$this->em->persist($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ final readonly class BookingManager
|
|||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private BookingRepository $bookings,
|
private BookingRepository $bookings,
|
||||||
private AuditLogger $audit,
|
private AuditLogger $audit,
|
||||||
|
private BookingEventRecorder $events,
|
||||||
private BookingNotifier $notifier,
|
private BookingNotifier $notifier,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,10 @@ final readonly class BookingManager
|
|||||||
$this->em->persist($booking);
|
$this->em->persist($booking);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
// A second flush, because the event wants the id the first one assigned.
|
||||||
|
$this->events->bookingCreated($booking);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
return $booking;
|
return $booking;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,7 +123,11 @@ final readonly class BookingManager
|
|||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return new SeriesResult($series, $created, $skipped);
|
$result = new SeriesResult($series, $created, $skipped);
|
||||||
|
$this->events->seriesCreated($result);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->audit->seriesCreated($result);
|
$this->audit->seriesCreated($result);
|
||||||
@@ -134,13 +143,14 @@ final readonly class BookingManager
|
|||||||
{
|
{
|
||||||
$previous = $booking->getTimeRange();
|
$previous = $booking->getTimeRange();
|
||||||
|
|
||||||
$this->em->wrapInTransaction(function () use ($booking, $time): void {
|
$this->em->wrapInTransaction(function () use ($booking, $time, $previous): void {
|
||||||
$resource = $booking->getResource();
|
$resource = $booking->getResource();
|
||||||
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
|
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
|
||||||
|
|
||||||
$this->assertFree($resource, $booking->getDate(), $time, $booking);
|
$this->assertFree($resource, $booking->getDate(), $time, $booking);
|
||||||
|
|
||||||
$booking->changeTime($time);
|
$booking->changeTime($time);
|
||||||
|
$this->events->bookingRescheduled($booking, $previous);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,8 +162,11 @@ final readonly class BookingManager
|
|||||||
// Before the flush, while the booking still knows its own id.
|
// Before the flush, while the booking still knows its own id.
|
||||||
$this->audit->bookingReleased($booking);
|
$this->audit->bookingReleased($booking);
|
||||||
|
|
||||||
$this->em->remove($booking);
|
$this->em->wrapInTransaction(function () use ($booking): void {
|
||||||
$this->em->flush();
|
$this->events->bookingReleased($booking);
|
||||||
|
$this->em->remove($booking);
|
||||||
|
$this->em->flush();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +181,7 @@ final readonly class BookingManager
|
|||||||
// longer knows its own id.
|
// longer knows its own id.
|
||||||
$seriesId = $series->getId();
|
$seriesId = $series->getId();
|
||||||
|
|
||||||
$removed = $this->em->wrapInTransaction(function () use ($series, $from): int {
|
$removed = $this->em->wrapInTransaction(function () use ($series, $seriesId, $from): int {
|
||||||
$from = $from->setTime(0, 0);
|
$from = $from->setTime(0, 0);
|
||||||
$removed = 0;
|
$removed = 0;
|
||||||
|
|
||||||
@@ -183,6 +196,7 @@ final readonly class BookingManager
|
|||||||
$series->setEndDate($from->modify('-1 day'));
|
$series->setEndDate($from->modify('-1 day'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->events->seriesEnded($series, $seriesId, $from, $removed);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return $removed;
|
return $removed;
|
||||||
|
|||||||
@@ -4,14 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Email\Mailer;
|
||||||
use App\Entity\Booking;
|
use App\Entity\Booking;
|
||||||
use App\Enum\ResourceType;
|
use App\Enum\ResourceType;
|
||||||
use App\Model\SeriesResult;
|
use App\Model\SeriesResult;
|
||||||
use App\Repository\ResourceTypeSettingsRepository;
|
use App\Repository\ResourceTypeSettingsRepository;
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
|
||||||
use Symfony\Component\Mailer\MailerInterface;
|
|
||||||
use Symfony\Component\Mime\Address;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells whoever an administrator named that something was booked.
|
* Tells whoever an administrator named that something was booked.
|
||||||
@@ -22,25 +19,14 @@ use Symfony\Component\Mime\Address;
|
|||||||
* would otherwise never notify anybody.
|
* would otherwise never notify anybody.
|
||||||
*
|
*
|
||||||
* **Nothing here may break a booking.** It runs after the transaction has committed, so a
|
* **Nothing here may break a booking.** It runs after the transaction has committed, so a
|
||||||
* failure cannot roll anything back, and a transport that is down is logged rather than thrown:
|
* failure cannot roll anything back -- and Mailer never throws, so the person who booked is not
|
||||||
* the booking is made, and the person who made it should not be shown an error about somebody
|
* shown an error about somebody else's mail server.
|
||||||
* else's mail server.
|
|
||||||
*
|
|
||||||
* Every recipient gets their own mail, addressed only to them. Nobody sees who else is on the
|
|
||||||
* list, and a single bad address loses its own mail rather than everybody's.
|
|
||||||
*
|
|
||||||
* Each mail is multipart: the MyE&P layout for clients that render HTML, and a hand-written text
|
|
||||||
* part for those that do not. The text part is written rather than generated because there is no
|
|
||||||
* html-to-markdown converter installed, and three facts and a name read better as plain lines
|
|
||||||
* than as a flattened table anyway.
|
|
||||||
*/
|
*/
|
||||||
final readonly class BookingNotifier
|
final readonly class BookingNotifier
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MailerInterface $mailer,
|
private Mailer $mailer,
|
||||||
private ResourceTypeSettingsRepository $settings,
|
private ResourceTypeSettingsRepository $settings,
|
||||||
private LoggerInterface $logger,
|
|
||||||
private string $sender,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,42 +66,10 @@ final readonly class BookingNotifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $template the template pair's basename; both .html.twig and
|
|
||||||
* .txt.twig of it are rendered
|
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
private function send(ResourceType $type, string $subject, string $template, array $context): void
|
private function send(ResourceType $type, string $subject, string $template, array $context): void
|
||||||
{
|
{
|
||||||
$recipients = $this->settings->recipientsFor($type);
|
$this->mailer->send($this->settings->recipientsFor($type), $subject, $template, $context);
|
||||||
|
|
||||||
if ([] === $recipients) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($recipients as $recipient) {
|
|
||||||
// A fresh message per recipient: ->to() on a reused one appends rather than replaces,
|
|
||||||
// which would put the whole list back on everybody's copy.
|
|
||||||
$email = (new TemplatedEmail())
|
|
||||||
->from(new Address($this->sender, 'E&P Platz'))
|
|
||||||
->to($recipient)
|
|
||||||
->subject($subject)
|
|
||||||
->htmlTemplate($template.'.html.twig')
|
|
||||||
->textTemplate($template.'.txt.twig')
|
|
||||||
->context($context);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->mailer->send($email);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// Caught per recipient, so one unreachable address does not cost the others theirs.
|
|
||||||
// \Throwable rather than TransportExceptionInterface because the templates are
|
|
||||||
// rendered inside send(): a typo in one of them would otherwise surface as a failed
|
|
||||||
// booking, which is exactly what this class promises never to do.
|
|
||||||
$this->logger->error('Booking notification could not be sent.', [
|
|
||||||
'subject' => $subject,
|
|
||||||
'recipient' => $recipient,
|
|
||||||
'exception' => $e,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Mitarbeitende benachrichtigen?{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>
|
||||||
|
{{ recipients }} Mitarbeitende erhalten eine E-Mail, dass sich die Zugangscodes geändert
|
||||||
|
haben, mit einem Link zu dieser Anwendung.
|
||||||
|
</p>
|
||||||
|
<p class="pt-2 text-sm text-gray-500">
|
||||||
|
Die Codes selbst stehen aus Sicherheitsgründen nicht in der E-Mail.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block button_confirm %}Senden{% endblock %}
|
||||||
@@ -20,10 +20,16 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1>Zugangscodes verwalten</h1>
|
<h1>Zugangscodes verwalten</h1>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="button button--small button--primary"
|
<div class="flex flex-wrap gap-2">
|
||||||
hx-get="{{ path('app_admin_access_code_new') }}"
|
<button type="button" class="button button--small button--neutral"
|
||||||
hx-target="body"
|
hx-get="{{ path('app_admin_access_code_notify') }}"
|
||||||
hx-swap="beforeend">Code hinzufügen</button>
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">Mitarbeitende benachrichtigen</button>
|
||||||
|
<button type="button" class="button button--small button--primary"
|
||||||
|
hx-get="{{ path('app_admin_access_code_new') }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">Code hinzufügen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if rows is empty %}
|
{% if rows is empty %}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{#
|
||||||
|
The event history. One row per thing that happened -- a series is one row, not one per day --
|
||||||
|
so a year of standing bookings does not drown out this morning.
|
||||||
|
|
||||||
|
"Für" and "Durchgeführt von" are both shown on every row; when they differ, somebody acted on
|
||||||
|
another person's booking, and the row says so with a badge rather than leaving it to be
|
||||||
|
noticed.
|
||||||
|
#}
|
||||||
|
{% extends 'layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Ereignishistorie · E&P Platz{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
|
||||||
|
|
||||||
|
<h1 class="pt-2">Ereignishistorie</h1>
|
||||||
|
<p class="subline">Angelegte, geänderte und freigegebene Buchungen der letzten zwölf Monate.</p>
|
||||||
|
|
||||||
|
{{ form_start(form, { attr: { class: 'mt-6 rounded-md border border-gray-200 p-4' } }) }}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{{ form_row(form.from) }}
|
||||||
|
{{ form_row(form.to) }}
|
||||||
|
{{ form_row(form.actor) }}
|
||||||
|
{{ form_row(form.resourceType) }}
|
||||||
|
</div>
|
||||||
|
<fieldset class="pt-4">
|
||||||
|
<legend class="block font-bold pb-2">{{ form.types.vars.label }}</legend>
|
||||||
|
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||||
|
{% for choice in form.types %}
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
{{ form_widget(choice) }}
|
||||||
|
{{ choice.vars.label }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<a class="button button--small button--neutral" href="{{ path('app_admin_booking_event') }}">Zurücksetzen</a>
|
||||||
|
<button type="submit" class="button button--small button--primary">Filtern</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
|
||||||
|
<p class="subline pt-8 pb-4">
|
||||||
|
{{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
|
||||||
|
{{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||||
|
· {{ pagination.totalItemCount }} {{ pagination.totalItemCount == 1 ? 'Ereignis' : 'Ereignisse' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="data-table-wrapper">
|
||||||
|
<div class="data-table-wrapper__inner text-sm">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ knp_pagination_sortable(pagination, 'Zeitpunkt', 'e.occurredAt') }}</th>
|
||||||
|
<th>{{ knp_pagination_sortable(pagination, 'Ereignis', 'e.type') }}</th>
|
||||||
|
<th>{{ knp_pagination_sortable(pagination, 'Ressource', 'e.resourceLabel') }}</th>
|
||||||
|
<th>{{ knp_pagination_sortable(pagination, 'Buchung', 'e.date') }}</th>
|
||||||
|
<th>{{ knp_pagination_sortable(pagination, 'Für', 'e.holderName') }}</th>
|
||||||
|
<th>{{ knp_pagination_sortable(pagination, 'Durchgeführt von', 'e.actorName') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in pagination %}
|
||||||
|
{% set type = event.type.value %}
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap">{{ event.occurredAt|format_datetime(pattern: 'dd.MM.y, HH:mm') }}</td>
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
<span class="badge {{ event.type.badgeClass }}">{{ event.type.label }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ event.resourceLabel }}
|
||||||
|
<span class="block text-xs text-gray-500">{{ event.resourceType.label }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if type in ['series_created', 'series_ended'] %}
|
||||||
|
{{ event.rule ? event.rule.label }}{% if event.timeRange %}, {{ event.timeRange }}{% endif %}
|
||||||
|
<span class="block text-xs text-gray-500">
|
||||||
|
{% if type == 'series_created' %}
|
||||||
|
{{ event.date|format_datetime(pattern: 'd. MMM y') }} – {{ event.endDate|format_datetime(pattern: 'd. MMM y') }}
|
||||||
|
· {{ event.details.created }} {{ event.details.created == 1 ? 'Termin' : 'Termine' }}
|
||||||
|
{%- if event.details.skipped is not empty %}, {{ event.details.skipped|length }} übersprungen{% endif %}
|
||||||
|
{% else %}
|
||||||
|
ab {{ event.date|format_datetime(pattern: 'd. MMM y') }}
|
||||||
|
· {{ event.details.removed }} {{ event.details.removed == 1 ? 'Termin' : 'Termine' }} entfernt
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% elseif type == 'resource_deleted' %}
|
||||||
|
{{ event.details.removed }} {{ event.details.removed == 1 ? 'Buchung' : 'Buchungen' }} gelöscht
|
||||||
|
{% else %}
|
||||||
|
<span class="whitespace-nowrap">{{ event.date|format_datetime(pattern: 'EEE, d. MMM y') }}</span>
|
||||||
|
<span class="block text-xs text-gray-500">
|
||||||
|
{% if event.previousTimeRange %}
|
||||||
|
{{ event.previousTimeRange }} → {{ event.timeRange }}
|
||||||
|
{% else %}
|
||||||
|
{{ event.timeRange }}
|
||||||
|
{% endif %}
|
||||||
|
{%- if event.seriesId %} · wiederkehrend{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ event.holderName ?? '–' }}</td>
|
||||||
|
<td>
|
||||||
|
{{ event.actorName }}
|
||||||
|
{% if event.onBehalf %}
|
||||||
|
<span class="badge bg-gray-100 text-gray-600">stellvertretend</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-gray-500">Keine Ereignisse in diesem Zeitraum.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ knp_pagination_render(pagination) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -40,10 +40,12 @@
|
|||||||
<section>
|
<section>
|
||||||
{% if spot_group.group %}
|
{% if spot_group.group %}
|
||||||
{% set free = spot_group.freeCount %}
|
{% set free = spot_group.freeCount %}
|
||||||
<h3 class="!mb-0 pb-2 text-sm font-semibold text-gray-500 md:text-sm">
|
{# Prominent on purpose: desk labels repeat from room to room, so the room is
|
||||||
|
the only thing telling one "Schreibtisch 1" from the next. #}
|
||||||
|
<h3 class="!mb-0 pb-2 text-base font-semibold text-gray-900 md:text-lg">
|
||||||
{{- spot_group.group.label -}}
|
{{- spot_group.group.label -}}
|
||||||
{%- if free > 0 %} <span class="text-emerald-600">· {{ free }} frei</span>
|
{%- if free > 0 %} <span class="text-sm font-normal text-emerald-600">· {{ free }} frei</span>
|
||||||
{%- else %} <span>· belegt</span>{% endif -%}
|
{%- else %} <span class="text-sm font-normal text-gray-500">· belegt</span>{% endif -%}
|
||||||
</h3>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% set interactive = not is_past and spot.actionable %}
|
{% set interactive = not is_past and spot.actionable %}
|
||||||
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
|
{# A supervisor (and so an administrator) may free somebody else's desk -- a colleague calls in
|
||||||
should go back into the pool. Colours are untouched: MINE still means mine, and only
|
sick and the spot should go back into the pool. Colours are untouched: MINE still means
|
||||||
clickability widens. Past days stay inert for admins too; there is no past-write path.
|
mine, and only clickability widens. Past days stay inert for them too; there is no
|
||||||
|
past-write path.
|
||||||
|
|
||||||
Only a card that is not already actionable: a partly booked one still opens the create
|
Only a card that is not already actionable: a partly booked one still opens the create
|
||||||
modal, because taking the free window is the more useful act there and an admin can reach
|
modal, because taking the free window is the more useful act there and a supervisor can
|
||||||
that booking from /admin/bookings anyway. #}
|
reach that booking from /manage/bookings anyway. #}
|
||||||
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
|
{% set manageable = not is_past and not interactive and is_granted('ROLE_SUPERVISOR') and spot.bookings is not empty %}
|
||||||
|
|
||||||
{# One line per booking, then the window still free if there is one -- that last line is the
|
{# One line per booking, then the window still free if there is one -- that last line is the
|
||||||
reason a partly booked card is worth clicking, so it earns its place. #}
|
reason a partly booked card is worth clicking, so it earns its place. #}
|
||||||
@@ -78,9 +79,9 @@
|
|||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
{%- elseif manageable %}
|
{%- elseif manageable %}
|
||||||
{# Not app_booking_edit: a card can hold several bookings on one day, and an admin
|
{# Not app_booking_edit: a card can hold several bookings on one day, and a supervisor
|
||||||
releases rather than reschedules. The modal lists them and frees one. #}
|
releases rather than reschedules. The modal lists them and frees one. #}
|
||||||
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
hx-get="{{ path('app_manage_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
hx-swap="beforeend"
|
||||||
{%- endif %}>
|
{%- endif %}>
|
||||||
|
|||||||
@@ -49,24 +49,22 @@
|
|||||||
<p class="text-sm text-gray-500 pb-4">Alle Buchungen verwalten.</p>
|
<p class="text-sm text-gray-500 pb-4">Alle Buchungen verwalten.</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# The route 404s while the feature is off, so the card has to go with it. #}
|
<a href="{{ path('app_access_code') }}"
|
||||||
{% if feature('access_codes') %}
|
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
|
||||||
<a href="{{ path('app_access_code') }}"
|
<div class="flex items-center gap-3 pb-2">
|
||||||
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
|
{{ ux_icon('lucide:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||||
<div class="flex items-center gap-3 pb-2">
|
<span class="text-lg font-bold">Zugangscodes</span>
|
||||||
{{ ux_icon('lucide:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
</div>
|
||||||
<span class="text-lg font-bold">Zugangscodes</span>
|
<p class="text-sm text-gray-500 pb-4">Codes für Türen, Aufzug und Schranke.</p>
|
||||||
</div>
|
</a>
|
||||||
<p class="text-sm text-gray-500 pb-4">Codes für Türen, Aufzug und Schranke.</p>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{#
|
{#
|
||||||
The header is deliberately not navigation, so the dashboard is the only place an
|
The header is deliberately not navigation, so the dashboard is the only place the
|
||||||
admin screen can be reached from.
|
overview and the admin screens can be reached from. ROLE_SUPERVISOR includes
|
||||||
|
administrators through role_hierarchy.
|
||||||
#}
|
#}
|
||||||
{% if is_granted('ROLE_ADMIN') %}
|
{% if is_granted('ROLE_SUPERVISOR') %}
|
||||||
<a href="{{ path('app_admin_booking') }}"
|
<a href="{{ path('app_manage_booking') }}"
|
||||||
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:shield-check', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
{{ ux_icon('lucide:shield-check', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||||
@@ -76,7 +74,9 @@
|
|||||||
Buchungen aller Mitarbeitenden einsehen und ggf. freigeben.
|
Buchungen aller Mitarbeitenden einsehen und ggf. freigeben.
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
<a href="{{ path('app_admin_resource') }}"
|
<a href="{{ path('app_admin_resource') }}"
|
||||||
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">
|
||||||
@@ -87,6 +87,17 @@
|
|||||||
Buchbare Ressourcen und ihre Standorte verwalten.
|
Buchbare Ressourcen und ihre Standorte verwalten.
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ path('app_admin_booking_event') }}"
|
||||||
|
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">
|
||||||
|
{{ ux_icon('lucide:history', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||||
|
<span class="text-lg font-bold">Ereignishistorie</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 pb-4">
|
||||||
|
Nachvollziehen, wer welche Buchung angelegt, geändert oder freigegeben hat.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{# No codes and no door names -- see AccessCodeNotifier for why. #}
|
||||||
|
{% extends 'email/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h1>Neue Zugangscodes</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Die Zugangscodes wurden aktualisiert. Die aktuell gültigen Codes findest du in E&P Platz.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{{ url('app_access_code') }}" class="button">Zugangscodes ansehen</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{# No codes and no door names -- see AccessCodeNotifier for why. #}
|
||||||
|
Die Zugangscodes wurden aktualisiert. Die aktuell gültigen Codes findest du in E&P Platz:
|
||||||
|
|
||||||
|
{{ url('app_access_code') }}
|
||||||
|
|
||||||
|
--
|
||||||
|
E&P Platz
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{#
|
{#
|
||||||
What an administrator sees when clicking an occupied shape on the floor plan.
|
What a supervisor (or an administrator) sees when clicking an occupied shape on the floor plan.
|
||||||
|
|
||||||
Not the edit modal: a shape can hold several bookings on one day once part-day spans exist,
|
Not the edit modal: a shape can hold several bookings on one day once part-day spans exist,
|
||||||
so "the booking" has no single referent -- and an administrator releases rather than
|
so "the booking" has no single referent -- and a supervisor releases rather than reschedules.
|
||||||
reschedules. Each row posts to the ordinary app_booking_release route, whose ownership check
|
Each row posts to the ordinary app_booking_release route, whose ownership check admits
|
||||||
admits an admin, so there is still one code path that removes a booking.
|
supervisors, so there is still one code path that removes a booking.
|
||||||
#}
|
#}
|
||||||
{% extends 'htmx_modal.html.twig' %}
|
{% extends 'htmx_modal.html.twig' %}
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Filtering is a plain GET form -- the house rule hands navigation to the browser. The two
|
Filtering is a plain GET form -- the house rule hands navigation to the browser. The two
|
||||||
destructive actions open the shared confirmation modal via htmx and then navigate back here,
|
destructive actions open the shared confirmation modal via htmx and then navigate back here,
|
||||||
which is what ?return=admin says; without it a confirmed release swaps board fragments this
|
which is what ?return=manage says; without it a confirmed release swaps board fragments this
|
||||||
page does not have.
|
page does not have.
|
||||||
|
|
||||||
The appointment table is paginated; the series table is not, because a company has a handful of
|
The appointment table is paginated; the series table is not, because a company has a handful of
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
{{ form_row(form.user) }}
|
{{ form_row(form.user) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<a class="button button--small button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a>
|
<a class="button button--small button--neutral" href="{{ path('app_manage_booking') }}">Zurücksetzen</a>
|
||||||
<button type="submit" class="button button--small button--primary">Filtern</button>
|
<button type="submit" class="button button--small button--primary">Filtern</button>
|
||||||
</div>
|
</div>
|
||||||
{{ form_end(form) }}
|
{{ form_end(form) }}
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td>
|
<td>{{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<button type="button" class="button button--small button--warning"
|
<button type="button" class="button button--small button--warning"
|
||||||
hx-get="{{ path('app_series_end', { id: entry.id, return: 'admin' }) }}"
|
hx-get="{{ path('app_series_end', { id: entry.id, return: 'manage' }) }}"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend">
|
hx-swap="beforeend">
|
||||||
Wiederholung beenden
|
Wiederholung beenden
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
<a class="button button--small button--neutral"
|
<a class="button button--small button--neutral"
|
||||||
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
|
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
|
||||||
<button type="button" class="button button--small button--warning"
|
<button type="button" class="button button--small button--warning"
|
||||||
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'admin' }) }}"
|
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'manage' }) }}"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend">
|
hx-swap="beforeend">
|
||||||
Freigeben
|
Freigeben
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Command;
|
||||||
|
|
||||||
|
use App\Entity\BookingEvent;
|
||||||
|
use App\Enum\BookingEventType;
|
||||||
|
use App\Enum\ResourceType;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The retention period of the event history, enforced from cron.
|
||||||
|
*/
|
||||||
|
class PruneBookingEventsCommandTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private CommandTester $command;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->em->getConnection()->beginTransaction();
|
||||||
|
$this->command = new CommandTester((new Application(self::$kernel))->find('app:booking-events:prune'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->em->getConnection()->rollBack();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEventsOlderThanAYearAreDeleted(): void
|
||||||
|
{
|
||||||
|
$old = $this->event('-13 months');
|
||||||
|
$recent = $this->event('-11 months');
|
||||||
|
|
||||||
|
$this->command->execute([]);
|
||||||
|
|
||||||
|
$this->command->assertCommandIsSuccessful();
|
||||||
|
self::assertStringContainsString('Deleted 1 booking events', $this->command->getDisplay());
|
||||||
|
self::assertNull($this->em->find(BookingEvent::class, $old));
|
||||||
|
self::assertNotNull($this->em->find(BookingEvent::class, $recent));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheRetentionCanBeShortened(): void
|
||||||
|
{
|
||||||
|
$event = $this->event('-2 months');
|
||||||
|
|
||||||
|
$this->command->execute(['--months' => '1']);
|
||||||
|
|
||||||
|
$this->command->assertCommandIsSuccessful();
|
||||||
|
self::assertNull($this->em->find(BookingEvent::class, $event));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testANonsensicalRetentionIsRefused(): void
|
||||||
|
{
|
||||||
|
$event = $this->event('-2 months');
|
||||||
|
|
||||||
|
self::assertSame(Command::INVALID, $this->command->execute(['--months' => '0']));
|
||||||
|
self::assertNotNull($this->em->find(BookingEvent::class, $event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function event(string $when): int
|
||||||
|
{
|
||||||
|
$event = new BookingEvent(BookingEventType::BOOKING_CREATED, null, BookingEvent::SYSTEM_ACTOR, null, 'Tisch 1', ResourceType::DESK);
|
||||||
|
$this->em->persist($event);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->em->getConnection()->executeStatement(
|
||||||
|
'UPDATE booking_event SET occurred_at = ? WHERE id = ?',
|
||||||
|
[(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()],
|
||||||
|
);
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
return (int) $event->getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,46 @@ class AccessCodeControllerTest extends WebTestCase
|
|||||||
self::assertStringContainsString('Administration', $card->text());
|
self::assertStringContainsString('Administration', $card->text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A supervisor code is read by supervisors and by administrators, who hold no
|
||||||
|
* ROLE_SUPERVISOR of their own -- role_hierarchy does not reach the stored roles the query
|
||||||
|
* filters on. And a supervisor is not an administrator: the admin-only code stays out of
|
||||||
|
* their response.
|
||||||
|
*/
|
||||||
|
public function testASupervisorCodeIsReadBySupervisorsAndAdminsOnly(): void
|
||||||
|
{
|
||||||
|
$this->code('Management office', '5555', visibility: CodeVisibility::SUPERVISOR);
|
||||||
|
$this->code('Serverraum', '9999', visibility: CodeVisibility::ADMIN);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
$this->client->request('GET', '/access-codes');
|
||||||
|
self::assertStringNotContainsString('5555', (string) $this->client->getResponse()->getContent());
|
||||||
|
|
||||||
|
$this->client->loginUser($this->user('[email protected]', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
|
||||||
|
$card = $this->client->request('GET', '/access-codes')->filter('[data-access-code]');
|
||||||
|
self::assertCount(1, $card);
|
||||||
|
self::assertSame('5555', $card->filter('[data-code]')->text());
|
||||||
|
// The visibility caption the page shows, which is German like all user-facing text.
|
||||||
|
self::assertStringContainsString('Leitung', $card->text());
|
||||||
|
self::assertStringNotContainsString('9999', (string) $this->client->getResponse()->getContent());
|
||||||
|
|
||||||
|
$this->client->loginUser($this->user('[email protected]', ['ROLE_ADMIN']));
|
||||||
|
self::assertCount(2, $this->client->request('GET', '/access-codes')->filter('[data-access-code]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A supervisor reads codes but does not manage them.
|
||||||
|
*/
|
||||||
|
public function testASupervisorIsNotOfferedTheTable(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->user('[email protected]', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/access-codes');
|
||||||
|
|
||||||
|
self::assertCount(0, $crawler->filter('a[href="/admin/access-codes"]'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotating a code. Only the one in force is shown: its predecessor is history, and its
|
* Rotating a code. Only the one in force is shown: its predecessor is history, and its
|
||||||
* successor does not open anything yet -- both would only be another number to mistype.
|
* successor does not open anything yet -- both would only be another number to mistype.
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ class AccessCodeAdminControllerTest extends WebTestCase
|
|||||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
$this->em->clear();
|
$this->em->clear();
|
||||||
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
|
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
|
||||||
|
|
||||||
|
$this->client->request('POST', '/admin/access-codes/notify', ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
self::assertEmailCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,6 +223,59 @@ class AccessCodeAdminControllerTest extends WebTestCase
|
|||||||
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
|
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everybody who can sign in gets a mail of their own, and none of them contains a code -- not
|
||||||
|
* even one only the recipient's own role could read.
|
||||||
|
*/
|
||||||
|
public function testNotifyingTheStaffMailsEveryActiveUserWithoutTheCodes(): void
|
||||||
|
{
|
||||||
|
$this->code('Aufzug', '4711');
|
||||||
|
$this->code('Tresor', '9876');
|
||||||
|
$admin = $this->admin();
|
||||||
|
$this->user('[email protected]');
|
||||||
|
$locked = $this->user('[email protected]');
|
||||||
|
$locked->disable('ausgeschieden');
|
||||||
|
$this->em->flush();
|
||||||
|
$expected = $this->em->getRepository(User::class)->count(['disabledAt' => null]);
|
||||||
|
$this->client->loginUser($admin);
|
||||||
|
$uri = '/admin/access-codes/notify';
|
||||||
|
|
||||||
|
$modal = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
self::assertStringContainsString(\sprintf('%d Mitarbeitende', $expected), $modal->text());
|
||||||
|
|
||||||
|
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
|
self::assertSame('/admin/access-codes', $this->client->getResponse()->headers->get('HX-Redirect'));
|
||||||
|
self::assertEmailCount($expected);
|
||||||
|
|
||||||
|
$recipients = [];
|
||||||
|
foreach (self::getMailerMessages() as $email) {
|
||||||
|
self::assertCount(1, $email->getTo());
|
||||||
|
$recipients[] = $email->getTo()[0]->getAddress();
|
||||||
|
|
||||||
|
foreach ([$email->getTextBody(), $email->getHtmlBody()] as $body) {
|
||||||
|
self::assertStringContainsString('https://localhost/access-codes', $body);
|
||||||
|
self::assertStringNotContainsString('4711', $body);
|
||||||
|
self::assertStringNotContainsString('9876', $body);
|
||||||
|
self::assertStringNotContainsString('Aufzug', $body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertContains('[email protected]', $recipients);
|
||||||
|
self::assertContains('[email protected]', $recipients);
|
||||||
|
self::assertNotContains('[email protected]', $recipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNotifyingTheStaffNeedsAValidToken(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$this->client->request('POST', '/admin/access-codes/notify', ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
self::assertEmailCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the modal, then posts the given fields with its CSRF token -- the round trip a
|
* Opens the modal, then posts the given fields with its CSRF token -- the round trip a
|
||||||
* browser makes.
|
* browser makes.
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Entity\Booking;
|
||||||
|
use App\Entity\BookingEvent;
|
||||||
|
use App\Entity\Resource;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Enum\BookingEventType;
|
||||||
|
use App\Enum\ResourceType;
|
||||||
|
use App\Model\TimeRange;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event history at /admin/history: who may see it, what it shows, and that its filters and
|
||||||
|
* sort links narrow what they say they do. What gets *recorded* is tested in BookingManagerTest;
|
||||||
|
* the one end-to-end test here goes through a real release, because "acting for somebody else"
|
||||||
|
* is only meaningful with a signed-in actor.
|
||||||
|
*/
|
||||||
|
class BookingEventControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = self::createClient(server: ['HTTPS' => true]);
|
||||||
|
$this->client->disableReboot();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->em->getConnection()->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->em->getConnection()->rollBack();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supervisors free bookings themselves, but the history across the whole staff is the
|
||||||
|
* administrators' alone.
|
||||||
|
*/
|
||||||
|
public function testOnlyAnAdminCanReachTheHistory(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
$this->client->request('GET', '/admin/history');
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
|
||||||
|
$this->client->loginUser($this->user('[email protected]', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
|
||||||
|
$this->client->request('GET', '/admin/history');
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||||
|
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
$this->client->request('GET', '/admin/history');
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheDashboardTileIsForAdminsOnly(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->user('[email protected]', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']));
|
||||||
|
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/history"]'));
|
||||||
|
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/history"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAReleaseOnSomebodysBehalfShowsBothPeople(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$booking = new Booking($bea, $this->resource('Tisch 12'), new \DateTimeImmutable('today'));
|
||||||
|
$this->em->persist($booking);
|
||||||
|
$this->em->flush();
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$uri = \sprintf('/bookings/%d/release', $booking->getId());
|
||||||
|
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
$crawler = $this->client->request('GET', '/admin/history');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$row = $crawler->filter('tbody tr')->first()->text();
|
||||||
|
self::assertStringContainsString('Buchung freigegeben', $row);
|
||||||
|
self::assertStringContainsString('Tisch 12', $row);
|
||||||
|
self::assertStringContainsString('Bea Berger', $row);
|
||||||
|
self::assertStringContainsString('Chef Chefin', $row);
|
||||||
|
self::assertStringContainsString('stellvertretend', $row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSomebodyActingOnTheirOwnBookingIsNotMarked(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea);
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/admin/history');
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A console command or the worker has no booking of its own, so it acts for nobody.
|
||||||
|
*/
|
||||||
|
public function testTheSystemActingIsNotMarked(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$event = (new BookingEvent(BookingEventType::BOOKING_RELEASED, null, BookingEvent::SYSTEM_ACTOR, null, 'Tisch 1', ResourceType::DESK))
|
||||||
|
->setHolder($bea);
|
||||||
|
$this->em->persist($event);
|
||||||
|
$this->em->flush();
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/admin/history');
|
||||||
|
|
||||||
|
self::assertStringContainsString('Bea Berger', $crawler->filter('tbody')->text());
|
||||||
|
self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The person filter is about who carried the change out: Carl's booking freed by Bea is
|
||||||
|
* Bea's line, not Carl's.
|
||||||
|
*/
|
||||||
|
public function testThePersonFilterMatchesWhoActed(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$carl = $this->user('[email protected]', 'Carl Conrad');
|
||||||
|
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $carl, 'Tisch 1');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2');
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/admin/history?actor='.$bea->getId());
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$text = $crawler->filter('tbody')->text();
|
||||||
|
self::assertStringContainsString('Tisch 1', $text);
|
||||||
|
self::assertStringNotContainsString('Tisch 2', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheTypeFilterAcceptsSeveralKinds(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
|
||||||
|
$this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea, 'Tisch 2');
|
||||||
|
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea, 'Tisch 3');
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/admin/history?types[]=booking_created&types[]=booking_released');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$text = $crawler->filter('tbody')->text();
|
||||||
|
self::assertStringContainsString('Tisch 1', $text);
|
||||||
|
self::assertStringNotContainsString('Tisch 2', $text);
|
||||||
|
self::assertStringContainsString('Tisch 3', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheResourceTypeFilter(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'P1', ResourceType::PARKING);
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/admin/history?resourceType=parking');
|
||||||
|
|
||||||
|
$text = $crawler->filter('tbody')->text();
|
||||||
|
self::assertStringContainsString('P1', $text);
|
||||||
|
self::assertStringNotContainsString('Tisch 1', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window is about when it happened. The default reaches back thirty days, and the "to"
|
||||||
|
* day is included in full, not cut off at its midnight.
|
||||||
|
*/
|
||||||
|
public function testTheDateWindowIsAboutWhenItHappened(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch alt'), '-40 days');
|
||||||
|
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch gestern'), 'yesterday 23:30');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch heute');
|
||||||
|
$this->em->clear();
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$text = $this->client->request('GET', '/admin/history')->filter('tbody')->text();
|
||||||
|
self::assertStringNotContainsString('Tisch alt', $text);
|
||||||
|
self::assertStringContainsString('Tisch gestern', $text);
|
||||||
|
self::assertStringContainsString('Tisch heute', $text);
|
||||||
|
|
||||||
|
$yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d');
|
||||||
|
$text = $this->client->request('GET', '/admin/history?from='.$yesterday.'&to='.$yesterday)->filter('tbody')->text();
|
||||||
|
self::assertStringContainsString('Tisch gestern', $text);
|
||||||
|
self::assertStringNotContainsString('Tisch heute', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheNewestEventComesFirst(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch früher'), '-2 hours');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch später');
|
||||||
|
$this->em->clear();
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$rows = $this->client->request('GET', '/admin/history')->filter('tbody tr');
|
||||||
|
|
||||||
|
self::assertStringContainsString('Tisch später', $rows->eq(0)->text());
|
||||||
|
self::assertStringContainsString('Tisch früher', $rows->eq(1)->text());
|
||||||
|
|
||||||
|
$rows = $this->client->request('GET', '/admin/history?sort=e.occurredAt&direction=asc')->filter('tbody tr');
|
||||||
|
self::assertStringContainsString('Tisch früher', $rows->eq(0)->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSortingByWhoActedKeepsTheFilter(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$carl = $this->user('[email protected]', 'Carl Conrad');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2');
|
||||||
|
$this->event(BookingEventType::BOOKING_RELEASED, $carl, $carl, 'Tisch 3');
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$rows = $this->client
|
||||||
|
->request('GET', '/admin/history?types[]=booking_created&sort=e.actorName&direction=desc')
|
||||||
|
->filter('tbody tr');
|
||||||
|
|
||||||
|
self::assertCount(2, $rows);
|
||||||
|
self::assertStringContainsString('Carl Conrad', $rows->eq(0)->text());
|
||||||
|
self::assertStringContainsString('Bea Berger', $rows->eq(1)->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every kind has its own branch in the "Buchung" column; each is rendered once here so a typo
|
||||||
|
* in one of them cannot hide until somebody filters for it.
|
||||||
|
*/
|
||||||
|
public function testEveryKindOfEventRenders(): void
|
||||||
|
{
|
||||||
|
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||||
|
$this->event(BookingEventType::BOOKING_CREATED, $bea, $bea);
|
||||||
|
$this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea)
|
||||||
|
->setTimeRange(TimeRange::fromMinutes(600, 720), TimeRange::fromMinutes(540, 780));
|
||||||
|
$this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea);
|
||||||
|
$this->event(BookingEventType::SERIES_CREATED, $bea, $bea)
|
||||||
|
->setDates(new \DateTimeImmutable('today'), new \DateTimeImmutable('+4 weeks'))
|
||||||
|
->setDetails(['rule' => 'weekly', 'created' => 4, 'skipped' => ['2026-10-20']]);
|
||||||
|
$this->event(BookingEventType::SERIES_ENDED, $bea, $bea)
|
||||||
|
->setDetails(['rule' => 'weekly', 'removed' => 3]);
|
||||||
|
$deleted = new BookingEvent(BookingEventType::RESOURCE_DELETED, $bea, 'Bea Berger', 7, 'Tisch 9', ResourceType::DESK);
|
||||||
|
$this->em->persist($deleted->setDetails(['removed' => 5]));
|
||||||
|
$this->em->flush();
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/admin/history');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$text = $crawler->filter('tbody')->text();
|
||||||
|
self::assertStringContainsString('09:00–13:00 → 10:00–12:00', $text);
|
||||||
|
self::assertStringContainsString('Wöchentlich', $text);
|
||||||
|
self::assertStringContainsString('4 Termine, 1 übersprungen', $text);
|
||||||
|
self::assertStringContainsString('3 Termine entfernt', $text);
|
||||||
|
self::assertStringContainsString('5 Buchungen gelöscht', $text);
|
||||||
|
self::assertStringContainsString('6 Ereignisse', $crawler->filter('body')->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function event(
|
||||||
|
BookingEventType $type,
|
||||||
|
User $actor,
|
||||||
|
User $holder,
|
||||||
|
string $label = 'Tisch 1',
|
||||||
|
ResourceType $resourceType = ResourceType::DESK,
|
||||||
|
): BookingEvent {
|
||||||
|
$event = (new BookingEvent($type, $actor, $actor->getName(), null, $label, $resourceType))
|
||||||
|
->setHolder($holder)
|
||||||
|
->setDates(new \DateTimeImmutable('today'))
|
||||||
|
->setTimeRange(TimeRange::wholeDay());
|
||||||
|
$this->em->persist($event);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The constructor stamps the current time and nothing should be able to change it afterwards,
|
||||||
|
* so a test that needs an older event rewrites the row. Call clear() once the setup is done,
|
||||||
|
* or the page renders the stale time still held in the identity map.
|
||||||
|
*/
|
||||||
|
private function occurredAt(BookingEvent $event, string $when): void
|
||||||
|
{
|
||||||
|
$this->em->getConnection()->executeStatement(
|
||||||
|
'UPDATE booking_event SET occurred_at = ? WHERE id = ?',
|
||||||
|
[(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function confirmationToken(string $uri): string
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals');
|
||||||
|
|
||||||
|
return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function admin(): User
|
||||||
|
{
|
||||||
|
return $this->user('[email protected]', 'Chef Chefin', ['ROLE_ADMIN']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roles
|
||||||
|
*/
|
||||||
|
private function user(string $email, string $name = 'Test', array $roles = ['ROLE_EMPLOYEE']): User
|
||||||
|
{
|
||||||
|
$user = new User($email, $name);
|
||||||
|
$user->setRoles($roles);
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(string $label): Resource
|
||||||
|
{
|
||||||
|
$resource = new Resource(ResourceType::DESK, $label);
|
||||||
|
$this->em->persist($resource);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +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\BookingEvent;
|
||||||
use App\Entity\BookingSeries;
|
use App\Entity\BookingSeries;
|
||||||
use App\Entity\Resource;
|
use App\Entity\Resource;
|
||||||
use App\Entity\ResourceGroup;
|
use App\Entity\ResourceGroup;
|
||||||
use App\Entity\ResourceTypeSettings;
|
use App\Entity\ResourceTypeSettings;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Enum\BookingEventType;
|
||||||
use App\Enum\RecurrenceRule;
|
use App\Enum\RecurrenceRule;
|
||||||
use App\Enum\ResourceType;
|
use App\Enum\ResourceType;
|
||||||
use App\Model\TimeRange;
|
use App\Model\TimeRange;
|
||||||
@@ -268,6 +270,29 @@ class ResourceAdminControllerTest extends WebTestCase
|
|||||||
$remaining = $this->em->getRepository(Booking::class)->findAll();
|
$remaining = $this->em->getRepository(Booking::class)->findAll();
|
||||||
self::assertCount(1, $remaining);
|
self::assertCount(1, $remaining);
|
||||||
self::assertSame($other->getId(), $remaining[0]->getResource()->getId());
|
self::assertSame($other->getId(), $remaining[0]->getResource()->getId());
|
||||||
|
|
||||||
|
// The bookings went without passing through BookingManager, so the history gets one line
|
||||||
|
// for the lot here.
|
||||||
|
$events = $this->em->getRepository(BookingEvent::class)->findAll();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertSame(BookingEventType::RESOURCE_DELETED, $events[0]->getType());
|
||||||
|
self::assertSame('Schreibtisch 01', $events[0]->getResourceLabel());
|
||||||
|
self::assertSame(3, $events[0]->getDetails()['removed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unused resource is configuration, not booking history.
|
||||||
|
*/
|
||||||
|
public function testDeletingAResourceWithoutBookingsRecordsNoEvent(): void
|
||||||
|
{
|
||||||
|
$desk = $this->resource('Schreibtisch 01');
|
||||||
|
$this->client->loginUser($this->admin());
|
||||||
|
$uri = \sprintf('/admin/resources/%d/delete', $desk->getId());
|
||||||
|
|
||||||
|
$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'));
|
||||||
|
self::assertSame(0, $this->em->getRepository(BookingEvent::class)->count([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDeletingAResourceNeedsAValidToken(): void
|
public function testDeletingAResourceNeedsAValidToken(): void
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
|
private \DateTimeImmutable $monday;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
@@ -33,6 +34,10 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->disableReboot();
|
$this->client->disableReboot();
|
||||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
$this->em->getConnection()->beginTransaction();
|
$this->em->getConnection()->beginTransaction();
|
||||||
|
// Every day these tests book on is counted from here. Fixed dates fell into the past and
|
||||||
|
// started being refused; the next Monday is always ahead, always inside the booking
|
||||||
|
// horizon, and keeps the weekday the series tests count on.
|
||||||
|
$this->monday = new \DateTimeImmutable('next monday');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
@@ -45,11 +50,11 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$this->client->request('GET', '/book/desk/day/2026-10-01', server: ['HTTP_HX-Request' => 'true']);
|
$this->client->request('GET', '/book/desk/day/'.$this->day(), server: ['HTTP_HX-Request' => 'true']);
|
||||||
$response = $this->client->getResponse();
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
self::assertSame('/book/desk?date=2026-10-01', $response->headers->get('HX-Push-Url'));
|
self::assertSame('/book/desk?date='.$this->day(), $response->headers->get('HX-Push-Url'));
|
||||||
self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
|
self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
|
||||||
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
|
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
|
||||||
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
|
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
|
||||||
@@ -59,9 +64,9 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$this->client->request('GET', '/book/desk/day/2026-10-01');
|
$this->client->request('GET', '/book/desk/day/'.$this->day());
|
||||||
|
|
||||||
self::assertResponseRedirects('/book/desk?date=2026-10-01');
|
self::assertResponseRedirects('/book/desk?date='.$this->day());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +78,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
|
|
||||||
$response = $this->submitBookingModal($desk, '2026-10-01');
|
$response = $this->submitBookingModal($desk, $this->day());
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
// The modal is gone from the response, which is what closes it: an out-of-band-only
|
// The modal is gone from the response, which is what closes it: an out-of-band-only
|
||||||
@@ -91,7 +96,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', ['allDay' => '1', 'date' => '2020-01-01']);
|
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), ['allDay' => '1', 'date' => '2020-01-01']);
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||||
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
|
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
|
||||||
@@ -104,15 +109,15 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testADeskAlreadyTakenIsReportedInsideTheModal(): void
|
public function testADeskAlreadyTakenIsReportedInsideTheModal(): void
|
||||||
{
|
{
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
|
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day())));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$response = $this->submitBookingModal($desk, '2026-10-01');
|
$response = $this->submitBookingModal($desk, $this->day());
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||||
self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent());
|
self::assertStringContainsString(\sprintf('ist am %s bereits belegt', $this->day(0, 'd.m.Y')), (string) $response->getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,12 +128,12 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testASecondDeskAtTheSameTimeIsBookedAndWarnedAbout(): void
|
public function testASecondDeskAtTheSameTimeIsBookedAndWarnedAbout(): void
|
||||||
{
|
{
|
||||||
$anna = $this->user('[email protected]');
|
$anna = $this->user('[email protected]');
|
||||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01');
|
$response = $this->submitBookingModal($this->resource('D2'), $this->day());
|
||||||
$content = (string) $response->getContent();
|
$content = (string) $response->getContent();
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
@@ -139,13 +144,13 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testEveryClashingDeskIsNamedInTheWarning(): void
|
public function testEveryClashingDeskIsNamedInTheWarning(): void
|
||||||
{
|
{
|
||||||
$anna = $this->user('[email protected]');
|
$anna = $this->user('[email protected]');
|
||||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
|
||||||
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable('2026-10-01')));
|
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable($this->day())));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('D3'), '2026-10-01');
|
$response = $this->submitBookingModal($this->resource('D3'), $this->day());
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
self::assertStringContainsString('bereits 2 weitere Schreibtische gebucht: D1, D2', (string) $response->getContent());
|
self::assertStringContainsString('bereits 2 weitere Schreibtische gebucht: D1, D2', (string) $response->getContent());
|
||||||
@@ -154,12 +159,12 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testTwoDesksAtDifferentTimesAreNotWarnedAbout(): void
|
public function testTwoDesksAtDifferentTimesAreNotWarnedAbout(): void
|
||||||
{
|
{
|
||||||
$anna = $this->user('[email protected]');
|
$anna = $this->user('[email protected]');
|
||||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 720)));
|
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 720)));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', [
|
$response = $this->submitBookingModal($this->resource('D2'), $this->day(), [
|
||||||
'startTime' => '13:00',
|
'startTime' => '13:00',
|
||||||
'endTime' => '17:00',
|
'endTime' => '17:00',
|
||||||
]);
|
]);
|
||||||
@@ -175,12 +180,12 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testADeskAndAParkingSpotOnTheSameDayAreBothAllowedWithoutAWarning(): void
|
public function testADeskAndAParkingSpotOnTheSameDayAreBothAllowedWithoutAWarning(): void
|
||||||
{
|
{
|
||||||
$anna = $this->user('[email protected]');
|
$anna = $this->user('[email protected]');
|
||||||
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
$this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day())));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01');
|
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), $this->day());
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
self::assertStringNotContainsString('Hinweis', (string) $response->getContent());
|
self::assertStringNotContainsString('Hinweis', (string) $response->getContent());
|
||||||
@@ -191,33 +196,33 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$anna = $this->user('[email protected]');
|
$anna = $this->user('[email protected]');
|
||||||
$other = $this->resource('D2');
|
$other = $this->resource('D2');
|
||||||
// Two of the four Mondays the series lands on, and one Tuesday it does not.
|
// Two of the four Mondays the series lands on, and one Tuesday it does not.
|
||||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-05'), TimeRange::fromMinutes(600, 660)));
|
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(600, 660)));
|
||||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-19'), TimeRange::fromMinutes(600, 660)));
|
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day(14)), TimeRange::fromMinutes(600, 660)));
|
||||||
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable('2026-10-20'), TimeRange::fromMinutes(600, 660)));
|
$this->em->persist(new Booking($anna, $other, new \DateTimeImmutable($this->day(15)), TimeRange::fromMinutes(600, 660)));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
|
||||||
'startTime' => '09:00',
|
'startTime' => '09:00',
|
||||||
'endTime' => '12:00',
|
'endTime' => '12:00',
|
||||||
'repeat' => 'weekly',
|
'repeat' => 'weekly',
|
||||||
'until' => '2026-10-30',
|
'until' => $this->day(25),
|
||||||
]);
|
]);
|
||||||
$content = (string) $response->getContent();
|
$content = (string) $response->getContent();
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
self::assertSame(1, substr_count($content, 'Hinweis:'), 'one flash for the whole series');
|
self::assertSame(1, substr_count($content, 'Hinweis:'), 'one flash for the whole series');
|
||||||
self::assertStringContainsString('An 2 von 4 Terminen', $content);
|
self::assertStringContainsString('An 2 von 4 Terminen', $content);
|
||||||
self::assertStringContainsString('(5.10., 19.10.)', $content);
|
self::assertStringContainsString(\sprintf('(%s, %s)', $this->day(0, 'j.n.'), $this->day(14, 'j.n.')), $content);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMovingABookingOntoAnotherOneWarns(): void
|
public function testMovingABookingOntoAnotherOneWarns(): void
|
||||||
{
|
{
|
||||||
$anna = $this->user('[email protected]');
|
$anna = $this->user('[email protected]');
|
||||||
$mine = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(780, 1020));
|
$mine = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(780, 1020));
|
||||||
$this->em->persist($mine);
|
$this->em->persist($mine);
|
||||||
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 720)));
|
$this->em->persist(new Booking($anna, $this->resource('D2'), new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 720)));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
@@ -240,8 +245,8 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
|
|
||||||
$this->client->request('POST', \sprintf('/bookings/new/%d/2026-10-01', $desk->getId()), [
|
$this->client->request('POST', \sprintf('/bookings/new/%d/%s', $desk->getId(), $this->day()), [
|
||||||
'booking' => ['date' => '2026-10-01', '_token' => 'nope'],
|
'booking' => ['date' => $this->day(), '_token' => 'nope'],
|
||||||
], server: ['HTTP_HX-Request' => 'true']);
|
], server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode());
|
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode());
|
||||||
@@ -250,7 +255,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testReleasingAnotherPersonsBookingIsRefused(): void
|
public function testReleasingAnotherPersonsBookingIsRefused(): void
|
||||||
{
|
{
|
||||||
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
$booking = new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable($this->day()));
|
||||||
$this->em->persist($booking);
|
$this->em->persist($booking);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
@@ -264,7 +269,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testReleasingOwnBookingSwapsTheBoard(): void
|
public function testReleasingOwnBookingSwapsTheBoard(): void
|
||||||
{
|
{
|
||||||
$anna = $this->user('[email protected]');
|
$anna = $this->user('[email protected]');
|
||||||
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()));
|
||||||
$this->em->persist($booking);
|
$this->em->persist($booking);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
$id = $booking->getId();
|
$id = $booking->getId();
|
||||||
@@ -291,12 +296,12 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
|
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
|
||||||
{
|
{
|
||||||
$anna = $this->user('[email protected]');
|
$anna = $this->user('[email protected]');
|
||||||
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
$booking = new Booking($anna, $this->resource('D1'), new \DateTimeImmutable($this->day()));
|
||||||
$this->em->persist($booking);
|
$this->em->persist($booking);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
|
||||||
|
|
||||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId())));
|
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/edit"]', $booking->getId())));
|
||||||
}
|
}
|
||||||
@@ -312,13 +317,13 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void
|
public function testASecondPersonCanBookTheRestOfADayFromTheDefaultBoard(): void
|
||||||
{
|
{
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
|
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 780)));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
// 1. The shape is clickable on the all-day board.
|
// 1. The shape is clickable on the all-day board.
|
||||||
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
|
||||||
$opener = $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId()));
|
$opener = $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId()));
|
||||||
self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal');
|
self::assertCount(1, $opener, 'a partly booked desk must still open the booking modal');
|
||||||
|
|
||||||
@@ -330,7 +335,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
self::assertNull($modal->filter('#booking_endTime')->attr('value'));
|
self::assertNull($modal->filter('#booking_endTime')->attr('value'));
|
||||||
|
|
||||||
// 3. Submitting it works.
|
// 3. Submitting it works.
|
||||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
$response = $this->submitBookingModal($desk, $this->day(), [
|
||||||
'startTime' => '13:00',
|
'startTime' => '13:00',
|
||||||
'endTime' => '17:00',
|
'endTime' => '17:00',
|
||||||
]);
|
]);
|
||||||
@@ -342,11 +347,11 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testAFullyBookedDeskIsNotClickable(): void
|
public function testAFullyBookedDeskIsNotClickable(): void
|
||||||
{
|
{
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
|
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day())));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$crawler = $this->client->request('GET', '/book/desk?date=2026-10-01');
|
$crawler = $this->client->request('GET', '/book/desk?date='.$this->day());
|
||||||
|
|
||||||
self::assertCount(0, $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId())));
|
self::assertCount(0, $crawler->filter(\sprintf('[data-spot][hx-get*="/bookings/new/%d/"]', $desk->getId())));
|
||||||
}
|
}
|
||||||
@@ -356,7 +361,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
|
|
||||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
$response = $this->submitBookingModal($desk, $this->day(), [
|
||||||
'startTime' => '09:00',
|
'startTime' => '09:00',
|
||||||
'endTime' => '13:00',
|
'endTime' => '13:00',
|
||||||
]);
|
]);
|
||||||
@@ -372,7 +377,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
|
|
||||||
$this->submitBookingModal($desk, '2026-10-01');
|
$this->submitBookingModal($desk, $this->day());
|
||||||
|
|
||||||
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
|
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
|
||||||
self::assertNotNull($booking);
|
self::assertNotNull($booking);
|
||||||
@@ -383,7 +388,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', [
|
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
|
||||||
'startTime' => '13:00',
|
'startTime' => '13:00',
|
||||||
'endTime' => '09:00',
|
'endTime' => '09:00',
|
||||||
]);
|
]);
|
||||||
@@ -395,12 +400,12 @@ class BookingControllerTest extends WebTestCase
|
|||||||
public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void
|
public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void
|
||||||
{
|
{
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
|
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable($this->day()), TimeRange::fromMinutes(540, 780)));
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
$response = $this->submitBookingModal($desk, $this->day(), [
|
||||||
'startTime' => '13:00',
|
'startTime' => '13:00',
|
||||||
'endTime' => '17:00',
|
'endTime' => '17:00',
|
||||||
]);
|
]);
|
||||||
@@ -413,13 +418,13 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
|
|
||||||
// 2026-10-05 is a Monday, and the weekday is taken from it rather than asked for --
|
// day() is a Monday, and the weekday is taken from it rather than asked for -- the
|
||||||
// the Mondays up to the 30th are the 5th, 12th, 19th and 26th.
|
// Mondays up to the Friday three weeks on are four.
|
||||||
$response = $this->submitBookingModal($desk, '2026-10-05', [
|
$response = $this->submitBookingModal($desk, $this->day(), [
|
||||||
'startTime' => '09:00',
|
'startTime' => '09:00',
|
||||||
'endTime' => '12:00',
|
'endTime' => '12:00',
|
||||||
'repeat' => 'weekly',
|
'repeat' => 'weekly',
|
||||||
'until' => '2026-10-30',
|
'until' => $this->day(25),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
@@ -436,8 +441,8 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
|
|
||||||
// Relative to today, unlike the rest of this file: the three-month horizon moves with the
|
// The three-month horizon moves with the clock, so a fixed end date here would start
|
||||||
// clock, so a fixed end date here would start failing the day it fell outside it.
|
// failing the day it fell outside it.
|
||||||
$start = (new \DateTimeImmutable('today'))->modify('next monday');
|
$start = (new \DateTimeImmutable('today'))->modify('next monday');
|
||||||
$until = $start->modify('+56 days');
|
$until = $start->modify('+56 days');
|
||||||
|
|
||||||
@@ -459,7 +464,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
|
||||||
'allDay' => '1',
|
'allDay' => '1',
|
||||||
'repeat' => 'daily',
|
'repeat' => 'daily',
|
||||||
]);
|
]);
|
||||||
@@ -472,7 +477,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
$response = $this->submitBookingModal($this->resource('D1'), $this->day(), [
|
||||||
'allDay' => '1',
|
'allDay' => '1',
|
||||||
'repeat' => 'daily',
|
'repeat' => 'daily',
|
||||||
'until' => '2030-01-01',
|
'until' => '2030-01-01',
|
||||||
@@ -585,7 +590,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
|
|
||||||
$crawler = $this->client->request(
|
$crawler = $this->client->request(
|
||||||
'GET',
|
'GET',
|
||||||
\sprintf('/bookings/new/%d/2026-10-01?from=09:00&to=13:00', $desk->getId()),
|
\sprintf('/bookings/new/%d/%s?from=09:00&to=13:00', $desk->getId(), $this->day()),
|
||||||
server: ['HTTP_HX-Request' => 'true'],
|
server: ['HTTP_HX-Request' => 'true'],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -598,10 +603,10 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$this->client->request('GET', '/book/desk/day/2026-10-01?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']);
|
$this->client->request('GET', '/book/desk/day/'.$this->day().'?from=09:00&to=13:00', server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
self::assertSame('/book/desk?date=2026-10-01&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
|
self::assertSame('/book/desk?date='.$this->day().'&from=09:00&to=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
|
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
|
||||||
@@ -610,11 +615,11 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
$desk = $this->resource('D1');
|
$desk = $this->resource('D1');
|
||||||
|
|
||||||
$this->submitBookingModal($desk, '2026-10-05', [
|
$this->submitBookingModal($desk, $this->day(), [
|
||||||
'startTime' => '09:00',
|
'startTime' => '09:00',
|
||||||
'endTime' => '12:00',
|
'endTime' => '12:00',
|
||||||
'repeat' => 'weekly',
|
'repeat' => 'weekly',
|
||||||
'until' => '2026-10-26',
|
'until' => $this->day(21),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
|
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
|
||||||
@@ -671,6 +676,14 @@ class BookingControllerTest extends WebTestCase
|
|||||||
return $this->client->getResponse();
|
return $this->client->getResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day counted from the next Monday, as the routes and form fields spell it.
|
||||||
|
*/
|
||||||
|
private function day(int $offset = 0, string $format = 'Y-m-d'): string
|
||||||
|
{
|
||||||
|
return $this->monday->modify(\sprintf('+%d days', $offset))->format($format);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a confirmation modal and reads its CSRF token off the confirm button's hx-vals --
|
* Opens a confirmation modal and reads its CSRF token off the confirm button's hx-vals --
|
||||||
* the same round trip a browser makes, rather than reaching into the token manager (which
|
* the same round trip a browser makes, rather than reaching into the token manager (which
|
||||||
|
|||||||
+114
-27
@@ -2,24 +2,29 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Tests\Controller\Admin;
|
namespace App\Tests\Controller\Manage;
|
||||||
|
|
||||||
use App\Entity\Booking;
|
use App\Entity\Booking;
|
||||||
|
use App\Entity\BookingSeries;
|
||||||
use App\Entity\Resource;
|
use App\Entity\Resource;
|
||||||
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;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin area, and the widened ownership checks it leans on.
|
* The overview of everybody's bookings, and the widened ownership checks it leans on. Most tests
|
||||||
|
* sign in as an administrator, who reaches it through role_hierarchy; the supervisor ones below
|
||||||
|
* pin down that the role itself is enough, and that it reaches no further than this.
|
||||||
*
|
*
|
||||||
* The test that matters most here is the negative one: releasing somebody else's booking must
|
* The test that matters most here is the negative one: releasing somebody else's booking must
|
||||||
* still be refused for an ordinary employee. Everything else on this screen is a list.
|
* still be refused for an ordinary employee. Everything else on this screen is a list.
|
||||||
*/
|
*/
|
||||||
class BookingAdminControllerTest extends WebTestCase
|
class BookingManageControllerTest extends WebTestCase
|
||||||
{
|
{
|
||||||
private KernelBrowser $client;
|
private KernelBrowser $client;
|
||||||
private EntityManagerInterface $em;
|
private EntityManagerInterface $em;
|
||||||
@@ -38,21 +43,21 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAnEmployeeCannotReachTheAdminList(): void
|
public function testAnEmployeeCannotReachTheOverview(): void
|
||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$this->client->request('GET', '/admin/bookings');
|
$this->client->request('GET', '/manage/bookings');
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
|
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTheAdminListShowsOtherPeoplesBookings(): void
|
public function testTheOverviewShowsOtherPeoplesBookings(): void
|
||||||
{
|
{
|
||||||
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||||
$this->client->loginUser($this->admin());
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
$crawler = $this->client->request('GET', '/admin/bookings');
|
$crawler = $this->client->request('GET', '/manage/bookings');
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
|
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
|
||||||
@@ -66,15 +71,15 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
|
|
||||||
// The two-step flow a browser walks: the list opens the confirmation modal, the modal
|
// The two-step flow a browser walks: the list opens the confirmation modal, the modal
|
||||||
// carries the token and posts back to the same URI.
|
// carries the token and posts back to the same URI.
|
||||||
$crawler = $this->client->request('GET', '/admin/bookings');
|
$crawler = $this->client->request('GET', '/manage/bookings');
|
||||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=admin"]', $id)));
|
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=manage"]', $id)));
|
||||||
|
|
||||||
$uri = \sprintf('/bookings/%d/release?return=admin', $id);
|
$uri = \sprintf('/bookings/%d/release?return=manage', $id);
|
||||||
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
// ?return= means a genuine navigation rather than a board swap: this page has no board
|
// ?return= means a genuine navigation rather than a board swap: this page has no board
|
||||||
// fragments, so HX-Redirect is the honest answer.
|
// fragments, so HX-Redirect is the honest answer.
|
||||||
self::assertSame('/admin/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
|
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
|
||||||
$this->em->clear();
|
$this->em->clear();
|
||||||
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||||
}
|
}
|
||||||
@@ -95,7 +100,7 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
// The ownership guard runs before the CSRF one, so asserting on its message is what
|
// The ownership guard runs before the CSRF one, so asserting on its message is what
|
||||||
// distinguishes "refused because it is not yours" from "refused because of the token".
|
// distinguishes "refused because it is not yours" from "refused because of the token".
|
||||||
self::assertStringContainsString(
|
self::assertStringContainsString(
|
||||||
'released by the person holding it or by an administrator',
|
'released by the person holding it or by a supervisor',
|
||||||
(string) $this->client->getResponse()->getContent(),
|
(string) $this->client->getResponse()->getContent(),
|
||||||
);
|
);
|
||||||
self::assertNotNull($this->em->getRepository(Booking::class)->find($id));
|
self::assertNotNull($this->em->getRepository(Booking::class)->find($id));
|
||||||
@@ -122,7 +127,7 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
|
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
|
||||||
$this->client->loginUser($this->admin());
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId());
|
$crawler = $this->client->request('GET', '/manage/bookings?user='.$bea->getId());
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
$text = $crawler->filter('tbody')->text();
|
$text = $crawler->filter('tbody')->text();
|
||||||
@@ -136,7 +141,7 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING));
|
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING));
|
||||||
$this->client->loginUser($this->admin());
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
$crawler = $this->client->request('GET', '/admin/bookings?type=parking');
|
$crawler = $this->client->request('GET', '/manage/bookings?type=parking');
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
$text = $crawler->filter('tbody')->text();
|
$text = $crawler->filter('tbody')->text();
|
||||||
@@ -156,11 +161,11 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
|
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
||||||
self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/admin/spot/"]'));
|
self::assertCount(0, $crawler->filter('[data-spot][hx-get*="/manage/spot/"]'));
|
||||||
|
|
||||||
$this->client->loginUser($this->admin());
|
$this->client->loginUser($this->admin());
|
||||||
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
||||||
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
|
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/manage/spot/%d/%s"]', $desk->getId(), $date)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
|
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
|
||||||
@@ -171,7 +176,7 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
|
|
||||||
$crawler = $this->client->request(
|
$crawler = $this->client->request(
|
||||||
'GET',
|
'GET',
|
||||||
\sprintf('/admin/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
|
\sprintf('/manage/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
|
||||||
server: ['HTTP_HX-Request' => 'true'],
|
server: ['HTTP_HX-Request' => 'true'],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -192,7 +197,7 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
|
|
||||||
$crawler = $this->client->request(
|
$crawler = $this->client->request(
|
||||||
'GET',
|
'GET',
|
||||||
\sprintf('/admin/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
|
\sprintf('/manage/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
|
||||||
server: ['HTTP_HX-Request' => 'true'],
|
server: ['HTTP_HX-Request' => 'true'],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -201,13 +206,90 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
self::assertStringNotContainsString('Carl Conrad', $text);
|
self::assertStringNotContainsString('Carl Conrad', $text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTheDashboardTileIsAdminOnly(): void
|
public function testTheDashboardTileIsForSupervisorsAndAdminsOnly(): void
|
||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
|
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/manage/bookings"]'));
|
||||||
|
|
||||||
|
$this->client->loginUser($this->supervisor());
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
self::assertCount(1, $crawler->filter('a[href="/manage/bookings"]'));
|
||||||
|
// The tile beside it leads into /admin, which a supervisor cannot enter.
|
||||||
|
self::assertCount(0, $crawler->filter('a[href="/admin/resources"]'));
|
||||||
|
|
||||||
$this->client->loginUser($this->admin());
|
$this->client->loginUser($this->admin());
|
||||||
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
|
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/manage/bookings"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASupervisorSeesTheOverview(): void
|
||||||
|
{
|
||||||
|
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||||
|
$this->client->loginUser($this->supervisor());
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/manage/bookings');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASupervisorReleasesABookingOnBehalfOfSomebodyElse(): void
|
||||||
|
{
|
||||||
|
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||||
|
$id = $booking->getId();
|
||||||
|
$this->client->loginUser($this->supervisor());
|
||||||
|
|
||||||
|
$uri = \sprintf('/bookings/%d/release?return=manage', $id);
|
||||||
|
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
|
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASupervisorEndsSomebodyElsesSeries(): void
|
||||||
|
{
|
||||||
|
$series = new BookingSeries(
|
||||||
|
$this->user('[email protected]', 'Bea Berger'),
|
||||||
|
$this->resource('D1'),
|
||||||
|
RecurrenceRule::DAILY,
|
||||||
|
new \DateTimeImmutable('+1 day'),
|
||||||
|
new \DateTimeImmutable('+2 days'),
|
||||||
|
TimeRange::wholeDay(),
|
||||||
|
);
|
||||||
|
$this->em->persist($series);
|
||||||
|
$this->em->flush();
|
||||||
|
$this->client->loginUser($this->supervisor());
|
||||||
|
|
||||||
|
$uri = \sprintf('/series/%d/end?return=manage', $series->getId());
|
||||||
|
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
|
self::assertSame('/manage/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnOccupiedDeskIsClickableForASupervisor(): void
|
||||||
|
{
|
||||||
|
$desk = $this->resource('D1');
|
||||||
|
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
|
||||||
|
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
|
||||||
|
$this->client->loginUser($this->supervisor());
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
||||||
|
|
||||||
|
self::assertCount(1, $crawler->filter(\sprintf('[data-spot][hx-get="/manage/spot/%d/%s"]', $desk->getId(), $date)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A supervisor frees bookings; configuring what can be booked and who reads which code stays
|
||||||
|
* with the administrators.
|
||||||
|
*/
|
||||||
|
public function testASupervisorCannotEnterTheAdminArea(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->supervisor());
|
||||||
|
|
||||||
|
foreach (['/admin/resources', '/admin/access-codes'] as $path) {
|
||||||
|
$this->client->request('GET', $path);
|
||||||
|
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), $path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,16 +304,16 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
|
|
||||||
$crawler = $this->client->request(
|
$crawler = $this->client->request(
|
||||||
'GET',
|
'GET',
|
||||||
\sprintf('/bookings/%d/release?return=admin', $booking->getId()),
|
\sprintf('/bookings/%d/release?return=manage', $booking->getId()),
|
||||||
server: ['HTTP_HX-Request' => 'true'],
|
server: ['HTTP_HX-Request' => 'true'],
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
$modal = $crawler->filter('#htmx-confirmation-modal');
|
$modal = $crawler->filter('#htmx-confirmation-modal');
|
||||||
self::assertStringContainsString('Bea Berger', $modal->text());
|
self::assertStringContainsString('Bea Berger', $modal->text());
|
||||||
// Posts back to the same URI, which is what carries ?return=admin through the round trip.
|
// Posts back to the same URI, which is what carries ?return=manage through the round trip.
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
\sprintf('https://localhost/bookings/%d/release?return=admin', $booking->getId()),
|
\sprintf('https://localhost/bookings/%d/release?return=manage', $booking->getId()),
|
||||||
$modal->filter('[hx-post]')->attr('hx-post'),
|
$modal->filter('[hx-post]')->attr('hx-post'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,12 +332,12 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
}
|
}
|
||||||
$this->client->loginUser($this->admin());
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
$crawler = $this->client->request('GET', '/admin/bookings');
|
$crawler = $this->client->request('GET', '/manage/bookings');
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
self::assertCount(25, $crawler->filter('tbody tr'));
|
self::assertCount(25, $crawler->filter('tbody tr'));
|
||||||
|
|
||||||
$crawler = $this->client->request('GET', '/admin/bookings?page=2');
|
$crawler = $this->client->request('GET', '/manage/bookings?page=2');
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
self::assertCount(5, $crawler->filter('tbody tr'));
|
self::assertCount(5, $crawler->filter('tbody tr'));
|
||||||
@@ -272,7 +354,7 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
|
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
|
||||||
$this->client->loginUser($this->admin());
|
$this->client->loginUser($this->admin());
|
||||||
|
|
||||||
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId().'&sort=u.name&direction=desc');
|
$crawler = $this->client->request('GET', '/manage/bookings?user='.$bea->getId().'&sort=u.name&direction=desc');
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
$text = $crawler->filter('tbody')->text();
|
$text = $crawler->filter('tbody')->text();
|
||||||
@@ -299,6 +381,11 @@ class BookingAdminControllerTest extends WebTestCase
|
|||||||
return $booking;
|
return $booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function supervisor(string $email = '[email protected]', string $name = 'Sam Supervisor'): User
|
||||||
|
{
|
||||||
|
return $this->user($email, $name, ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR']);
|
||||||
|
}
|
||||||
|
|
||||||
private function admin(string $email = '[email protected]', string $name = 'Chef Chefin'): User
|
private function admin(string $email = '[email protected]', string $name = 'Chef Chefin'): User
|
||||||
{
|
{
|
||||||
return $this->user($email, $name, ['ROLE_ADMIN']);
|
return $this->user($email, $name, ['ROLE_ADMIN']);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Email;
|
||||||
|
|
||||||
|
use App\Email\Mailer;
|
||||||
|
use Monolog\Handler\TestHandler;
|
||||||
|
use Monolog\Logger;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
|
use Symfony\Component\Mailer\Envelope;
|
||||||
|
use Symfony\Component\Mailer\Exception\TransportException;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Component\Mime\Address;
|
||||||
|
use Symfony\Component\Mime\RawMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two promises every caller relies on: one mail per recipient, addressed to them alone, and
|
||||||
|
* a failure that costs only its own recipient and is never thrown.
|
||||||
|
*/
|
||||||
|
class MailerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testEveryRecipientGetsTheirOwnMail(): void
|
||||||
|
{
|
||||||
|
$transport = $this->transport();
|
||||||
|
$mailer = new Mailer($transport, new Logger('test'), '[email protected]');
|
||||||
|
|
||||||
|
$report = $mailer->send(
|
||||||
|
['[email protected]', new Address('[email protected]', 'B')],
|
||||||
|
'Betreff',
|
||||||
|
'email/some',
|
||||||
|
['key' => 'value'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(2, $report->sent);
|
||||||
|
self::assertSame(0, $report->failed);
|
||||||
|
self::assertCount(2, $transport->sent);
|
||||||
|
|
||||||
|
foreach ($transport->sent as $i => $email) {
|
||||||
|
self::assertCount(1, $email->getTo());
|
||||||
|
self::assertSame(['[email protected]', '[email protected]'][$i], $email->getTo()[0]->getAddress());
|
||||||
|
self::assertSame('[email protected]', $email->getFrom()[0]->getAddress());
|
||||||
|
self::assertSame('E&P Platz', $email->getFrom()[0]->getName());
|
||||||
|
self::assertSame('Betreff', $email->getSubject());
|
||||||
|
self::assertSame('email/some.html.twig', $email->getHtmlTemplate());
|
||||||
|
self::assertSame('email/some.txt.twig', $email->getTextTemplate());
|
||||||
|
self::assertSame(['key' => 'value'], $email->getContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedRecipientIsLoggedAndTheOthersStillGetTheirs(): void
|
||||||
|
{
|
||||||
|
$transport = $this->transport(failFor: '[email protected]');
|
||||||
|
$log = new TestHandler();
|
||||||
|
$mailer = new Mailer($transport, new Logger('test', [$log]), '[email protected]');
|
||||||
|
|
||||||
|
$report = $mailer->send(['[email protected]', '[email protected]', '[email protected]'], 'Betreff', 'email/some');
|
||||||
|
|
||||||
|
self::assertSame(2, $report->sent);
|
||||||
|
self::assertSame(1, $report->failed);
|
||||||
|
self::assertCount(2, $transport->sent);
|
||||||
|
self::assertTrue($log->hasErrorThatContains('Email could not be sent.'));
|
||||||
|
self::assertSame('[email protected]', $log->getRecords()[0]->context['recipient']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRecipientsSendsNothing(): void
|
||||||
|
{
|
||||||
|
$transport = $this->transport();
|
||||||
|
$mailer = new Mailer($transport, new Logger('test'), '[email protected]');
|
||||||
|
|
||||||
|
$report = $mailer->send([], 'Betreff', 'email/some');
|
||||||
|
|
||||||
|
self::assertSame(0, $report->sent);
|
||||||
|
self::assertSame([], $transport->sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return MailerInterface&object{sent: list<TemplatedEmail>}
|
||||||
|
*/
|
||||||
|
private function transport(?string $failFor = null): MailerInterface
|
||||||
|
{
|
||||||
|
return new class($failFor) implements MailerInterface {
|
||||||
|
/** @var list<TemplatedEmail> */
|
||||||
|
public array $sent = [];
|
||||||
|
|
||||||
|
public function __construct(private readonly ?string $failFor)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(RawMessage $message, ?Envelope $envelope = null): void
|
||||||
|
{
|
||||||
|
\assert($message instanceof TemplatedEmail);
|
||||||
|
|
||||||
|
if ($message->getTo()[0]->getAddress() === $this->failFor) {
|
||||||
|
throw new TransportException('550 mailbox unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sent[] = $message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,14 +9,18 @@ use PHPUnit\Framework\TestCase;
|
|||||||
|
|
||||||
class RoleTest extends TestCase
|
class RoleTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testBothEligibleRolesAreRecognised(): void
|
public function testAllEligibleRolesAreRecognised(): void
|
||||||
{
|
{
|
||||||
self::assertSame([Role::EMPLOYEE, Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_EMPLOYEE']));
|
self::assertSame(
|
||||||
|
[Role::EMPLOYEE, Role::SUPERVISOR, Role::ADMIN],
|
||||||
|
Role::eligibleOnly(['ROLE_ADMIN', 'ROLE_SUPERVISOR', 'ROLE_EMPLOYEE']),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEitherRoleOnItsOwnIsEnough(): void
|
public function testEachRoleOnItsOwnIsEnough(): void
|
||||||
{
|
{
|
||||||
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly(['ROLE_EMPLOYEE']));
|
self::assertSame([Role::EMPLOYEE], Role::eligibleOnly(['ROLE_EMPLOYEE']));
|
||||||
|
self::assertSame([Role::SUPERVISOR], Role::eligibleOnly(['ROLE_SUPERVISOR']));
|
||||||
self::assertSame([Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN']));
|
self::assertSame([Role::ADMIN], Role::eligibleOnly(['ROLE_ADMIN']));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +39,7 @@ class RoleTest extends TestCase
|
|||||||
{
|
{
|
||||||
// myep strips these before exporting, but that is its policy and not our guarantee.
|
// myep strips these before exporting, but that is its policy and not our guarantee.
|
||||||
self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING']));
|
self::assertSame([], Role::eligibleOnly(['ROLE_ADMIN_PENDING']));
|
||||||
|
self::assertSame([], Role::eligibleOnly(['ROLE_SUPERVISOR_PENDING']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void
|
public function testTheOrderIsStableRegardlessOfWhatMyepSends(): void
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ class AuditLoggerTest extends TestCase
|
|||||||
self::assertSame(7, $records[2]->context['access_code_id']);
|
self::assertSame(7, $records[2]->context['access_code_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAnAnnouncementIsRecordedWithItsCounts(): void
|
||||||
|
{
|
||||||
|
$this->audit->accessCodesAnnounced(50, 1);
|
||||||
|
|
||||||
|
self::assertSame('Access code update announced.', $this->record()->message);
|
||||||
|
self::assertSame(['sent' => 50, 'failed' => 1], $this->record()->context);
|
||||||
|
}
|
||||||
|
|
||||||
private function record(): LogRecord
|
private function record(): LogRecord
|
||||||
{
|
{
|
||||||
return $this->handler->getRecords()[0];
|
return $this->handler->getRecords()[0];
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
use App\Entity\Booking;
|
use App\Entity\Booking;
|
||||||
|
use App\Entity\BookingEvent;
|
||||||
use App\Entity\BookingSeries;
|
use App\Entity\BookingSeries;
|
||||||
use App\Entity\Resource;
|
use App\Entity\Resource;
|
||||||
use App\Entity\ResourceTypeSettings;
|
use App\Entity\ResourceTypeSettings;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Enum\BookingEventType;
|
||||||
use App\Enum\RecurrenceRule;
|
use App\Enum\RecurrenceRule;
|
||||||
use App\Enum\ResourceType;
|
use App\Enum\ResourceType;
|
||||||
use App\Exception\ResourceAlreadyBookedException;
|
use App\Exception\ResourceAlreadyBookedException;
|
||||||
@@ -368,6 +370,99 @@ class BookingManagerTest extends KernelTestCase
|
|||||||
self::assertEmailCount(1);
|
self::assertEmailCount(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testABookingRecordsAnEvent(): void
|
||||||
|
{
|
||||||
|
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
|
||||||
|
|
||||||
|
$event = $this->onlyEvent();
|
||||||
|
self::assertSame(BookingEventType::BOOKING_CREATED, $event->getType());
|
||||||
|
self::assertSame($booking->getId(), $event->getBookingId());
|
||||||
|
self::assertSame('D1', $event->getResourceLabel());
|
||||||
|
self::assertSame('Test', $event->getHolderName());
|
||||||
|
self::assertSame('2026-10-01', $event->getDate()?->format('Y-m-d'));
|
||||||
|
self::assertSame('09:00–13:00', (string) $event->getTimeRange());
|
||||||
|
// No token in a kernel test, as for a console command.
|
||||||
|
self::assertNull($event->getActor());
|
||||||
|
self::assertSame(BookingEvent::SYSTEM_ACTOR, $event->getActorName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event is written in the booking's transaction, so a refusal leaves nothing behind.
|
||||||
|
*/
|
||||||
|
public function testARefusedBookingRecordsNoEvent(): void
|
||||||
|
{
|
||||||
|
$desk = $this->resource('D1');
|
||||||
|
$this->manager->book($this->user('[email protected]'), $desk, $this->date());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->manager->book($this->user('[email protected]'), $desk, $this->date());
|
||||||
|
self::fail('The second booking should have been refused.');
|
||||||
|
} catch (ResourceAlreadyBookedException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame(1, $this->em->getRepository(BookingEvent::class)->count([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReschedulingRecordsTheOldAndTheNewTimes(): void
|
||||||
|
{
|
||||||
|
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
|
||||||
|
|
||||||
|
$this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720));
|
||||||
|
|
||||||
|
$event = $this->latestEvent();
|
||||||
|
self::assertSame(BookingEventType::BOOKING_RESCHEDULED, $event->getType());
|
||||||
|
self::assertSame('09:00–13:00', (string) $event->getPreviousTimeRange());
|
||||||
|
self::assertSame('10:00–12:00', (string) $event->getTimeRange());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReleasingRecordsTheBookingThatWent(): void
|
||||||
|
{
|
||||||
|
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date());
|
||||||
|
$id = $booking->getId();
|
||||||
|
|
||||||
|
$this->manager->release($booking);
|
||||||
|
|
||||||
|
$event = $this->latestEvent();
|
||||||
|
self::assertSame(BookingEventType::BOOKING_RELEASED, $event->getType());
|
||||||
|
self::assertSame($id, $event->getBookingId());
|
||||||
|
self::assertSame('D1', $event->getResourceLabel());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event for the series, not one per day -- the same rule as the audit log.
|
||||||
|
*/
|
||||||
|
public function testASeriesRecordsOneEvent(): void
|
||||||
|
{
|
||||||
|
$series = $this->weeklySeries();
|
||||||
|
|
||||||
|
$this->manager->bookSeries($series);
|
||||||
|
|
||||||
|
$event = $this->onlyEvent();
|
||||||
|
self::assertSame(BookingEventType::SERIES_CREATED, $event->getType());
|
||||||
|
self::assertSame($series->getId(), $event->getSeriesId());
|
||||||
|
self::assertSame(RecurrenceRule::WEEKLY, $event->getRule());
|
||||||
|
self::assertSame(4, $event->getDetails()['created']);
|
||||||
|
self::assertSame('2026-10-30', $event->getEndDate()?->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ending a series that has nothing left deletes it, so the id has to survive from before.
|
||||||
|
*/
|
||||||
|
public function testEndingASeriesRecordsItsIdEvenWhenTheSeriesIsGone(): void
|
||||||
|
{
|
||||||
|
$series = $this->weeklySeries();
|
||||||
|
$this->manager->bookSeries($series);
|
||||||
|
$id = $series->getId();
|
||||||
|
|
||||||
|
$this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05'));
|
||||||
|
|
||||||
|
$event = $this->latestEvent();
|
||||||
|
self::assertSame(BookingEventType::SERIES_ENDED, $event->getType());
|
||||||
|
self::assertSame($id, $event->getSeriesId());
|
||||||
|
self::assertSame(4, $event->getDetails()['removed']);
|
||||||
|
self::assertSame('2026-10-05', $event->getDate()?->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $recipients
|
* @param list<string> $recipients
|
||||||
*/
|
*/
|
||||||
@@ -415,9 +510,26 @@ class BookingManagerTest extends KernelTestCase
|
|||||||
return $resource;
|
return $resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function onlyEvent(): BookingEvent
|
||||||
|
{
|
||||||
|
$events = $this->em->getRepository(BookingEvent::class)->findAll();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
|
||||||
|
return $events[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestEvent(): BookingEvent
|
||||||
|
{
|
||||||
|
$event = $this->em->getRepository(BookingEvent::class)->findOneBy([], ['id' => 'DESC']);
|
||||||
|
self::assertInstanceOf(BookingEvent::class, $event);
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
private function wipe(): void
|
private function wipe(): void
|
||||||
{
|
{
|
||||||
$connection = $this->em->getConnection();
|
$connection = $this->em->getConnection();
|
||||||
|
$connection->executeStatement('DELETE FROM booking_event');
|
||||||
$connection->executeStatement('DELETE FROM booking');
|
$connection->executeStatement('DELETE FROM booking');
|
||||||
$connection->executeStatement('DELETE FROM booking_series');
|
$connection->executeStatement('DELETE FROM booking_series');
|
||||||
$connection->executeStatement('DELETE FROM resource');
|
$connection->executeStatement('DELETE FROM resource');
|
||||||
|
|||||||
Reference in new issue
Block a user