From 9b868ae5c46fd80d3f631f1d80072872d8107b4a Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Thu, 19 Mar 2026 21:58:56 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20aux=20enseignants=20de=20co?= =?UTF-8?q?ntourner=20les=20r=C3=A8gles=20de=20devoirs=20avec=20justificat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Akeneo permet de configurer des règles de devoirs en mode Hard qui bloquent totalement la création. Or certains cas légitimes (sorties scolaires, événements exceptionnels) nécessitent de passer outre ces règles. Sans mécanisme d'exception, l'enseignant est bloqué et doit contacter manuellement la direction. Cette implémentation ajoute un flux complet d'exception : l'enseignant justifie sa demande (min 20 caractères), le devoir est créé immédiatement, et la direction est notifiée par email. Le handler vérifie côté serveur que les règles sont réellement bloquantes avant d'accepter l'exception, empêchant toute fabrication de fausses exceptions via l'API. La direction dispose d'un rapport filtrable par période, enseignant et type de règle. --- backend/config/services.yaml | 3 + backend/migrations/Version20260319142344.php | 39 ++ .../CreateHomeworkWithExceptionCommand.php | 24 + .../CreateHomeworkWithExceptionHandler.php | 101 ++++ .../GetHomeworkExceptionsReportHandler.php | 150 ++++++ .../GetHomeworkExceptionsReportQuery.php | 17 + .../HomeworkExceptionDto.php | 20 + .../Domain/Event/ExceptionDevoirDemandee.php | 43 ++ .../ExceptionNonNecessaireException.php | 15 + .../JustificationTropCourteException.php | 15 + .../Model/Homework/HomeworkRuleException.php | 101 ++++ .../Homework/HomeworkRuleExceptionId.php | 11 + .../HomeworkRuleExceptionRepository.php | 27 + .../CreateHomeworkWithExceptionProcessor.php | 93 ++++ .../Provider/HomeworkCollectionProvider.php | 14 + .../HomeworkExceptionsReportProvider.php | 92 ++++ .../Api/Provider/HomeworkItemProvider.php | 5 + .../Resource/HomeworkExceptionResource.php | 71 +++ .../Api/Resource/HomeworkResource.php | 11 + .../OnExceptionDevoirDemandeeHandler.php | 107 ++++ ...octrineHomeworkRuleExceptionRepository.php | 123 +++++ ...nMemoryHomeworkRuleExceptionRepository.php | 59 +++ .../homework_exception_notification.html.twig | 112 +++++ ...CreateHomeworkWithExceptionHandlerTest.php | 252 ++++++++++ ...GetHomeworkExceptionsReportHandlerTest.php | 206 ++++++++ .../Homework/HomeworkRuleExceptionTest.php | 170 +++++++ .../OnExceptionDevoirDemandeeHandlerTest.php | 224 +++++++++ frontend/e2e/homework-exception.spec.ts | 365 ++++++++++++++ frontend/e2e/homework-rules-hard.spec.ts | 6 +- .../ExceptionRequestModal.svelte | 292 +++++++++++ .../RuleBlockedModal/RuleBlockedModal.svelte | 49 +- frontend/src/routes/admin/+layout.svelte | 3 +- .../admin/homework-exceptions/+page.svelte | 468 ++++++++++++++++++ .../dashboard/teacher/homework/+page.svelte | 192 ++++++- 34 files changed, 3467 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/Version20260319142344.php create mode 100644 backend/src/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionCommand.php create mode 100644 backend/src/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportHandler.php create mode 100644 backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportQuery.php create mode 100644 backend/src/Scolarite/Application/Query/GetHomeworkExceptionsReport/HomeworkExceptionDto.php create mode 100644 backend/src/Scolarite/Domain/Event/ExceptionDevoirDemandee.php create mode 100644 backend/src/Scolarite/Domain/Exception/ExceptionNonNecessaireException.php create mode 100644 backend/src/Scolarite/Domain/Exception/JustificationTropCourteException.php create mode 100644 backend/src/Scolarite/Domain/Model/Homework/HomeworkRuleException.php create mode 100644 backend/src/Scolarite/Domain/Model/Homework/HomeworkRuleExceptionId.php create mode 100644 backend/src/Scolarite/Domain/Repository/HomeworkRuleExceptionRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkWithExceptionProcessor.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkExceptionsReportProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkExceptionResource.php create mode 100644 backend/src/Scolarite/Infrastructure/Messaging/OnExceptionDevoirDemandeeHandler.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRuleExceptionRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkRuleExceptionRepository.php create mode 100644 backend/templates/emails/homework_exception_notification.html.twig create mode 100644 backend/tests/Unit/Scolarite/Application/Command/CreateHomeworkWithException/CreateHomeworkWithExceptionHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Query/GetHomeworkExceptionsReport/GetHomeworkExceptionsReportHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkRuleExceptionTest.php create mode 100644 backend/tests/Unit/Scolarite/Infrastructure/Messaging/OnExceptionDevoirDemandeeHandlerTest.php create mode 100644 frontend/e2e/homework-exception.spec.ts create mode 100644 frontend/src/lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte create mode 100644 frontend/src/routes/admin/homework-exceptions/+page.svelte 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..3dc808f --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkExceptionsReportProvider.php @@ -0,0 +1,92 @@ + + */ +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.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + if (!in_array(Role::ADMIN->value, $user->getRoles(), true) + && !in_array(Role::SUPER_ADMIN->value, $user->getRoles(), true)) { + 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..0f6bdd2 --- /dev/null +++ b/frontend/e2e/homework-exception.spec.ts @@ -0,0 +1,365 @@ +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 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 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 user + 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 --firstName=Jean --lastName=Exception 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.describe.configure({ mode: 'serial' }); + + 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 + // ============================================================================ + // AC5 (Direction exceptions report) is covered by unit tests + // (GetHomeworkExceptionsReportHandlerTest) because E2E testing requires + // multi-tenant admin login which is not reliably testable in parallel mode. + + // ============================================================================ + // 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/e2e/homework-rules-hard.spec.ts b/frontend/e2e/homework-rules-hard.spec.ts index 890c9e7..31c0ab1 100644 --- a/frontend/e2e/homework-rules-hard.spec.ts +++ b/frontend/e2e/homework-rules-hard.spec.ts @@ -276,7 +276,7 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => { // AC3: Exception request information // ============================================================================ test.describe('AC3: Exception request information', () => { - test('shows exception contact information in blocking modal', async ({ page }) => { + test('shows exception request button in blocking modal', async ({ page }) => { seedHardRules(); clearCache(); @@ -287,8 +287,8 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => { const blockedDialog = page.getByRole('alertdialog'); await expect(blockedDialog).toBeVisible({ timeout: 10000 }); - // Exception information visible - await expect(blockedDialog.getByText(/exception.*contactez/i)).toBeVisible(); + // Exception request button visible (Story 5.6 replaced static text with a real button) + await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).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}