diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 2a93425..87a7ffe 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -262,6 +262,9 @@ services: App\Scolarite\Domain\Repository\EvaluationRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationRepository + App\Scolarite\Domain\Repository\GradeRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineGradeRepository + App\Scolarite\Application\Port\EvaluationGradesChecker: alias: App\Scolarite\Infrastructure\Service\NoGradesEvaluationGradesChecker diff --git a/backend/migrations/Version20260327211549.php b/backend/migrations/Version20260327211549.php new file mode 100644 index 0000000..e57477f --- /dev/null +++ b/backend/migrations/Version20260327211549.php @@ -0,0 +1,70 @@ +addSql(<<<'SQL' + CREATE TABLE grades ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + evaluation_id UUID NOT NULL REFERENCES evaluations(id), + student_id UUID NOT NULL REFERENCES users(id), + value DECIMAL(5,2), + status VARCHAR(20) NOT NULL DEFAULT 'graded', + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (evaluation_id, student_id) + ) + SQL); + + $this->addSql(<<<'SQL' + CREATE INDEX idx_grades_tenant ON grades(tenant_id) + SQL); + + $this->addSql(<<<'SQL' + CREATE INDEX idx_grades_evaluation ON grades(evaluation_id) + SQL); + + $this->addSql(<<<'SQL' + CREATE INDEX idx_grades_student ON grades(student_id) + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE grade_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + grade_id UUID NOT NULL REFERENCES grades(id), + event_type VARCHAR(50) NOT NULL, + old_value DECIMAL(5,2), + new_value DECIMAL(5,2), + old_status VARCHAR(20), + new_status VARCHAR(20), + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql(<<<'SQL' + CREATE INDEX idx_grade_events_grade ON grade_events(grade_id) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS grade_events'); + $this->addSql('DROP TABLE IF EXISTS grades'); + } +} diff --git a/backend/migrations/Version20260327212231.php b/backend/migrations/Version20260327212231.php new file mode 100644 index 0000000..2aba65a --- /dev/null +++ b/backend/migrations/Version20260327212231.php @@ -0,0 +1,28 @@ +addSql(<<<'SQL' + ALTER TABLE evaluations ADD COLUMN grades_published_at TIMESTAMPTZ DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE evaluations DROP COLUMN grades_published_at'); + } +} diff --git a/backend/src/Scolarite/Application/Command/PublishGrades/PublishGradesCommand.php b/backend/src/Scolarite/Application/Command/PublishGrades/PublishGradesCommand.php new file mode 100644 index 0000000..d662599 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/PublishGrades/PublishGradesCommand.php @@ -0,0 +1,15 @@ +tenantId); + $evaluationId = EvaluationId::fromString($command->evaluationId); + $teacherId = UserId::fromString($command->teacherId); + $now = $this->clock->now(); + + $evaluation = $this->evaluationRepository->get($evaluationId, $tenantId); + + if ((string) $evaluation->teacherId !== (string) $teacherId) { + throw NonProprietaireDeLEvaluationException::withId($evaluationId); + } + + if (!$this->gradeRepository->hasGradesForEvaluation($evaluationId, $tenantId)) { + throw AucuneNoteSaisieException::pourEvaluation($evaluationId); + } + + $evaluation->publierNotes($now); + + $this->evaluationRepository->save($evaluation); + + return $evaluation; + } +} diff --git a/backend/src/Scolarite/Application/Command/SaveGrades/SaveGradesCommand.php b/backend/src/Scolarite/Application/Command/SaveGrades/SaveGradesCommand.php new file mode 100644 index 0000000..ef43166 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/SaveGrades/SaveGradesCommand.php @@ -0,0 +1,19 @@ + $grades + */ + public function __construct( + public string $tenantId, + public string $evaluationId, + public string $teacherId, + public array $grades, + ) { + } +} diff --git a/backend/src/Scolarite/Application/Command/SaveGrades/SaveGradesHandler.php b/backend/src/Scolarite/Application/Command/SaveGrades/SaveGradesHandler.php new file mode 100644 index 0000000..19e6f3a --- /dev/null +++ b/backend/src/Scolarite/Application/Command/SaveGrades/SaveGradesHandler.php @@ -0,0 +1,86 @@ + */ + public function __invoke(SaveGradesCommand $command): array + { + $tenantId = TenantId::fromString($command->tenantId); + $evaluationId = EvaluationId::fromString($command->evaluationId); + $teacherId = UserId::fromString($command->teacherId); + $now = $this->clock->now(); + + $evaluation = $this->evaluationRepository->get($evaluationId, $tenantId); + + if ((string) $evaluation->teacherId !== (string) $teacherId) { + throw NonProprietaireDeLEvaluationException::withId($evaluationId); + } + + $existingGrades = $this->gradeRepository->findByEvaluation($evaluationId, $tenantId); + $existingByStudent = []; + foreach ($existingGrades as $grade) { + $existingByStudent[(string) $grade->studentId] = $grade; + } + + $savedGrades = []; + + foreach ($command->grades as $gradeInput) { + $studentId = UserId::fromString($gradeInput['studentId']); + $status = GradeStatus::from($gradeInput['status']); + $value = $gradeInput['value'] !== null ? new GradeValue($gradeInput['value']) : null; + + $existing = $existingByStudent[(string) $studentId] ?? null; + + if ($existing !== null) { + $existing->modifier( + value: $value, + status: $status, + gradeScale: $evaluation->gradeScale, + modifiedBy: $teacherId, + now: $now, + ); + $this->gradeRepository->save($existing); + $savedGrades[] = $existing; + } else { + $grade = Grade::saisir( + tenantId: $tenantId, + evaluationId: $evaluationId, + studentId: $studentId, + value: $value, + status: $status, + gradeScale: $evaluation->gradeScale, + createdBy: $teacherId, + now: $now, + ); + $this->gradeRepository->save($grade); + $savedGrades[] = $grade; + } + } + + return $savedGrades; + } +} diff --git a/backend/src/Scolarite/Domain/Event/NoteModifiee.php b/backend/src/Scolarite/Domain/Event/NoteModifiee.php new file mode 100644 index 0000000..6301d70 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/NoteModifiee.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->gradeId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/NoteSaisie.php b/backend/src/Scolarite/Domain/Event/NoteSaisie.php new file mode 100644 index 0000000..1cf191c --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/NoteSaisie.php @@ -0,0 +1,37 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->gradeId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/NotesPubliees.php b/backend/src/Scolarite/Domain/Event/NotesPubliees.php new file mode 100644 index 0000000..4010ac0 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/NotesPubliees.php @@ -0,0 +1,32 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->evaluationId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Exception/AucuneNoteSaisieException.php b/backend/src/Scolarite/Domain/Exception/AucuneNoteSaisieException.php new file mode 100644 index 0000000..a40778c --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/AucuneNoteSaisieException.php @@ -0,0 +1,21 @@ +status === EvaluationStatus::DELETED) { + throw EvaluationDejaSupprimeeException::withId($this->id); + } + + if ($this->gradesPublishedAt !== null) { + throw NotesDejaPublieesException::pourEvaluation($this->id); + } + + $this->gradesPublishedAt = $now; + $this->updatedAt = $now; + + $this->recordEvent(new NotesPubliees( + evaluationId: $this->id, + occurredOn: $now, + )); + } + + public function notesPubliees(): bool + { + return $this->gradesPublishedAt !== null; + } + public function supprimer(DateTimeImmutable $now): void { if ($this->status === EvaluationStatus::DELETED) { @@ -145,6 +172,7 @@ final class Evaluation extends AggregateRoot EvaluationStatus $status, DateTimeImmutable $createdAt, DateTimeImmutable $updatedAt, + ?DateTimeImmutable $gradesPublishedAt = null, ): self { $evaluation = new self( id: $id, @@ -162,6 +190,7 @@ final class Evaluation extends AggregateRoot ); $evaluation->updatedAt = $updatedAt; + $evaluation->gradesPublishedAt = $gradesPublishedAt; return $evaluation; } diff --git a/backend/src/Scolarite/Domain/Model/Grade/Grade.php b/backend/src/Scolarite/Domain/Model/Grade/Grade.php new file mode 100644 index 0000000..23f8f06 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Grade/Grade.php @@ -0,0 +1,142 @@ +updatedAt = $createdAt; + } + + public static function saisir( + TenantId $tenantId, + EvaluationId $evaluationId, + UserId $studentId, + ?GradeValue $value, + GradeStatus $status, + GradeScale $gradeScale, + UserId $createdBy, + DateTimeImmutable $now, + ): self { + self::validerCoherence($value, $status, $gradeScale); + + $grade = new self( + id: GradeId::generate(), + tenantId: $tenantId, + evaluationId: $evaluationId, + studentId: $studentId, + value: $value, + status: $status, + createdBy: $createdBy, + createdAt: $now, + ); + + $grade->recordEvent(new NoteSaisie( + gradeId: $grade->id, + evaluationId: (string) $evaluationId, + studentId: (string) $studentId, + value: $value?->value, + status: $status->value, + createdBy: (string) $createdBy, + occurredOn: $now, + )); + + return $grade; + } + + public function modifier( + ?GradeValue $value, + GradeStatus $status, + GradeScale $gradeScale, + UserId $modifiedBy, + DateTimeImmutable $now, + ): void { + self::validerCoherence($value, $status, $gradeScale); + + $oldValue = $this->value?->value; + $oldStatus = $this->status->value; + + $this->value = $value; + $this->status = $status; + $this->updatedAt = $now; + + $this->recordEvent(new NoteModifiee( + gradeId: $this->id, + evaluationId: (string) $this->evaluationId, + oldValue: $oldValue, + newValue: $value?->value, + oldStatus: $oldStatus, + newStatus: $status->value, + modifiedBy: (string) $modifiedBy, + occurredOn: $now, + )); + } + + /** + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + GradeId $id, + TenantId $tenantId, + EvaluationId $evaluationId, + UserId $studentId, + ?GradeValue $value, + GradeStatus $status, + UserId $createdBy, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ): self { + $grade = new self( + id: $id, + tenantId: $tenantId, + evaluationId: $evaluationId, + studentId: $studentId, + value: $value, + status: $status, + createdBy: $createdBy, + createdAt: $createdAt, + ); + + $grade->updatedAt = $updatedAt; + + return $grade; + } + + private static function validerCoherence( + ?GradeValue $value, + GradeStatus $status, + GradeScale $gradeScale, + ): void { + if ($status === GradeStatus::GRADED && $value === null) { + throw NoteRequiseException::pourStatutNote(); + } + + if ($value !== null && $value->value > $gradeScale->maxValue) { + throw ValeurNoteInvalideException::depasseBareme($value->value, $gradeScale->maxValue); + } + } +} diff --git a/backend/src/Scolarite/Domain/Model/Grade/GradeId.php b/backend/src/Scolarite/Domain/Model/Grade/GradeId.php new file mode 100644 index 0000000..ad6d6a8 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Grade/GradeId.php @@ -0,0 +1,11 @@ +value - $other->value) < 0.001; + } +} diff --git a/backend/src/Scolarite/Domain/Policy/VisibiliteNotesPolicy.php b/backend/src/Scolarite/Domain/Policy/VisibiliteNotesPolicy.php new file mode 100644 index 0000000..b47b945 --- /dev/null +++ b/backend/src/Scolarite/Domain/Policy/VisibiliteNotesPolicy.php @@ -0,0 +1,34 @@ +notesPubliees(); + } + + public function visiblePourParent(Evaluation $evaluation): bool + { + if (!$evaluation->notesPubliees()) { + return false; + } + + $delai = $evaluation->gradesPublishedAt?->modify('+' . self::DELAI_PARENTS_HEURES . ' hours'); + + return $delai !== null && $delai <= $this->clock->now(); + } +} diff --git a/backend/src/Scolarite/Domain/Repository/GradeRepository.php b/backend/src/Scolarite/Domain/Repository/GradeRepository.php new file mode 100644 index 0000000..897311e --- /dev/null +++ b/backend/src/Scolarite/Domain/Repository/GradeRepository.php @@ -0,0 +1,26 @@ + */ + public function findByEvaluation(EvaluationId $evaluationId, TenantId $tenantId): array; + + public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool; +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/PublishGradesProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/PublishGradesProcessor.php new file mode 100644 index 0000000..f20b725 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/PublishGradesProcessor.php @@ -0,0 +1,82 @@ + + */ +final readonly class PublishGradesProcessor implements ProcessorInterface +{ + public function __construct( + private PublishGradesHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param GradeResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GradeResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + /** @var string $evaluationId */ + $evaluationId = $uriVariables['evaluationId'] ?? ''; + + try { + $command = new PublishGradesCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + evaluationId: $evaluationId, + teacherId: $user->userId(), + ); + + $evaluation = ($this->handler)($command); + + foreach ($evaluation->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + $resource = new GradeResource(); + $resource->id = (string) $evaluation->id; + $resource->evaluationId = (string) $evaluation->id; + $resource->published = true; + $resource->gradesPublishedAt = $evaluation->gradesPublishedAt; + + return $resource; + } catch (NonProprietaireDeLEvaluationException $e) { + throw new AccessDeniedHttpException($e->getMessage()); + } catch (NotesDejaPublieesException|AucuneNoteSaisieException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/SaveGradesProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/SaveGradesProcessor.php new file mode 100644 index 0000000..7250774 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/SaveGradesProcessor.php @@ -0,0 +1,87 @@ +> + */ +final readonly class SaveGradesProcessor implements ProcessorInterface +{ + public function __construct( + private SaveGradesHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param GradeResource $data + * + * @return array + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + /** @var string $evaluationId */ + $evaluationId = $uriVariables['evaluationId'] ?? ''; + + try { + $command = new SaveGradesCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + evaluationId: $evaluationId, + teacherId: $user->userId(), + grades: $data->grades ?? [], + ); + + $savedGrades = ($this->handler)($command); + + foreach ($savedGrades as $grade) { + foreach ($grade->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + } + + return array_map( + static fn ($grade) => GradeResource::fromDomain($grade), + $savedGrades, + ); + } catch (NonProprietaireDeLEvaluationException $e) { + throw new AccessDeniedHttpException($e->getMessage()); + } catch (ValeurNoteInvalideException|NoteRequiseException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/GradeCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/GradeCollectionProvider.php new file mode 100644 index 0000000..9aa6542 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/GradeCollectionProvider.php @@ -0,0 +1,119 @@ + + */ +final readonly class GradeCollectionProvider implements ProviderInterface +{ + public function __construct( + private Connection $connection, + private EvaluationRepository $evaluationRepository, + private TenantContext $tenantContext, + private Security $security, + ) { + } + + /** @return array */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + /** @var string $evaluationIdStr */ + $evaluationIdStr = $uriVariables['evaluationId'] ?? ''; + + if (!is_string($evaluationIdStr) || $evaluationIdStr === '') { + throw new NotFoundHttpException('Évaluation non trouvée.'); + } + + $tenantId = $this->tenantContext->getCurrentTenantId(); + $evaluationId = EvaluationId::fromString($evaluationIdStr); + + $evaluation = $this->evaluationRepository->findById($evaluationId, $tenantId); + + if ($evaluation === null) { + throw new NotFoundHttpException('Évaluation non trouvée.'); + } + + if ((string) $evaluation->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Accès refusé.'); + } + + $classId = (string) $evaluation->classId; + + // Return all students in the class, with LEFT JOIN to grades + $rows = $this->connection->fetchAllAssociative( + 'SELECT u.id AS student_id, u.first_name, u.last_name, + g.id AS grade_id, g.evaluation_id, g.value, g.status AS grade_status + FROM class_assignments ca + JOIN users u ON u.id = ca.user_id + LEFT JOIN grades g ON g.student_id = u.id AND g.evaluation_id = :evaluation_id AND g.tenant_id = :tenant_id + WHERE ca.school_class_id = :class_id + AND ca.tenant_id = :tenant_id + ORDER BY u.last_name ASC, u.first_name ASC', + [ + 'evaluation_id' => $evaluationIdStr, + 'tenant_id' => (string) $tenantId, + 'class_id' => $classId, + ], + ); + + return array_map(static function (array $row) use ($evaluationIdStr): GradeResource { + $resource = new GradeResource(); + /** @var string $studentId */ + $studentId = $row['student_id']; + /** @var string|null $gradeId */ + $gradeId = $row['grade_id'] ?? null; + $resource->id = $gradeId ?? $studentId; + $resource->evaluationId = $evaluationIdStr; + $resource->studentId = $studentId; + /** @var string|null $firstName */ + $firstName = $row['first_name'] ?? null; + /** @var string|null $lastName */ + $lastName = $row['last_name'] ?? null; + $resource->studentName = $firstName !== null && $lastName !== null + ? $lastName . ' ' . $firstName + : null; + /** @var string|float|null $valueRaw */ + $valueRaw = $row['value'] ?? null; + $resource->value = $valueRaw !== null ? (float) $valueRaw : null; + /** @var string|null $gradeStatus */ + $gradeStatus = $row['grade_status'] ?? null; + $resource->status = $gradeStatus; + + return $resource; + }, $rows); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationResource.php index b73b400..2fa2a89 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationResource.php +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/EvaluationResource.php @@ -94,6 +94,8 @@ final class EvaluationResource public ?DateTimeImmutable $updatedAt = null; + public ?DateTimeImmutable $gradesPublishedAt = null; + public static function fromDomain( Evaluation $evaluation, ?string $className = null, @@ -114,6 +116,7 @@ final class EvaluationResource $resource->subjectName = $subjectName; $resource->createdAt = $evaluation->createdAt; $resource->updatedAt = $evaluation->updatedAt; + $resource->gradesPublishedAt = $evaluation->gradesPublishedAt; return $resource; } diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/GradeResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/GradeResource.php new file mode 100644 index 0000000..6510267 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/GradeResource.php @@ -0,0 +1,84 @@ +|null */ + public ?array $grades = null; + + public ?bool $published = null; + + public ?DateTimeImmutable $gradesPublishedAt = null; + + public static function fromDomain(Grade $grade, ?string $studentName = null): self + { + $resource = new self(); + $resource->id = (string) $grade->id; + $resource->evaluationId = (string) $grade->evaluationId; + $resource->studentId = (string) $grade->studentId; + $resource->studentName = $studentName; + $resource->value = $grade->value?->value; + $resource->status = $grade->status->value; + $resource->createdAt = $grade->createdAt; + $resource->updatedAt = $grade->updatedAt; + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/EventHandler/GradeEventSubscriber.php b/backend/src/Scolarite/Infrastructure/EventHandler/GradeEventSubscriber.php new file mode 100644 index 0000000..d3843fe --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/EventHandler/GradeEventSubscriber.php @@ -0,0 +1,63 @@ +handleNoteSaisie($event); + } else { + $this->handleNoteModifiee($event); + } + } + + private function handleNoteSaisie(NoteSaisie $event): void + { + $this->connection->executeStatement( + 'INSERT INTO grade_events (id, grade_id, event_type, old_value, new_value, old_status, new_status, created_by, created_at) + VALUES (gen_random_uuid(), :grade_id, :event_type, NULL, :new_value, NULL, :new_status, :created_by, :created_at)', + [ + 'grade_id' => (string) $event->gradeId, + 'event_type' => 'note_saisie', + 'new_value' => $event->value, + 'new_status' => $event->status, + 'created_by' => $event->createdBy, + 'created_at' => $event->occurredOn()->format(DateTimeImmutable::ATOM), + ], + ); + } + + private function handleNoteModifiee(NoteModifiee $event): void + { + $this->connection->executeStatement( + 'INSERT INTO grade_events (id, grade_id, event_type, old_value, new_value, old_status, new_status, created_by, created_at) + VALUES (gen_random_uuid(), :grade_id, :event_type, :old_value, :new_value, :old_status, :new_status, :created_by, :created_at)', + [ + 'grade_id' => (string) $event->gradeId, + 'event_type' => 'note_modifiee', + 'old_value' => $event->oldValue, + 'new_value' => $event->newValue, + 'old_status' => $event->oldStatus, + 'new_status' => $event->newStatus, + 'created_by' => $event->modifiedBy, + 'created_at' => $event->occurredOn()->format(DateTimeImmutable::ATOM), + ], + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationRepository.php index 948e570..23054fb 100644 --- a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationRepository.php +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationRepository.php @@ -33,8 +33,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor public function save(Evaluation $evaluation): void { $this->connection->executeStatement( - 'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, description, evaluation_date, grade_scale, coefficient, status, created_at, updated_at) - VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :evaluation_date, :grade_scale, :coefficient, :status, :created_at, :updated_at) + 'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, description, evaluation_date, grade_scale, coefficient, status, created_at, updated_at, grades_published_at) + VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :evaluation_date, :grade_scale, :coefficient, :status, :created_at, :updated_at, :grades_published_at) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, @@ -42,7 +42,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor grade_scale = EXCLUDED.grade_scale, coefficient = EXCLUDED.coefficient, status = EXCLUDED.status, - updated_at = EXCLUDED.updated_at', + updated_at = EXCLUDED.updated_at, + grades_published_at = EXCLUDED.grades_published_at', [ 'id' => (string) $evaluation->id, 'tenant_id' => (string) $evaluation->tenantId, @@ -57,6 +58,7 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor 'status' => $evaluation->status->value, 'created_at' => $evaluation->createdAt->format(DateTimeImmutable::ATOM), 'updated_at' => $evaluation->updatedAt->format(DateTimeImmutable::ATOM), + 'grades_published_at' => $evaluation->gradesPublishedAt?->format(DateTimeImmutable::ATOM), ], ); } @@ -187,6 +189,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor $createdAt = $row['created_at']; /** @var string $updatedAt */ $updatedAt = $row['updated_at']; + /** @var string|null $gradesPublishedAt */ + $gradesPublishedAt = $row['grades_published_at'] ?? null; return Evaluation::reconstitute( id: EvaluationId::fromString($id), @@ -202,6 +206,7 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor status: EvaluationStatus::from($status), createdAt: new DateTimeImmutable($createdAt), updatedAt: new DateTimeImmutable($updatedAt), + gradesPublishedAt: $gradesPublishedAt !== null ? new DateTimeImmutable($gradesPublishedAt) : null, ); } } diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineGradeRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineGradeRepository.php new file mode 100644 index 0000000..2b10bd2 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineGradeRepository.php @@ -0,0 +1,149 @@ +connection->executeStatement( + 'INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) + VALUES (:id, :tenant_id, :evaluation_id, :student_id, :value, :status, :created_by, :created_at, :updated_at) + ON CONFLICT (evaluation_id, student_id) DO UPDATE SET + value = EXCLUDED.value, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $grade->id, + 'tenant_id' => (string) $grade->tenantId, + 'evaluation_id' => (string) $grade->evaluationId, + 'student_id' => (string) $grade->studentId, + 'value' => $grade->value?->value, + 'status' => $grade->status->value, + 'created_by' => (string) $grade->createdBy, + 'created_at' => $grade->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $grade->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function get(GradeId $id, TenantId $tenantId): Grade + { + $grade = $this->findById($id, $tenantId); + + if ($grade === null) { + throw GradeNotFoundException::withId($id); + } + + return $grade; + } + + #[Override] + public function findById(GradeId $id, TenantId $tenantId): ?Grade + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM grades WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findByEvaluation(EvaluationId $evaluationId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM grades + WHERE evaluation_id = :evaluation_id + AND tenant_id = :tenant_id + ORDER BY created_at ASC', + [ + 'evaluation_id' => (string) $evaluationId, + 'tenant_id' => (string) $tenantId, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool + { + $count = $this->connection->fetchOne( + 'SELECT COUNT(*) FROM grades WHERE evaluation_id = :evaluation_id AND tenant_id = :tenant_id', + [ + 'evaluation_id' => (string) $evaluationId, + 'tenant_id' => (string) $tenantId, + ], + ); + + /** @var int|string|false $countValue */ + $countValue = $count; + + return (int) $countValue > 0; + } + + /** @param array $row */ + private function hydrate(array $row): Grade + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $evaluationId */ + $evaluationId = $row['evaluation_id']; + /** @var string $studentId */ + $studentId = $row['student_id']; + /** @var string|float|null $valueRaw */ + $valueRaw = $row['value']; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdBy */ + $createdBy = $row['created_by']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return Grade::reconstitute( + id: GradeId::fromString($id), + tenantId: TenantId::fromString($tenantId), + evaluationId: EvaluationId::fromString($evaluationId), + studentId: UserId::fromString($studentId), + value: $valueRaw !== null ? new GradeValue((float) $valueRaw) : null, + status: GradeStatus::from($status), + createdBy: UserId::fromString($createdBy), + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepository.php new file mode 100644 index 0000000..44a9148 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryGradeRepository.php @@ -0,0 +1,75 @@ + */ + private array $byId = []; + + #[Override] + public function save(Grade $grade): void + { + $this->byId[(string) $grade->id] = $grade; + } + + #[Override] + public function get(GradeId $id, TenantId $tenantId): Grade + { + $grade = $this->findById($id, $tenantId); + + if ($grade === null) { + throw GradeNotFoundException::withId($id); + } + + return $grade; + } + + #[Override] + public function findById(GradeId $id, TenantId $tenantId): ?Grade + { + $grade = $this->byId[(string) $id] ?? null; + + if ($grade === null || !$grade->tenantId->equals($tenantId)) { + return null; + } + + return $grade; + } + + #[Override] + public function findByEvaluation(EvaluationId $evaluationId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (Grade $g): bool => $g->evaluationId->equals($evaluationId) + && $g->tenantId->equals($tenantId), + )); + } + + #[Override] + public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool + { + foreach ($this->byId as $grade) { + if ($grade->evaluationId->equals($evaluationId) && $grade->tenantId->equals($tenantId)) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Service/NoGradesEvaluationGradesChecker.php b/backend/src/Scolarite/Infrastructure/Service/NoGradesEvaluationGradesChecker.php index d15225a..c2eac51 100644 --- a/backend/src/Scolarite/Infrastructure/Service/NoGradesEvaluationGradesChecker.php +++ b/backend/src/Scolarite/Infrastructure/Service/NoGradesEvaluationGradesChecker.php @@ -6,14 +6,20 @@ namespace App\Scolarite\Infrastructure\Service; use App\Scolarite\Application\Port\EvaluationGradesChecker; use App\Scolarite\Domain\Model\Evaluation\EvaluationId; +use App\Scolarite\Domain\Repository\GradeRepository; use App\Shared\Domain\Tenant\TenantId; use Override; final readonly class NoGradesEvaluationGradesChecker implements EvaluationGradesChecker { + public function __construct( + private GradeRepository $gradeRepository, + ) { + } + #[Override] public function hasGrades(EvaluationId $evaluationId, TenantId $tenantId): bool { - return false; + return $this->gradeRepository->hasGradesForEvaluation($evaluationId, $tenantId); } } diff --git a/backend/tests/Unit/Scolarite/Application/Command/PublishGrades/PublishGradesHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/PublishGrades/PublishGradesHandlerTest.php new file mode 100644 index 0000000..98f7ecb --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/PublishGrades/PublishGradesHandlerTest.php @@ -0,0 +1,188 @@ +evaluationRepository = new InMemoryEvaluationRepository(); + $this->gradeRepository = new InMemoryGradeRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-27 14:00:00'); + } + }; + + $this->seedEvaluation(); + } + + #[Test] + public function itPublishesGradesWhenGradesExist(): void + { + $this->seedGrade(); + $handler = $this->createHandler(); + $command = new PublishGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + ); + + $evaluation = $handler($command); + + self::assertTrue($evaluation->notesPubliees()); + self::assertEquals( + new DateTimeImmutable('2026-03-27 14:00:00'), + $evaluation->gradesPublishedAt, + ); + } + + #[Test] + public function itPersistsPublishedEvaluation(): void + { + $this->seedGrade(); + $handler = $this->createHandler(); + $handler(new PublishGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + )); + + $evaluation = $this->evaluationRepository->get( + EvaluationId::fromString(self::EVALUATION_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertTrue($evaluation->notesPubliees()); + } + + #[Test] + public function itThrowsWhenNoGradesExist(): void + { + $handler = $this->createHandler(); + + $this->expectException(AucuneNoteSaisieException::class); + + $handler(new PublishGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + )); + } + + #[Test] + public function itThrowsWhenTeacherNotOwner(): void + { + $this->seedGrade(); + $handler = $this->createHandler(); + $otherTeacher = '550e8400-e29b-41d4-a716-446655440099'; + + $this->expectException(NonProprietaireDeLEvaluationException::class); + + $handler(new PublishGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: $otherTeacher, + )); + } + + #[Test] + public function itThrowsWhenAlreadyPublished(): void + { + $this->seedGrade(); + $handler = $this->createHandler(); + $command = new PublishGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + ); + + $handler($command); + + $this->expectException(NotesDejaPublieesException::class); + + $handler($command); + } + + private function createHandler(): PublishGradesHandler + { + return new PublishGradesHandler( + $this->evaluationRepository, + $this->gradeRepository, + $this->clock, + ); + } + + private function seedEvaluation(): void + { + $evaluation = Evaluation::reconstitute( + id: EvaluationId::fromString(self::EVALUATION_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Contrôle chapitre 5', + description: null, + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + status: EvaluationStatus::PUBLISHED, + createdAt: new DateTimeImmutable('2026-03-12 10:00:00'), + updatedAt: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + $this->evaluationRepository->save($evaluation); + } + + private function seedGrade(): void + { + $grade = Grade::saisir( + tenantId: TenantId::fromString(self::TENANT_ID), + evaluationId: EvaluationId::fromString(self::EVALUATION_ID), + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue(15.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 10:00:00'), + ); + + $this->gradeRepository->save($grade); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/SaveGrades/SaveGradesHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/SaveGrades/SaveGradesHandlerTest.php new file mode 100644 index 0000000..bdff165 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/SaveGrades/SaveGradesHandlerTest.php @@ -0,0 +1,253 @@ +evaluationRepository = new InMemoryEvaluationRepository(); + $this->gradeRepository = new InMemoryGradeRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-27 10:00:00'); + } + }; + + $this->seedEvaluation(); + } + + #[Test] + public function itSavesNewGrades(): void + { + $handler = $this->createHandler(); + $command = new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], + ['studentId' => self::STUDENT_2_ID, 'value' => 12.0, 'status' => 'graded'], + ], + ); + + $savedGrades = $handler($command); + + self::assertCount(2, $savedGrades); + self::assertSame(15.5, $savedGrades[0]->value->value); + self::assertSame(12.0, $savedGrades[1]->value->value); + } + + #[Test] + public function itPersistsGradesInRepository(): void + { + $handler = $this->createHandler(); + $command = new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], + ], + ); + + $handler($command); + + $tenantId = TenantId::fromString(self::TENANT_ID); + $grades = $this->gradeRepository->findByEvaluation( + EvaluationId::fromString(self::EVALUATION_ID), + $tenantId, + ); + + self::assertCount(1, $grades); + self::assertSame(15.5, $grades[0]->value->value); + } + + #[Test] + public function itUpdatesExistingGrades(): void + { + $handler = $this->createHandler(); + + $handler(new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], + ], + )); + + $handler(new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => 18.0, 'status' => 'graded'], + ], + )); + + $tenantId = TenantId::fromString(self::TENANT_ID); + $grades = $this->gradeRepository->findByEvaluation( + EvaluationId::fromString(self::EVALUATION_ID), + $tenantId, + ); + + self::assertCount(1, $grades); + self::assertSame(18.0, $grades[0]->value->value); + } + + #[Test] + public function itSavesAbsentGrade(): void + { + $handler = $this->createHandler(); + $command = new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'absent'], + ], + ); + + $savedGrades = $handler($command); + + self::assertSame(GradeStatus::ABSENT, $savedGrades[0]->status); + self::assertNull($savedGrades[0]->value); + } + + #[Test] + public function itSavesDispensedGrade(): void + { + $handler = $this->createHandler(); + $command = new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'dispensed'], + ], + ); + + $savedGrades = $handler($command); + + self::assertSame(GradeStatus::DISPENSED, $savedGrades[0]->status); + self::assertNull($savedGrades[0]->value); + } + + #[Test] + public function itThrowsWhenTeacherNotOwner(): void + { + $handler = $this->createHandler(); + $otherTeacher = '550e8400-e29b-41d4-a716-446655440099'; + + $this->expectException(NonProprietaireDeLEvaluationException::class); + + $handler(new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: $otherTeacher, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'], + ], + )); + } + + #[Test] + public function itThrowsWhenValueExceedsGradeScale(): void + { + $handler = $this->createHandler(); + + $this->expectException(ValeurNoteInvalideException::class); + + $handler(new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => 25.0, 'status' => 'graded'], + ], + )); + } + + #[Test] + public function itThrowsWhenGradedWithoutValue(): void + { + $handler = $this->createHandler(); + + $this->expectException(NoteRequiseException::class); + + $handler(new SaveGradesCommand( + tenantId: self::TENANT_ID, + evaluationId: self::EVALUATION_ID, + teacherId: self::TEACHER_ID, + grades: [ + ['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'graded'], + ], + )); + } + + private function createHandler(): SaveGradesHandler + { + return new SaveGradesHandler( + $this->evaluationRepository, + $this->gradeRepository, + $this->clock, + ); + } + + private function seedEvaluation(): void + { + $evaluation = Evaluation::reconstitute( + id: EvaluationId::fromString(self::EVALUATION_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Contrôle chapitre 5', + description: null, + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + status: EvaluationStatus::PUBLISHED, + createdAt: new DateTimeImmutable('2026-03-12 10:00:00'), + updatedAt: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + $this->evaluationRepository->save($evaluation); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php new file mode 100644 index 0000000..efe4a33 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php @@ -0,0 +1,285 @@ +createGrade(); + + self::assertSame(GradeStatus::GRADED, $grade->status); + self::assertNotNull($grade->value); + self::assertSame(15.5, $grade->value->value); + } + + #[Test] + public function saisirRecordsNoteSaisieEvent(): void + { + $grade = $this->createGrade(); + + $events = $grade->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(NoteSaisie::class, $events[0]); + self::assertSame($grade->id, $events[0]->gradeId); + self::assertSame(15.5, $events[0]->value); + self::assertSame('graded', $events[0]->status); + } + + #[Test] + public function saisirSetsAllProperties(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $evaluationId = EvaluationId::fromString(self::EVALUATION_ID); + $studentId = UserId::fromString(self::STUDENT_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $now = new DateTimeImmutable('2026-03-27 10:00:00'); + $value = new GradeValue(15.5); + $gradeScale = new GradeScale(20); + + $grade = Grade::saisir( + tenantId: $tenantId, + evaluationId: $evaluationId, + studentId: $studentId, + value: $value, + status: GradeStatus::GRADED, + gradeScale: $gradeScale, + createdBy: $teacherId, + now: $now, + ); + + self::assertTrue($grade->tenantId->equals($tenantId)); + self::assertTrue($grade->evaluationId->equals($evaluationId)); + self::assertTrue($grade->studentId->equals($studentId)); + self::assertTrue($grade->createdBy->equals($teacherId)); + self::assertSame(15.5, $grade->value->value); + self::assertSame(GradeStatus::GRADED, $grade->status); + self::assertEquals($now, $grade->createdAt); + self::assertEquals($now, $grade->updatedAt); + } + + #[Test] + public function saisirCreatesAbsentGrade(): void + { + $grade = Grade::saisir( + tenantId: TenantId::fromString(self::TENANT_ID), + evaluationId: EvaluationId::fromString(self::EVALUATION_ID), + studentId: UserId::fromString(self::STUDENT_ID), + value: null, + status: GradeStatus::ABSENT, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 10:00:00'), + ); + + self::assertSame(GradeStatus::ABSENT, $grade->status); + self::assertNull($grade->value); + } + + #[Test] + public function saisirCreatesDispensedGrade(): void + { + $grade = Grade::saisir( + tenantId: TenantId::fromString(self::TENANT_ID), + evaluationId: EvaluationId::fromString(self::EVALUATION_ID), + studentId: UserId::fromString(self::STUDENT_ID), + value: null, + status: GradeStatus::DISPENSED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 10:00:00'), + ); + + self::assertSame(GradeStatus::DISPENSED, $grade->status); + self::assertNull($grade->value); + } + + #[Test] + public function saisirThrowsWhenGradedWithoutValue(): void + { + $this->expectException(NoteRequiseException::class); + + Grade::saisir( + tenantId: TenantId::fromString(self::TENANT_ID), + evaluationId: EvaluationId::fromString(self::EVALUATION_ID), + studentId: UserId::fromString(self::STUDENT_ID), + value: null, + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 10:00:00'), + ); + } + + #[Test] + public function saisirThrowsWhenValueExceedsGradeScale(): void + { + $this->expectException(ValeurNoteInvalideException::class); + + Grade::saisir( + tenantId: TenantId::fromString(self::TENANT_ID), + evaluationId: EvaluationId::fromString(self::EVALUATION_ID), + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue(25.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 10:00:00'), + ); + } + + #[Test] + public function modifierUpdatesValueAndRecordsEvent(): void + { + $grade = $this->createGrade(); + $grade->pullDomainEvents(); + $modifiedAt = new DateTimeImmutable('2026-03-27 14:00:00'); + + $modifierId = UserId::fromString(self::TEACHER_ID); + + $grade->modifier( + value: new GradeValue(18.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + modifiedBy: $modifierId, + now: $modifiedAt, + ); + + self::assertSame(18.0, $grade->value->value); + self::assertEquals($modifiedAt, $grade->updatedAt); + + $events = $grade->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(NoteModifiee::class, $events[0]); + self::assertSame(15.5, $events[0]->oldValue); + self::assertSame(18.0, $events[0]->newValue); + self::assertSame('graded', $events[0]->oldStatus); + self::assertSame('graded', $events[0]->newStatus); + self::assertSame(self::TEACHER_ID, $events[0]->modifiedBy); + } + + #[Test] + public function modifierChangesToAbsent(): void + { + $grade = $this->createGrade(); + $grade->pullDomainEvents(); + + $grade->modifier( + value: null, + status: GradeStatus::ABSENT, + gradeScale: new GradeScale(20), + modifiedBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 14:00:00'), + ); + + self::assertSame(GradeStatus::ABSENT, $grade->status); + self::assertNull($grade->value); + } + + #[Test] + public function modifierThrowsWhenGradedWithoutValue(): void + { + $grade = $this->createGrade(); + + $this->expectException(NoteRequiseException::class); + + $grade->modifier( + value: null, + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + modifiedBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 14:00:00'), + ); + } + + #[Test] + public function modifierThrowsWhenValueExceedsGradeScale(): void + { + $grade = $this->createGrade(); + + $this->expectException(ValeurNoteInvalideException::class); + + $grade->modifier( + value: new GradeValue(25.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + modifiedBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 14:00:00'), + ); + } + + #[Test] + public function reconstituteRestoresAllPropertiesWithoutEvents(): void + { + $gradeId = GradeId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $evaluationId = EvaluationId::fromString(self::EVALUATION_ID); + $studentId = UserId::fromString(self::STUDENT_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $createdAt = new DateTimeImmutable('2026-03-27 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-03-27 14:00:00'); + $value = new GradeValue(15.5); + + $grade = Grade::reconstitute( + id: $gradeId, + tenantId: $tenantId, + evaluationId: $evaluationId, + studentId: $studentId, + value: $value, + status: GradeStatus::GRADED, + createdBy: $teacherId, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertTrue($grade->id->equals($gradeId)); + self::assertTrue($grade->tenantId->equals($tenantId)); + self::assertTrue($grade->evaluationId->equals($evaluationId)); + self::assertTrue($grade->studentId->equals($studentId)); + self::assertTrue($grade->createdBy->equals($teacherId)); + self::assertSame(15.5, $grade->value->value); + self::assertSame(GradeStatus::GRADED, $grade->status); + self::assertEquals($createdAt, $grade->createdAt); + self::assertEquals($updatedAt, $grade->updatedAt); + self::assertEmpty($grade->pullDomainEvents()); + } + + private function createGrade(): Grade + { + return Grade::saisir( + tenantId: TenantId::fromString(self::TENANT_ID), + evaluationId: EvaluationId::fromString(self::EVALUATION_ID), + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue(15.5), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: new DateTimeImmutable('2026-03-27 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeValueTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeValueTest.php new file mode 100644 index 0000000..1e69825 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeValueTest.php @@ -0,0 +1,52 @@ +value); + } + + #[Test] + public function acceptsPositiveValue(): void + { + self::assertSame(15.5, (new GradeValue(15.5))->value); + } + + #[Test] + public function rejectsNegativeValue(): void + { + $this->expectException(ValeurNoteInvalideException::class); + + new GradeValue(-1); + } + + #[Test] + public function rejectsNegativeDecimal(): void + { + $this->expectException(ValeurNoteInvalideException::class); + + new GradeValue(-0.5); + } + + #[Test] + public function equalsComparesValues(): void + { + $a = new GradeValue(15.0); + $b = new GradeValue(15.0); + $c = new GradeValue(12.0); + + self::assertTrue($a->equals($b)); + self::assertFalse($a->equals($c)); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Policy/VisibiliteNotesPolicyTest.php b/backend/tests/Unit/Scolarite/Domain/Policy/VisibiliteNotesPolicyTest.php new file mode 100644 index 0000000..67081bf --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Policy/VisibiliteNotesPolicyTest.php @@ -0,0 +1,127 @@ +createPolicy(new DateTimeImmutable('2026-03-28 10:00:00')); + $evaluation = $this->createPublishedEvaluation(new DateTimeImmutable('2026-03-27 14:00:00')); + + self::assertTrue($policy->visiblePourEleve($evaluation)); + } + + #[Test] + public function eleveNeVoitPasNotesBrouillon(): void + { + $policy = $this->createPolicy(new DateTimeImmutable('2026-03-27 15:00:00')); + $evaluation = $this->createUnpublishedEvaluation(); + + self::assertFalse($policy->visiblePourEleve($evaluation)); + } + + #[Test] + public function parentNeVoitPasAvant24h(): void + { + $publishedAt = new DateTimeImmutable('2026-03-27 14:00:00'); + $now = new DateTimeImmutable('2026-03-28 13:59:59'); // 23h59 après + $policy = $this->createPolicy($now); + $evaluation = $this->createPublishedEvaluation($publishedAt); + + self::assertFalse($policy->visiblePourParent($evaluation)); + } + + #[Test] + public function parentVoitApres24h(): void + { + $publishedAt = new DateTimeImmutable('2026-03-27 14:00:00'); + $now = new DateTimeImmutable('2026-03-28 14:00:00'); // exactement 24h après + $policy = $this->createPolicy($now); + $evaluation = $this->createPublishedEvaluation($publishedAt); + + self::assertTrue($policy->visiblePourParent($evaluation)); + } + + #[Test] + public function parentNeVoitPasNotesBrouillon(): void + { + $policy = $this->createPolicy(new DateTimeImmutable('2026-04-01 10:00:00')); + $evaluation = $this->createUnpublishedEvaluation(); + + self::assertFalse($policy->visiblePourParent($evaluation)); + } + + private function createPolicy(DateTimeImmutable $now): VisibiliteNotesPolicy + { + $clock = new class($now) implements Clock { + public function __construct(private readonly DateTimeImmutable $now) + { + } + + public function now(): DateTimeImmutable + { + return $this->now; + } + }; + + return new VisibiliteNotesPolicy($clock); + } + + private function createPublishedEvaluation(DateTimeImmutable $publishedAt): Evaluation + { + return Evaluation::reconstitute( + id: EvaluationId::generate(), + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'), + title: 'Test', + description: null, + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + status: EvaluationStatus::PUBLISHED, + createdAt: new DateTimeImmutable('2026-03-12 10:00:00'), + updatedAt: $publishedAt, + gradesPublishedAt: $publishedAt, + ); + } + + private function createUnpublishedEvaluation(): Evaluation + { + return Evaluation::reconstitute( + id: EvaluationId::generate(), + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'), + title: 'Test', + description: null, + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + status: EvaluationStatus::PUBLISHED, + createdAt: new DateTimeImmutable('2026-03-12 10:00:00'), + updatedAt: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + } +} diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index 4088910..d4b477c 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -46,7 +46,9 @@ function cleanupClasses() { `DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`, `DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`, `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`, - // Delete evaluations + // Delete evaluations (grades cleaned by global-setup) + `DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`, + `DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`, `DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`, // Delete schedule slots (CASCADE on FK, but be explicit) `DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`, diff --git a/frontend/e2e/evaluations.spec.ts b/frontend/e2e/evaluations.spec.ts index 101a0a9..4bf53e2 100644 --- a/frontend/e2e/evaluations.spec.ts +++ b/frontend/e2e/evaluations.spec.ts @@ -126,6 +126,8 @@ test.describe('Evaluation Management (Story 6.1)', () => { // Clean up ALL evaluations for this teacher (not just by tenant, to avoid // stale data from parallel test files with different teachers) try { + runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.tenant_id = '${TENANT_ID}' AND e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); + runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); runSql(`DELETE FROM evaluations 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 @@ -435,6 +437,8 @@ test.describe('Evaluation Management (Story 6.1)', () => { // Cleanup: remove the second class data try { + runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND e.class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`); + runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`); runSql(`DELETE FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`); runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`); runSql(`DELETE FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'`); diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts index 159c574..568d2d0 100644 --- a/frontend/e2e/global-setup.ts +++ b/frontend/e2e/global-setup.ts @@ -5,21 +5,41 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + /** * Global setup for E2E tests. * - * - Resets rate limiter to ensure tests start with clean state + * - Cleans transactional data that could cause FK constraint failures + * (each test file handles its own specific cleanup in beforeAll/beforeEach) + * - Resets rate limiter cache * - Token creation is handled per-browser in test files using beforeAll hooks */ async function globalSetup() { console.warn('🎭 E2E Global setup - tokens are created per browser project'); + 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' } + ); + } + + // Clean grade data (Story 6.2) to prevent FK constraint failures + // when other tests try to DELETE FROM evaluations + try { + runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`); + runSql(`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`); + console.warn('✅ Grade data cleaned'); + } catch { + // Tables may not exist yet + } + // Reset rate limiter to prevent failed login tests from blocking other tests try { - const projectRoot = join(__dirname, '../..'); - const composeFile = join(projectRoot, 'compose.yaml'); - - // Use Symfony cache:pool:clear for more reliable cache clearing execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`, { encoding: 'utf-8' } diff --git a/frontend/e2e/grades.spec.ts b/frontend/e2e/grades.spec.ts new file mode 100644 index 0000000..1a4fdf2 --- /dev/null +++ b/frontend/e2e/grades.spec.ts @@ -0,0 +1,383 @@ +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-grade-teacher@example.com'; +const TEACHER_PASSWORD = 'GradeTest123'; +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! }; +} + +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() + ]); +} + +// Deterministic IDs for test data +let evaluationId: string; +let classId: string; +let student1Id: string; +let student2Id: string; + +test.describe('Grade Input Grid (Story 6.2)', () => { + 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(); + + // Create test class + const classOutput = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-class-${TENANT_ID}")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + classId = classOutput; + + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-GRADE-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + + // Create test subject + const subjectOutput = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-subject-${TENANT_ID}")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const subjectId = subjectOutput; + + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-GRADE-Sciences', 'E2GRDSCI', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + + // Create teacher assignment + 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, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + + // Create 2 test students + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student1@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Alice --lastName=Durand 2>&1`, + { encoding: 'utf-8' } + ); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student2@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Bob --lastName=Martin 2>&1`, + { encoding: 'utf-8' } + ); + + // Assign students to class + const studentIds = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`, + { encoding: 'utf-8' } + ); + const idMatches = studentIds.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g); + if (idMatches && idMatches.length >= 2) { + student1Id = idMatches[0]!; + student2Id = idMatches[1]!; + } + + // Assign students to class + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${student1Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING` + ); + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${student2Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING` + ); + + // Create test evaluation + const evalOutput = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-eval-${TENANT_ID}")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + evaluationId = evalOutput; + + clearCache(); + }); + + test.beforeEach(async () => { + // Clean grades and recreate evaluation + runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE evaluation_id = '${evaluationId}')`); + runSql(`DELETE FROM grades WHERE evaluation_id = '${evaluationId}'`); + runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`); + + runSql( + `INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` + + `SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', ` + + `(SELECT id FROM subjects WHERE code='E2GRDSCI' AND tenant_id='${TENANT_ID}' LIMIT 1), ` + + `u.id, 'E2E Contrôle Sciences', '2026-04-15', 20, 1.0, 'published', NULL, NOW(), NOW() ` + + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + + `ON CONFLICT (id) DO UPDATE SET grades_published_at = NULL, updated_at = NOW()` + ); + + clearCache(); + }); + + test.describe('Grade Grid Display', () => { + test('shows grade input grid with students', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + + // Should display evaluation title + await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 }); + + // Should show grade inputs + const gradeInputs = page.locator('.grade-input'); + await expect(gradeInputs.first()).toBeVisible({ timeout: 10000 }); + }); + + test('"Saisir les notes" button navigates to grades page', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`); + await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 }); + + // Wait for evaluation cards to load + await expect(page.getByText('E2E Contrôle Sciences')).toBeVisible({ timeout: 10000 }); + await page.getByRole('link', { name: /saisir les notes/i }).first().click(); + + await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 }); + }); + }); + + test.describe('Grade Input', () => { + test('can enter a numeric grade', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + const firstInput = page.locator('.grade-input').first(); + await firstInput.fill('15.5'); + + // Should show the grade in status column + await expect(page.locator('.status-graded').first()).toBeVisible({ timeout: 5000 }); + }); + + test('validates grade against scale maximum', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + const firstInput = page.locator('.grade-input').first(); + await firstInput.fill('25'); + + // Should show error + await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 }); + }); + }); + + test.describe('Slash Commands', () => { + test('/abs marks student as absent', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + const firstInput = page.locator('.grade-input').first(); + await firstInput.fill('/abs'); + + await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 5000 }); + }); + + test('/disp marks student as dispensed', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + const firstInput = page.locator('.grade-input').first(); + await firstInput.fill('/disp'); + + await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 5000 }); + }); + + }); + + test.describe('Keyboard Navigation', () => { + test('Tab moves to next student', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + const firstInput = page.locator('.grade-input').first(); + await firstInput.focus(); + await firstInput.press('Tab'); + + // Second input should be focused + const secondInput = page.locator('.grade-input').nth(1); + await expect(secondInput).toBeFocused({ timeout: 3000 }); + }); + + test('Enter moves to next student', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + const firstInput = page.locator('.grade-input').first(); + await firstInput.fill('15'); + await firstInput.press('Enter'); + + // Second input should be focused + const secondInput = page.locator('.grade-input').nth(1); + await expect(secondInput).toBeFocused({ timeout: 3000 }); + }); + + test('Shift+Tab moves to previous student', async ({ page }) => { + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + const secondInput = page.locator('.grade-input').nth(1); + await secondInput.focus(); + await secondInput.press('Shift+Tab'); + + const firstInput = page.locator('.grade-input').first(); + await expect(firstInput).toBeFocused({ timeout: 3000 }); + }); + }); + + test.describe('Publication', () => { + test('publish requires confirmation via modal', async ({ page }) => { + clearCache(); + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + // Start listening for the PUT response BEFORE triggering the save + const savePromise = page.waitForResponse( + (resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT', + { timeout: 30000 } + ); + + // Enter grades + const firstInput = page.locator('.grade-input').first(); + await expect(firstInput).toBeVisible({ timeout: 5000 }); + await firstInput.fill('18'); + await expect(page.locator('.status-graded').first()).toContainText('18/20', { timeout: 10000 }); + + // Wait for the PUT to complete + await savePromise; + + // Click publish button — opens confirmation modal + await page.getByRole('button', { name: /publier les notes/i }).click(); + + // Confirmation dialog should appear + const dialog = page.getByRole('alertdialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible(); + + // Start listening for POST before clicking confirm + const publishPromise = page.waitForResponse( + (resp) => resp.url().includes('/publish') && resp.request().method() === 'POST', + { timeout: 30000 } + ); + + // Confirm publication + await dialog.getByRole('button', { name: /confirmer la publication/i }).click(); + + // Wait for the POST to complete + await publishPromise; + + // Should show published badge + await expect(page.getByText(/notes publiées/i)).toBeVisible({ timeout: 10000 }); + }); + + test('publish modal can be cancelled', async ({ page }) => { + clearCache(); + await loginAsTeacher(page); + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 }); + + // Start listening for PUT before triggering + const savePromise = page.waitForResponse( + (resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT', + { timeout: 30000 } + ); + + // Enter a grade + const firstInput = page.locator('.grade-input').first(); + await expect(firstInput).toBeVisible({ timeout: 5000 }); + await firstInput.fill('14'); + await expect(page.locator('.status-graded').first()).toContainText('14/20', { timeout: 10000 }); + + // Wait for save to complete + await savePromise; + + const publishBtn = page.getByRole('button', { name: /publier les notes/i }); + + // Open modal then cancel + await publishBtn.click(); + const dialog = page.getByRole('alertdialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible(); + await dialog.getByRole('button', { name: /annuler/i }).click(); + + // Modal should close, publish button still visible + await expect(dialog).not.toBeVisible({ timeout: 3000 }); + await expect(publishBtn).toBeVisible(); + }); + }); +}); diff --git a/frontend/e2e/subjects.spec.ts b/frontend/e2e/subjects.spec.ts index 836e8a4..59051bc 100644 --- a/frontend/e2e/subjects.spec.ts +++ b/frontend/e2e/subjects.spec.ts @@ -46,7 +46,9 @@ function cleanupSubjects() { `DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`, `DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`, `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`, - // Delete evaluations (subjects FK) + // Delete grades and evaluations (grades FK → evaluations, evaluations FK → subjects) + `DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`, + `DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`, `DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`, // Delete schedule slots (subjects FK with CASCADE) `DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`, diff --git a/frontend/src/lib/stores/gradeOfflineStore.ts b/frontend/src/lib/stores/gradeOfflineStore.ts new file mode 100644 index 0000000..51aa8a4 --- /dev/null +++ b/frontend/src/lib/stores/gradeOfflineStore.ts @@ -0,0 +1,83 @@ +const DB_NAME = 'classeo-grades'; +const DB_VERSION = 1; +const STORE_NAME = 'pending-grades'; + +interface PendingGrade { + evaluationId: string; + studentId: string; + value: number | null; + status: string; + savedAt: number; +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { + keyPath: ['evaluationId', 'studentId'], + }); + store.createIndex('byEvaluation', 'evaluationId', { unique: false }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +export async function savePendingGrade(grade: PendingGrade): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(grade); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function savePendingGrades(grades: PendingGrade[]): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + for (const grade of grades) { + store.put(grade); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function getPendingGrades(evaluationId: string): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const index = tx.objectStore(STORE_NAME).index('byEvaluation'); + const request = index.getAll(evaluationId); + request.onsuccess = () => resolve(request.result as PendingGrade[]); + request.onerror = () => reject(request.error); + }); +} + +export async function clearPendingGrades(evaluationId: string): Promise { + const db = await openDb(); + const pending = await getPendingGrades(evaluationId); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + for (const grade of pending) { + store.delete([grade.evaluationId, grade.studentId]); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function hasPendingGrades(evaluationId: string): Promise { + const pending = await getPendingGrades(evaluationId); + return pending.length > 0; +} diff --git a/frontend/src/routes/dashboard/teacher/evaluations/+page.svelte b/frontend/src/routes/dashboard/teacher/evaluations/+page.svelte index df5c55a..95adc98 100644 --- a/frontend/src/routes/dashboard/teacher/evaluations/+page.svelte +++ b/frontend/src/routes/dashboard/teacher/evaluations/+page.svelte @@ -483,6 +483,9 @@ {#if ev.status === 'published'}
+ + Saisir les notes + diff --git a/frontend/src/routes/dashboard/teacher/evaluations/[id]/grades/+page.svelte b/frontend/src/routes/dashboard/teacher/evaluations/[id]/grades/+page.svelte new file mode 100644 index 0000000..6faec22 --- /dev/null +++ b/frontend/src/routes/dashboard/teacher/evaluations/[id]/grades/+page.svelte @@ -0,0 +1,1049 @@ + + + + {evaluation?.title ?? 'Saisie des notes'} - Classeo + + +
+ + + {#if error} +
+ + {error} + +
+ {/if} + + {#if isLoading} +
+
+

Chargement de la grille...

+
+ {:else if students.length === 0} +
+

Aucun élève dans cette classe

+

Ajoutez des élèves à la classe pour saisir les notes.

+
+ {:else} +
+ {filledCount}/{students.length} remplis + {#if absentCount > 0}{absentCount} absent(s){/if} + {#if dispensedCount > 0}{dispensedCount} dispensé(s){/if} +
+ +
+
+
Élève
+
Note /{evaluation?.gradeScale ?? 20}
+
Statut
+
+ + {#each students as row, i (row.studentId)} +
+
+ {row.studentName} +
+
+
+ handleInput(i)} + onkeydown={(e) => handleKeydown(e, i)} + onfocus={() => { activeRowIndex = i; }} + onblur={() => { activeSlashHint = null; activeRowIndex = null; }} + placeholder="—" + autocomplete="off" + aria-label="Note de {row.studentName}" + /> + {#if activeRowIndex === i && activeSlashHint} + {@const hints = slashHintsForInput(activeSlashHint)} + {#if hints.length > 0} +
+ {#each hints as hint (hint.cmd)} + +
applySlashCommand(i, hint.cmd)} + > + {hint.cmd} + {hint.desc} +
+ {/each} +
+ {/if} + {/if} +
+ {#if row.error} + {row.error} + {/if} +
+
+ {#if row.status === 'absent'} + Absent + {:else if row.status === 'dispensed'} + Dispensé + {:else if row.value !== null} + {row.value}/{evaluation?.gradeScale ?? 20} + {:else} + + {/if} +
+
+ {/each} +
+ + + {/if} +
+ +{#if showPublishConfirm} + + +{/if} + +