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} +