diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 243fc4f..5a6c87b 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -242,6 +242,12 @@ services: App\Scolarite\Application\Port\EnseignantAffectationChecker: alias: App\Scolarite\Infrastructure\Service\CurrentYearEnseignantAffectationChecker + App\Scolarite\Domain\Repository\EvaluationRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationRepository + + App\Scolarite\Application\Port\EvaluationGradesChecker: + alias: App\Scolarite\Infrastructure\Service\NoGradesEvaluationGradesChecker + # Super Admin Repositories (Story 2.10 - Multi-établissements) App\SuperAdmin\Domain\Repository\SuperAdminRepository: alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository diff --git a/backend/migrations/Version20260323114411.php b/backend/migrations/Version20260323114411.php new file mode 100644 index 0000000..f2362d0 --- /dev/null +++ b/backend/migrations/Version20260323114411.php @@ -0,0 +1,45 @@ +addSql('CREATE TABLE evaluations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + class_id UUID NOT NULL REFERENCES school_classes(id), + subject_id UUID NOT NULL REFERENCES subjects(id), + teacher_id UUID NOT NULL REFERENCES users(id), + title VARCHAR(255) NOT NULL, + description TEXT, + evaluation_date DATE NOT NULL, + grade_scale SMALLINT NOT NULL DEFAULT 20, + coefficient DECIMAL(3,1) NOT NULL DEFAULT 1.0, + status VARCHAR(20) NOT NULL DEFAULT \'published\', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )'); + + $this->addSql('CREATE INDEX idx_evaluations_tenant ON evaluations(tenant_id)'); + $this->addSql('CREATE INDEX idx_evaluations_class ON evaluations(class_id)'); + $this->addSql('CREATE INDEX idx_evaluations_teacher ON evaluations(teacher_id)'); + $this->addSql('CREATE INDEX idx_evaluations_date ON evaluations(evaluation_date)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS evaluations'); + } +} diff --git a/backend/src/Scolarite/Application/Command/CreateEvaluation/CreateEvaluationCommand.php b/backend/src/Scolarite/Application/Command/CreateEvaluation/CreateEvaluationCommand.php new file mode 100644 index 0000000..be79383 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/CreateEvaluation/CreateEvaluationCommand.php @@ -0,0 +1,21 @@ +tenantId); + $classId = ClassId::fromString($command->classId); + $subjectId = SubjectId::fromString($command->subjectId); + $teacherId = UserId::fromString($command->teacherId); + $now = $this->clock->now(); + + if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) { + throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId); + } + + $evaluation = Evaluation::creer( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: $command->title, + description: $command->description, + evaluationDate: new DateTimeImmutable($command->evaluationDate), + gradeScale: new GradeScale($command->gradeScale), + coefficient: new Coefficient($command->coefficient), + now: $now, + ); + + $this->evaluationRepository->save($evaluation); + + return $evaluation; + } +} diff --git a/backend/src/Scolarite/Application/Command/DeleteEvaluation/DeleteEvaluationCommand.php b/backend/src/Scolarite/Application/Command/DeleteEvaluation/DeleteEvaluationCommand.php new file mode 100644 index 0000000..ebf3606 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/DeleteEvaluation/DeleteEvaluationCommand.php @@ -0,0 +1,15 @@ +tenantId); + $evaluationId = EvaluationId::fromString($command->evaluationId); + + $evaluation = $this->evaluationRepository->get($evaluationId, $tenantId); + + $teacherId = UserId::fromString($command->teacherId); + if ((string) $evaluation->teacherId !== (string) $teacherId) { + throw NonProprietaireDeLEvaluationException::withId($evaluationId); + } + + $evaluation->supprimer($this->clock->now()); + + $this->evaluationRepository->save($evaluation); + + return $evaluation; + } +} diff --git a/backend/src/Scolarite/Application/Command/UpdateEvaluation/UpdateEvaluationCommand.php b/backend/src/Scolarite/Application/Command/UpdateEvaluation/UpdateEvaluationCommand.php new file mode 100644 index 0000000..6ce7a4c --- /dev/null +++ b/backend/src/Scolarite/Application/Command/UpdateEvaluation/UpdateEvaluationCommand.php @@ -0,0 +1,20 @@ +tenantId); + $evaluationId = EvaluationId::fromString($command->evaluationId); + $now = $this->clock->now(); + + $evaluation = $this->evaluationRepository->get($evaluationId, $tenantId); + + $teacherId = UserId::fromString($command->teacherId); + if ((string) $evaluation->teacherId !== (string) $teacherId) { + throw NonProprietaireDeLEvaluationException::withId($evaluationId); + } + + $hasGrades = $this->gradesChecker->hasGrades($evaluationId, $tenantId); + + $evaluation->modifier( + title: $command->title, + description: $command->description, + coefficient: new Coefficient($command->coefficient), + evaluationDate: new DateTimeImmutable($command->evaluationDate), + gradeScale: $command->gradeScale !== null ? new GradeScale($command->gradeScale) : null, + hasGrades: $hasGrades, + now: $now, + ); + + $this->evaluationRepository->save($evaluation); + + return $evaluation; + } +} diff --git a/backend/src/Scolarite/Application/Port/EvaluationGradesChecker.php b/backend/src/Scolarite/Application/Port/EvaluationGradesChecker.php new file mode 100644 index 0000000..945ed72 --- /dev/null +++ b/backend/src/Scolarite/Application/Port/EvaluationGradesChecker.php @@ -0,0 +1,13 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->evaluationId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/EvaluationModifiee.php b/backend/src/Scolarite/Domain/Event/EvaluationModifiee.php new file mode 100644 index 0000000..0aaaa00 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/EvaluationModifiee.php @@ -0,0 +1,34 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->evaluationId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/EvaluationSupprimee.php b/backend/src/Scolarite/Domain/Event/EvaluationSupprimee.php new file mode 100644 index 0000000..c816bcc --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/EvaluationSupprimee.php @@ -0,0 +1,32 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->evaluationId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Exception/BaremeInvalideException.php b/backend/src/Scolarite/Domain/Exception/BaremeInvalideException.php new file mode 100644 index 0000000..a14606c --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/BaremeInvalideException.php @@ -0,0 +1,20 @@ + 10) { + throw CoefficientInvalideException::avecValeur($value); + } + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/backend/src/Scolarite/Domain/Model/Evaluation/Evaluation.php b/backend/src/Scolarite/Domain/Model/Evaluation/Evaluation.php new file mode 100644 index 0000000..6bc9a22 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Evaluation/Evaluation.php @@ -0,0 +1,168 @@ +updatedAt = $createdAt; + } + + public static function creer( + TenantId $tenantId, + ClassId $classId, + SubjectId $subjectId, + UserId $teacherId, + string $title, + ?string $description, + DateTimeImmutable $evaluationDate, + GradeScale $gradeScale, + Coefficient $coefficient, + DateTimeImmutable $now, + ): self { + $evaluation = new self( + id: EvaluationId::generate(), + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: $title, + description: $description, + evaluationDate: $evaluationDate, + gradeScale: $gradeScale, + coefficient: $coefficient, + status: EvaluationStatus::PUBLISHED, + createdAt: $now, + ); + + $evaluation->recordEvent(new EvaluationCreee( + evaluationId: $evaluation->id, + classId: (string) $classId, + subjectId: (string) $subjectId, + teacherId: (string) $teacherId, + title: $title, + evaluationDate: $evaluationDate, + occurredOn: $now, + )); + + return $evaluation; + } + + public function modifier( + string $title, + ?string $description, + Coefficient $coefficient, + DateTimeImmutable $evaluationDate, + ?GradeScale $gradeScale, + bool $hasGrades, + DateTimeImmutable $now, + ): void { + if ($this->status === EvaluationStatus::DELETED) { + throw EvaluationDejaSupprimeeException::withId($this->id); + } + + if ($gradeScale !== null && !$this->gradeScale->equals($gradeScale) && $hasGrades) { + throw BaremeNonModifiableException::carNotesExistantes($this->id); + } + + $this->title = $title; + $this->description = $description; + $this->coefficient = $coefficient; + $this->evaluationDate = $evaluationDate; + + if ($gradeScale !== null && !$hasGrades) { + $this->gradeScale = $gradeScale; + } + + $this->updatedAt = $now; + + $this->recordEvent(new EvaluationModifiee( + evaluationId: $this->id, + title: $title, + evaluationDate: $evaluationDate, + occurredOn: $now, + )); + } + + public function supprimer(DateTimeImmutable $now): void + { + if ($this->status === EvaluationStatus::DELETED) { + throw EvaluationDejaSupprimeeException::withId($this->id); + } + + $this->status = EvaluationStatus::DELETED; + $this->updatedAt = $now; + + $this->recordEvent(new EvaluationSupprimee( + evaluationId: $this->id, + occurredOn: $now, + )); + } + + /** + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + EvaluationId $id, + TenantId $tenantId, + ClassId $classId, + SubjectId $subjectId, + UserId $teacherId, + string $title, + ?string $description, + DateTimeImmutable $evaluationDate, + GradeScale $gradeScale, + Coefficient $coefficient, + EvaluationStatus $status, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ): self { + $evaluation = new self( + id: $id, + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: $title, + description: $description, + evaluationDate: $evaluationDate, + gradeScale: $gradeScale, + coefficient: $coefficient, + status: $status, + createdAt: $createdAt, + ); + + $evaluation->updatedAt = $updatedAt; + + return $evaluation; + } +} diff --git a/backend/src/Scolarite/Domain/Model/Evaluation/EvaluationId.php b/backend/src/Scolarite/Domain/Model/Evaluation/EvaluationId.php new file mode 100644 index 0000000..2e5f37d --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/Evaluation/EvaluationId.php @@ -0,0 +1,11 @@ + 100) { + throw BaremeInvalideException::avecValeur($maxValue); + } + } + + public function convertTo20(float $grade): float + { + return round(($grade / $this->maxValue) * 20, 2); + } + + public function equals(self $other): bool + { + return $this->maxValue === $other->maxValue; + } +} diff --git a/backend/src/Scolarite/Domain/Repository/EvaluationRepository.php b/backend/src/Scolarite/Domain/Repository/EvaluationRepository.php new file mode 100644 index 0000000..feb034b --- /dev/null +++ b/backend/src/Scolarite/Domain/Repository/EvaluationRepository.php @@ -0,0 +1,31 @@ + */ + public function findByTeacher(UserId $teacherId, TenantId $tenantId): array; + + /** @return array */ + public function findByTeacherAndClass(UserId $teacherId, ClassId $classId, TenantId $tenantId): array; + + /** @return array */ + public function findByClass(ClassId $classId, TenantId $tenantId): array; +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/CreateEvaluationProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateEvaluationProcessor.php new file mode 100644 index 0000000..e621e34 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateEvaluationProcessor.php @@ -0,0 +1,79 @@ + + */ +final readonly class CreateEvaluationProcessor implements ProcessorInterface +{ + public function __construct( + private CreateEvaluationHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param EvaluationResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EvaluationResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + try { + $command = new CreateEvaluationCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + classId: $data->classId ?? '', + subjectId: $data->subjectId ?? '', + teacherId: $user->userId(), + title: $data->title ?? '', + description: $data->description, + evaluationDate: $data->evaluationDate ?? '', + gradeScale: $data->gradeScale ?? 20, + coefficient: $data->coefficient ?? 1.0, + ); + + $evaluation = ($this->handler)($command); + + foreach ($evaluation->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return EvaluationResource::fromDomain($evaluation); + } catch (EnseignantNonAffecteException|BaremeInvalideException|CoefficientInvalideException $e) { + throw new BadRequestHttpException($e->getMessage()); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteEvaluationProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteEvaluationProcessor.php new file mode 100644 index 0000000..b85f1b4 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/DeleteEvaluationProcessor.php @@ -0,0 +1,79 @@ + + */ +final readonly class DeleteEvaluationProcessor implements ProcessorInterface +{ + public function __construct( + private DeleteEvaluationHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param EvaluationResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EvaluationResource + { + 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 $id */ + $id = $uriVariables['id']; + + try { + $command = new DeleteEvaluationCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + evaluationId: $id, + teacherId: $user->userId(), + ); + + $evaluation = ($this->handler)($command); + + foreach ($evaluation->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return EvaluationResource::fromDomain($evaluation); + } catch (EvaluationNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (NonProprietaireDeLEvaluationException $e) { + throw new AccessDeniedHttpException($e->getMessage()); + } catch (EvaluationDejaSupprimeeException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateEvaluationProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateEvaluationProcessor.php new file mode 100644 index 0000000..32e5780 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/UpdateEvaluationProcessor.php @@ -0,0 +1,87 @@ + + */ +final readonly class UpdateEvaluationProcessor implements ProcessorInterface +{ + public function __construct( + private UpdateEvaluationHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param EvaluationResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EvaluationResource + { + 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 $id */ + $id = $uriVariables['id']; + + try { + $command = new UpdateEvaluationCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + evaluationId: $id, + teacherId: $user->userId(), + title: $data->title ?? '', + description: $data->description, + evaluationDate: $data->evaluationDate ?? '', + coefficient: $data->coefficient ?? 1.0, + gradeScale: $data->gradeScale, + ); + + $evaluation = ($this->handler)($command); + + foreach ($evaluation->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return EvaluationResource::fromDomain($evaluation); + } catch (EvaluationNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (NonProprietaireDeLEvaluationException $e) { + throw new AccessDeniedHttpException($e->getMessage()); + } catch (EvaluationDejaSupprimeeException|BaremeNonModifiableException|BaremeInvalideException|CoefficientInvalideException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationCollectionProvider.php new file mode 100644 index 0000000..995a382 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationCollectionProvider.php @@ -0,0 +1,160 @@ + + */ +final readonly class EvaluationCollectionProvider implements ProviderInterface +{ + public function __construct( + private Connection $connection, + 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.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + $teacherId = $user->userId(); + + /** @var array $filters */ + $filters = $context['filters'] ?? []; + $classIdFilter = $filters['classId'] ?? null; + $search = $filters['search'] ?? null; + + $sql = 'SELECT e.*, c.name AS class_name, s.name AS subject_name + FROM evaluations e + LEFT JOIN school_classes c ON c.id = e.class_id + LEFT JOIN subjects s ON s.id = e.subject_id + WHERE e.teacher_id = :teacher_id + AND e.tenant_id = :tenant_id + AND e.status != :deleted'; + + $params = [ + 'teacher_id' => $teacherId, + 'tenant_id' => $tenantId, + 'deleted' => EvaluationStatus::DELETED->value, + ]; + + if (is_string($classIdFilter) && $classIdFilter !== '') { + try { + ClassId::fromString($classIdFilter); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('UUID de classe invalide : ' . $e->getMessage()); + } + $sql .= ' AND e.class_id = :class_id'; + $params['class_id'] = $classIdFilter; + } + + if (is_string($search) && $search !== '') { + $sql .= ' AND e.title ILIKE :search'; + $params['search'] = '%' . $search . '%'; + } + + $sql .= ' ORDER BY e.evaluation_date DESC'; + + $rows = $this->connection->fetchAllAssociative($sql, $params); + + return array_map(static function (array $row): EvaluationResource { + /** @var string $className */ + $className = $row['class_name'] ?? null; + /** @var string $subjectName */ + $subjectName = $row['subject_name'] ?? null; + + $evaluation = self::hydrateEvaluation($row); + + return EvaluationResource::fromDomain($evaluation, $className, $subjectName); + }, $rows); + } + + /** @param array $row */ + private static function hydrateEvaluation(array $row): Evaluation + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $classId */ + $classId = $row['class_id']; + /** @var string $subjectId */ + $subjectId = $row['subject_id']; + /** @var string $teacherId */ + $teacherId = $row['teacher_id']; + /** @var string $title */ + $title = $row['title']; + /** @var string|null $description */ + $description = $row['description']; + /** @var string $evaluationDate */ + $evaluationDate = $row['evaluation_date']; + /** @var string|int $gradeScaleRaw */ + $gradeScaleRaw = $row['grade_scale']; + /** @var string|float $coefficientRaw */ + $coefficientRaw = $row['coefficient']; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return Evaluation::reconstitute( + id: EvaluationId::fromString($id), + tenantId: TenantId::fromString($tenantId), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString($subjectId), + teacherId: UserId::fromString($teacherId), + title: $title, + description: $description, + evaluationDate: new DateTimeImmutable($evaluationDate), + gradeScale: new GradeScale((int) $gradeScaleRaw), + coefficient: new Coefficient((float) $coefficientRaw), + status: EvaluationStatus::from($status), + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationItemProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationItemProvider.php new file mode 100644 index 0000000..1af2aab --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationItemProvider.php @@ -0,0 +1,74 @@ + + */ +final readonly class EvaluationItemProvider implements ProviderInterface +{ + public function __construct( + private EvaluationRepository $evaluationRepository, + private TenantContext $tenantContext, + private Security $security, + private ClassRepository $classRepository, + private SubjectRepository $subjectRepository, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): EvaluationResource + { + 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 $id */ + $id = $uriVariables['id']; + + $evaluation = $this->evaluationRepository->findById( + EvaluationId::fromString($id), + $this->tenantContext->getCurrentTenantId(), + ); + + if ($evaluation === null) { + throw new NotFoundHttpException('Évaluation non trouvée.'); + } + + if ((string) $evaluation->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Vous n\'êtes pas le propriétaire de cette évaluation.'); + } + + $class = $this->classRepository->findById($evaluation->classId); + $subject = $this->subjectRepository->findById($evaluation->subjectId); + + return EvaluationResource::fromDomain( + $evaluation, + $class !== null ? (string) $class->name : null, + $subject !== null ? (string) $subject->name : null, + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationWithNames.php b/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationWithNames.php new file mode 100644 index 0000000..078028b --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/EvaluationWithNames.php @@ -0,0 +1,17 @@ + ['Default', 'create']], + name: 'create_evaluation', + ), + new Patch( + uriTemplate: '/evaluations/{id}', + provider: EvaluationItemProvider::class, + processor: UpdateEvaluationProcessor::class, + validationContext: ['groups' => ['Default', 'update']], + name: 'update_evaluation', + ), + new Delete( + uriTemplate: '/evaluations/{id}', + provider: EvaluationItemProvider::class, + processor: DeleteEvaluationProcessor::class, + name: 'delete_evaluation', + ), + ], +)] +final class EvaluationResource +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + #[Assert\NotBlank(message: 'La classe est requise.', groups: ['create'])] + #[Assert\Uuid(message: 'L\'identifiant de la classe doit être un UUID valide.', groups: ['create'])] + public ?string $classId = null; + + #[Assert\NotBlank(message: 'La matière est requise.', groups: ['create'])] + #[Assert\Uuid(message: 'L\'identifiant de la matière doit être un UUID valide.', groups: ['create'])] + public ?string $subjectId = null; + + public ?string $teacherId = null; + + #[Assert\NotBlank(message: 'Le titre est requis.', groups: ['create', 'update'])] + #[Assert\Length(max: 255, maxMessage: 'Le titre ne peut pas dépasser 255 caractères.')] + public ?string $title = null; + + public ?string $description = null; + + #[Assert\NotBlank(message: 'La date d\'évaluation est requise.', groups: ['create', 'update'])] + public ?string $evaluationDate = null; + + #[Assert\Range(min: 1, max: 100, notInRangeMessage: 'Le barème doit être compris entre 1 et 100.')] + public ?int $gradeScale = null; + + #[Assert\Range(min: 0.1, max: 10, notInRangeMessage: 'Le coefficient doit être compris entre 0.1 et 10.')] + public ?float $coefficient = null; + + public ?string $status = null; + + public ?string $className = null; + + public ?string $subjectName = null; + + public ?DateTimeImmutable $createdAt = null; + + public ?DateTimeImmutable $updatedAt = null; + + public static function fromDomain( + Evaluation $evaluation, + ?string $className = null, + ?string $subjectName = null, + ): self { + $resource = new self(); + $resource->id = (string) $evaluation->id; + $resource->classId = (string) $evaluation->classId; + $resource->subjectId = (string) $evaluation->subjectId; + $resource->teacherId = (string) $evaluation->teacherId; + $resource->title = $evaluation->title; + $resource->description = $evaluation->description; + $resource->evaluationDate = $evaluation->evaluationDate->format('Y-m-d'); + $resource->gradeScale = $evaluation->gradeScale->maxValue; + $resource->coefficient = $evaluation->coefficient->value; + $resource->status = $evaluation->status->value; + $resource->className = $className; + $resource->subjectName = $subjectName; + $resource->createdAt = $evaluation->createdAt; + $resource->updatedAt = $evaluation->updatedAt; + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationRepository.php new file mode 100644 index 0000000..948e570 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineEvaluationRepository.php @@ -0,0 +1,207 @@ +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) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + evaluation_date = EXCLUDED.evaluation_date, + grade_scale = EXCLUDED.grade_scale, + coefficient = EXCLUDED.coefficient, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $evaluation->id, + 'tenant_id' => (string) $evaluation->tenantId, + 'class_id' => (string) $evaluation->classId, + 'subject_id' => (string) $evaluation->subjectId, + 'teacher_id' => (string) $evaluation->teacherId, + 'title' => $evaluation->title, + 'description' => $evaluation->description, + 'evaluation_date' => $evaluation->evaluationDate->format('Y-m-d'), + 'grade_scale' => $evaluation->gradeScale->maxValue, + 'coefficient' => $evaluation->coefficient->value, + 'status' => $evaluation->status->value, + 'created_at' => $evaluation->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $evaluation->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function get(EvaluationId $id, TenantId $tenantId): Evaluation + { + $evaluation = $this->findById($id, $tenantId); + + if ($evaluation === null) { + throw EvaluationNotFoundException::withId($id); + } + + return $evaluation; + } + + #[Override] + public function findById(EvaluationId $id, TenantId $tenantId): ?Evaluation + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM evaluations 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 findByTeacher(UserId $teacherId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT e.*, c.name AS class_name, s.name AS subject_name + FROM evaluations e + LEFT JOIN school_classes c ON c.id = e.class_id + LEFT JOIN subjects s ON s.id = e.subject_id + WHERE e.teacher_id = :teacher_id + AND e.tenant_id = :tenant_id + AND e.status != :deleted + ORDER BY e.evaluation_date DESC', + [ + 'teacher_id' => (string) $teacherId, + 'tenant_id' => (string) $tenantId, + 'deleted' => EvaluationStatus::DELETED->value, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function findByTeacherAndClass(UserId $teacherId, ClassId $classId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT e.*, c.name AS class_name, s.name AS subject_name + FROM evaluations e + LEFT JOIN school_classes c ON c.id = e.class_id + LEFT JOIN subjects s ON s.id = e.subject_id + WHERE e.teacher_id = :teacher_id + AND e.class_id = :class_id + AND e.tenant_id = :tenant_id + AND e.status != :deleted + ORDER BY e.evaluation_date DESC', + [ + 'teacher_id' => (string) $teacherId, + 'class_id' => (string) $classId, + 'tenant_id' => (string) $tenantId, + 'deleted' => EvaluationStatus::DELETED->value, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function findByClass(ClassId $classId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT e.*, c.name AS class_name, s.name AS subject_name + FROM evaluations e + LEFT JOIN school_classes c ON c.id = e.class_id + LEFT JOIN subjects s ON s.id = e.subject_id + WHERE e.class_id = :class_id + AND e.tenant_id = :tenant_id + AND e.status != :deleted + ORDER BY e.evaluation_date DESC', + [ + 'class_id' => (string) $classId, + 'tenant_id' => (string) $tenantId, + 'deleted' => EvaluationStatus::DELETED->value, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + /** @param array $row */ + private function hydrate(array $row): Evaluation + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $classId */ + $classId = $row['class_id']; + /** @var string $subjectId */ + $subjectId = $row['subject_id']; + /** @var string $teacherId */ + $teacherId = $row['teacher_id']; + /** @var string $title */ + $title = $row['title']; + /** @var string|null $description */ + $description = $row['description']; + /** @var string $evaluationDate */ + $evaluationDate = $row['evaluation_date']; + /** @var string|int $gradeScaleRaw */ + $gradeScaleRaw = $row['grade_scale']; + $gradeScale = (int) $gradeScaleRaw; + /** @var string|float $coefficientRaw */ + $coefficientRaw = $row['coefficient']; + $coefficient = (float) $coefficientRaw; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return Evaluation::reconstitute( + id: EvaluationId::fromString($id), + tenantId: TenantId::fromString($tenantId), + classId: ClassId::fromString($classId), + subjectId: SubjectId::fromString($subjectId), + teacherId: UserId::fromString($teacherId), + title: $title, + description: $description, + evaluationDate: new DateTimeImmutable($evaluationDate), + gradeScale: new GradeScale($gradeScale), + coefficient: new Coefficient($coefficient), + status: EvaluationStatus::from($status), + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryEvaluationRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryEvaluationRepository.php new file mode 100644 index 0000000..ab65cdf --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryEvaluationRepository.php @@ -0,0 +1,89 @@ + */ + private array $byId = []; + + #[Override] + public function save(Evaluation $evaluation): void + { + $this->byId[(string) $evaluation->id] = $evaluation; + } + + #[Override] + public function get(EvaluationId $id, TenantId $tenantId): Evaluation + { + $evaluation = $this->findById($id, $tenantId); + + if ($evaluation === null) { + throw EvaluationNotFoundException::withId($id); + } + + return $evaluation; + } + + #[Override] + public function findById(EvaluationId $id, TenantId $tenantId): ?Evaluation + { + $evaluation = $this->byId[(string) $id] ?? null; + + if ($evaluation === null || !$evaluation->tenantId->equals($tenantId)) { + return null; + } + + return $evaluation; + } + + #[Override] + public function findByTeacher(UserId $teacherId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (Evaluation $e): bool => $e->teacherId->equals($teacherId) + && $e->tenantId->equals($tenantId) + && $e->status !== EvaluationStatus::DELETED, + )); + } + + #[Override] + public function findByClass(ClassId $classId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (Evaluation $e): bool => $e->classId->equals($classId) + && $e->tenantId->equals($tenantId) + && $e->status !== EvaluationStatus::DELETED, + )); + } + + #[Override] + public function findByTeacherAndClass(UserId $teacherId, ClassId $classId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (Evaluation $e): bool => $e->teacherId->equals($teacherId) + && $e->classId->equals($classId) + && $e->tenantId->equals($tenantId) + && $e->status !== EvaluationStatus::DELETED, + )); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Service/NoGradesEvaluationGradesChecker.php b/backend/src/Scolarite/Infrastructure/Service/NoGradesEvaluationGradesChecker.php new file mode 100644 index 0000000..d15225a --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/NoGradesEvaluationGradesChecker.php @@ -0,0 +1,19 @@ +evaluationRepository = new InMemoryEvaluationRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-12 10:00:00'); + } + }; + } + + #[Test] + public function itCreatesEvaluationSuccessfully(): void + { + $handler = $this->createHandler(affecte: true); + $command = $this->createCommand(); + + $evaluation = $handler($command); + + self::assertNotEmpty((string) $evaluation->id); + self::assertSame(EvaluationStatus::PUBLISHED, $evaluation->status); + self::assertSame('Contrôle chapitre 5', $evaluation->title); + self::assertSame(20, $evaluation->gradeScale->maxValue); + self::assertSame(1.0, $evaluation->coefficient->value); + } + + #[Test] + public function itPersistsEvaluationInRepository(): void + { + $handler = $this->createHandler(affecte: true); + $command = $this->createCommand(); + + $created = $handler($command); + + $evaluation = $this->evaluationRepository->get( + EvaluationId::fromString((string) $created->id), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertSame('Contrôle chapitre 5', $evaluation->title); + } + + #[Test] + public function itThrowsWhenTeacherNotAffected(): void + { + $handler = $this->createHandler(affecte: false); + + $this->expectException(EnseignantNonAffecteException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itCreatesEvaluationWithCustomGradeScale(): void + { + $handler = $this->createHandler(affecte: true); + $command = $this->createCommand(gradeScale: 10); + + $evaluation = $handler($command); + + self::assertSame(10, $evaluation->gradeScale->maxValue); + } + + #[Test] + public function itCreatesEvaluationWithCustomCoefficient(): void + { + $handler = $this->createHandler(affecte: true); + $command = $this->createCommand(coefficient: 2.5); + + $evaluation = $handler($command); + + self::assertSame(2.5, $evaluation->coefficient->value); + } + + #[Test] + public function itThrowsWhenGradeScaleIsInvalid(): void + { + $handler = $this->createHandler(affecte: true); + + $this->expectException(BaremeInvalideException::class); + + $handler($this->createCommand(gradeScale: 0)); + } + + #[Test] + public function itThrowsWhenCoefficientIsInvalid(): void + { + $handler = $this->createHandler(affecte: true); + + $this->expectException(CoefficientInvalideException::class); + + $handler($this->createCommand(coefficient: 0.0)); + } + + #[Test] + public function itAllowsNullDescription(): void + { + $handler = $this->createHandler(affecte: true); + $command = $this->createCommand(description: null); + + $evaluation = $handler($command); + + self::assertNull($evaluation->description); + } + + private function createHandler(bool $affecte): CreateEvaluationHandler + { + $affectationChecker = new class($affecte) implements EnseignantAffectationChecker { + public function __construct(private readonly bool $affecte) + { + } + + public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool + { + return $this->affecte; + } + }; + + return new CreateEvaluationHandler( + $this->evaluationRepository, + $affectationChecker, + $this->clock, + ); + } + + private function createCommand( + ?string $description = 'Évaluation sur les fonctions', + int $gradeScale = 20, + float $coefficient = 1.0, + ): CreateEvaluationCommand { + return new CreateEvaluationCommand( + tenantId: self::TENANT_ID, + classId: self::CLASS_ID, + subjectId: self::SUBJECT_ID, + teacherId: self::TEACHER_ID, + title: 'Contrôle chapitre 5', + description: $description, + evaluationDate: '2026-04-15', + gradeScale: $gradeScale, + coefficient: $coefficient, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/DeleteEvaluation/DeleteEvaluationHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/DeleteEvaluation/DeleteEvaluationHandlerTest.php new file mode 100644 index 0000000..94854de --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/DeleteEvaluation/DeleteEvaluationHandlerTest.php @@ -0,0 +1,136 @@ +evaluationRepository = new InMemoryEvaluationRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-14 08:00:00'); + } + }; + } + + #[Test] + public function itSoftDeletesEvaluation(): void + { + $evaluation = $this->createAndSaveEvaluation(); + $handler = $this->createHandler(); + + $result = $handler(new DeleteEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: (string) $evaluation->id, + teacherId: self::TEACHER_ID, + )); + + self::assertSame(EvaluationStatus::DELETED, $result->status); + } + + #[Test] + public function itThrowsWhenTeacherIsNotOwner(): void + { + $evaluation = $this->createAndSaveEvaluation(); + $handler = $this->createHandler(); + + $this->expectException(NonProprietaireDeLEvaluationException::class); + + $handler(new DeleteEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: (string) $evaluation->id, + teacherId: self::OTHER_TEACHER_ID, + )); + } + + #[Test] + public function itThrowsWhenEvaluationNotFound(): void + { + $handler = $this->createHandler(); + + $this->expectException(EvaluationNotFoundException::class); + + $handler(new DeleteEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: '550e8400-e29b-41d4-a716-446655449999', + teacherId: self::TEACHER_ID, + )); + } + + #[Test] + public function itThrowsWhenAlreadyDeleted(): void + { + $evaluation = $this->createAndSaveEvaluation(); + $evaluation->supprimer(new DateTimeImmutable('2026-03-13')); + $this->evaluationRepository->save($evaluation); + $handler = $this->createHandler(); + + $this->expectException(EvaluationDejaSupprimeeException::class); + + $handler(new DeleteEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: (string) $evaluation->id, + teacherId: self::TEACHER_ID, + )); + } + + private function createHandler(): DeleteEvaluationHandler + { + return new DeleteEvaluationHandler( + $this->evaluationRepository, + $this->clock, + ); + } + + private function createAndSaveEvaluation(): Evaluation + { + $evaluation = Evaluation::creer( + 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: 'Contrôle chapitre 5', + description: 'Évaluation sur les fonctions', + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + $this->evaluationRepository->save($evaluation); + + return $evaluation; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/UpdateEvaluation/UpdateEvaluationHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/UpdateEvaluation/UpdateEvaluationHandlerTest.php new file mode 100644 index 0000000..22dd9ef --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/UpdateEvaluation/UpdateEvaluationHandlerTest.php @@ -0,0 +1,207 @@ +evaluationRepository = new InMemoryEvaluationRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-13 14:00:00'); + } + }; + } + + #[Test] + public function itUpdatesEvaluationSuccessfully(): void + { + $evaluation = $this->createAndSaveEvaluation(); + $handler = $this->createHandler(hasGrades: false); + + $command = new UpdateEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: (string) $evaluation->id, + teacherId: self::TEACHER_ID, + title: 'Titre modifié', + description: 'Nouvelle description', + evaluationDate: '2026-04-20', + coefficient: 2.0, + ); + + $updated = $handler($command); + + self::assertSame('Titre modifié', $updated->title); + self::assertSame('Nouvelle description', $updated->description); + self::assertSame(2.0, $updated->coefficient->value); + } + + #[Test] + public function itAllowsGradeScaleChangeWhenNoGrades(): void + { + $evaluation = $this->createAndSaveEvaluation(); + $handler = $this->createHandler(hasGrades: false); + + $command = new UpdateEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: (string) $evaluation->id, + teacherId: self::TEACHER_ID, + title: $evaluation->title, + description: $evaluation->description, + evaluationDate: '2026-04-15', + gradeScale: 10, + ); + + $updated = $handler($command); + + self::assertSame(10, $updated->gradeScale->maxValue); + } + + #[Test] + public function itBlocksGradeScaleChangeWhenGradesExist(): void + { + $evaluation = $this->createAndSaveEvaluation(); + $handler = $this->createHandler(hasGrades: true); + + $this->expectException(BaremeNonModifiableException::class); + + $handler(new UpdateEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: (string) $evaluation->id, + teacherId: self::TEACHER_ID, + title: $evaluation->title, + description: $evaluation->description, + evaluationDate: '2026-04-15', + gradeScale: 10, + )); + } + + #[Test] + public function itThrowsWhenTeacherIsNotOwner(): void + { + $evaluation = $this->createAndSaveEvaluation(); + $handler = $this->createHandler(hasGrades: false); + + $this->expectException(NonProprietaireDeLEvaluationException::class); + + $handler(new UpdateEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: (string) $evaluation->id, + teacherId: self::OTHER_TEACHER_ID, + title: 'Titre', + description: null, + evaluationDate: '2026-04-15', + )); + } + + #[Test] + public function itThrowsWhenEvaluationNotFound(): void + { + $handler = $this->createHandler(hasGrades: false); + + $this->expectException(EvaluationNotFoundException::class); + + $handler(new UpdateEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: '550e8400-e29b-41d4-a716-446655449999', + teacherId: self::TEACHER_ID, + title: 'Titre', + description: null, + evaluationDate: '2026-04-15', + )); + } + + #[Test] + public function itThrowsWhenEvaluationIsDeleted(): void + { + $evaluation = $this->createAndSaveEvaluation(); + $evaluation->supprimer(new DateTimeImmutable('2026-03-13')); + $this->evaluationRepository->save($evaluation); + $handler = $this->createHandler(hasGrades: false); + + $this->expectException(EvaluationDejaSupprimeeException::class); + + $handler(new UpdateEvaluationCommand( + tenantId: self::TENANT_ID, + evaluationId: (string) $evaluation->id, + teacherId: self::TEACHER_ID, + title: 'Titre', + description: null, + evaluationDate: '2026-04-15', + )); + } + + private function createHandler(bool $hasGrades): UpdateEvaluationHandler + { + $gradesChecker = new class($hasGrades) implements EvaluationGradesChecker { + public function __construct(private readonly bool $hasGrades) + { + } + + public function hasGrades(EvaluationId $evaluationId, TenantId $tenantId): bool + { + return $this->hasGrades; + } + }; + + return new UpdateEvaluationHandler( + $this->evaluationRepository, + $gradesChecker, + $this->clock, + ); + } + + private function createAndSaveEvaluation(): Evaluation + { + $evaluation = Evaluation::creer( + 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: 'Contrôle chapitre 5', + description: 'Évaluation sur les fonctions', + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + $this->evaluationRepository->save($evaluation); + + return $evaluation; + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/CoefficientTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/CoefficientTest.php new file mode 100644 index 0000000..53321e8 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/CoefficientTest.php @@ -0,0 +1,59 @@ +value); + self::assertSame(0.5, (new Coefficient(0.5))->value); + self::assertSame(1.0, (new Coefficient(1.0))->value); + self::assertSame(1.5, (new Coefficient(1.5))->value); + self::assertSame(2.0, (new Coefficient(2.0))->value); + self::assertSame(10.0, (new Coefficient(10.0))->value); + } + + #[Test] + public function rejectsTooSmall(): void + { + $this->expectException(CoefficientInvalideException::class); + + new Coefficient(0.0); + } + + #[Test] + public function rejectsTooLarge(): void + { + $this->expectException(CoefficientInvalideException::class); + + new Coefficient(10.1); + } + + #[Test] + public function rejectsNegative(): void + { + $this->expectException(CoefficientInvalideException::class); + + new Coefficient(-1.0); + } + + #[Test] + public function equalsComparesValue(): void + { + $a = new Coefficient(1.5); + $b = new Coefficient(1.5); + $c = new Coefficient(2.0); + + self::assertTrue($a->equals($b)); + self::assertFalse($a->equals($c)); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/EvaluationTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/EvaluationTest.php new file mode 100644 index 0000000..d045ef7 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/EvaluationTest.php @@ -0,0 +1,288 @@ +createEvaluation(); + + self::assertSame(EvaluationStatus::PUBLISHED, $evaluation->status); + } + + #[Test] + public function creerRecordsEvaluationCreeeEvent(): void + { + $evaluation = $this->createEvaluation(); + + $events = $evaluation->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(EvaluationCreee::class, $events[0]); + self::assertSame($evaluation->id, $events[0]->evaluationId); + } + + #[Test] + public function creerSetsAllProperties(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $classId = ClassId::fromString(self::CLASS_ID); + $subjectId = SubjectId::fromString(self::SUBJECT_ID); + $teacherId = UserId::fromString(self::TEACHER_ID); + $evaluationDate = new DateTimeImmutable('2026-04-15'); + $now = new DateTimeImmutable('2026-03-12 10:00:00'); + $gradeScale = new GradeScale(20); + $coefficient = new Coefficient(1.5); + + $evaluation = Evaluation::creer( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: 'Contrôle chapitre 5', + description: 'Évaluation sur les fonctions', + evaluationDate: $evaluationDate, + gradeScale: $gradeScale, + coefficient: $coefficient, + now: $now, + ); + + self::assertTrue($evaluation->tenantId->equals($tenantId)); + self::assertTrue($evaluation->classId->equals($classId)); + self::assertTrue($evaluation->subjectId->equals($subjectId)); + self::assertTrue($evaluation->teacherId->equals($teacherId)); + self::assertSame('Contrôle chapitre 5', $evaluation->title); + self::assertSame('Évaluation sur les fonctions', $evaluation->description); + self::assertEquals($evaluationDate, $evaluation->evaluationDate); + self::assertSame(20, $evaluation->gradeScale->maxValue); + self::assertSame(1.5, $evaluation->coefficient->value); + self::assertSame(EvaluationStatus::PUBLISHED, $evaluation->status); + self::assertEquals($now, $evaluation->createdAt); + self::assertEquals($now, $evaluation->updatedAt); + } + + #[Test] + public function creerAllowsNullDescription(): void + { + $evaluation = Evaluation::creer( + 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: 'Évaluation sans description', + description: null, + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + + self::assertNull($evaluation->description); + } + + #[Test] + public function modifierUpdatesFieldsAndRecordsEvent(): void + { + $evaluation = $this->createEvaluation(); + $evaluation->pullDomainEvents(); + $modifiedAt = new DateTimeImmutable('2026-03-13 14:00:00'); + $newDate = new DateTimeImmutable('2026-04-20'); + $newCoefficient = new Coefficient(2.0); + + $evaluation->modifier( + title: 'Titre modifié', + description: 'Nouvelle description', + coefficient: $newCoefficient, + evaluationDate: $newDate, + gradeScale: null, + hasGrades: false, + now: $modifiedAt, + ); + + self::assertSame('Titre modifié', $evaluation->title); + self::assertSame('Nouvelle description', $evaluation->description); + self::assertSame(2.0, $evaluation->coefficient->value); + self::assertEquals($newDate, $evaluation->evaluationDate); + self::assertEquals($modifiedAt, $evaluation->updatedAt); + + $events = $evaluation->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(EvaluationModifiee::class, $events[0]); + self::assertSame($evaluation->id, $events[0]->evaluationId); + } + + #[Test] + public function modifierAllowsGradeScaleChangeWhenNoGrades(): void + { + $evaluation = $this->createEvaluation(); + $evaluation->pullDomainEvents(); + $newGradeScale = new GradeScale(10); + + $evaluation->modifier( + title: $evaluation->title, + description: $evaluation->description, + coefficient: $evaluation->coefficient, + evaluationDate: $evaluation->evaluationDate, + gradeScale: $newGradeScale, + hasGrades: false, + now: new DateTimeImmutable('2026-03-13 14:00:00'), + ); + + self::assertSame(10, $evaluation->gradeScale->maxValue); + } + + #[Test] + public function modifierBlocksGradeScaleChangeWhenGradesExist(): void + { + $evaluation = $this->createEvaluation(); + + $this->expectException(BaremeNonModifiableException::class); + + $evaluation->modifier( + title: $evaluation->title, + description: $evaluation->description, + coefficient: $evaluation->coefficient, + evaluationDate: $evaluation->evaluationDate, + gradeScale: new GradeScale(10), + hasGrades: true, + now: new DateTimeImmutable('2026-03-13 14:00:00'), + ); + } + + #[Test] + public function modifierThrowsWhenDeleted(): void + { + $evaluation = $this->createEvaluation(); + $evaluation->supprimer(new DateTimeImmutable('2026-03-13')); + + $this->expectException(EvaluationDejaSupprimeeException::class); + + $evaluation->modifier( + title: 'Titre', + description: null, + coefficient: new Coefficient(1.0), + evaluationDate: new DateTimeImmutable('2026-04-20'), + gradeScale: null, + hasGrades: false, + now: new DateTimeImmutable('2026-03-14'), + ); + } + + #[Test] + public function supprimerChangesStatusAndRecordsEvent(): void + { + $evaluation = $this->createEvaluation(); + $evaluation->pullDomainEvents(); + $deletedAt = new DateTimeImmutable('2026-03-14 08:00:00'); + + $evaluation->supprimer($deletedAt); + + self::assertSame(EvaluationStatus::DELETED, $evaluation->status); + self::assertEquals($deletedAt, $evaluation->updatedAt); + + $events = $evaluation->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(EvaluationSupprimee::class, $events[0]); + self::assertSame($evaluation->id, $events[0]->evaluationId); + } + + #[Test] + public function supprimerThrowsWhenAlreadyDeleted(): void + { + $evaluation = $this->createEvaluation(); + $evaluation->supprimer(new DateTimeImmutable('2026-03-14')); + + $this->expectException(EvaluationDejaSupprimeeException::class); + + $evaluation->supprimer(new DateTimeImmutable('2026-03-15')); + } + + #[Test] + public function reconstituteRestoresAllPropertiesWithoutEvents(): void + { + $id = EvaluationId::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); + $evaluationDate = new DateTimeImmutable('2026-04-15'); + $createdAt = new DateTimeImmutable('2026-03-12 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-03-13 14:00:00'); + $gradeScale = new GradeScale(20); + $coefficient = new Coefficient(1.5); + + $evaluation = Evaluation::reconstitute( + id: $id, + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: 'Contrôle chapitre 5', + description: 'Évaluation sur les fonctions', + evaluationDate: $evaluationDate, + gradeScale: $gradeScale, + coefficient: $coefficient, + status: EvaluationStatus::PUBLISHED, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertTrue($evaluation->id->equals($id)); + self::assertTrue($evaluation->tenantId->equals($tenantId)); + self::assertTrue($evaluation->classId->equals($classId)); + self::assertTrue($evaluation->subjectId->equals($subjectId)); + self::assertTrue($evaluation->teacherId->equals($teacherId)); + self::assertSame('Contrôle chapitre 5', $evaluation->title); + self::assertSame('Évaluation sur les fonctions', $evaluation->description); + self::assertEquals($evaluationDate, $evaluation->evaluationDate); + self::assertSame(20, $evaluation->gradeScale->maxValue); + self::assertSame(1.5, $evaluation->coefficient->value); + self::assertSame(EvaluationStatus::PUBLISHED, $evaluation->status); + self::assertEquals($createdAt, $evaluation->createdAt); + self::assertEquals($updatedAt, $evaluation->updatedAt); + self::assertEmpty($evaluation->pullDomainEvents()); + } + + private function createEvaluation(): Evaluation + { + return Evaluation::creer( + 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: 'Contrôle chapitre 5', + description: 'Évaluation sur les fonctions', + evaluationDate: new DateTimeImmutable('2026-04-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/GradeScaleTest.php b/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/GradeScaleTest.php new file mode 100644 index 0000000..6e94c31 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/Evaluation/GradeScaleTest.php @@ -0,0 +1,70 @@ +maxValue); + self::assertSame(10, (new GradeScale(10))->maxValue); + self::assertSame(20, (new GradeScale(20))->maxValue); + self::assertSame(100, (new GradeScale(100))->maxValue); + } + + #[Test] + public function rejectsZero(): void + { + $this->expectException(BaremeInvalideException::class); + + new GradeScale(0); + } + + #[Test] + public function rejectsNegative(): void + { + $this->expectException(BaremeInvalideException::class); + + new GradeScale(-1); + } + + #[Test] + public function rejectsAbove100(): void + { + $this->expectException(BaremeInvalideException::class); + + new GradeScale(101); + } + + #[Test] + public function convertTo20ConvertsCorrectly(): void + { + $scale10 = new GradeScale(10); + self::assertSame(14.0, $scale10->convertTo20(7)); + + $scale20 = new GradeScale(20); + self::assertSame(15.0, $scale20->convertTo20(15)); + + $scale100 = new GradeScale(100); + self::assertSame(17.0, $scale100->convertTo20(85)); + } + + #[Test] + public function equalsComparesMaxValue(): void + { + $a = new GradeScale(20); + $b = new GradeScale(20); + $c = new GradeScale(10); + + self::assertTrue($a->equals($b)); + self::assertFalse($a->equals($c)); + } +} diff --git a/frontend/e2e/evaluations.spec.ts b/frontend/e2e/evaluations.spec.ts new file mode 100644 index 0000000..519bd2f --- /dev/null +++ b/frontend/e2e/evaluations.spec.ts @@ -0,0 +1,481 @@ +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-eval-teacher@example.com'; +const TEACHER_PASSWORD = 'EvalTest123'; +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() + ]); +} + +async function navigateToEvaluations(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`); + await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 }); +} + +async function selectClassAndSubject(page: import('@playwright/test').Page) { + const classSelect = page.locator('#ev-class'); + await expect(classSelect).toBeVisible(); + await classSelect.selectOption({ index: 1 }); + + // Wait for subject options to appear after class selection + const subjectSelect = page.locator('#ev-subject'); + await expect(subjectSelect).toBeEnabled({ timeout: 5000 }); + await expect(subjectSelect.locator('option')).not.toHaveCount(1, { timeout: 10000 }); + await subjectSelect.selectOption({ index: 1 }); +} + +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 + } +} + +test.describe('Evaluation Management (Story 6.1)', () => { + 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' } + ); + + // Ensure classes and subject exist + 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-EVAL-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-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + clearCache(); + }); + + test.beforeEach(async () => { + // Clean up evaluation data + try { + 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 + } + + 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-EVAL-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-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + clearCache(); + }); + + // ============================================================================ + // Navigation + // ============================================================================ + test.describe('Navigation', () => { + test('evaluations link appears in teacher navigation', async ({ page }) => { + await loginAsTeacher(page); + const nav = page.locator('.desktop-nav'); + await expect(nav.getByRole('link', { name: /évaluations/i })).toBeVisible({ timeout: 15000 }); + }); + + test('can navigate to evaluations page', async ({ page }) => { + await loginAsTeacher(page); + await navigateToEvaluations(page); + await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible(); + }); + }); + + // ============================================================================ + // Empty State + // ============================================================================ + test.describe('Empty State', () => { + test('shows empty state when no evaluations exist', async ({ page }) => { + await loginAsTeacher(page); + await navigateToEvaluations(page); + await expect(page.getByText(/aucune évaluation/i)).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // AC1-AC5: Create Evaluation + // ============================================================================ + test.describe('AC1-AC5: Create Evaluation', () => { + test('can create a new evaluation with default grade scale and coefficient', async ({ page }) => { + await loginAsTeacher(page); + await navigateToEvaluations(page); + + // Open create modal + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + // Select class and subject + await selectClassAndSubject(page); + + // Fill title + await page.locator('#ev-title').fill('Contrôle chapitre 5'); + + // Fill date + await page.locator('#ev-date').fill('2026-06-15'); + + // Submit + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + + // Wait for modal to close (creation succeeded) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); + + // Verify evaluation appears in list + await expect(page.getByText('Contrôle chapitre 5')).toBeVisible({ timeout: 10000 }); + // Verify default grade scale and coefficient badges + await expect(page.getByText('/20')).toBeVisible(); + await expect(page.getByText('x1')).toBeVisible(); + }); + + test('can create evaluation with custom grade scale and coefficient', async ({ page }) => { + await loginAsTeacher(page); + await navigateToEvaluations(page); + + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + // Select class and subject + await selectClassAndSubject(page); + + // Fill form + await page.locator('#ev-title').fill('QCM rapide'); + await page.locator('#ev-date').fill('2026-06-20'); + + // Set custom grade scale /10 + await page.locator('#ev-scale').fill('10'); + + // Set coefficient to 0.5 + await page.locator('#ev-coeff').fill('0.5'); + + // Submit + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + + // Verify evaluation with custom values + await expect(page.getByText('QCM rapide')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('/10')).toBeVisible(); + await expect(page.getByText('x0.5')).toBeVisible(); + }); + }); + + // ============================================================================ + // AC6: Edit Evaluation + // ============================================================================ + test.describe('AC6: Edit Evaluation', () => { + test('can modify title, description, and coefficient', async ({ page }) => { + await loginAsTeacher(page); + await navigateToEvaluations(page); + + // Create an evaluation first + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + await selectClassAndSubject(page); + await page.locator('#ev-title').fill('Évaluation originale'); + await page.locator('#ev-date').fill('2026-06-15'); + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + await expect(page.getByText('Évaluation originale')).toBeVisible({ timeout: 10000 }); + + // Open edit modal + await page.getByRole('button', { name: /modifier/i }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + + // Modify title + await page.locator('#edit-title').fill('Évaluation modifiée'); + + // Modify coefficient + await page.locator('#edit-coeff').fill('2'); + + // Submit + await page.getByRole('button', { name: /enregistrer/i }).click(); + + // Verify changes + await expect(page.getByText('Évaluation modifiée')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('x2')).toBeVisible(); + }); + }); + + // ============================================================================ + // Delete Evaluation + // ============================================================================ + test.describe('Delete Evaluation', () => { + test('can delete an evaluation', async ({ page }) => { + await loginAsTeacher(page); + await navigateToEvaluations(page); + + // Create an evaluation first + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + await selectClassAndSubject(page); + await page.locator('#ev-title').fill('Évaluation à supprimer'); + await page.locator('#ev-date').fill('2026-06-15'); + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + await expect(page.getByText('Évaluation à supprimer')).toBeVisible({ timeout: 10000 }); + + // Open delete modal + await page.getByRole('button', { name: /supprimer/i }).first().click(); + await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 10000 }); + + // Confirm deletion + await page.getByRole('alertdialog').getByRole('button', { name: /supprimer/i }).click(); + + // Verify evaluation is removed + await expect(page.getByText(/aucune évaluation/i)).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // T1: Search evaluations by title (P2) + // ============================================================================ + test.describe('Search evaluations', () => { + test('filters evaluations when searching by title', async ({ page }) => { + await loginAsTeacher(page); + await navigateToEvaluations(page); + + // Create first evaluation: "Contrôle géométrie" + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + await selectClassAndSubject(page); + await page.locator('#ev-title').fill('Contrôle géométrie'); + await page.locator('#ev-date').fill('2026-06-15'); + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 }); + + // Create second evaluation: "QCM algèbre" + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + await selectClassAndSubject(page); + await page.locator('#ev-title').fill('QCM algèbre'); + await page.locator('#ev-date').fill('2026-06-20'); + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); + await expect(page.getByText('QCM algèbre')).toBeVisible({ timeout: 10000 }); + + // Both evaluations should be visible + await expect(page.getByText('Contrôle géométrie')).toBeVisible(); + await expect(page.getByText('QCM algèbre')).toBeVisible(); + + // Search for "géométrie" + const searchInput = page.getByRole('searchbox', { name: /rechercher par titre/i }); + await searchInput.fill('géométrie'); + + // Wait for debounced search to trigger and results to update + await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('QCM algèbre')).not.toBeVisible({ timeout: 10000 }); + + // Clear search and verify both reappear + await page.getByRole('button', { name: /effacer la recherche/i }).click(); + await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('QCM algèbre')).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // T2: Filter evaluations by class (P2) + // ============================================================================ + test.describe('Filter by class', () => { + test('class filter dropdown filters the evaluation list', async ({ page }) => { + // Seed a second class and assignment for this test + 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-EVAL-5B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + 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.name = 'E2E-EVAL-5B' AND c.tenant_id = '${TENANT_ID}' ` + + `AND s.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + clearCache(); + + await loginAsTeacher(page); + await navigateToEvaluations(page); + + // Create evaluation in first class (E2E-EVAL-6A) + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + const classSelect = page.locator('#ev-class'); + await expect(classSelect).toBeVisible(); + await classSelect.selectOption({ label: 'E2E-EVAL-6A' }); + const subjectSelect = page.locator('#ev-subject'); + await expect(subjectSelect).toBeEnabled({ timeout: 5000 }); + await expect(subjectSelect.locator('option')).not.toHaveCount(1, { timeout: 10000 }); + await subjectSelect.selectOption({ index: 1 }); + await page.locator('#ev-title').fill('Eval classe 6A'); + await page.locator('#ev-date').fill('2026-06-15'); + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 }); + + // Create evaluation in second class (E2E-EVAL-5B) + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + const classSelect2 = page.locator('#ev-class'); + await classSelect2.selectOption({ label: 'E2E-EVAL-5B' }); + const subjectSelect2 = page.locator('#ev-subject'); + await expect(subjectSelect2).toBeEnabled({ timeout: 5000 }); + await expect(subjectSelect2.locator('option')).not.toHaveCount(1, { timeout: 10000 }); + await subjectSelect2.selectOption({ index: 1 }); + await page.locator('#ev-title').fill('Eval classe 5B'); + await page.locator('#ev-date').fill('2026-06-20'); + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Eval classe 5B')).toBeVisible({ timeout: 10000 }); + + // Both evaluations visible initially + await expect(page.getByText('Eval classe 6A')).toBeVisible(); + await expect(page.getByText('Eval classe 5B')).toBeVisible(); + + // Filter by E2E-EVAL-6A + const filterSelect = page.getByRole('combobox', { name: /filtrer par classe/i }); + await expect(filterSelect).toBeVisible(); + await filterSelect.selectOption({ label: 'E2E-EVAL-6A' }); + + // Wait for filtered results + await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Eval classe 5B')).not.toBeVisible({ timeout: 10000 }); + + // Reset filter to "Toutes les classes" + await filterSelect.selectOption({ label: 'Toutes les classes' }); + + // Both should reappear + await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Eval classe 5B')).toBeVisible({ timeout: 10000 }); + + // Cleanup: remove the second class data + try { + 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}'`); + } catch { + // Cleanup is best-effort + } + }); + }); + + // ============================================================================ + // T3: Grade scale equivalence preview (P2) + // ============================================================================ + test.describe('Grade scale preview', () => { + test('shows equivalence preview when barème is not 20', async ({ page }) => { + await loginAsTeacher(page); + await navigateToEvaluations(page); + + // Open create modal + await page.getByRole('button', { name: /nouvelle évaluation/i }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Default barème is 20 - no preview should appear + const scaleInput = dialog.locator('#ev-scale'); + await expect(scaleInput).toHaveValue('20'); + const previewHint = dialog.locator('#ev-scale ~ .form-hint'); + await expect(previewHint).not.toBeVisible(); + + // Change barème to 10 + await scaleInput.fill('10'); + + // Verify equivalence preview appears: (10/10 = 20.0/20) + await expect(previewHint).toBeVisible({ timeout: 5000 }); + await expect(previewHint).toHaveText('(10/10 = 20.0/20)'); + + // Change barème to 5 -> (10/5 = 40.0/20) + await scaleInput.fill('5'); + await expect(previewHint).toHaveText('(10/5 = 40.0/20)'); + + // Change back to 20 -> preview should disappear + await scaleInput.fill('20'); + await expect(previewHint).not.toBeVisible({ timeout: 5000 }); + }); + }); +}); diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 5a5b4d0..511f9ca 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -105,6 +105,7 @@ Tableau de bord {#if isProf} Devoirs + Évaluations {/if} {#if isEleve} Mon EDT @@ -154,6 +155,9 @@ Devoirs + + Évaluations + {/if} {#if isEleve} diff --git a/frontend/src/routes/dashboard/teacher/evaluations/+page.svelte b/frontend/src/routes/dashboard/teacher/evaluations/+page.svelte new file mode 100644 index 0000000..df5c55a --- /dev/null +++ b/frontend/src/routes/dashboard/teacher/evaluations/+page.svelte @@ -0,0 +1,1176 @@ + + + + Mes évaluations - Classeo + + +
+ + + {#if error} +
+ + {error} + +
+ {/if} + +
+ +
+ +
+
+ + {#if isLoading} +
+
+

Chargement des évaluations...

+
+ {:else if evaluations.length === 0} +
+ 📝 + {#if searchTerm} +

Aucun résultat

+

Aucune évaluation ne correspond à votre recherche

+ + {:else} +

Aucune évaluation

+

Commencez par créer votre première évaluation

+ + {/if} +
+ {:else} +
+ {#each evaluations as ev (ev.id)} +
+
+

{ev.title}

+
+ {formatGradeScale(ev.gradeScale)} + x{ev.coefficient} +
+
+ +
+ + 🏫 + {ev.className ?? getClassName(ev.classId)} + + + 📖 + {ev.subjectName ?? getSubjectName(ev.subjectId)} + + + 📅 + {formatDate(ev.evaluationDate)} + +
+ + {#if ev.description} +

{ev.description}

+ {/if} + + {#if ev.status === 'published'} +
+ + +
+ {/if} +
+ {/each} +
+ + {/if} +
+ + +{#if showCreateModal} + + +{/if} + + +{#if showEditModal && editEvaluation} + + +{/if} + + +{#if showDeleteModal && evaluationToDelete} + + +{/if} + +