From c46d053db7b0e9b20225a1c4852d2251ea489506 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 18 Mar 2026 16:37:16 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Avertir=20l'enseignant=20quand=20un=20d?= =?UTF-8?q?evoir=20ne=20respecte=20pas=20les=20r=C3=A8gles=20(mode=20soft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand un établissement configure des règles de devoirs en mode "soft", l'enseignant est maintenant averti avant la création si la date d'échéance ne respecte pas les contraintes (délai minimum, pas de lundi après un certain créneau). Il peut alors choisir de continuer (avec traçabilité) ou de modifier la date vers une date conforme. Le mode "hard" (blocage) reste protégé : acknowledgeWarning ne permet pas de contourner les règles bloquantes, préparant la story 5.5. --- backend/migrations/Version20260318004535.php | 26 ++ .../CreateHomework/CreateHomeworkCommand.php | 1 + .../CreateHomework/CreateHomeworkHandler.php | 17 + .../Port/HomeworkRulesCheckResult.php | 79 ++++ .../Application/Port/HomeworkRulesChecker.php | 23 ++ .../Application/Port/RuleWarning.php | 33 ++ .../ReglesDevoirsNonRespecteesException.php | 23 ++ .../Domain/Model/Homework/Homework.php | 18 + .../Api/Processor/CreateHomeworkProcessor.php | 12 +- .../Api/Resource/HomeworkResource.php | 5 + .../Doctrine/DoctrineHomeworkRepository.php | 21 +- .../AdministrationHomeworkRulesChecker.php | 80 ++++ .../Scolarite/Api/HomeworkEndpointsTest.php | 182 +++++++++ .../CreateHomeworkHandlerTest.php | 118 +++++- .../Domain/Model/Homework/HomeworkTest.php | 36 ++ frontend/e2e/homework-rules-warning.spec.ts | 361 ++++++++++++++++++ .../dashboard/teacher/homework/+page.svelte | 199 +++++++++- 17 files changed, 1223 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/Version20260318004535.php create mode 100644 backend/src/Scolarite/Application/Port/HomeworkRulesCheckResult.php create mode 100644 backend/src/Scolarite/Application/Port/HomeworkRulesChecker.php create mode 100644 backend/src/Scolarite/Application/Port/RuleWarning.php create mode 100644 backend/src/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesException.php create mode 100644 backend/src/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesChecker.php create mode 100644 frontend/e2e/homework-rules-warning.spec.ts diff --git a/backend/migrations/Version20260318004535.php b/backend/migrations/Version20260318004535.php new file mode 100644 index 0000000..23d3e06 --- /dev/null +++ b/backend/migrations/Version20260318004535.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE homework ADD COLUMN rule_override JSONB DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE homework DROP COLUMN rule_override'); + } +} diff --git a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkCommand.php b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkCommand.php index f3a4547..d32c47b 100644 --- a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkCommand.php +++ b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkCommand.php @@ -14,6 +14,7 @@ final readonly class CreateHomeworkCommand public string $title, public ?string $description, public string $dueDate, + public bool $acknowledgeWarning = false, ) { } } diff --git a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php index f4f5882..b752b20 100644 --- a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php +++ b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php @@ -9,7 +9,9 @@ use App\Administration\Domain\Model\Subject\SubjectId; use App\Administration\Domain\Model\User\UserId; use App\Scolarite\Application\Port\CurrentCalendarProvider; use App\Scolarite\Application\Port\EnseignantAffectationChecker; +use App\Scolarite\Application\Port\HomeworkRulesChecker; use App\Scolarite\Domain\Exception\EnseignantNonAffecteException; +use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException; use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Repository\HomeworkRepository; use App\Scolarite\Domain\Service\DueDateValidator; @@ -26,6 +28,7 @@ final readonly class CreateHomeworkHandler private EnseignantAffectationChecker $affectationChecker, private CurrentCalendarProvider $calendarProvider, private DueDateValidator $dueDateValidator, + private HomeworkRulesChecker $rulesChecker, private Clock $clock, ) { } @@ -46,6 +49,16 @@ final readonly class CreateHomeworkHandler $dueDate = new DateTimeImmutable($command->dueDate); $this->dueDateValidator->valider($dueDate, $now, $calendar); + $rulesResult = $this->rulesChecker->verifier($tenantId, $dueDate, $now); + + if ($rulesResult->estBloquant()) { + throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray()); + } + + if ($rulesResult->estAvertissement() && !$command->acknowledgeWarning) { + throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray()); + } + $homework = Homework::creer( tenantId: $tenantId, classId: $classId, @@ -57,6 +70,10 @@ final readonly class CreateHomeworkHandler now: $now, ); + if ($command->acknowledgeWarning && $rulesResult->estAvertissement()) { + $homework->acknowledgeRuleWarning($rulesResult->ruleTypes(), $now); + } + $this->homeworkRepository->save($homework); return $homework; diff --git a/backend/src/Scolarite/Application/Port/HomeworkRulesCheckResult.php b/backend/src/Scolarite/Application/Port/HomeworkRulesCheckResult.php new file mode 100644 index 0000000..2f64aae --- /dev/null +++ b/backend/src/Scolarite/Application/Port/HomeworkRulesCheckResult.php @@ -0,0 +1,79 @@ +warnings) === 0; + } + + public function estAvertissement(): bool + { + return !$this->estValide() && !$this->bloquant; + } + + public function estBloquant(): bool + { + return !$this->estValide() && $this->bloquant; + } + + /** + * @return string[] + */ + public function messages(): array + { + return array_map( + static fn (RuleWarning $w): string => $w->message, + $this->warnings, + ); + } + + /** + * @return string[] + */ + public function ruleTypes(): array + { + return array_map( + static fn (RuleWarning $w): string => $w->ruleType, + $this->warnings, + ); + } + + /** + * @return array}> + */ + public function toArray(): array + { + return array_map( + static fn (RuleWarning $w): array => $w->toArray(), + $this->warnings, + ); + } + + public static function ok(): self + { + return new self(warnings: [], bloquant: false); + } +} diff --git a/backend/src/Scolarite/Application/Port/HomeworkRulesChecker.php b/backend/src/Scolarite/Application/Port/HomeworkRulesChecker.php new file mode 100644 index 0000000..f2848fa --- /dev/null +++ b/backend/src/Scolarite/Application/Port/HomeworkRulesChecker.php @@ -0,0 +1,23 @@ + $params Paramètres de la règle pour le frontend + */ + public function __construct( + public string $ruleType, + public string $message, + public array $params = [], + ) { + } + + /** + * @return array{ruleType: string, message: string, params: array} + */ + public function toArray(): array + { + return [ + 'ruleType' => $this->ruleType, + 'message' => $this->message, + 'params' => $this->params, + ]; + } +} diff --git a/backend/src/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesException.php b/backend/src/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesException.php new file mode 100644 index 0000000..837e1a4 --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesException.php @@ -0,0 +1,23 @@ +}> $warnings + */ + public function __construct( + public readonly array $warnings, + ) { + parent::__construct('Le devoir ne respecte pas les règles configurées.'); + } +} diff --git a/backend/src/Scolarite/Domain/Model/Homework/Homework.php b/backend/src/Scolarite/Domain/Model/Homework/Homework.php index ec465e2..ae7de84 100644 --- a/backend/src/Scolarite/Domain/Model/Homework/Homework.php +++ b/backend/src/Scolarite/Domain/Model/Homework/Homework.php @@ -19,6 +19,9 @@ final class Homework extends AggregateRoot { public private(set) DateTimeImmutable $updatedAt; + /** @var array{warnings: string[], acknowledgedAt: string}|null */ + public private(set) ?array $ruleOverride = null; + private function __construct( public private(set) HomeworkId $id, public private(set) TenantId $tenantId, @@ -34,6 +37,17 @@ final class Homework extends AggregateRoot $this->updatedAt = $createdAt; } + /** + * @param string[] $ruleTypes Types de règles contournées (ex: ['minimum_delay']) + */ + public function acknowledgeRuleWarning(array $ruleTypes, DateTimeImmutable $now): void + { + $this->ruleOverride = [ + 'warnings' => $ruleTypes, + 'acknowledgedAt' => $now->format(DateTimeImmutable::ATOM), + ]; + } + public static function creer( TenantId $tenantId, ClassId $classId, @@ -110,6 +124,8 @@ final class Homework extends AggregateRoot /** * @internal Pour usage Infrastructure uniquement + * + * @param array{warnings: string[], acknowledgedAt: string}|null $ruleOverride */ public static function reconstitute( HomeworkId $id, @@ -123,6 +139,7 @@ final class Homework extends AggregateRoot HomeworkStatus $status, DateTimeImmutable $createdAt, DateTimeImmutable $updatedAt, + ?array $ruleOverride = null, ): self { $homework = new self( id: $id, @@ -138,6 +155,7 @@ final class Homework extends AggregateRoot ); $homework->updatedAt = $updatedAt; + $homework->ruleOverride = $ruleOverride; return $homework; } diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php index 1f64cd9..bd59c83 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php @@ -11,11 +11,14 @@ use App\Scolarite\Application\Command\CreateHomework\CreateHomeworkCommand; use App\Scolarite\Application\Command\CreateHomework\CreateHomeworkHandler; use App\Scolarite\Domain\Exception\DateEcheanceInvalideException; use App\Scolarite\Domain\Exception\EnseignantNonAffecteException; +use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException; use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource; use App\Shared\Infrastructure\Tenant\TenantContext; use Override; use Ramsey\Uuid\Exception\InvalidUuidStringException; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Messenger\MessageBusInterface; @@ -37,7 +40,7 @@ final readonly class CreateHomeworkProcessor implements ProcessorInterface * @param HomeworkResource $data */ #[Override] - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource|JsonResponse { if (!$this->tenantContext->hasTenant()) { throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); @@ -58,6 +61,7 @@ final readonly class CreateHomeworkProcessor implements ProcessorInterface title: $data->title ?? '', description: $data->description, dueDate: $data->dueDate ?? '', + acknowledgeWarning: $data->acknowledgeWarning ?? false, ); $homework = ($this->handler)($command); @@ -67,6 +71,12 @@ final readonly class CreateHomeworkProcessor implements ProcessorInterface } return HomeworkResource::fromDomain($homework); + } catch (ReglesDevoirsNonRespecteesException $e) { + return new JsonResponse([ + 'type' => 'homework_rules_warning', + 'message' => $e->getMessage(), + 'warnings' => $e->warnings, + ], Response::HTTP_CONFLICT); } catch (EnseignantNonAffecteException|DateEcheanceInvalideException $e) { throw new BadRequestHttpException($e->getMessage()); } catch (InvalidUuidStringException $e) { diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php index ecf6839..1cd7437 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/HomeworkResource.php @@ -80,6 +80,8 @@ final class HomeworkResource public ?string $status = null; + public ?bool $acknowledgeWarning = null; + public ?string $className = null; public ?string $subjectName = null; @@ -88,6 +90,8 @@ final class HomeworkResource public ?DateTimeImmutable $updatedAt = null; + public ?bool $hasRuleOverride = null; + public static function fromDomain( Homework $homework, ?string $className = null, @@ -106,6 +110,7 @@ final class HomeworkResource $resource->subjectName = $subjectName; $resource->createdAt = $homework->createdAt; $resource->updatedAt = $homework->updatedAt; + $resource->hasRuleOverride = $homework->ruleOverride !== null; return $resource; } diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRepository.php index 19e29d8..f0e35c8 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRepository.php @@ -18,6 +18,13 @@ use function array_map; use DateTimeImmutable; use Doctrine\DBAL\Connection; + +use function is_string; +use function json_decode; +use function json_encode; + +use const JSON_THROW_ON_ERROR; + use Override; final readonly class DoctrineHomeworkRepository implements HomeworkRepository @@ -31,13 +38,14 @@ final readonly class DoctrineHomeworkRepository implements HomeworkRepository public function save(Homework $homework): void { $this->connection->executeStatement( - 'INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) - VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :due_date, :status, :created_at, :updated_at) + 'INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, rule_override, created_at, updated_at) + VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :due_date, :status, :rule_override, :created_at, :updated_at) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, due_date = EXCLUDED.due_date, status = EXCLUDED.status, + rule_override = EXCLUDED.rule_override, updated_at = EXCLUDED.updated_at', [ 'id' => (string) $homework->id, @@ -49,6 +57,7 @@ final readonly class DoctrineHomeworkRepository implements HomeworkRepository 'description' => $homework->description, 'due_date' => $homework->dueDate->format('Y-m-d'), 'status' => $homework->status->value, + 'rule_override' => $homework->ruleOverride !== null ? json_encode($homework->ruleOverride, JSON_THROW_ON_ERROR) : null, 'created_at' => $homework->createdAt->format(DateTimeImmutable::ATOM), 'updated_at' => $homework->updatedAt->format(DateTimeImmutable::ATOM), ], @@ -154,6 +163,13 @@ final readonly class DoctrineHomeworkRepository implements HomeworkRepository $createdAt = $row['created_at']; /** @var string $updatedAt */ $updatedAt = $row['updated_at']; + /** @var string|null $ruleOverrideJson */ + $ruleOverrideJson = $row['rule_override'] ?? null; + + /** @var array{warnings: string[], acknowledgedAt: string}|null $ruleOverride */ + $ruleOverride = is_string($ruleOverrideJson) + ? json_decode($ruleOverrideJson, true, 512, JSON_THROW_ON_ERROR) + : null; return Homework::reconstitute( id: HomeworkId::fromString($id), @@ -167,6 +183,7 @@ final readonly class DoctrineHomeworkRepository implements HomeworkRepository status: HomeworkStatus::from($status), createdAt: new DateTimeImmutable($createdAt), updatedAt: new DateTimeImmutable($updatedAt), + ruleOverride: $ruleOverride, ); } } diff --git a/backend/src/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesChecker.php b/backend/src/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesChecker.php new file mode 100644 index 0000000..3da5809 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesChecker.php @@ -0,0 +1,80 @@ +rulesRepository->findByTenantId($tenantId); + + if ($rules === null) { + return HomeworkRulesCheckResult::ok(); + } + + $result = $this->validator->valider($rules, $dueDate, $creationDate); + + return $this->toCheckResult($result, $rules); + } + + private function toCheckResult(HomeworkRulesValidationResult $result, HomeworkRules $rules): HomeworkRulesCheckResult + { + return new HomeworkRulesCheckResult( + warnings: array_map( + fn (RuleViolation $v): RuleWarning => new RuleWarning( + ruleType: $v->ruleType->value, + message: $v->message, + params: $this->findRuleParams($rules, $v), + ), + $result->violations, + ), + bloquant: $result->estBloquant(), + ); + } + + /** + * @return array + */ + private function findRuleParams(HomeworkRules $rules, RuleViolation $violation): array + { + foreach ($rules->rules as $rule) { + /** @var HomeworkRule $rule */ + if ($rule->type === $violation->ruleType) { + return $rule->params; + } + } + + return []; + } +} diff --git a/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php b/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php index df5667a..95eb433 100644 --- a/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php +++ b/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php @@ -16,7 +16,11 @@ use App\Scolarite\Domain\Repository\HomeworkRepository; use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; use Doctrine\DBAL\Connection; + +use const JSON_THROW_ON_ERROR; + use PHPUnit\Framework\Attributes\Test; +use Ramsey\Uuid\Uuid; /** * Tests for homework API endpoints. @@ -53,6 +57,7 @@ final class HomeworkEndpointsTest extends ApiTestCase /** @var Connection $connection */ $connection = $container->get(Connection::class); $connection->executeStatement('DELETE FROM homework WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM homework_rules WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); $connection->executeStatement('DELETE FROM school_classes WHERE id IN (:id1, :id2)', ['id1' => self::CLASS_ID, 'id2' => self::TARGET_CLASS_ID]); $connection->executeStatement('DELETE FROM subjects WHERE id = :id', ['id' => self::SUBJECT_ID]); $connection->executeStatement('DELETE FROM users WHERE id IN (:o, :t)', ['o' => self::OWNER_TEACHER_ID, 't' => self::OTHER_TEACHER_ID]); @@ -506,6 +511,118 @@ final class HomeworkEndpointsTest extends ApiTestCase self::assertResponseStatusCodeSame(404); } + // ========================================================================= + // 5.4-FUNC-001 (P2) - GET /homework/{id} with rule override -> hasRuleOverride = true + // ========================================================================= + + #[Test] + public function getHomeworkShowsHasRuleOverrideTrue(): void + { + $this->persistHomeworkWithRuleOverride(self::HOMEWORK_ID); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID, [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(200); + self::assertJsonContains(['hasRuleOverride' => true]); + } + + // ========================================================================= + // 5.4-FUNC-002 (P2) - GET /homework/{id} without rule override -> hasRuleOverride = false + // ========================================================================= + + #[Test] + public function getHomeworkShowsHasRuleOverrideFalse(): void + { + $this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID, [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(200); + self::assertJsonContains(['hasRuleOverride' => false]); + } + + // ========================================================================= + // 5.4-FUNC-003 (P1) - POST /homework with soft rules violated → 409 with warnings + // ========================================================================= + + #[Test] + public function createHomeworkReturns409WhenSoftRulesViolated(): void + { + $this->persistSoftRulesWithMinimumDelay(7); + $this->seedTeacherAssignment(); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + // Due date tomorrow = violates 7-day minimum_delay + $tomorrow = (new DateTimeImmutable('+1 weekday'))->format('Y-m-d'); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'classId' => self::CLASS_ID, + 'subjectId' => self::SUBJECT_ID, + 'title' => 'Devoir test 409', + 'dueDate' => $tomorrow, + ], + ]); + + self::assertResponseStatusCodeSame(409); + $json = $client->getResponse()->toArray(false); + self::assertSame('homework_rules_warning', $json['type']); + self::assertNotEmpty($json['warnings']); + self::assertSame('minimum_delay', $json['warnings'][0]['ruleType']); + } + + // ========================================================================= + // 5.4-FUNC-004 (P1) - POST /homework with acknowledgeWarning → 201 created + // ========================================================================= + + #[Test] + public function createHomeworkReturns201WhenSoftRulesAcknowledged(): void + { + $this->persistSoftRulesWithMinimumDelay(7); + $this->seedTeacherAssignment(); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $tomorrow = (new DateTimeImmutable('+1 weekday'))->format('Y-m-d'); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'classId' => self::CLASS_ID, + 'subjectId' => self::SUBJECT_ID, + 'title' => 'Devoir acknowledge', + 'dueDate' => $tomorrow, + 'acknowledgeWarning' => true, + ], + ]); + + self::assertResponseStatusCodeSame(201); + self::assertJsonContains([ + 'title' => 'Devoir acknowledge', + 'hasRuleOverride' => true, + ]); + } + // ========================================================================= // Helpers // ========================================================================= @@ -527,6 +644,71 @@ final class HomeworkEndpointsTest extends ApiTestCase return $client; } + private function persistSoftRulesWithMinimumDelay(int $days): void + { + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $rulesJson = json_encode([['type' => 'minimum_delay', 'params' => ['days' => $days]]], JSON_THROW_ON_ERROR); + $connection->executeStatement( + "INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) + VALUES (gen_random_uuid(), :tid, :rules::jsonb, 'soft', true, NOW(), NOW()) + ON CONFLICT (tenant_id) DO UPDATE SET rules = :rules::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()", + ['tid' => self::TENANT_ID, 'rules' => $rulesJson], + ); + } + + private function seedTeacherAssignment(): void + { + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + + // Compute the current academic year UUID the same way CurrentAcademicYearResolver does + $month = (int) date('n'); + $year = (int) date('Y'); + $startYear = $month >= 9 ? $year : $year - 1; + $academicYearId = Uuid::uuid5( + '6ba7b814-9dad-11d1-80b4-00c04fd430c8', + self::TENANT_ID . ':' . $startYear . '-' . ($startYear + 1), + )->toString(); + + $connection->executeStatement( + "INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) + VALUES (gen_random_uuid(), :tid, :teacher, :class, :subject, :ayid, 'active', NOW(), NOW(), NOW()) + ON CONFLICT DO NOTHING", + [ + 'tid' => self::TENANT_ID, + 'teacher' => self::OWNER_TEACHER_ID, + 'class' => self::CLASS_ID, + 'subject' => self::SUBJECT_ID, + 'ayid' => $academicYearId, + ], + ); + } + + private function persistHomeworkWithRuleOverride(string $homeworkId): void + { + $now = new DateTimeImmutable(); + + $homework = Homework::reconstitute( + id: HomeworkId::fromString($homeworkId), + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::OWNER_TEACHER_ID), + title: 'Devoir existant', + description: null, + dueDate: new DateTimeImmutable('2026-06-15'), + status: HomeworkStatus::PUBLISHED, + createdAt: $now, + updatedAt: $now, + ruleOverride: ['warnings' => ['minimum_delay'], 'acknowledgedAt' => '2026-03-18T10:00:00+00:00'], + ); + + /** @var HomeworkRepository $repository */ + $repository = static::getContainer()->get(HomeworkRepository::class); + $repository->save($homework); + } + private function persistHomework(string $homeworkId, HomeworkStatus $status): void { $now = new DateTimeImmutable(); diff --git a/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php index 2d22a31..a28ec7d 100644 --- a/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php @@ -13,8 +13,12 @@ use App\Scolarite\Application\Command\CreateHomework\CreateHomeworkCommand; use App\Scolarite\Application\Command\CreateHomework\CreateHomeworkHandler; use App\Scolarite\Application\Port\CurrentCalendarProvider; use App\Scolarite\Application\Port\EnseignantAffectationChecker; +use App\Scolarite\Application\Port\HomeworkRulesChecker; +use App\Scolarite\Application\Port\HomeworkRulesCheckResult; +use App\Scolarite\Application\Port\RuleWarning; use App\Scolarite\Domain\Exception\DateEcheanceInvalideException; use App\Scolarite\Domain\Exception\EnseignantNonAffecteException; +use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException; use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Model\Homework\HomeworkStatus; use App\Scolarite\Domain\Service\DueDateValidator; @@ -106,7 +110,103 @@ final class CreateHomeworkHandlerTest extends TestCase self::assertNull($homework->description); } - private function createHandler(bool $affecte): CreateHomeworkHandler + #[Test] + public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void + { + $warning = new RuleWarning( + ruleType: 'minimum_delay', + message: 'Le devoir doit être créé au moins 3 jours avant.', + params: ['days' => 3], + ); + $rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: false); + $handler = $this->createHandler(affecte: true, rulesResult: $rulesResult); + + $this->expectException(ReglesDevoirsNonRespecteesException::class); + + $handler($this->createCommand(acknowledgeWarning: false)); + } + + #[Test] + public function itThrowsWhenHardRulesViolatedAndNotAcknowledged(): void + { + $warning = new RuleWarning( + ruleType: 'minimum_delay', + message: 'Le devoir doit être créé au moins 3 jours avant.', + params: ['days' => 3], + ); + $rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: true); + $handler = $this->createHandler(affecte: true, rulesResult: $rulesResult); + + $this->expectException(ReglesDevoirsNonRespecteesException::class); + + $handler($this->createCommand(acknowledgeWarning: false)); + } + + #[Test] + public function itThrowsWhenHardRulesViolatedEvenWhenAcknowledged(): void + { + $warning = new RuleWarning( + ruleType: 'minimum_delay', + message: 'Le devoir doit être créé au moins 3 jours avant.', + params: ['days' => 3], + ); + $rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: true); + $handler = $this->createHandler(affecte: true, rulesResult: $rulesResult); + + $this->expectException(ReglesDevoirsNonRespecteesException::class); + + $handler($this->createCommand(acknowledgeWarning: true)); + } + + #[Test] + public function itCreatesHomeworkWhenSoftRulesViolatedButAcknowledged(): void + { + $warning = new RuleWarning( + ruleType: 'minimum_delay', + message: 'Le devoir doit être créé au moins 3 jours avant.', + params: ['days' => 3], + ); + $rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: false); + $handler = $this->createHandler(affecte: true, rulesResult: $rulesResult); + + $homework = $handler($this->createCommand(acknowledgeWarning: true)); + + self::assertNotEmpty((string) $homework->id); + self::assertSame(HomeworkStatus::PUBLISHED, $homework->status); + } + + #[Test] + public function itRecordsRuleOverrideWhenAcknowledged(): void + { + $warning = new RuleWarning( + ruleType: 'minimum_delay', + message: 'Le devoir doit être créé au moins 3 jours avant.', + params: ['days' => 3], + ); + $rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: false); + $handler = $this->createHandler(affecte: true, rulesResult: $rulesResult); + + $homework = $handler($this->createCommand(acknowledgeWarning: true)); + + self::assertNotNull($homework->ruleOverride); + self::assertArrayHasKey('warnings', $homework->ruleOverride); + self::assertArrayHasKey('acknowledgedAt', $homework->ruleOverride); + self::assertSame(['minimum_delay'], $homework->ruleOverride['warnings']); + self::assertSame('2026-03-12T10:00:00+00:00', $homework->ruleOverride['acknowledgedAt']); + } + + #[Test] + public function itCreatesHomeworkWithNoOverrideWhenRulesPass(): void + { + $rulesResult = HomeworkRulesCheckResult::ok(); + $handler = $this->createHandler(affecte: true, rulesResult: $rulesResult); + + $homework = $handler($this->createCommand()); + + self::assertNull($homework->ruleOverride); + } + + private function createHandler(bool $affecte, ?HomeworkRulesCheckResult $rulesResult = null): CreateHomeworkHandler { $affectationChecker = new class($affecte) implements EnseignantAffectationChecker { public function __construct(private readonly bool $affecte) @@ -131,18 +231,31 @@ final class CreateHomeworkHandlerTest extends TestCase } }; + $rulesChecker = new class($rulesResult ?? HomeworkRulesCheckResult::ok()) implements HomeworkRulesChecker { + public function __construct(private readonly HomeworkRulesCheckResult $result) + { + } + + public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult + { + return $this->result; + } + }; + return new CreateHomeworkHandler( $this->homeworkRepository, $affectationChecker, $calendarProvider, new DueDateValidator(), + $rulesChecker, $this->clock, ); } private function createCommand( ?string $dueDate = null, - mixed $description = 'Faire les exercices 1 à 10', + ?string $description = 'Faire les exercices 1 à 10', + bool $acknowledgeWarning = false, ): CreateHomeworkCommand { return new CreateHomeworkCommand( tenantId: self::TENANT_ID, @@ -152,6 +265,7 @@ final class CreateHomeworkHandlerTest extends TestCase title: 'Exercices chapitre 5', description: $description, dueDate: $dueDate ?? '2026-04-15', + acknowledgeWarning: $acknowledgeWarning, ); } } diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkTest.php index cd13500..96c2612 100644 --- a/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkTest.php +++ b/backend/tests/Unit/Scolarite/Domain/Model/Homework/HomeworkTest.php @@ -207,6 +207,42 @@ final class HomeworkTest extends TestCase self::assertEmpty($homework->pullDomainEvents()); } + #[Test] + public function acknowledgeRuleWarningRecordsOverrideData(): void + { + $homework = $this->createHomework(); + + $now = new DateTimeImmutable('2026-03-18 10:00:00'); + $homework->acknowledgeRuleWarning(['minimum_delay', 'max_per_day'], $now); + + self::assertNotNull($homework->ruleOverride); + self::assertSame(['minimum_delay', 'max_per_day'], $homework->ruleOverride['warnings']); + self::assertSame('2026-03-18T10:00:00+00:00', $homework->ruleOverride['acknowledgedAt']); + } + + #[Test] + public function reconstitutePreservesRuleOverride(): void + { + $overrideData = ['warnings' => ['minimum_delay'], 'acknowledgedAt' => '2026-03-18T10:00:00+00:00']; + + $homework = Homework::reconstitute( + id: HomeworkId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Exercices chapitre 5', + description: 'Faire les exercices 1 à 10', + dueDate: new DateTimeImmutable('2026-04-15'), + status: HomeworkStatus::PUBLISHED, + createdAt: new DateTimeImmutable('2026-03-12 10:00:00'), + updatedAt: new DateTimeImmutable('2026-03-13 14:00:00'), + ruleOverride: $overrideData, + ); + + self::assertSame($overrideData, $homework->ruleOverride); + } + private function createHomework(): Homework { return Homework::creer( diff --git a/frontend/e2e/homework-rules-warning.spec.ts b/frontend/e2e/homework-rules-warning.spec.ts new file mode 100644 index 0000000..9283e9a --- /dev/null +++ b/frontend/e2e/homework-rules-warning.spec.ts @@ -0,0 +1,361 @@ +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-rules-warn-teacher@example.com'; +const TEACHER_PASSWORD = 'RulesWarn123'; +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! }; +} + +/** + * Returns a weekday date string (YYYY-MM-DD), N days from now. + * Skips weekends. + */ +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 + } +} + +/** + * Configure homework rules with minimum_delay of 7 days in soft mode + * so that a homework due in 1-2 days triggers a warning. + */ +function seedSoftRules() { + // Use escaped quotes for JSON inside double-quoted shell command + 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()`, + ); +} + +function clearRules() { + try { + runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } +} + +test.describe('Homework Rules - Soft Warning (Story 5.4)', () => { + 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 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-RW-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-RW-Maths', 'E2ERWM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`, + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + }); + + test.beforeEach(async () => { + try { + 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 { + // Table may not exist + } + + clearRules(); + clearCache(); + }); + + // ============================================================================ + // AC1 + AC2: Warning displayed with rule description + // ============================================================================ + test.describe('AC1 + AC2: Warning display', () => { + test('shows warning modal when due date violates soft rule', async ({ page }) => { + seedSoftRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + // Open create modal + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + // Fill form with near due date (2 days → violates 7-day minimum_delay) + const nearDate = getNextWeekday(2); + 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('Devoir test warning'); + await page.locator('#hw-due-date').fill(nearDate); + + // Submit + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + // Warning modal appears + const warningDialog = page.getByRole('alertdialog'); + await expect(warningDialog).toBeVisible({ timeout: 10000 }); + await expect(warningDialog.getByText(/ne respecte pas les règles/i)).toBeVisible(); + await expect(warningDialog.getByText(/au moins/i)).toBeVisible(); + await expect(warningDialog.getByText(/votre choix sera enregistré/i)).toBeVisible(); + }); + + test('no warning when rules not configured', async ({ page }) => { + clearRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + const nearDate = getNextWeekday(2); + 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('Devoir sans rules'); + await page.locator('#hw-due-date').fill(nearDate); + + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + // No warning, homework created directly + await expect(page.getByRole('alertdialog')).not.toBeVisible({ timeout: 3000 }); + await expect(page.getByText('Devoir sans rules')).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC3: Continue despite warning → homework created, event traced + // ============================================================================ + test.describe('AC3: Continue despite warning', () => { + test('creates homework when choosing to continue', async ({ page }) => { + seedSoftRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + const nearDate = getNextWeekday(2); + 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('Devoir continue warning'); + await page.locator('#hw-due-date').fill(nearDate); + + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + // Warning modal appears + const warningDialog = page.getByRole('alertdialog'); + await expect(warningDialog).toBeVisible({ timeout: 10000 }); + + // Click continue + await warningDialog.getByRole('button', { name: /continuer malgré tout/i }).click(); + + // Warning modal closes, homework appears in list + await expect(warningDialog).not.toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir continue warning')).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC4: Modify date → calendar reopens + // ============================================================================ + test.describe('AC4: Modify date', () => { + test('reopens create form when choosing to modify date', async ({ page }) => { + seedSoftRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + const nearDate = getNextWeekday(2); + 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('Devoir modifier date'); + await page.locator('#hw-due-date').fill(nearDate); + + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + // Warning modal appears + const warningDialog = page.getByRole('alertdialog'); + await expect(warningDialog).toBeVisible({ timeout: 10000 }); + + // Click modify date + await warningDialog.getByRole('button', { name: /modifier la date/i }).click(); + + // Warning closes, create form reopens + await expect(warningDialog).not.toBeVisible({ timeout: 3000 }); + const createDialog = page.getByRole('dialog'); + await expect(createDialog).toBeVisible(); + await expect(createDialog.locator('#hw-due-date')).toBeVisible(); + + // Change to a compliant date (15 days from now) + const farDate = getNextWeekday(15); + await page.locator('#hw-due-date').fill(farDate); + + // Submit again — should succeed without warning + await page.getByRole('button', { name: /créer le devoir/i }).click(); + await expect(page.getByText('Devoir modifier date')).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC5: History badge for overridden homeworks + // ============================================================================ + test.describe('AC5: Discreet badge in history', () => { + test('shows warning badge on homework created with override', async ({ page }) => { + seedSoftRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + // Create homework with override + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + const nearDate = getNextWeekday(2); + 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('Devoir avec badge'); + await page.locator('#hw-due-date').fill(nearDate); + + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + const warningDialog = page.getByRole('alertdialog'); + await expect(warningDialog).toBeVisible({ timeout: 10000 }); + await warningDialog.getByRole('button', { name: /continuer malgré tout/i }).click(); + + // Homework card should have the warning badge + await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 }); + const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' }); + await expect(card.locator('.badge-rule-override')).toBeVisible(); + }); + + test('no badge on homework created without override', async ({ page }) => { + clearRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + const farDate = getNextWeekday(15); + 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('Devoir normal'); + await page.locator('#hw-due-date').fill(farDate); + + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + await expect(page.getByText('Devoir normal')).toBeVisible({ timeout: 10000 }); + const card = page.locator('.homework-card', { hasText: 'Devoir normal' }); + await expect(card.locator('.badge-rule-override')).not.toBeVisible(); + }); + }); +}); diff --git a/frontend/src/routes/dashboard/teacher/homework/+page.svelte b/frontend/src/routes/dashboard/teacher/homework/+page.svelte index 8c3c922..76083a2 100644 --- a/frontend/src/routes/dashboard/teacher/homework/+page.svelte +++ b/frontend/src/routes/dashboard/teacher/homework/+page.svelte @@ -18,10 +18,17 @@ status: string; className: string | null; subjectName: string | null; + hasRuleOverride: boolean; createdAt: string; updatedAt: string; } + interface RuleWarning { + ruleType: string; + message: string; + params: Record; + } + interface TeacherAssignment { id: string; classId: string; @@ -87,6 +94,11 @@ let duplicateValidationResults = $state>([]); let duplicateWarnings = $state>([]); + // Rule warning modal + let showRuleWarningModal = $state(false); + let ruleWarnings = $state([]); + let ruleConformMinDate = $state(''); + // Class filter let filterClassId = $state(page.url.searchParams.get('classId') ?? ''); @@ -287,13 +299,14 @@ newTitle = ''; newDescription = ''; newDueDate = ''; + ruleConformMinDate = ''; } function closeCreateModal() { showCreateModal = false; } - async function handleCreate() { + async function handleCreate(acknowledgeWarning = false) { if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return; try { @@ -309,9 +322,20 @@ title: newTitle.trim(), description: newDescription.trim() || null, dueDate: newDueDate, + acknowledgeWarning, }), }); + if (response.status === 409) { + const data = await response.json().catch(() => null); + if (data?.type === 'homework_rules_warning' && Array.isArray(data.warnings)) { + ruleWarnings = data.warnings; + showCreateModal = false; + showRuleWarningModal = true; + return; + } + } + if (!response.ok) { const errorData = await response.json().catch(() => null); const msg = @@ -323,6 +347,8 @@ } closeCreateModal(); + showRuleWarningModal = false; + ruleWarnings = []; await loadHomeworks(); } catch (e) { error = e instanceof Error ? e.message : 'Erreur lors de la création'; @@ -331,6 +357,48 @@ } } + function handleContinueDespiteWarning() { + showRuleWarningModal = false; + handleCreate(true); + } + + function computeConformMinDate(warnings: RuleWarning[]): string { + let minDate = new Date(); + minDate.setDate(minDate.getDate() + 1); // au moins demain + + for (const w of warnings) { + if (w.ruleType === 'minimum_delay' && typeof w.params['days'] === 'number') { + const ruleMin = new Date(); + ruleMin.setDate(ruleMin.getDate() + (w.params['days'] as number)); + if (ruleMin > minDate) minDate = ruleMin; + } + if (w.ruleType === 'no_monday_after') { + // Si le lundi est interdit (deadline dépassée), proposer mardi + // car le problème ne concerne que les devoirs pour lundi + const nextTuesday = new Date(); + nextTuesday.setDate(nextTuesday.getDate() + ((9 - nextTuesday.getDay()) % 7 || 7)); + if (nextTuesday > minDate) minDate = nextTuesday; + } + } + + // Sauter les weekends + const day = minDate.getDay(); + if (day === 0) minDate.setDate(minDate.getDate() + 1); + if (day === 6) minDate.setDate(minDate.getDate() + 2); + + const y = minDate.getFullYear(); + const m = String(minDate.getMonth() + 1).padStart(2, '0'); + const d = String(minDate.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + + function handleModifyDate() { + ruleConformMinDate = computeConformMinDate(ruleWarnings); + showRuleWarningModal = false; + showCreateModal = true; + newDueDate = ruleConformMinDate; + } + // --- Edit --- function openEditModal(hw: Homework) { editHomework = hw; @@ -584,9 +652,14 @@

{hw.title}

- - {hw.status === 'published' ? 'Publié' : 'Supprimé'} - +
+ {#if hw.hasRuleOverride} + + {/if} + + {hw.status === 'published' ? 'Publié' : 'Supprimé'} + +
@@ -706,8 +779,14 @@
- - La date doit être au minimum demain, hors jours fériés et vacances + + {#if ruleConformMinDate} + + Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })} + + {:else} + La date doit être au minimum demain, hors jours fériés et vacances + {/if}
{/if} + +{#if showRuleWarningModal && ruleWarnings.length > 0} + + +{/if} +