feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
Les administrateurs devaient recréer manuellement l'emploi du temps chaque semaine. Cette implémentation introduit un système de récurrence hebdomadaire avec gestion des exceptions par occurrence, permettant de modifier ou annuler un cours spécifique sans affecter les autres semaines. Le ScheduleResolver calcule dynamiquement l'EDT réel en combinant les créneaux récurrents, les exceptions ponctuelles et le calendrier scolaire (vacances/fériés).
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionCommand;
|
||||
use App\Scolarite\Application\Command\CreateScheduleException\CreateScheduleExceptionHandler;
|
||||
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceCommand;
|
||||
use App\Scolarite\Application\Command\TruncateSlotRecurrence\TruncateSlotRecurrenceHandler;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleOccurrenceResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<ScheduleOccurrenceResource, ScheduleOccurrenceResource>
|
||||
*/
|
||||
final readonly class CancelOccurrenceProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CreateScheduleExceptionHandler $cancelHandler,
|
||||
private TruncateSlotRecurrenceHandler $truncateHandler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private TokenStorageInterface $tokenStorage,
|
||||
private RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleOccurrenceResource|null $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleOccurrenceResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::DELETE)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string $slotId */
|
||||
$slotId = $uriVariables['id'] ?? '';
|
||||
/** @var string $date */
|
||||
$date = $uriVariables['date'] ?? '';
|
||||
|
||||
$user = $this->tokenStorage->getToken()?->getUser();
|
||||
$userId = $user instanceof SecurityUser ? $user->userId() : '';
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
$scope = $this->requestStack->getCurrentRequest()?->query->getString('scope', 'this_occurrence');
|
||||
|
||||
try {
|
||||
if ($scope === 'all_future') {
|
||||
($this->truncateHandler)(new TruncateSlotRecurrenceCommand(
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
fromDate: $date,
|
||||
updatedBy: $userId,
|
||||
));
|
||||
|
||||
$type = 'truncated';
|
||||
} else {
|
||||
($this->cancelHandler)(new CreateScheduleExceptionCommand(
|
||||
tenantId: $tenantId,
|
||||
slotId: $slotId,
|
||||
exceptionDate: $date,
|
||||
type: 'cancelled',
|
||||
createdBy: $userId,
|
||||
reason: $data?->reason,
|
||||
));
|
||||
|
||||
$type = 'cancelled';
|
||||
}
|
||||
|
||||
$resource = new ScheduleOccurrenceResource();
|
||||
$resource->id = $slotId . '_' . $date;
|
||||
$resource->slotId = $slotId;
|
||||
$resource->date = $date;
|
||||
$resource->type = $type;
|
||||
|
||||
return $resource;
|
||||
} catch (ScheduleSlotNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
} catch (DateExceptionInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotCommand;
|
||||
use App\Scolarite\Application\Command\UpdateRecurringSlot\UpdateRecurringSlotHandler;
|
||||
use App\Scolarite\Domain\Exception\DateExceptionInvalideException;
|
||||
use App\Scolarite\Domain\Exception\ScheduleSlotNotFoundException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ScheduleOccurrenceResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use ValueError;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<ScheduleOccurrenceResource, ScheduleOccurrenceResource>
|
||||
*/
|
||||
final readonly class ModifyOccurrenceProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateRecurringSlotHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private TokenStorageInterface $tokenStorage,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScheduleOccurrenceResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ScheduleOccurrenceResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::EDIT)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à modifier l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string $slotId */
|
||||
$slotId = $uriVariables['id'] ?? '';
|
||||
/** @var string $date */
|
||||
$date = $uriVariables['date'] ?? '';
|
||||
|
||||
$user = $this->tokenStorage->getToken()?->getUser();
|
||||
$userId = $user instanceof SecurityUser ? $user->userId() : '';
|
||||
|
||||
try {
|
||||
$command = new UpdateRecurringSlotCommand(
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
slotId: $slotId,
|
||||
occurrenceDate: $date,
|
||||
scope: $data->scope ?? 'this_occurrence',
|
||||
classId: $data->classId ?? '',
|
||||
subjectId: $data->subjectId ?? '',
|
||||
teacherId: $data->teacherId ?? '',
|
||||
dayOfWeek: $data->dayOfWeek ?? 1,
|
||||
startTime: $data->startTime ?? '',
|
||||
endTime: $data->endTime ?? '',
|
||||
room: $data->room,
|
||||
updatedBy: $userId,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
|
||||
$resource = new ScheduleOccurrenceResource();
|
||||
$resource->id = $slotId . '_' . $date;
|
||||
$resource->slotId = $slotId;
|
||||
$resource->date = $date;
|
||||
$resource->scope = $data->scope;
|
||||
$resource->type = $result['exception'] !== null ? 'modified' : 'recurring_updated';
|
||||
|
||||
return $resource;
|
||||
} catch (ScheduleSlotNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
} catch (DateExceptionInvalideException|ValueError $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\ResolvedScheduleSlotResource;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Provider pour l'emploi du temps résolu d'une semaine.
|
||||
*
|
||||
* @implements ProviderInterface<ResolvedScheduleSlotResource>
|
||||
*/
|
||||
final readonly class ResolvedScheduleWeekProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduleResolver $scheduleResolver,
|
||||
private SchoolCalendarRepository $calendarRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ResolvedScheduleSlotResource> */
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ScheduleSlotVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException("Vous n'êtes pas autorisé à consulter l'emploi du temps.");
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var array<string, string> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
if (!isset($filters['classId'])) {
|
||||
throw new BadRequestHttpException('Le paramètre classId est requis.');
|
||||
}
|
||||
|
||||
/** @var string $dateParam */
|
||||
$dateParam = $uriVariables['date'] ?? '';
|
||||
$weekStart = new DateTimeImmutable($dateParam);
|
||||
|
||||
$tenantId = TenantId::fromString((string) $this->tenantContext->getCurrentTenantId());
|
||||
$classId = ClassId::fromString((string) $filters['classId']);
|
||||
|
||||
$calendar = $this->loadCalendar($tenantId);
|
||||
|
||||
// TODO: cache-aside par classId+weekStart si la perf le nécessite
|
||||
$resolved = $this->scheduleResolver->resolveForWeek(
|
||||
$classId,
|
||||
$weekStart,
|
||||
$tenantId,
|
||||
$calendar,
|
||||
);
|
||||
|
||||
return array_map(ResolvedScheduleSlotResource::fromDomain(...), $resolved);
|
||||
}
|
||||
|
||||
private function loadCalendar(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
return SchoolCalendar::initialiser($tenantId, AcademicYearId::generate());
|
||||
}
|
||||
|
||||
$calendar = $this->calendarRepository->findByTenantAndYear(
|
||||
$tenantId,
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
);
|
||||
|
||||
return $calendar ?? SchoolCalendar::initialiser($tenantId, AcademicYearId::generate());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\ResolvedScheduleWeekProvider;
|
||||
|
||||
/**
|
||||
* API Resource pour l'emploi du temps résolu (récurrences + exceptions + calendrier).
|
||||
*
|
||||
* @see Story 4.2 - Récurrences Hebdomadaires
|
||||
* @see FR27 - Définir une semaine type qui se répète automatiquement
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ResolvedScheduleSlot',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/schedule/week/{date}',
|
||||
uriVariables: [
|
||||
'date' => new Link(
|
||||
fromClass: self::class,
|
||||
identifiers: ['date'],
|
||||
),
|
||||
],
|
||||
provider: ResolvedScheduleWeekProvider::class,
|
||||
name: 'get_resolved_schedule_week',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ResolvedScheduleSlotResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
public ?string $slotId = null;
|
||||
|
||||
public ?string $classId = null;
|
||||
|
||||
public ?string $subjectId = null;
|
||||
|
||||
public ?string $teacherId = null;
|
||||
|
||||
public ?int $dayOfWeek = null;
|
||||
|
||||
public ?string $startTime = null;
|
||||
|
||||
public ?string $endTime = null;
|
||||
|
||||
public ?string $room = null;
|
||||
|
||||
public ?string $date = null;
|
||||
|
||||
public ?bool $isModified = null;
|
||||
|
||||
public ?string $exceptionId = null;
|
||||
|
||||
public static function fromDomain(ResolvedScheduleSlot $resolved): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = (string) $resolved->slotId . '_' . $resolved->date->format('Y-m-d');
|
||||
$resource->slotId = (string) $resolved->slotId;
|
||||
$resource->classId = (string) $resolved->classId;
|
||||
$resource->subjectId = (string) $resolved->subjectId;
|
||||
$resource->teacherId = (string) $resolved->teacherId;
|
||||
$resource->dayOfWeek = $resolved->dayOfWeek->value;
|
||||
$resource->startTime = $resolved->timeSlot->startTime;
|
||||
$resource->endTime = $resolved->timeSlot->endTime;
|
||||
$resource->room = $resolved->room;
|
||||
$resource->date = $resolved->date->format('Y-m-d');
|
||||
$resource->isModified = $resolved->isModified;
|
||||
$resource->exceptionId = $resolved->exceptionId !== null ? (string) $resolved->exceptionId : null;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\CancelOccurrenceProcessor;
|
||||
use App\Scolarite\Infrastructure\Api\Processor\ModifyOccurrenceProcessor;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour les modifications ponctuelles d'occurrences de cours récurrents.
|
||||
*
|
||||
* @see Story 4.2 - Récurrences Hebdomadaires (AC3, AC5)
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'ScheduleOccurrence',
|
||||
operations: [
|
||||
new Put(
|
||||
uriTemplate: '/schedule/slots/{id}/occurrence/{date}',
|
||||
read: false,
|
||||
processor: ModifyOccurrenceProcessor::class,
|
||||
name: 'modify_schedule_occurrence',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/schedule/slots/{id}/occurrence/{date}',
|
||||
read: false,
|
||||
processor: CancelOccurrenceProcessor::class,
|
||||
name: 'cancel_schedule_occurrence',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ScheduleOccurrenceResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
/**
|
||||
* 'this_occurrence' (modifier uniquement cette date) ou 'all_future' (toutes les futures).
|
||||
*/
|
||||
#[Assert\Choice(choices: ['this_occurrence', 'all_future'], message: 'Le scope doit être "this_occurrence" ou "all_future".')]
|
||||
public ?string $scope = 'this_occurrence';
|
||||
|
||||
public ?string $classId = null;
|
||||
|
||||
public ?string $subjectId = null;
|
||||
|
||||
public ?string $teacherId = null;
|
||||
|
||||
public ?int $dayOfWeek = null;
|
||||
|
||||
#[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
|
||||
public ?string $startTime = null;
|
||||
|
||||
#[Assert\Regex(pattern: '/^([01]\d|2[0-3]):[0-5]\d$/', message: "L'heure doit être au format HH:MM.")]
|
||||
public ?string $endTime = null;
|
||||
|
||||
public ?string $room = null;
|
||||
|
||||
public ?string $reason = null;
|
||||
|
||||
public ?string $type = null;
|
||||
|
||||
public ?string $date = null;
|
||||
|
||||
public ?string $slotId = null;
|
||||
}
|
||||
Reference in New Issue
Block a user