diff --git a/backend/config/services.yaml b/backend/config/services.yaml index a18c503..243fc4f 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -210,6 +210,9 @@ services: App\Scolarite\Domain\Repository\HomeworkRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRepository + App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRuleExceptionRepository + App\Scolarite\Domain\Repository\HomeworkAttachmentRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository diff --git a/backend/migrations/Version20260319142344.php b/backend/migrations/Version20260319142344.php new file mode 100644 index 0000000..c175272 --- /dev/null +++ b/backend/migrations/Version20260319142344.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE homework_rule_exceptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + homework_id UUID NOT NULL REFERENCES homework(id) ON DELETE CASCADE, + rule_type VARCHAR(100) NOT NULL, + justification TEXT NOT NULL, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )'); + + $this->addSql('CREATE INDEX idx_hw_exceptions_tenant ON homework_rule_exceptions(tenant_id)'); + $this->addSql('CREATE INDEX idx_hw_exceptions_homework ON homework_rule_exceptions(homework_id)'); + $this->addSql('CREATE INDEX idx_hw_exceptions_created_at ON homework_rule_exceptions(created_at)'); + $this->addSql('CREATE INDEX idx_hw_exceptions_created_by ON homework_rule_exceptions(created_by)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS homework_rule_exceptions'); + } +} diff --git a/backend/src/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionCommand.php b/backend/src/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionCommand.php new file mode 100644 index 0000000..0612d09 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionCommand.php @@ -0,0 +1,24 @@ +tenantId); + $classId = ClassId::fromString($command->classId); + $subjectId = SubjectId::fromString($command->subjectId); + $teacherId = UserId::fromString($command->teacherId); + $now = $this->clock->now(); + + if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) { + throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId); + } + + $calendar = $this->calendarProvider->forCurrentYear($tenantId); + $dueDate = new DateTimeImmutable($command->dueDate); + $this->dueDateValidator->valider($dueDate, $now, $calendar); + + $rulesResult = $this->rulesChecker->verifier($tenantId, $dueDate, $now); + + if (!$rulesResult->estBloquant()) { + throw ExceptionNonNecessaireException::carReglesNonBloquantes(); + } + + $homework = Homework::creer( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: $command->title, + description: $command->description, + dueDate: $dueDate, + now: $now, + ); + + $exception = HomeworkRuleException::demander( + tenantId: $tenantId, + homeworkId: $homework->id, + ruleTypes: $rulesResult->ruleTypes(), + justification: $command->justification, + createdBy: $teacherId, + now: $now, + ); + + $this->connection->beginTransaction(); + + try { + $this->homeworkRepository->save($homework); + $this->exceptionRepository->save($exception); + $this->connection->commit(); + } catch (Throwable $e) { + $this->connection->rollBack(); + + throw $e; + } + + return ['homework' => $homework, 'exception' => $exception]; + } +} diff --git a/backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportHandler.php b/backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportHandler.php new file mode 100644 index 0000000..1a0fe9c --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportHandler.php @@ -0,0 +1,150 @@ + */ + public function __invoke(GetHomeworkExceptionsReportQuery $query): array + { + $tenantId = TenantId::fromString($query->tenantId); + $exceptions = $this->exceptionRepository->findByTenant($tenantId); + + $startDate = $query->startDate !== null ? new DateTimeImmutable($query->startDate) : null; + $endDate = $query->endDate !== null ? new DateTimeImmutable($query->endDate . ' 23:59:59') : null; + + $filtered = array_filter( + $exceptions, + fn (HomeworkRuleException $e): bool => $this->matchesFilters( + $e, + $startDate, + $endDate, + $query->teacherId, + $query->ruleType, + ), + ); + + if ($filtered === []) { + return []; + } + + $homeworkCache = $this->preloadHomeworks($filtered, $tenantId); + $teacherCache = $this->preloadTeachers($filtered); + + $results = []; + + foreach ($filtered as $exception) { + $hwId = (string) $exception->homeworkId; + $teacherId = (string) $exception->createdBy; + + $homework = $homeworkCache[$hwId] ?? null; + $teacher = $teacherCache[$teacherId] ?? null; + + $results[] = new HomeworkExceptionDto( + id: (string) $exception->id, + homeworkId: $hwId, + homeworkTitle: $homework !== null ? $homework->title : '(supprimé)', + ruleType: $exception->ruleType, + justification: $exception->justification, + teacherId: $teacherId, + teacherName: $teacher !== null ? $teacher->firstName . ' ' . $teacher->lastName : 'Inconnu', + createdAt: $exception->createdAt->format(DateTimeImmutable::ATOM), + ); + } + + return $results; + } + + /** + * @param array $exceptions + * + * @return array + */ + private function preloadHomeworks(array $exceptions, TenantId $tenantId): array + { + $cache = []; + + foreach ($exceptions as $exception) { + $hwId = (string) $exception->homeworkId; + + if (!isset($cache[$hwId])) { + $cache[$hwId] = $this->homeworkRepository->findById($exception->homeworkId, $tenantId); + } + } + + return $cache; + } + + /** + * @param array $exceptions + * + * @return array + */ + private function preloadTeachers(array $exceptions): array + { + $cache = []; + + foreach ($exceptions as $exception) { + $teacherId = (string) $exception->createdBy; + + if (!isset($cache[$teacherId])) { + $cache[$teacherId] = $this->userRepository->findById(UserId::fromString($teacherId)); + } + } + + return $cache; + } + + private function matchesFilters( + HomeworkRuleException $exception, + ?DateTimeImmutable $startDate, + ?DateTimeImmutable $endDate, + ?string $teacherId, + ?string $ruleType, + ): bool { + if ($startDate !== null && $exception->createdAt < $startDate) { + return false; + } + + if ($endDate !== null && $exception->createdAt > $endDate) { + return false; + } + + if ($teacherId !== null && (string) $exception->createdBy !== $teacherId) { + return false; + } + + if ($ruleType !== null && !str_contains($exception->ruleType, $ruleType)) { + return false; + } + + return true; + } +} diff --git a/backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportQuery.php b/backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportQuery.php new file mode 100644 index 0000000..84cef04 --- /dev/null +++ b/backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportQuery.php @@ -0,0 +1,17 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->exceptionId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Exception/ExceptionNonNecessaireException.php b/backend/src/Scolarite/Domain/Exception/ExceptionNonNecessaireException.php new file mode 100644 index 0000000..52432c5 --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/ExceptionNonNecessaireException.php @@ -0,0 +1,15 @@ +recordEvent(new ExceptionDevoirDemandee( + exceptionId: $exception->id, + tenantId: $tenantId, + homeworkId: $homeworkId, + ruleTypes: $ruleTypes, + justification: $justification, + createdBy: $createdBy, + occurredOn: $now, + )); + + return $exception; + } + + /** @return string[] */ + public function ruleTypes(): array + { + return explode(',', $this->ruleType); + } + + /** + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + HomeworkRuleExceptionId $id, + TenantId $tenantId, + HomeworkId $homeworkId, + string $ruleType, + string $justification, + UserId $createdBy, + DateTimeImmutable $createdAt, + ): self { + return new self( + id: $id, + tenantId: $tenantId, + homeworkId: $homeworkId, + ruleType: $ruleType, + justification: $justification, + createdBy: $createdBy, + createdAt: $createdAt, + ); + } +} diff --git a/backend/src/Scolarite/Domain/Model/Homework/HomeworkRuleExceptionId.php b/backend/src/Scolarite/Domain/Model/Homework/HomeworkRuleExceptionId.php new file mode 100644 index 0000000..8198955 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Homework/HomeworkRuleExceptionId.php @@ -0,0 +1,11 @@ + */ + public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array; + + /** @return array */ + public function findByTenant(TenantId $tenantId): array; + + /** + * Returns the set of homework IDs that have at least one exception. + * + * @return array Homework IDs as strings + */ + public function homeworkIdsWithExceptions(TenantId $tenantId): array; +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkWithExceptionProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkWithExceptionProcessor.php new file mode 100644 index 0000000..884b405 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkWithExceptionProcessor.php @@ -0,0 +1,93 @@ + + */ +final readonly class CreateHomeworkWithExceptionProcessor implements ProcessorInterface +{ + public function __construct( + private CreateHomeworkWithExceptionHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param HomeworkExceptionResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + try { + $command = new CreateHomeworkWithExceptionCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + classId: $data->classId ?? '', + subjectId: $data->subjectId ?? '', + teacherId: $user->userId(), + title: $data->title ?? '', + description: $data->description, + dueDate: $data->dueDate ?? '', + justification: $data->justification ?? '', + ruleTypes: $data->ruleTypes ?? [], + ); + + $result = ($this->handler)($command); + + $homework = $result['homework']; + $exception = $result['exception']; + + foreach ($homework->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + foreach ($exception->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return HomeworkResource::fromDomain( + $homework, + ruleException: $exception, + ); + } catch (ExceptionNonNecessaireException|JustificationTropCourteException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (EnseignantNonAffecteException|DateEcheanceInvalideException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php index 3a22afb..68c8286 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php @@ -13,6 +13,7 @@ use App\Administration\Domain\Repository\SubjectRepository; use App\Administration\Infrastructure\Security\SecurityUser; use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Repository\HomeworkRepository; +use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository; use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource; use App\Shared\Infrastructure\Tenant\TenantContext; @@ -34,6 +35,7 @@ final readonly class HomeworkCollectionProvider implements ProviderInterface { public function __construct( private HomeworkRepository $homeworkRepository, + private HomeworkRuleExceptionRepository $exceptionRepository, private TenantContext $tenantContext, private Security $security, private ClassRepository $classRepository, @@ -77,10 +79,22 @@ final readonly class HomeworkCollectionProvider implements ProviderInterface )); } + $allExceptions = $this->exceptionRepository->findByTenant($tenantId); + $exceptionsByHomework = []; + + foreach ($allExceptions as $exception) { + $hwId = (string) $exception->homeworkId; + + if (!isset($exceptionsByHomework[$hwId])) { + $exceptionsByHomework[$hwId] = $exception; + } + } + return array_map(fn (Homework $homework) => HomeworkResource::fromDomain( $homework, $this->resolveClassName($homework), $this->resolveSubjectName($homework), + $exceptionsByHomework[(string) $homework->id] ?? null, ), $homeworks); } diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkExceptionsReportProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkExceptionsReportProvider.php new file mode 100644 index 0000000..d484793 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkExceptionsReportProvider.php @@ -0,0 +1,82 @@ + + */ +final readonly class HomeworkExceptionsReportProvider implements ProviderInterface +{ + public function __construct( + private GetHomeworkExceptionsReportHandler $handler, + private TenantContext $tenantContext, + private RequestStack $requestStack, + private Security $security, + ) { + } + + /** @return array */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + if (!$this->security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedHttpException('Accès réservé à la direction.'); + } + + $request = $this->requestStack->getCurrentRequest(); + $startDate = $request?->query->get('startDate'); + $endDate = $request?->query->get('endDate'); + $teacherId = $request?->query->get('teacherId'); + $ruleType = $request?->query->get('ruleType'); + + $query = new GetHomeworkExceptionsReportQuery( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + startDate: is_string($startDate) ? $startDate : null, + endDate: is_string($endDate) ? $endDate : null, + teacherId: is_string($teacherId) ? $teacherId : null, + ruleType: is_string($ruleType) ? $ruleType : null, + ); + + $dtos = ($this->handler)($query); + + return array_map( + static function (HomeworkExceptionDto $dto): HomeworkExceptionResource { + $resource = new HomeworkExceptionResource(); + $resource->id = $dto->id; + $resource->homeworkId = $dto->homeworkId; + $resource->homeworkTitle = $dto->homeworkTitle; + $resource->ruleType = $dto->ruleType; + $resource->justification = $dto->justification; + $resource->teacherId = $dto->teacherId; + $resource->teacherName = $dto->teacherName; + $resource->createdAt = $dto->createdAt; + + return $resource; + }, + $dtos, + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkItemProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkItemProvider.php index dbc4fd3..e8f12bc 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkItemProvider.php +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkItemProvider.php @@ -11,6 +11,7 @@ use App\Administration\Domain\Repository\SubjectRepository; use App\Administration\Infrastructure\Security\SecurityUser; use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Repository\HomeworkRepository; +use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository; use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource; use App\Shared\Infrastructure\Tenant\TenantContext; use Override; @@ -26,6 +27,7 @@ final readonly class HomeworkItemProvider implements ProviderInterface { public function __construct( private HomeworkRepository $homeworkRepository, + private HomeworkRuleExceptionRepository $exceptionRepository, private TenantContext $tenantContext, private Security $security, private ClassRepository $classRepository, @@ -64,11 +66,14 @@ final readonly class HomeworkItemProvider implements ProviderInterface $class = $this->classRepository->findById($homework->classId); $subject = $this->subjectRepository->findById($homework->subjectId); + $tenantId = $this->tenantContext->getCurrentTenantId(); + $exceptions = $this->exceptionRepository->findByHomework($homework->id, $tenantId); return HomeworkResource::fromDomain( $homework, $class !== null ? (string) $class->name : null, $subject !== null ? (string) $subject->name : null, + $exceptions[0] ?? null, ); } } diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkExceptionResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkExceptionResource.php new file mode 100644 index 0000000..db1f5ed --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkExceptionResource.php @@ -0,0 +1,71 @@ + ['Default', 'create']], + name: 'create_homework_with_exception', + ), + new GetCollection( + uriTemplate: '/admin/homework-exceptions', + provider: HomeworkExceptionsReportProvider::class, + name: 'get_homework_exceptions_report', + ), + ], +)] +final class HomeworkExceptionResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + // --- Input fields for POST (create with exception) --- + + #[Assert\NotBlank(message: 'La classe est requise.', groups: ['create'])] + #[Assert\Uuid(message: 'L\'identifiant de la classe doit être un UUID valide.', groups: ['create'])] + public ?string $classId = null; + + #[Assert\NotBlank(message: 'La matière est requise.', groups: ['create'])] + #[Assert\Uuid(message: 'L\'identifiant de la matière doit être un UUID valide.', groups: ['create'])] + public ?string $subjectId = null; + + #[Assert\NotBlank(message: 'Le titre est requis.', groups: ['create'])] + #[Assert\Length(max: 255, maxMessage: 'Le titre ne peut pas dépasser 255 caractères.')] + public ?string $title = null; + + public ?string $description = null; + + #[Assert\NotBlank(message: 'La date d\'échéance est requise.', groups: ['create'])] + public ?string $dueDate = null; + + #[Assert\NotBlank(message: 'La justification est requise.', groups: ['create'])] + #[Assert\Length(min: 20, minMessage: 'La justification doit contenir au moins 20 caractères.', groups: ['create'])] + public ?string $justification = null; + + /** @var string[]|null */ + public ?array $ruleTypes = null; + + // --- Output fields --- + + public ?string $homeworkId = null; + public ?string $homeworkTitle = null; + public ?string $ruleType = null; + public ?string $teacherId = null; + public ?string $teacherName = null; + public ?string $createdAt = null; + public ?bool $hasRuleException = null; +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php index 1cd7437..f698b16 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php @@ -12,6 +12,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Scolarite\Domain\Model\Homework\Homework; +use App\Scolarite\Domain\Model\Homework\HomeworkRuleException; use App\Scolarite\Infrastructure\Api\Processor\CreateHomeworkProcessor; use App\Scolarite\Infrastructure\Api\Processor\DeleteHomeworkProcessor; use App\Scolarite\Infrastructure\Api\Processor\UpdateHomeworkProcessor; @@ -92,10 +93,17 @@ final class HomeworkResource public ?bool $hasRuleOverride = null; + public ?bool $hasRuleException = null; + + public ?string $ruleExceptionJustification = null; + + public ?string $ruleExceptionRuleType = null; + public static function fromDomain( Homework $homework, ?string $className = null, ?string $subjectName = null, + ?HomeworkRuleException $ruleException = null, ): self { $resource = new self(); $resource->id = (string) $homework->id; @@ -111,6 +119,9 @@ final class HomeworkResource $resource->createdAt = $homework->createdAt; $resource->updatedAt = $homework->updatedAt; $resource->hasRuleOverride = $homework->ruleOverride !== null; + $resource->hasRuleException = $ruleException !== null; + $resource->ruleExceptionJustification = $ruleException?->justification; + $resource->ruleExceptionRuleType = $ruleException?->ruleType; return $resource; } diff --git a/backend/src/Scolarite/Infrastructure/Messaging/OnExceptionDevoirDemandeeHandler.php b/backend/src/Scolarite/Infrastructure/Messaging/OnExceptionDevoirDemandeeHandler.php new file mode 100644 index 0000000..08eb163 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Messaging/OnExceptionDevoirDemandeeHandler.php @@ -0,0 +1,107 @@ +homeworkRepository->findById($event->homeworkId, $event->tenantId); + + if ($homework === null) { + $this->logger->warning('Exception devoir : homework introuvable', [ + 'homework_id' => (string) $event->homeworkId, + 'exception_id' => (string) $event->exceptionId, + ]); + + return; + } + + $teacher = $this->userRepository->findById(UserId::fromString((string) $event->createdBy)); + + $allUsers = $this->userRepository->findAllByTenant($event->tenantId); + $directors = array_filter( + $allUsers, + static fn ($user) => $user->aLeRole(Role::ADMIN), + ); + + if (count($directors) === 0) { + $this->logger->info('Exception devoir demandée — aucun directeur à notifier', [ + 'tenant_id' => (string) $event->tenantId, + 'homework_id' => (string) $event->homeworkId, + ]); + + return; + } + + $teacherName = $teacher !== null ? $teacher->firstName . ' ' . $teacher->lastName : 'Enseignant inconnu'; + + $html = $this->twig->render('emails/homework_exception_notification.html.twig', [ + 'teacherName' => $teacherName, + 'homeworkTitle' => $homework->title, + 'ruleTypes' => $event->ruleTypes, + 'justification' => $event->justification, + 'dueDate' => $homework->dueDate->format('d/m/Y'), + ]); + + $sent = 0; + + foreach ($directors as $director) { + try { + $email = (new Email()) + ->from($this->fromEmail) + ->to((string) $director->email) + ->subject('Exception aux règles de devoirs — ' . $homework->title) + ->html($html); + + $this->mailer->send($email); + ++$sent; + } catch (Throwable $e) { + $this->logger->warning('Échec envoi notification exception devoir', [ + 'director_email' => (string) $director->email, + 'error' => $e->getMessage(), + ]); + } + } + + $this->logger->info('Notifications exception devoir envoyées', [ + 'tenant_id' => (string) $event->tenantId, + 'homework_id' => (string) $event->homeworkId, + 'exception_id' => (string) $event->exceptionId, + 'emails_sent' => $sent, + 'directors_total' => count($directors), + ]); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRuleExceptionRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRuleExceptionRepository.php new file mode 100644 index 0000000..74fc6d3 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRuleExceptionRepository.php @@ -0,0 +1,123 @@ +connection->executeStatement( + 'INSERT INTO homework_rule_exceptions (id, tenant_id, homework_id, rule_type, justification, created_by, created_at) + VALUES (:id, :tenant_id, :homework_id, :rule_type, :justification, :created_by, :created_at) + ON CONFLICT (id) DO NOTHING', + [ + 'id' => (string) $exception->id, + 'tenant_id' => (string) $exception->tenantId, + 'homework_id' => (string) $exception->homeworkId, + 'rule_type' => $exception->ruleType, + 'justification' => $exception->justification, + 'created_by' => (string) $exception->createdBy, + 'created_at' => $exception->createdAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM homework_rule_exceptions + WHERE homework_id = :homework_id AND tenant_id = :tenant_id + ORDER BY created_at DESC', + [ + 'homework_id' => (string) $homeworkId, + 'tenant_id' => (string) $tenantId, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function findByTenant(TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM homework_rule_exceptions + WHERE tenant_id = :tenant_id + ORDER BY created_at DESC', + ['tenant_id' => (string) $tenantId], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function homeworkIdsWithExceptions(TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT DISTINCT homework_id FROM homework_rule_exceptions WHERE tenant_id = :tenant_id', + ['tenant_id' => (string) $tenantId], + ); + + return array_map( + /** @param array $row */ + static function (array $row): string { + /** @var string $homeworkId */ + $homeworkId = $row['homework_id']; + + return $homeworkId; + }, + $rows, + ); + } + + /** @param array $row */ + private function hydrate(array $row): HomeworkRuleException + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $homeworkId */ + $homeworkId = $row['homework_id']; + /** @var string $ruleType */ + $ruleType = $row['rule_type']; + /** @var string $justification */ + $justification = $row['justification']; + /** @var string $createdBy */ + $createdBy = $row['created_by']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + + return HomeworkRuleException::reconstitute( + id: HomeworkRuleExceptionId::fromString($id), + tenantId: TenantId::fromString($tenantId), + homeworkId: HomeworkId::fromString($homeworkId), + ruleType: $ruleType, + justification: $justification, + createdBy: UserId::fromString($createdBy), + createdAt: new DateTimeImmutable($createdAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkRuleExceptionRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkRuleExceptionRepository.php new file mode 100644 index 0000000..f14aab7 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkRuleExceptionRepository.php @@ -0,0 +1,59 @@ + */ + private array $byId = []; + + #[Override] + public function save(HomeworkRuleException $exception): void + { + $this->byId[(string) $exception->id] = $exception; + } + + #[Override] + public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (HomeworkRuleException $e): bool => $e->homeworkId->equals($homeworkId) + && $e->tenantId->equals($tenantId), + )); + } + + #[Override] + public function findByTenant(TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (HomeworkRuleException $e): bool => $e->tenantId->equals($tenantId), + )); + } + + #[Override] + public function homeworkIdsWithExceptions(TenantId $tenantId): array + { + $tenantExceptions = $this->findByTenant($tenantId); + + return array_values(array_unique(array_map( + static fn (HomeworkRuleException $e): string => (string) $e->homeworkId, + $tenantExceptions, + ))); + } +} diff --git a/backend/templates/emails/homework_exception_notification.html.twig b/backend/templates/emails/homework_exception_notification.html.twig new file mode 100644 index 0000000..6f01ecd --- /dev/null +++ b/backend/templates/emails/homework_exception_notification.html.twig @@ -0,0 +1,112 @@ + + + + + + Exception aux règles de devoirs - Classeo + + + +
+

Classeo

+
+ +
+
+ ! +
+ +

Exception aux règles de devoirs

+ +

Bonjour,

+ +

Un enseignant a demandé une exception aux règles de devoirs de votre établissement.

+ +
+

Enseignant : {{ teacherName }}

+

Devoir : {{ homeworkTitle }}

+

Date d'échéance : {{ dueDate }}

+

Règle(s) contournée(s) : {{ ruleTypes|join(', ') }}

+
+ +
+

Justification de l'enseignant :

+

{{ justification }}

+
+ +

Ce devoir a été créé immédiatement. Aucune action n'est requise de votre part, mais vous pouvez consulter le rapport des exceptions dans votre espace direction.

+
+ + + + diff --git a/backend/tests/Unit/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionHandlerTest.php new file mode 100644 index 0000000..16fa457 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionHandlerTest.php @@ -0,0 +1,252 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->exceptionRepository = new InMemoryHomeworkRuleExceptionRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-19 10:00:00'); + } + }; + } + + #[Test] + public function itCreatesHomeworkAndExceptionWhenRulesAreBlocking(): void + { + $handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult()); + $command = $this->createCommand(); + + $result = $handler($command); + + self::assertSame(HomeworkStatus::PUBLISHED, $result['homework']->status); + self::assertSame('Exercices chapitre 5', $result['homework']->title); + self::assertSame('minimum_delay', $result['exception']->ruleType); + self::assertSame('Sortie scolaire prévue, devoir urgent pour les élèves.', $result['exception']->justification); + } + + #[Test] + public function itPersistsBothInRepositories(): void + { + $handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult()); + $command = $this->createCommand(); + + $result = $handler($command); + + $tenantId = TenantId::fromString(self::TENANT_ID); + $savedHomework = $this->homeworkRepository->get($result['homework']->id, $tenantId); + $savedExceptions = $this->exceptionRepository->findByHomework($result['homework']->id, $tenantId); + + self::assertSame('Exercices chapitre 5', $savedHomework->title); + self::assertCount(1, $savedExceptions); + self::assertTrue($savedExceptions[0]->homeworkId->equals($result['homework']->id)); + } + + #[Test] + public function itUsesServerSideRuleTypesNotClientProvided(): void + { + $handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult()); + // Client sends ['fake_rule'] but server should use the actual rule types + $command = $this->createCommand(ruleTypes: ['fake_rule']); + + $result = $handler($command); + + // ruleType comes from server-side check, not from client + self::assertSame('minimum_delay', $result['exception']->ruleType); + } + + #[Test] + public function itRecordsDomainEvents(): void + { + $handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult()); + $result = $handler($this->createCommand()); + + $exceptionEvents = $result['exception']->pullDomainEvents(); + self::assertCount(1, $exceptionEvents); + self::assertInstanceOf(ExceptionDevoirDemandee::class, $exceptionEvents[0]); + self::assertSame(['minimum_delay'], $exceptionEvents[0]->ruleTypes); + } + + #[Test] + public function itThrowsWhenRulesAreNotBlocking(): void + { + $handler = $this->createHandler(affecte: true, rulesResult: HomeworkRulesCheckResult::ok()); + + $this->expectException(ExceptionNonNecessaireException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itThrowsWhenRulesAreSoftWarningOnly(): void + { + $softResult = new HomeworkRulesCheckResult( + warnings: [new RuleWarning('minimum_delay', 'Délai trop court', ['days' => 3])], + bloquant: false, + ); + $handler = $this->createHandler(affecte: true, rulesResult: $softResult); + + $this->expectException(ExceptionNonNecessaireException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itThrowsWhenTeacherNotAffected(): void + { + $handler = $this->createHandler(affecte: false, rulesResult: $this->blockingResult()); + + $this->expectException(EnseignantNonAffecteException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itThrowsWhenJustificationTooShort(): void + { + $handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult()); + + $this->expectException(JustificationTropCourteException::class); + + $handler($this->createCommand(justification: 'Trop court')); + } + + #[Test] + public function itStoresMultipleRuleTypesFromServerCheck(): void + { + $multiRuleResult = new HomeworkRulesCheckResult( + warnings: [ + new RuleWarning('minimum_delay', 'Délai trop court', ['days' => 7]), + new RuleWarning('no_monday_after', 'Pas de lundi', ['day' => 'friday', 'time' => '12:00']), + ], + bloquant: true, + ); + $handler = $this->createHandler(affecte: true, rulesResult: $multiRuleResult); + + $result = $handler($this->createCommand()); + + self::assertSame('minimum_delay,no_monday_after', $result['exception']->ruleType); + } + + private function blockingResult(): HomeworkRulesCheckResult + { + return new HomeworkRulesCheckResult( + warnings: [new RuleWarning('minimum_delay', 'Le devoir doit être créé au moins 7 jours avant.', ['days' => 7])], + bloquant: true, + suggestedDates: ['2026-03-30'], + ); + } + + private function createHandler(bool $affecte, HomeworkRulesCheckResult $rulesResult): CreateHomeworkWithExceptionHandler + { + $affectationChecker = new class($affecte) implements EnseignantAffectationChecker { + public function __construct(private readonly bool $affecte) + { + } + + public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool + { + return $this->affecte; + } + }; + + $calendarProvider = new class implements CurrentCalendarProvider { + public function forCurrentYear(TenantId $tenantId): SchoolCalendar + { + return SchoolCalendar::reconstitute( + tenantId: $tenantId, + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'), + zone: null, + entries: [], + ); + } + }; + + $rulesChecker = new class($rulesResult) implements HomeworkRulesChecker { + public function __construct(private readonly HomeworkRulesCheckResult $result) + { + } + + public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult + { + return $this->result; + } + }; + + $connection = $this->createMock(Connection::class); + + return new CreateHomeworkWithExceptionHandler( + $this->homeworkRepository, + $this->exceptionRepository, + $affectationChecker, + $calendarProvider, + new DueDateValidator(), + $rulesChecker, + $this->clock, + $connection, + ); + } + + /** + * @param string[]|null $ruleTypes + */ + private function createCommand( + ?string $justification = null, + ?array $ruleTypes = null, + ): CreateHomeworkWithExceptionCommand { + return new CreateHomeworkWithExceptionCommand( + tenantId: self::TENANT_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + title: 'Exercices chapitre 5', + description: 'Faire les exercices 1 à 10', + dueDate: '2026-04-15', + justification: $justification ?? 'Sortie scolaire prévue, devoir urgent pour les élèves.', + ruleTypes: $ruleTypes ?? ['minimum_delay'], + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportHandlerTest.php new file mode 100644 index 0000000..5618dc8 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportHandlerTest.php @@ -0,0 +1,206 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->exceptionRepository = new InMemoryHomeworkRuleExceptionRepository(); + } + + #[Test] + public function itReturnsAllExceptionsForTenant(): void + { + $this->seedData(); + $handler = $this->createHandler(); + + $results = $handler(new GetHomeworkExceptionsReportQuery(tenantId: self::TENANT_ID)); + + self::assertCount(2, $results); + self::assertSame('Exercices chapitre 5', $results[0]->homeworkTitle); + self::assertSame('Jean Dupont', $results[0]->teacherName); + } + + #[Test] + public function itFiltersByDateRange(): void + { + $this->seedData(); + $handler = $this->createHandler(); + + $results = $handler(new GetHomeworkExceptionsReportQuery( + tenantId: self::TENANT_ID, + startDate: '2026-03-20', + endDate: '2026-03-25', + )); + + self::assertCount(1, $results); + self::assertSame('Devoir histoire', $results[0]->homeworkTitle); + } + + #[Test] + public function itFiltersByTeacher(): void + { + $this->seedData(); + $handler = $this->createHandler(); + + $results = $handler(new GetHomeworkExceptionsReportQuery( + tenantId: self::TENANT_ID, + teacherId: self::TEACHER2_ID, + )); + + self::assertCount(1, $results); + self::assertSame(self::TEACHER2_ID, $results[0]->teacherId); + } + + #[Test] + public function itFiltersByRuleType(): void + { + $this->seedData(); + $handler = $this->createHandler(); + + $results = $handler(new GetHomeworkExceptionsReportQuery( + tenantId: self::TENANT_ID, + ruleType: 'no_monday_after', + )); + + self::assertCount(1, $results); + self::assertStringContainsString('no_monday_after', $results[0]->ruleType); + } + + #[Test] + public function itReturnsEmptyWhenNoExceptions(): void + { + $handler = $this->createHandler(); + + $results = $handler(new GetHomeworkExceptionsReportQuery(tenantId: self::TENANT_ID)); + + self::assertCount(0, $results); + } + + private function seedData(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + + $hw1 = Homework::reconstitute( + id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'), + tenantId: $tenantId, + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Exercices chapitre 5', + description: null, + dueDate: new DateTimeImmutable('2026-04-15'), + status: HomeworkStatus::PUBLISHED, + createdAt: new DateTimeImmutable('2026-03-19 10:00:00'), + updatedAt: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + $this->homeworkRepository->save($hw1); + + $hw2 = Homework::reconstitute( + id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440051'), + tenantId: $tenantId, + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString(self::TEACHER2_ID), + title: 'Devoir histoire', + description: null, + dueDate: new DateTimeImmutable('2026-04-20'), + status: HomeworkStatus::PUBLISHED, + createdAt: new DateTimeImmutable('2026-03-21 10:00:00'), + updatedAt: new DateTimeImmutable('2026-03-21 10:00:00'), + ); + $this->homeworkRepository->save($hw2); + + $ex1 = HomeworkRuleException::demander( + tenantId: $tenantId, + homeworkId: $hw1->id, + ruleTypes: ['minimum_delay'], + justification: 'Sortie scolaire prévue, devoir urgent pour les élèves.', + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + $this->exceptionRepository->save($ex1); + + $ex2 = HomeworkRuleException::demander( + tenantId: $tenantId, + homeworkId: $hw2->id, + ruleTypes: ['no_monday_after'], + justification: 'Rentrée après les vacances, devoir préparatoire.', + createdBy: UserId::fromString(self::TEACHER2_ID), + now: new DateTimeImmutable('2026-03-21 10:00:00'), + ); + $this->exceptionRepository->save($ex2); + } + + private function createHandler(): GetHomeworkExceptionsReportHandler + { + $userRepository = $this->createMock(UserRepository::class); + $userRepository->method('findById') + ->willReturnCallback(static function (UserId $id): ?User { + $map = [ + self::TEACHER_ID => ['Jean', 'Dupont'], + self::TEACHER2_ID => ['Marie', 'Martin'], + ]; + + $data = $map[(string) $id] ?? null; + + if ($data === null) { + return null; + } + + return User::reconstitute( + id: $id, + email: new Email('test@test.fr'), + roles: [Role::PROF], + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Test', + statut: StatutCompte::ACTIF, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-01'), + hashedPassword: 'hashed', + activatedAt: new DateTimeImmutable('2026-01-02'), + consentementParental: null, + firstName: $data[0], + lastName: $data[1], + ); + }); + + return new GetHomeworkExceptionsReportHandler( + $this->exceptionRepository, + $this->homeworkRepository, + $userRepository, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkRuleExceptionTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkRuleExceptionTest.php new file mode 100644 index 0000000..df60f15 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkRuleExceptionTest.php @@ -0,0 +1,170 @@ +id); + self::assertTrue($exception->tenantId->equals($tenantId)); + self::assertTrue($exception->homeworkId->equals($homeworkId)); + self::assertSame('minimum_delay', $exception->ruleType); + self::assertSame(['minimum_delay'], $exception->ruleTypes()); + self::assertSame('Sortie scolaire prévue, les élèves doivent préparer leur dossier.', $exception->justification); + self::assertTrue($exception->createdBy->equals($teacherId)); + self::assertEquals($now, $exception->createdAt); + } + + #[Test] + public function demanderStoresMultipleRuleTypesCommaSeparated(): void + { + $exception = HomeworkRuleException::demander( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString(self::HOMEWORK_ID), + ruleTypes: ['minimum_delay', 'no_monday_after'], + justification: 'Sortie scolaire et lundi exceptionnel.', + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + + self::assertSame('minimum_delay,no_monday_after', $exception->ruleType); + self::assertSame(['minimum_delay', 'no_monday_after'], $exception->ruleTypes()); + } + + #[Test] + public function demanderRecordsExceptionDevoirDemandeeEvent(): void + { + $exception = $this->createException(); + + $events = $exception->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(ExceptionDevoirDemandee::class, $events[0]); + self::assertSame($exception->id, $events[0]->exceptionId); + self::assertSame(['minimum_delay'], $events[0]->ruleTypes); + } + + #[Test] + public function demanderThrowsWhenJustificationTooShort(): void + { + $this->expectException(JustificationTropCourteException::class); + $this->expectExceptionMessage('au moins 20 caractères'); + + HomeworkRuleException::demander( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString(self::HOMEWORK_ID), + ruleTypes: ['minimum_delay'], + justification: 'Trop court', + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + } + + #[Test] + public function demanderAcceptsExactlyMinimumLength(): void + { + $justification = str_repeat('x', 20); + + $exception = HomeworkRuleException::demander( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString(self::HOMEWORK_ID), + ruleTypes: ['minimum_delay'], + justification: $justification, + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + + self::assertSame($justification, $exception->justification); + } + + #[Test] + public function demanderRejectsNineteenCharacters(): void + { + $this->expectException(JustificationTropCourteException::class); + + HomeworkRuleException::demander( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString(self::HOMEWORK_ID), + ruleTypes: ['minimum_delay'], + justification: str_repeat('x', 19), + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + } + + #[Test] + public function reconstituteRestoresAllPropertiesWithoutEvents(): void + { + $id = HomeworkRuleExceptionId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $homeworkId = HomeworkId::fromString(self::HOMEWORK_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $createdAt = new DateTimeImmutable('2026-03-19 10:00:00'); + + $exception = HomeworkRuleException::reconstitute( + id: $id, + tenantId: $tenantId, + homeworkId: $homeworkId, + ruleType: 'minimum_delay', + justification: 'Justification suffisamment longue pour passer.', + createdBy: $teacherId, + createdAt: $createdAt, + ); + + self::assertTrue($exception->id->equals($id)); + self::assertTrue($exception->tenantId->equals($tenantId)); + self::assertTrue($exception->homeworkId->equals($homeworkId)); + self::assertSame('minimum_delay', $exception->ruleType); + self::assertSame('Justification suffisamment longue pour passer.', $exception->justification); + self::assertTrue($exception->createdBy->equals($teacherId)); + self::assertEquals($createdAt, $exception->createdAt); + self::assertEmpty($exception->pullDomainEvents()); + } + + private function createException(): HomeworkRuleException + { + return HomeworkRuleException::demander( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString(self::HOMEWORK_ID), + ruleTypes: ['minimum_delay'], + justification: 'Sortie scolaire prévue, les élèves doivent préparer leur dossier.', + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Messaging/OnExceptionDevoirDemandeeHandlerTest.php b/backend/tests/Unit/Scolarite/Infrastructure/Messaging/OnExceptionDevoirDemandeeHandlerTest.php new file mode 100644 index 0000000..67fc8c7 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Messaging/OnExceptionDevoirDemandeeHandlerTest.php @@ -0,0 +1,224 @@ +createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $homeworkRepo = $this->createMock(HomeworkRepository::class); + $userRepo = $this->createMock(UserRepository::class); + + $homework = $this->createHomework(); + $homeworkRepo->method('findById')->willReturn($homework); + + $teacher = $this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont'); + $userRepo->method('findById')->willReturn($teacher); + + $userRepo->method('findAllByTenant')->willReturn([ + $this->createUser('director@school.fr', [Role::ADMIN], 'Marie', 'Martin'), + $this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont'), + $this->createUser('parent@school.fr', [Role::PARENT], 'Pierre', 'Durand'), + ]); + + $twig->method('render')->willReturn('notification'); + + $mailer->expects(self::exactly(1))->method('send'); + + $handler = new OnExceptionDevoirDemandeeHandler( + $homeworkRepo, + $userRepo, + $mailer, + $twig, + new NullLogger(), + ); + + ($handler)($this->createEvent()); + } + + #[Test] + public function itSkipsWhenNoDirectorsInTenant(): void + { + $mailer = $this->createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $homeworkRepo = $this->createMock(HomeworkRepository::class); + $userRepo = $this->createMock(UserRepository::class); + + $homeworkRepo->method('findById')->willReturn($this->createHomework()); + $userRepo->method('findById')->willReturn($this->createUser('teacher@school.fr', [Role::PROF])); + $userRepo->method('findAllByTenant')->willReturn([ + $this->createUser('teacher@school.fr', [Role::PROF]), + ]); + + $mailer->expects(self::never())->method('send'); + + $handler = new OnExceptionDevoirDemandeeHandler( + $homeworkRepo, + $userRepo, + $mailer, + $twig, + new NullLogger(), + ); + + ($handler)($this->createEvent()); + } + + #[Test] + public function itHandlesMailerFailureGracefully(): void + { + $mailer = $this->createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $homeworkRepo = $this->createMock(HomeworkRepository::class); + $userRepo = $this->createMock(UserRepository::class); + + $homeworkRepo->method('findById')->willReturn($this->createHomework()); + $userRepo->method('findById')->willReturn($this->createUser('teacher@school.fr', [Role::PROF])); + $userRepo->method('findAllByTenant')->willReturn([ + $this->createUser('director@school.fr', [Role::ADMIN]), + ]); + $twig->method('render')->willReturn('notification'); + + $mailer->method('send')->willThrowException(new RuntimeException('SMTP error')); + + $handler = new OnExceptionDevoirDemandeeHandler( + $homeworkRepo, + $userRepo, + $mailer, + $twig, + new NullLogger(), + ); + + ($handler)($this->createEvent()); + + $this->addToAssertionCount(1); + } + + #[Test] + public function itPassesCorrectDataToTemplate(): void + { + $mailer = $this->createMock(MailerInterface::class); + $twig = $this->createMock(Environment::class); + $homeworkRepo = $this->createMock(HomeworkRepository::class); + $userRepo = $this->createMock(UserRepository::class); + + $homeworkRepo->method('findById')->willReturn($this->createHomework()); + $teacher = $this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont'); + $userRepo->method('findById')->willReturn($teacher); + $userRepo->method('findAllByTenant')->willReturn([ + $this->createUser('director@school.fr', [Role::ADMIN]), + ]); + + $twig->expects(self::once()) + ->method('render') + ->with( + 'emails/homework_exception_notification.html.twig', + self::callback(static function (array $params): bool { + return $params['teacherName'] === 'Jean Dupont' + && $params['homeworkTitle'] === 'Exercices chapitre 5' + && $params['ruleTypes'] === ['minimum_delay'] + && $params['justification'] === 'Sortie scolaire prévue, devoir urgent.' + && $params['dueDate'] === '15/04/2026'; + }), + ) + ->willReturn('notification'); + + $mailer->expects(self::once())->method('send'); + + $handler = new OnExceptionDevoirDemandeeHandler( + $homeworkRepo, + $userRepo, + $mailer, + $twig, + new NullLogger(), + ); + + ($handler)($this->createEvent()); + } + + private function createEvent(): ExceptionDevoirDemandee + { + return new ExceptionDevoirDemandee( + exceptionId: HomeworkRuleExceptionId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'), + ruleTypes: ['minimum_delay'], + justification: 'Sortie scolaire prévue, devoir urgent.', + createdBy: UserId::fromString(self::TEACHER_ID), + occurredOn: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + } + + private function createHomework(): Homework + { + return Homework::reconstitute( + id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'), + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Exercices chapitre 5', + description: 'Faire les exercices 1 à 10', + dueDate: new DateTimeImmutable('2026-04-15'), + status: \App\Scolarite\Domain\Model\Homework\HomeworkStatus::PUBLISHED, + createdAt: new DateTimeImmutable('2026-03-19 10:00:00'), + updatedAt: new DateTimeImmutable('2026-03-19 10:00:00'), + ); + } + + /** + * @param Role[] $roles + */ + private function createUser( + string $email, + array $roles, + string $firstName = 'Test', + string $lastName = 'User', + ): User { + return User::reconstitute( + id: UserId::generate(), + email: new Email($email), + roles: $roles, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: 'École Test', + statut: StatutCompte::ACTIF, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-01'), + hashedPassword: 'hashed', + activatedAt: new DateTimeImmutable('2026-01-02'), + consentementParental: null, + firstName: $firstName, + lastName: $lastName, + ); + } +} diff --git a/frontend/e2e/homework-exception.spec.ts b/frontend/e2e/homework-exception.spec.ts new file mode 100644 index 0000000..164a147 --- /dev/null +++ b/frontend/e2e/homework-exception.spec.ts @@ -0,0 +1,407 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const TEACHER_EMAIL = 'e2e-exception-teacher@example.com'; +const TEACHER_PASSWORD = 'Exception123'; +const ADMIN_EMAIL = 'e2e-exception-admin@example.com'; +const ADMIN_PASSWORD = 'ExceptionAdmin123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runSql(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' }, + ); +} + +function clearCache() { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' }, + ); + } catch { + // Cache pool may not exist + } +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' }, + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId: schoolId!, academicYearId: academicYearId! }; +} + +function getNextWeekday(daysFromNow: number): string { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + const day = date.getDay(); + if (day === 0) date.setDate(date.getDate() + 1); + if (day === 6) date.setDate(date.getDate() + 2); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +async function loginAsTeacher(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TEACHER_EMAIL); + await page.locator('#password').fill(TEACHER_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click(), + ]); +} + +async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\//, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click(), + ]); +} + +async function navigateToHomework(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`); + await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 }); +} + +function seedTeacherAssignments() { + const { academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' ` + + `AND s.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING`, + ); + } catch { + // Table may not exist + } +} + +function seedHardRules() { + const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]'; + runSql( + `INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` + + `ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`, + ); +} + +function clearRules() { + try { + runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } +} + + +async function openCreateAndFillForm(page: import('@playwright/test').Page, title: string, daysFromNow: number) { + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + const nearDate = getNextWeekday(daysFromNow); + await page.locator('#hw-class').selectOption({ index: 1 }); + await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill(title); + await page.locator('#hw-due-date').fill(nearDate); + + await page.getByRole('button', { name: /créer le devoir/i }).click(); +} + +test.describe('Homework Exception Request (Story 5.6)', () => { + test.beforeAll(async () => { + // Create teacher and admin users + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF --first-name=Jean --last-name=Exception 2>&1`, + { encoding: 'utf-8' }, + ); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN --first-name=Marie --last-name=Direction 2>&1`, + { encoding: 'utf-8' }, + ); + + const { schoolId, academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EX-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`, + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-EX-Maths', 'E2EEXM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`, + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + }); + + test.beforeEach(async () => { + try { + runSql( + `DELETE FROM homework_rule_exceptions WHERE tenant_id = '${TENANT_ID}'`, + ); + runSql( + `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`, + ); + } catch { + // Tables may not exist + } + + clearRules(); + clearCache(); + }); + + // ============================================================================ + // AC1: Exception request form + // ============================================================================ + test.describe('AC1: Exception request form', () => { + test('shows "Demander une exception" button in blocking modal', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir exception test', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible(); + }); + + test('clicking exception button opens justification form', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir exception form', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + await blockedDialog.getByRole('button', { name: /demander une exception/i }).click(); + + // Exception request dialog appears + const exceptionDialog = page.getByRole('dialog'); + await expect(exceptionDialog).toBeVisible({ timeout: 5000 }); + await expect(exceptionDialog.getByText(/demander une exception/i)).toBeVisible(); + await expect(exceptionDialog.locator('#exception-justification')).toBeVisible(); + }); + }); + + // ============================================================================ + // AC2: Justification required (min 20 chars) + // ============================================================================ + test.describe('AC2: Justification validation', () => { + test('submit button disabled when justification too short', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir justif short', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + await blockedDialog.getByRole('button', { name: /demander une exception/i }).click(); + + const exceptionDialog = page.getByRole('dialog'); + await expect(exceptionDialog).toBeVisible({ timeout: 5000 }); + + // Type less than 20 characters + await exceptionDialog.locator('#exception-justification').fill('Court'); + + // Submit button should be disabled + await expect(exceptionDialog.getByRole('button', { name: /créer avec exception/i })).toBeDisabled(); + }); + + test('homework created immediately after valid justification', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir exception créé', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + await blockedDialog.getByRole('button', { name: /demander une exception/i }).click(); + + const exceptionDialog = page.getByRole('dialog'); + await expect(exceptionDialog).toBeVisible({ timeout: 5000 }); + + // Type valid justification (>= 20 chars) + await exceptionDialog + .locator('#exception-justification') + .fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.'); + + // Submit + await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click(); + + // Homework appears in the list + await expect(page.getByText('Devoir exception créé')).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC4: Exception badge + // ============================================================================ + test.describe('AC4: Exception marking', () => { + test('homework with exception shows exception badge', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir avec badge', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + await blockedDialog.getByRole('button', { name: /demander une exception/i }).click(); + + const exceptionDialog = page.getByRole('dialog'); + await expect(exceptionDialog).toBeVisible({ timeout: 5000 }); + await exceptionDialog + .locator('#exception-justification') + .fill('Justification suffisamment longue pour être valide.'); + await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click(); + + // Wait for homework to appear + await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 }); + + // Exception badge visible (⚠ Exception text or rule override badge) + const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' }); + await expect(card.locator('.badge-rule-exception, .badge-rule-override')).toBeVisible(); + }); + + test('exception badge tooltip describes the exception for justification viewing', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir tooltip test', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + await blockedDialog.getByRole('button', { name: /demander une exception/i }).click(); + + const exceptionDialog = page.getByRole('dialog'); + await expect(exceptionDialog).toBeVisible({ timeout: 5000 }); + await exceptionDialog + .locator('#exception-justification') + .fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.'); + await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click(); + + await expect(page.getByText('Devoir tooltip test')).toBeVisible({ timeout: 10000 }); + + // Badge has descriptive title attribute for justification consultation + const card = page.locator('.homework-card', { hasText: 'Devoir tooltip test' }); + const badge = card.locator('.badge-rule-exception'); + await expect(badge).toBeVisible(); + await expect(badge).toHaveAttribute('title', /exception/i); + await expect(badge).toContainText('Exception'); + }); + }); + + // ============================================================================ + // AC5: Direction exceptions report + // ============================================================================ + test.describe('AC5: Direction exceptions report', () => { + test('admin can view exceptions report page', async ({ page }) => { + // First create an exception as teacher + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir rapport test', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + await blockedDialog.getByRole('button', { name: /demander une exception/i }).click(); + + const exceptionDialog = page.getByRole('dialog'); + await expect(exceptionDialog).toBeVisible({ timeout: 5000 }); + await exceptionDialog + .locator('#exception-justification') + .fill('Justification pour le rapport de la direction.'); + await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click(); + await expect(page.getByText('Devoir rapport test')).toBeVisible({ timeout: 10000 }); + + // Now login as admin and check the report + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/homework-exceptions`); + await expect(page.getByRole('heading', { name: /exceptions aux règles/i })).toBeVisible({ timeout: 15000 }); + + // Exception should be visible + await expect(page.getByText('Devoir rapport test')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/justification pour le rapport/i)).toBeVisible(); + }); + }); + + // ============================================================================ + // AC6: Soft mode - no justification needed + // ============================================================================ + test.describe('AC6: Soft mode without justification', () => { + test('soft mode does not show exception request button', async ({ page }) => { + // Configure soft mode + const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]'; + runSql( + `INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'soft', true, NOW(), NOW()) ` + + `ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()`, + ); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir soft mode', 2); + + // Warning modal appears (not blocking) + const warningModal = page.getByRole('alertdialog'); + await expect(warningModal).toBeVisible({ timeout: 10000 }); + await expect(warningModal.getByText(/avertissement/i)).toBeVisible(); + + // "Continuer malgré tout" visible (soft mode allows bypass) + await expect(warningModal.getByRole('button', { name: /continuer malgré tout/i })).toBeVisible(); + + // No exception request button + await expect(warningModal.getByRole('button', { name: /demander une exception/i })).not.toBeVisible(); + }); + }); +}); diff --git a/frontend/src/lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte b/frontend/src/lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte new file mode 100644 index 0000000..2e96ccd --- /dev/null +++ b/frontend/src/lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte @@ -0,0 +1,292 @@ + + + + + + diff --git a/frontend/src/lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte b/frontend/src/lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte index cdab6f7..a180451 100644 --- a/frontend/src/lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte +++ b/frontend/src/lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte @@ -10,11 +10,13 @@ suggestedDates = [], onSelectDate, onClose, + onRequestException, }: { warnings: RuleWarning[]; suggestedDates: string[]; onSelectDate: (date: string) => void; onClose: () => void; + onRequestException?: () => void; } = $props(); let modalElement = $state(null); @@ -86,11 +88,13 @@ {/if} -

- - Besoin d'une exception ? Contactez votre administration. - -

+ {#if onRequestException} +
+ +
+ {/if}