diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 5e101d6..df95f54 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -38,6 +38,7 @@ project: classeo project_key: classeo tracking_system: file-system story_location: _bmad-output/implementation-artifacts +last_updated: 2026-04-17 development_status: # Epic 1: Fondations, Auth & Observabilité (9 stories) @@ -121,8 +122,8 @@ development_status: 6-6-consultation-notes-par-leleve: done 6-7-consultation-notes-par-le-parent: done 6-8-statistiques-enseignant: done - 6-9-grade-voter-et-acces-notes-affectations: review # Débloque tâches différées de 2-6, 2-8, 2-9 - 6-10-statistiques-notes-par-matiere-admin: ready-for-dev # Débloque tâches différées de 2-2 + 6-9-grade-voter-et-acces-notes-affectations: done # Débloque tâches différées de 2-6, 2-8, 2-9 + 6-10-statistiques-notes-par-matiere-admin: review # Débloque tâches différées de 2-2 6-11-audit-trail-evenements-notes: ready-for-dev # Débloque tâches différées de 1-7 6-12-correctifs-mode-competences: ready-for-dev # Patches critiques review 6-5 6-13-acces-evaluations-remplacant: ready-for-dev # UX : navigation évaluations pour le remplaçant (identifié en 6-9) diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 4d1ee82..b25f37a 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -451,6 +451,10 @@ services: App\Administration\Application\Port\GradeExistenceChecker: alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker + # SubjectGradeStatsReader (implémentation Scolarite via SQL) + App\Administration\Application\Port\SubjectGradeStatsReader: + alias: App\Scolarite\Infrastructure\Service\DoctrineSubjectGradeStatsReader + # ActiveRoleStore (session-scoped cache for active role switching) App\Administration\Application\Port\ActiveRoleStore: alias: App\Administration\Infrastructure\Service\CacheActiveRoleStore diff --git a/backend/src/Administration/Application/Port/SubjectGradeStats.php b/backend/src/Administration/Application/Port/SubjectGradeStats.php new file mode 100644 index 0000000..f43b436 --- /dev/null +++ b/backend/src/Administration/Application/Port/SubjectGradeStats.php @@ -0,0 +1,22 @@ +gradeCount > 0 || $this->evaluationCount > 0; + } +} diff --git a/backend/src/Administration/Application/Port/SubjectGradeStatsReader.php b/backend/src/Administration/Application/Port/SubjectGradeStatsReader.php new file mode 100644 index 0000000..00cd3a5 --- /dev/null +++ b/backend/src/Administration/Application/Port/SubjectGradeStatsReader.php @@ -0,0 +1,21 @@ +reader->countForSubject( + TenantId::fromString($query->tenantId), + SubjectId::fromString($query->subjectId), + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsQuery.php b/backend/src/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsQuery.php new file mode 100644 index 0000000..6fa2f8f --- /dev/null +++ b/backend/src/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsQuery.php @@ -0,0 +1,19 @@ +id, @@ -42,6 +46,13 @@ final readonly class SubjectDto updatedAt: $subject->updatedAt, teacherCount: $teacherCount, classCount: $classCount, + evaluationCount: $evaluationCount, + gradeCount: $gradeCount, ); } + + public function hasGrades(): bool + { + return $this->evaluationCount > 0 || $this->gradeCount > 0; + } } diff --git a/backend/src/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessor.php index 61a9e35..50be183 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessor.php @@ -8,13 +8,20 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectCommand; use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectHandler; +use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsHandler; +use App\Administration\Application\Query\GetSubjectGradeStats\GetSubjectGradeStatsQuery; use App\Administration\Domain\Exception\SubjectNotFoundException; use App\Administration\Infrastructure\Api\Resource\SubjectResource; use App\Administration\Infrastructure\Security\SubjectVoter; use App\Shared\Infrastructure\Tenant\TenantContext; use Override; use Ramsey\Uuid\Exception\InvalidUuidStringException; + +use function sprintf; + +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Messenger\MessageBusInterface; @@ -23,9 +30,15 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; /** * Processor API Platform pour supprimer (archiver) une matière. * - * Note: Cette implémentation fait un soft delete (archivage). - * La vérification des notes associées (T6) sera ajoutée ultérieurement - * quand le module Notes sera implémenté. + * Soft delete (archivage). Si des évaluations ou notes sont liées, une confirmation + * explicite est requise via le paramètre `?confirm=true` afin que l'admin ait + * conscience de l'impact. + * + * TOCTOU : la lecture des stats et l'archivage ne sont pas atomiques. Une évaluation + * peut être créée entre la vérification et l'archive — acceptable car : + * 1. L'archive est réversible (flag `deleted_at`, pas de DROP de données) + * 2. Les évaluations/notes créées pendant la fenêtre restent consultables via l'historique + * 3. L'impact affiché à l'admin est au pire sous-estimé, jamais sur-estimé * * @implements ProcessorInterface */ @@ -33,9 +46,11 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface { public function __construct( private ArchiveSubjectHandler $handler, + private GetSubjectGradeStatsHandler $gradeStatsHandler, private TenantContext $tenantContext, private MessageBusInterface $eventBus, private AuthorizationCheckerInterface $authorizationChecker, + private RequestStack $requestStack, ) { } @@ -62,8 +77,20 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface $tenantId = (string) $this->tenantContext->getCurrentTenantId(); try { - // TODO: Vérifier si des notes sont associées (T6) - // et retourner un warning si c'est le cas (via query param ?confirm=true) + if (!$this->isConfirmed()) { + $stats = ($this->gradeStatsHandler)(new GetSubjectGradeStatsQuery( + tenantId: $tenantId, + subjectId: $subjectId, + )); + + if ($stats->hasGrades()) { + throw new ConflictHttpException(sprintf( + 'Cette matière est liée à %d évaluation(s) et %d note(s). Confirmez la suppression pour continuer.', + $stats->evaluationCount, + $stats->gradeCount, + )); + } + } $command = new ArchiveSubjectCommand( subjectId: $subjectId, @@ -72,7 +99,7 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface $subject = ($this->handler)($command); - // Dispatch domain events from the archived aggregate + // Propage les événements domaine (MatiereSupprimee, etc.) émis par l'agrégat. foreach ($subject->pullDomainEvents() as $event) { $this->eventBus->dispatch($event); } @@ -82,4 +109,15 @@ final readonly class DeleteSubjectProcessor implements ProcessorInterface throw new NotFoundHttpException('Matière non trouvée.'); } } + + private function isConfirmed(): bool + { + $request = $this->requestStack->getCurrentRequest(); + + if ($request === null) { + return false; + } + + return $request->query->getBoolean('confirm'); + } } diff --git a/backend/src/Administration/Infrastructure/Api/Resource/SubjectResource.php b/backend/src/Administration/Infrastructure/Api/Resource/SubjectResource.php index b49a843..266e6ad 100644 --- a/backend/src/Administration/Infrastructure/Api/Resource/SubjectResource.php +++ b/backend/src/Administration/Infrastructure/Api/Resource/SubjectResource.php @@ -110,6 +110,18 @@ final class SubjectResource #[ApiProperty(readable: true, writable: false)] public ?int $classCount = null; + /** + * Statistiques : nombre d'évaluations créées pour cette matière. + */ + #[ApiProperty(readable: true, writable: false)] + public ?int $evaluationCount = null; + + /** + * Statistiques : nombre de notes saisies pour cette matière. + */ + #[ApiProperty(readable: true, writable: false)] + public ?int $gradeCount = null; + /** * Permet de supprimer explicitement la couleur lors d'un PATCH. * Si true, la couleur sera mise à null même si color n'est pas fourni. @@ -164,6 +176,9 @@ final class SubjectResource $resource->updatedAt = $dto->updatedAt; $resource->teacherCount = $dto->teacherCount; $resource->classCount = $dto->classCount; + $resource->evaluationCount = $dto->evaluationCount; + $resource->gradeCount = $dto->gradeCount; + $resource->hasGrades = $dto->hasGrades(); return $resource; } diff --git a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php index 5eb074e..fcde8ea 100644 --- a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php +++ b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php @@ -52,7 +52,9 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea s.id, s.name, s.code, s.color, s.description, s.status, s.created_at, s.updated_at, (SELECT COUNT(*) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS teacher_count, - (SELECT COUNT(DISTINCT ta.school_class_id) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS class_count + (SELECT COUNT(DISTINCT ta.school_class_id) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS class_count, + (SELECT COUNT(*) FROM evaluations e WHERE e.subject_id = s.id AND e.tenant_id = s.tenant_id) AS evaluation_count, + (SELECT COUNT(g.id) FROM grades g INNER JOIN evaluations e ON e.id = g.evaluation_id WHERE e.subject_id = s.id AND e.tenant_id = s.tenant_id AND g.tenant_id = s.tenant_id) AS grade_count FROM subjects s WHERE {$whereClause} ORDER BY s.name ASC @@ -85,6 +87,10 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea $teacherCountRaw = $row['teacher_count'] ?? 0; /** @var int|string $classCountRaw */ $classCountRaw = $row['class_count'] ?? 0; + /** @var int|string $evaluationCountRaw */ + $evaluationCountRaw = $row['evaluation_count'] ?? 0; + /** @var int|string $gradeCountRaw */ + $gradeCountRaw = $row['grade_count'] ?? 0; return new SubjectDto( id: $id, @@ -97,6 +103,8 @@ final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsRea updatedAt: new DateTimeImmutable($updatedAt), teacherCount: (int) $teacherCountRaw, classCount: (int) $classCountRaw, + evaluationCount: (int) $evaluationCountRaw, + gradeCount: (int) $gradeCountRaw, ); }, $rows); diff --git a/backend/src/Scolarite/Infrastructure/Controller/TestSeedSubjectWithGradesController.php b/backend/src/Scolarite/Infrastructure/Controller/TestSeedSubjectWithGradesController.php new file mode 100644 index 0000000..4e75876 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Controller/TestSeedSubjectWithGradesController.php @@ -0,0 +1,277 @@ +requireTenantId(); + $payload = $this->decodeJson($request->getContent()); + + $subjectId = $this->requireString($payload, 'subjectId'); + $teacherEmail = $this->requireString($payload, 'teacherEmail'); + $evaluationCount = $this->optionalInt($payload, 'evaluationCount', 2); + $gradesPerEval = $this->optionalInt($payload, 'gradesPerEval', 1); + + if ($evaluationCount < 1 || $gradesPerEval < 0) { + throw new BadRequestHttpException('evaluationCount must be >= 1 and gradesPerEval >= 0'); + } + + $teacherId = $this->resolveTeacherId($tenantId, $teacherEmail); + $classId = $this->findOrCreateClass($tenantId); + + $evaluationIds = []; + for ($i = 1; $i <= $evaluationCount; ++$i) { + $evaluationIds[] = $this->insertEvaluation( + tenantId: $tenantId, + classId: $classId, + subjectId: $subjectId, + teacherId: $teacherId, + title: sprintf('Eval %d', $i), + ); + } + + $gradeIds = []; + foreach ($evaluationIds as $evaluationId) { + for ($j = 1; $j <= $gradesPerEval; ++$j) { + $gradeIds[] = $this->insertGrade( + tenantId: $tenantId, + evaluationId: $evaluationId, + studentId: $teacherId, + value: 10.0 + $j, + ); + } + } + + return new JsonResponse([ + 'classId' => $classId, + 'evaluationIds' => $evaluationIds, + 'gradeIds' => $gradeIds, + ]); + } + + #[Route( + '/test/seed/subject-with-grades/{subjectId}', + name: 'test_seed_subject_with_grades_cleanup', + requirements: ['subjectId' => '[0-9a-f-]{36}'], + methods: ['DELETE'], + )] + public function cleanup(string $subjectId): JsonResponse + { + $tenantId = $this->requireTenantId(); + + $this->connection->executeStatement( + 'DELETE FROM grades + WHERE tenant_id = :tenant + AND evaluation_id IN ( + SELECT id FROM evaluations WHERE tenant_id = :tenant AND subject_id = :subject + )', + ['tenant' => $tenantId, 'subject' => $subjectId], + ); + + $this->connection->executeStatement( + 'DELETE FROM evaluations WHERE tenant_id = :tenant AND subject_id = :subject', + ['tenant' => $tenantId, 'subject' => $subjectId], + ); + + return new JsonResponse(null, 204); + } + + private function requireTenantId(): string + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + return (string) $this->tenantContext->getCurrentTenantId(); + } + + /** + * @return array + */ + private function decodeJson(string $raw): array + { + if ($raw === '') { + return []; + } + + try { + $decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new BadRequestHttpException('Invalid JSON payload: ' . $e->getMessage(), $e); + } + + if (!is_array($decoded)) { + throw new BadRequestHttpException('Payload must be a JSON object'); + } + + $result = []; + foreach ($decoded as $key => $value) { + if (!is_string($key)) { + throw new BadRequestHttpException('Payload must be a JSON object (got a list)'); + } + $result[$key] = $value; + } + + return $result; + } + + /** + * @param array $payload + */ + private function requireString(array $payload, string $key): string + { + $value = $payload[$key] ?? null; + if (!is_string($value) || $value === '') { + throw new BadRequestHttpException(sprintf('%s required (non-empty string)', $key)); + } + + return $value; + } + + /** + * @param array $payload + */ + private function optionalInt(array $payload, string $key, int $default): int + { + $value = $payload[$key] ?? $default; + if (!is_int($value)) { + throw new BadRequestHttpException(sprintf('%s must be an integer', $key)); + } + + return $value; + } + + private function resolveTeacherId(string $tenantId, string $email): string + { + /** @var string|false $teacherId */ + $teacherId = $this->connection->fetchOne( + 'SELECT id FROM users WHERE tenant_id = :tenant AND email = :email LIMIT 1', + ['tenant' => $tenantId, 'email' => $email], + ); + + if ($teacherId === false) { + throw new BadRequestHttpException(sprintf( + 'No user found for email "%s" in current tenant.', + $email, + )); + } + + return $teacherId; + } + + private function findOrCreateClass(string $tenantId): string + { + /** @var string|false $existing */ + $existing = $this->connection->fetchOne( + 'SELECT id FROM school_classes WHERE tenant_id = :tenant LIMIT 1', + ['tenant' => $tenantId], + ); + + if ($existing !== false) { + return $existing; + } + + $classId = Uuid::uuid4()->toString(); + $this->connection->executeStatement( + "INSERT INTO school_classes + (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) + VALUES (:id, :tenant, :tenant, :tenant, 'Classe E2E', '6e', 'active', NOW(), NOW())", + ['id' => $classId, 'tenant' => $tenantId], + ); + + return $classId; + } + + private function insertEvaluation( + string $tenantId, + string $classId, + string $subjectId, + string $teacherId, + string $title, + ): string { + $id = Uuid::uuid4()->toString(); + $this->connection->executeStatement( + 'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date) + VALUES (:id, :tenant, :class, :subject, :teacher, :title, CURRENT_DATE)', + [ + 'id' => $id, + 'tenant' => $tenantId, + 'class' => $classId, + 'subject' => $subjectId, + 'teacher' => $teacherId, + 'title' => $title, + ], + ); + + return $id; + } + + private function insertGrade( + string $tenantId, + string $evaluationId, + string $studentId, + float $value, + ): string { + $id = Uuid::uuid4()->toString(); + $this->connection->executeStatement( + 'INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, created_by) + VALUES (:id, :tenant, :eval, :student, :value, :student)', + [ + 'id' => $id, + 'tenant' => $tenantId, + 'eval' => $evaluationId, + 'student' => $studentId, + 'value' => $value, + ], + ); + + return $id; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReader.php b/backend/src/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReader.php new file mode 100644 index 0000000..e6a9fa9 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReader.php @@ -0,0 +1,54 @@ +connection->fetchOne( + 'SELECT COUNT(*) FROM evaluations WHERE subject_id = :subject_id AND tenant_id = :tenant_id', + [ + 'subject_id' => (string) $subjectId, + 'tenant_id' => (string) $tenantId, + ], + ); + + /** @var int|string|false $gradeCountRaw */ + $gradeCountRaw = $this->connection->fetchOne( + 'SELECT COUNT(g.id) + FROM grades g + INNER JOIN evaluations e ON e.id = g.evaluation_id + WHERE e.subject_id = :subject_id + AND e.tenant_id = :tenant_id + AND g.tenant_id = :tenant_id', + [ + 'subject_id' => (string) $subjectId, + 'tenant_id' => (string) $tenantId, + ], + ); + + return new SubjectGradeStats( + evaluationCount: (int) $evaluationCountRaw, + gradeCount: (int) $gradeCountRaw, + ); + } +} diff --git a/backend/tests/Functional/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReaderTest.php b/backend/tests/Functional/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReaderTest.php new file mode 100644 index 0000000..64765e6 --- /dev/null +++ b/backend/tests/Functional/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReaderTest.php @@ -0,0 +1,136 @@ +get(Connection::class); + $this->sharedConnection = $connection; + + /** @var PaginatedSubjectsReader $reader */ + $reader = static::getContainer()->get(PaginatedSubjectsReader::class); + $this->reader = $reader; + } + + protected function tearDown(): void + { + $this->cleanupSubjectStatsData([self::TENANT_A, self::TENANT_B]); + + parent::tearDown(); + } + + protected function connection(): Connection + { + return $this->sharedConnection; + } + + #[Test] + public function paginatedResultExposesZeroStatsWhenSubjectHasNoData(): void + { + $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Matière vide', 'EMPTY'); + + $result = $this->reader->findPaginated( + tenantId: self::TENANT_A, + schoolId: self::SCHOOL_ID, + search: 'Matière vide', + page: 1, + limit: 30, + ); + + self::assertCount(1, $result->items); + $dto = $result->items[0]; + self::assertSame('Matière vide', $dto->name); + self::assertSame(0, $dto->teacherCount); + self::assertSame(0, $dto->classCount); + self::assertSame(0, $dto->evaluationCount); + self::assertSame(0, $dto->gradeCount); + self::assertFalse($dto->hasGrades()); + } + + #[Test] + public function paginatedResultCountsEvaluationsGradesTeachersAndClassesPerSubject(): void + { + $subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Mathématiques', 'MATH2'); + $classId = $this->insertClass(self::TENANT_A, self::SCHOOL_ID); + $teacherId = $this->insertUser(self::TENANT_A, 'paginated-teacher@test.local'); + $studentId = $this->insertUser(self::TENANT_A, 'paginated-student@test.local'); + + $this->insertTeacherAssignment(self::TENANT_A, $teacherId, $classId, $subjectId, self::SCHOOL_ID); + + $eval1 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Eval 1'); + $eval2 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Eval 2'); + $this->insertGrade(self::TENANT_A, $eval1, $studentId, 15.0); + $this->insertGrade(self::TENANT_A, $eval2, $studentId, 18.0); + + $result = $this->reader->findPaginated( + tenantId: self::TENANT_A, + schoolId: self::SCHOOL_ID, + search: 'Mathématiques', + page: 1, + limit: 30, + ); + + self::assertCount(1, $result->items); + $dto = $result->items[0]; + self::assertSame(1, $dto->teacherCount); + self::assertSame(1, $dto->classCount); + self::assertSame(2, $dto->evaluationCount); + self::assertSame(2, $dto->gradeCount); + self::assertTrue($dto->hasGrades()); + } + + #[Test] + public function paginatedResultDoesNotLeakStatsFromOtherTenants(): void + { + $subjectA = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Isolation', 'ISO'); + + // Données tenant B avec le même nom de matière + $subjectB = $this->insertSubject(self::TENANT_B, self::SCHOOL_ID, 'Isolation', 'ISO'); + $classB = $this->insertClass(self::TENANT_B, self::SCHOOL_ID); + $teacherB = $this->insertUser(self::TENANT_B, 'isolation-teacher@test.local'); + $studentB = $this->insertUser(self::TENANT_B, 'isolation-student@test.local'); + $this->insertTeacherAssignment(self::TENANT_B, $teacherB, $classB, $subjectB, self::SCHOOL_ID); + $evalB = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B'); + $this->insertGrade(self::TENANT_B, $evalB, $studentB, 10.0); + + $resultA = $this->reader->findPaginated( + tenantId: self::TENANT_A, + schoolId: self::SCHOOL_ID, + search: 'Isolation', + page: 1, + limit: 30, + ); + + self::assertCount(1, $resultA->items); + $dto = $resultA->items[0]; + self::assertSame($subjectA, $dto->id); + self::assertSame(0, $dto->teacherCount); + self::assertSame(0, $dto->classCount); + self::assertSame(0, $dto->evaluationCount); + self::assertSame(0, $dto->gradeCount); + } +} diff --git a/backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php b/backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php new file mode 100644 index 0000000..ab4c75a --- /dev/null +++ b/backend/tests/Functional/Helpers/SubjectStatsSeedingTrait.php @@ -0,0 +1,174 @@ +toString(); + $this->connection()->executeStatement( + "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) + VALUES (:id, :tenant, :school, :name, :code, 'active', NOW(), NOW())", + [ + 'id' => $id, + 'tenant' => $tenantId, + 'school' => $schoolId, + 'name' => $name, + 'code' => $code, + ], + ); + + return $id; + } + + protected function insertClass(string $tenantId, string $schoolId): string + { + $id = Uuid::uuid4()->toString(); + $this->connection()->executeStatement( + "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) + VALUES (:id, :tenant, :school, :year, '6e A', '6e', 'active', NOW(), NOW())", + [ + 'id' => $id, + 'tenant' => $tenantId, + 'school' => $schoolId, + 'year' => $schoolId, + ], + ); + + return $id; + } + + protected function insertUser(string $tenantId, string $email): string + { + $id = Uuid::uuid4()->toString(); + $this->connection()->executeStatement( + "INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, statut, school_name, image_rights_status, created_at, updated_at) + VALUES (:id, :tenant, :email, 'Test', 'User', '[\"ROLE_USER\"]', 'active', 'Test School', 'not_requested', NOW(), NOW())", + [ + 'id' => $id, + 'tenant' => $tenantId, + 'email' => $email, + ], + ); + + return $id; + } + + protected function insertTeacherAssignment( + string $tenantId, + string $teacherId, + string $classId, + string $subjectId, + string $academicYearId, + ): void { + $this->connection()->executeStatement( + "INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, start_date, status, created_at, updated_at) + VALUES (:id, :tenant, :teacher, :class, :subject, :year, NOW(), 'active', NOW(), NOW())", + [ + 'id' => Uuid::uuid4()->toString(), + 'tenant' => $tenantId, + 'teacher' => $teacherId, + 'class' => $classId, + 'subject' => $subjectId, + 'year' => $academicYearId, + ], + ); + } + + protected function insertEvaluation( + string $tenantId, + string $classId, + string $subjectId, + string $teacherId, + string $title, + ): string { + $id = Uuid::uuid4()->toString(); + $this->connection()->executeStatement( + 'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date) + VALUES (:id, :tenant, :class, :subject, :teacher, :title, CURRENT_DATE)', + [ + 'id' => $id, + 'tenant' => $tenantId, + 'class' => $classId, + 'subject' => $subjectId, + 'teacher' => $teacherId, + 'title' => $title, + ], + ); + + return $id; + } + + protected function insertGrade(string $tenantId, string $evaluationId, string $studentId, float $value): void + { + $this->connection()->executeStatement( + 'INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, created_by) + VALUES (:id, :tenant, :eval, :student, :value, :student)', + [ + 'id' => Uuid::uuid4()->toString(), + 'tenant' => $tenantId, + 'eval' => $evaluationId, + 'student' => $studentId, + 'value' => $value, + ], + ); + } + + /** + * Purge les tables de seeding pour les tenants donnés. + * + * L'ordre respecte les clés étrangères (grades → evaluations → teacher_assignments → subjects → school_classes → users). + * + * @param list $tenantIds + */ + protected function cleanupSubjectStatsData(array $tenantIds): void + { + if ($tenantIds === []) { + return; + } + + $placeholders = implode(',', array_map(static fn (int $i) => ':tenant_' . $i, array_keys($tenantIds))); + $params = []; + foreach ($tenantIds as $i => $tenantId) { + $params['tenant_' . $i] = $tenantId; + } + + foreach ( + [ + 'grades', + 'evaluations', + 'teacher_assignments', + 'subjects', + 'school_classes', + 'users', + ] as $table + ) { + $this->connection()->executeStatement( + sprintf('DELETE FROM %s WHERE tenant_id IN (%s)', $table, $placeholders), + $params, + ); + } + } +} diff --git a/backend/tests/Functional/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReaderTest.php b/backend/tests/Functional/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReaderTest.php new file mode 100644 index 0000000..0c19a14 --- /dev/null +++ b/backend/tests/Functional/Scolarite/Infrastructure/Service/DoctrineSubjectGradeStatsReaderTest.php @@ -0,0 +1,118 @@ +get(Connection::class); + $this->sharedConnection = $connection; + + /** @var SubjectGradeStatsReader $reader */ + $reader = static::getContainer()->get(SubjectGradeStatsReader::class); + $this->reader = $reader; + } + + protected function tearDown(): void + { + $this->cleanupSubjectStatsData([self::TENANT_A, self::TENANT_B]); + + parent::tearDown(); + } + + protected function connection(): Connection + { + return $this->sharedConnection; + } + + #[Test] + public function returnsZeroStatsWhenSubjectHasNoEvaluation(): void + { + $subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Sans évaluation', 'EMPTY'); + + $stats = $this->reader->countForSubject( + TenantId::fromString(self::TENANT_A), + SubjectId::fromString($subjectId), + ); + + self::assertSame(0, $stats->evaluationCount); + self::assertSame(0, $stats->gradeCount); + self::assertFalse($stats->hasGrades()); + } + + #[Test] + public function countsEvaluationsAndGradesLinkedToSubject(): void + { + $subjectId = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Maths', 'MATH'); + $classId = $this->insertClass(self::TENANT_A, self::SCHOOL_ID); + $teacherId = $this->insertUser(self::TENANT_A, 'teacher-math@test.local'); + $studentAId = $this->insertUser(self::TENANT_A, 'student-a@test.local'); + $studentBId = $this->insertUser(self::TENANT_A, 'student-b@test.local'); + + $eval1 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Contrôle 1'); + $eval2 = $this->insertEvaluation(self::TENANT_A, $classId, $subjectId, $teacherId, 'Contrôle 2'); + + $this->insertGrade(self::TENANT_A, $eval1, $studentAId, 15.0); + $this->insertGrade(self::TENANT_A, $eval1, $studentBId, 12.5); + $this->insertGrade(self::TENANT_A, $eval2, $studentAId, 18.0); + + $stats = $this->reader->countForSubject( + TenantId::fromString(self::TENANT_A), + SubjectId::fromString($subjectId), + ); + + self::assertSame(2, $stats->evaluationCount); + self::assertSame(3, $stats->gradeCount); + self::assertTrue($stats->hasGrades()); + } + + #[Test] + public function doesNotCountDataFromOtherTenants(): void + { + $subjectA = $this->insertSubject(self::TENANT_A, self::SCHOOL_ID, 'Histoire', 'HIST'); + $subjectB = $this->insertSubject(self::TENANT_B, self::SCHOOL_ID, 'Histoire', 'HIST'); + + // Tenant B seed : 3 évaluations + 2 notes sur son subject + $classB = $this->insertClass(self::TENANT_B, self::SCHOOL_ID); + $teacherB = $this->insertUser(self::TENANT_B, 'teacher-b@test.local'); + $studentB = $this->insertUser(self::TENANT_B, 'student-b-isolation@test.local'); + $evalB1 = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B1'); + $evalB2 = $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B2'); + $this->insertEvaluation(self::TENANT_B, $classB, $subjectB, $teacherB, 'Eval B3'); + $this->insertGrade(self::TENANT_B, $evalB1, $studentB, 10.0); + $this->insertGrade(self::TENANT_B, $evalB2, $studentB, 14.0); + + $statsA = $this->reader->countForSubject( + TenantId::fromString(self::TENANT_A), + SubjectId::fromString($subjectA), + ); + + self::assertSame(0, $statsA->evaluationCount, 'Pas de fuite des évaluations du tenant B'); + self::assertSame(0, $statsA->gradeCount, 'Pas de fuite des notes du tenant B'); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsHandlerTest.php new file mode 100644 index 0000000..803dcb5 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetSubjectGradeStats/GetSubjectGradeStatsHandlerTest.php @@ -0,0 +1,128 @@ +createReader(evaluations: 0, grades: 0)); + + $stats = $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(0, $stats->evaluationCount); + self::assertSame(0, $stats->gradeCount); + self::assertFalse($stats->hasGrades()); + } + + #[Test] + public function itReturnsCountsWhenEvaluationsExist(): void + { + $handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 3, grades: 42)); + + $stats = $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(3, $stats->evaluationCount); + self::assertSame(42, $stats->gradeCount); + self::assertTrue($stats->hasGrades()); + } + + #[Test] + public function itConsidersSubjectWithEvaluationsButNoGradesAsHavingImpact(): void + { + $handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 2, grades: 0)); + + $stats = $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(2, $stats->evaluationCount); + self::assertSame(0, $stats->gradeCount); + self::assertTrue($stats->hasGrades(), 'Une évaluation sans notes reste un impact à signaler.'); + } + + #[Test] + public function itConsidersSubjectWithGradesButNoEvaluationsAsHavingImpact(): void + { + // Théoriquement impossible via la FK grades.evaluation_id → evaluations(id), + // mais on couvre la logique `||` du value object contre toute régression. + $handler = new GetSubjectGradeStatsHandler($this->createReader(evaluations: 0, grades: 5)); + + $stats = $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(0, $stats->evaluationCount); + self::assertSame(5, $stats->gradeCount); + self::assertTrue($stats->hasGrades()); + } + + #[Test] + public function itPassesQueryParamsToReader(): void + { + $reader = new class implements SubjectGradeStatsReader { + public ?string $receivedTenantId = null; + public ?string $receivedSubjectId = null; + + #[Override] + public function countForSubject(TenantId $tenantId, SubjectId $subjectId): SubjectGradeStats + { + $this->receivedTenantId = (string) $tenantId; + $this->receivedSubjectId = (string) $subjectId; + + return new SubjectGradeStats(0, 0); + } + }; + + $handler = new GetSubjectGradeStatsHandler($reader); + + $handler(new GetSubjectGradeStatsQuery( + tenantId: self::TENANT_ID, + subjectId: self::SUBJECT_ID, + )); + + self::assertSame(self::TENANT_ID, $reader->receivedTenantId); + self::assertSame(self::SUBJECT_ID, $reader->receivedSubjectId); + } + + private function createReader(int $evaluations, int $grades): SubjectGradeStatsReader + { + return new class($evaluations, $grades) implements SubjectGradeStatsReader { + public function __construct( + private int $evaluations, + private int $grades, + ) { + } + + #[Override] + public function countForSubject(TenantId $tenantId, SubjectId $subjectId): SubjectGradeStats + { + return new SubjectGradeStats($this->evaluations, $this->grades); + } + }; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessorTest.php new file mode 100644 index 0000000..858d77e --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/DeleteSubjectProcessorTest.php @@ -0,0 +1,232 @@ +subjectRepository = new InMemorySubjectRepository(); + $this->clock = new class implements Clock { + #[Override] + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-04-16 10:00:00'); + } + }; + + $this->tenantContext = new TenantContext(); + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_ID), + subdomain: 'ecole-alpha', + databaseUrl: 'postgresql://test', + )); + } + + #[Test] + public function itDeletesSubjectWhenNoGradesExist(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(statsReader: $this->statsReader(0, 0)); + + $result = $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + + self::assertNull($result); + $reloaded = $this->subjectRepository->get($subject->id); + self::assertNotNull($reloaded->deletedAt); + } + + #[Test] + public function itThrowsConflictWhenGradesExistAndConfirmNotSet(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(statsReader: $this->statsReader(3, 42)); + + $this->expectException(ConflictHttpException::class); + $this->expectExceptionMessageMatches('/3 évaluation\(s\) et 42 note\(s\)/'); + + $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + } + + #[Test] + public function itDeletesSubjectWhenConfirmIsTrue(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor( + statsReader: $this->statsReader(3, 42), + request: new Request(query: ['confirm' => 'true']), + ); + + $result = $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + + self::assertNull($result); + $reloaded = $this->subjectRepository->get($subject->id); + self::assertNotNull($reloaded->deletedAt); + } + + #[Test] + public function itRejectsUnauthorizedAccess(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(granted: false); + + $this->expectException(AccessDeniedHttpException::class); + + $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + } + + #[Test] + public function itRejectsWhenTenantNotSet(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(tenantContext: new TenantContext()); + + $this->expectException(UnauthorizedHttpException::class); + + $processor->process( + SubjectResource::fromDomain($subject), + new Delete(), + ['id' => (string) $subject->id], + ); + } + + #[Test] + public function itReturnsNotFoundWhenIdMissing(): void + { + $subject = $this->persistSubject(); + $processor = $this->createProcessor(); + + $this->expectException(NotFoundHttpException::class); + + $processor->process(SubjectResource::fromDomain($subject), new Delete(), []); + } + + private function persistSubject(): Subject + { + $subject = Subject::creer( + tenantId: DomainTenantId::fromString(self::TENANT_ID), + schoolId: SchoolId::fromString(self::SCHOOL_ID), + name: new SubjectName('Mathématiques'), + code: new SubjectCode('MATH'), + color: null, + createdAt: $this->clock->now(), + ); + + $this->subjectRepository->save($subject); + + return $subject; + } + + private function statsReader(int $evaluations, int $grades): SubjectGradeStatsReader + { + return new class($evaluations, $grades) implements SubjectGradeStatsReader { + public function __construct( + private int $evaluations, + private int $grades, + ) { + } + + #[Override] + public function countForSubject( + DomainTenantId $tenantId, + SubjectId $subjectId, + ): SubjectGradeStats { + return new SubjectGradeStats($this->evaluations, $this->grades); + } + }; + } + + private function createProcessor( + bool $granted = true, + ?TenantContext $tenantContext = null, + ?SubjectGradeStatsReader $statsReader = null, + ?Request $request = null, + ): DeleteSubjectProcessor { + $archiveHandler = new ArchiveSubjectHandler($this->subjectRepository, $this->clock); + $gradeStatsHandler = new GetSubjectGradeStatsHandler( + $statsReader ?? $this->statsReader(0, 0), + ); + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch')->willReturnCallback( + static fn (object $message) => new Envelope($message), + ); + + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted') + ->with(SubjectVoter::DELETE) + ->willReturn($granted); + + $requestStack = new RequestStack(); + if ($request !== null) { + $requestStack->push($request); + } + + return new DeleteSubjectProcessor( + $archiveHandler, + $gradeStatsHandler, + $tenantContext ?? $this->tenantContext, + $eventBus, + $authorizationChecker, + $requestStack, + ); + } +} diff --git a/frontend/e2e/subjects.spec.ts b/frontend/e2e/subjects.spec.ts index b68ee44..9fbc81c 100644 --- a/frontend/e2e/subjects.spec.ts +++ b/frontend/e2e/subjects.spec.ts @@ -456,21 +456,95 @@ test.describe('Subjects Management (Story 2.2)', () => { // AC3: Deletion with warning for subjects with grades // ============================================================================ test.describe('AC3: Deletion with warning for grades', () => { - // SKIP REASON: The Grades module is not yet implemented. - // HasGradesForSubjectHandler currently returns false (stub), so all subjects - // appear without grades and can be deleted without warning. This test will - // be enabled once the Grades module allows recording grades for subjects. - // - // When enabled, this test should: - // 1. Create a subject - // 2. Add at least one grade to it - // 3. Attempt to delete the subject - // 4. Verify the warning message about grades - // 5. Require explicit confirmation - test.skip('shows warning when trying to delete subject with grades', async ({ page }) => { + // Subjects seeded by tests in this describe — nettoyés en afterAll via + // l'endpoint DELETE /test/seed/subject-with-grades/{subjectId} qui purge + // les évaluations et notes associées (le subject lui-même étant soft-deleté + // par le flow normal via la modale). + const seededSubjectIds: string[] = []; + + // Helper to extract UUIDs from `dbal:run-sql` output — garde pour le + // `subjectId` créé par l'UI (une seule requête par test vs. 4 auparavant). + function firstUuidFromSql(sql: string): string | null { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); + const match = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/); + return match ? match[0] : null; + } + + test.afterAll(async ({ request }) => { + for (const subjectId of seededSubjectIds) { + try { + await request.delete(`${ALPHA_URL}/test/seed/subject-with-grades/${subjectId}`); + } catch { + // Best-effort : l'absence de cleanup ne doit pas faire échouer la suite. + } + } + seededSubjectIds.length = 0; + }); + + test('shows impact warning with evaluation and grade counts before deletion', async ({ + page + }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/subjects`); - // Implementation pending Grades module + + // Create a subject for which we will seed evaluations/grades + await openNewSubjectDialog(page); + const subjectName = `WithGrades-${Date.now()}`; + const subjectCode = `WG${Date.now() % 10000}`; + await page.locator('#subject-name').fill(subjectName); + await page.locator('#subject-code').fill(subjectCode); + await page.getByRole('button', { name: /créer la matière/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + + const subjectId = firstUuidFromSql( + `SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' AND code = '${subjectCode.toUpperCase()}' LIMIT 1` + ); + if (!subjectId) { + throw new Error('Failed to resolve subjectId'); + } + seededSubjectIds.push(subjectId); + + // Seed classe + 2 évaluations + 2 notes en UN appel HTTP au lieu de 6+ + // `docker exec dbal:run-sql`. Gain : ~30-60 s → ~5-10 s par test. + const seedResponse = await page.request.post( + `${ALPHA_URL}/test/seed/subject-with-grades`, + { + data: { + subjectId, + teacherEmail: ADMIN_EMAIL, + evaluationCount: 2, + gradesPerEval: 1 + } + } + ); + if (!seedResponse.ok()) { + throw new Error( + `Seed endpoint failed: ${seedResponse.status()} ${await seedResponse.text()}` + ); + } + + clearCache(); + await page.reload(); + + const subjectCard = page.locator('.subject-card', { hasText: subjectName }); + await subjectCard.getByRole('button', { name: /supprimer/i }).click(); + + const deleteModal = page.getByRole('alertdialog'); + await expect(deleteModal).toBeVisible({ timeout: 10000 }); + + const impact = deleteModal.getByTestId('delete-subject-impact'); + await expect(impact).toBeVisible(); + + // AC1 exact wording: "X évaluations et Y notes seront affectées" + const summary = deleteModal.getByTestId('delete-subject-impact-summary'); + await expect(summary).toHaveText(/2 évaluations et 2 notes seront affectées\./); + + await deleteModal.getByRole('button', { name: /supprimer/i }).click(); + await expect(deleteModal).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(subjectName)).not.toBeVisible({ timeout: 10000 }); }); }); diff --git a/frontend/src/lib/features/subjects/api/deleteSubject.ts b/frontend/src/lib/features/subjects/api/deleteSubject.ts new file mode 100644 index 0000000..89468bc --- /dev/null +++ b/frontend/src/lib/features/subjects/api/deleteSubject.ts @@ -0,0 +1,72 @@ +/** + * Logique de suppression d'une matière, extraite du composant Svelte + * pour être testable unitairement. + * + * Flux : + * - Si la liste indique `hasGrades === true`, on envoie `?confirm=true` pour forcer + * le backend à accepter la suppression (confirmation déjà donnée par l'admin). + * - Si `hasGrades` est `null` (stats non chargées) ou `false`, on envoie un DELETE simple : + * le backend décidera. En cas de stats obsolètes côté UI, il renverra 409. + * - 409 = les stats côté liste étaient périmées ; on renvoie `status: 'conflict'` pour + * que l'appelant rafraîchisse la liste et affiche un message explicatif plutôt qu'une + * erreur générique. + */ + +export interface DeleteSubjectInput { + id: string; + hasGrades: boolean | null; +} + +export type DeleteSubjectResult = + | { status: 'success' } + | { status: 'conflict'; message: string } + | { status: 'error'; message: string }; + +export type DeleteSubjectFetch = (url: string, init?: RequestInit) => Promise; + +export async function deleteSubject( + subject: DeleteSubjectInput, + fetchFn: DeleteSubjectFetch, + apiBaseUrl: string +): Promise { + const url = + subject.hasGrades === true + ? `${apiBaseUrl}/subjects/${subject.id}?confirm=true` + : `${apiBaseUrl}/subjects/${subject.id}`; + + const response = await fetchFn(url, { method: 'DELETE' }); + + if (response.ok) { + return { status: 'success' }; + } + + const message = await extractErrorMessage(response); + + if (response.status === 409) { + return { status: 'conflict', message }; + } + + return { status: 'error', message }; +} + +async function extractErrorMessage(response: Response): Promise { + const fallback = `Erreur lors de la suppression (${response.status})`; + try { + const errorData = await response.json(); + if (typeof errorData === 'object' && errorData !== null) { + const record = errorData as Record; + if (typeof record['hydra:description'] === 'string') { + return record['hydra:description']; + } + if (typeof record['message'] === 'string') { + return record['message']; + } + if (typeof record['detail'] === 'string') { + return record['detail']; + } + } + } catch { + // JSON parsing failed, keep fallback + } + return fallback; +} diff --git a/frontend/src/routes/admin/subjects/+page.svelte b/frontend/src/routes/admin/subjects/+page.svelte index d17e208..98bc519 100644 --- a/frontend/src/routes/admin/subjects/+page.svelte +++ b/frontend/src/routes/admin/subjects/+page.svelte @@ -5,6 +5,7 @@ import { authenticatedFetch } from '$lib/auth'; import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; + import { deleteSubject } from '$lib/features/subjects/api/deleteSubject'; import { untrack } from 'svelte'; // Types @@ -17,6 +18,9 @@ status: string; teacherCount: number | null; classCount: number | null; + evaluationCount: number | null; + gradeCount: number | null; + hasGrades: boolean | null; createdAt: string; updatedAt: string; } @@ -177,29 +181,26 @@ async function handleConfirmDelete() { if (!subjectToDelete) return; + if (isDeleting) return; try { isDeleting = true; - const apiUrl = getApiBaseUrl(); - const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectToDelete.id}`, { - method: 'DELETE' - }); + const result = await deleteSubject( + { id: subjectToDelete.id, hasGrades: subjectToDelete.hasGrades }, + authenticatedFetch, + getApiBaseUrl() + ); - if (!response.ok) { - let errorMessage = `Erreur lors de la suppression (${response.status})`; - try { - const errorData = await response.json(); - if (errorData['hydra:description']) { - errorMessage = errorData['hydra:description']; - } else if (errorData.message) { - errorMessage = errorData.message; - } else if (errorData.detail) { - errorMessage = errorData.detail; - } - } catch { - // JSON parsing failed, keep default message - } - throw new Error(errorMessage); + if (result.status === 'conflict') { + // Stats côté UI périmées : rafraîchir la liste pour que l'admin voie l'impact réel. + closeDeleteModal(); + await loadSubjects(); + error = `${result.message} La liste a été rafraîchie, réessayez pour voir l'impact exact.`; + return; + } + + if (result.status === 'error') { + throw new Error(result.message); } closeDeleteModal(); @@ -313,6 +314,10 @@ 🏫 {subject.classCount ?? 0} + + 📝 + {subject.evaluationCount ?? 0} + {subject.status === 'active' ? 'Active' : 'Archivée'} @@ -448,7 +453,9 @@ role="alertdialog" aria-modal="true" aria-labelledby="delete-modal-title" - aria-describedby="delete-modal-description" + aria-describedby={subjectToDelete.hasGrades + ? 'delete-modal-description delete-subject-impact' + : 'delete-modal-description'} tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }} @@ -463,6 +470,23 @@ Êtes-vous sûr de vouloir supprimer la matière {subjectToDelete.name} ({subjectToDelete.code}) ?

+ {#if subjectToDelete.hasGrades} +
+

+ ⚠️ {subjectToDelete.evaluationCount ?? 0} + évaluation{(subjectToDelete.evaluationCount ?? 0) > 1 ? 's' : ''} et + {subjectToDelete.gradeCount ?? 0} + note{(subjectToDelete.gradeCount ?? 0) > 1 ? 's' : ''} seront affectées. +

+

+ Ces données resteront consultables dans l'historique mais la matière ne sera plus sélectionnable. +

+
+ {/if}

Cette action est irréversible.

@@ -906,4 +930,26 @@ font-size: 0.875rem; color: #6b7280; } + + .delete-impact { + margin: 1rem 0 0; + padding: 0.875rem 1rem; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 0.375rem; + } + + .delete-impact-title { + margin: 0; + font-weight: 600; + color: #92400e; + font-size: 0.875rem; + } + + .delete-impact-note { + margin: 0.5rem 0 0; + font-size: 0.8125rem; + color: #78350f; + font-style: italic; + } diff --git a/frontend/tests/unit/lib/features/subjects/api/deleteSubject.test.ts b/frontend/tests/unit/lib/features/subjects/api/deleteSubject.test.ts new file mode 100644 index 0000000..1e87fbe --- /dev/null +++ b/frontend/tests/unit/lib/features/subjects/api/deleteSubject.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from 'vitest'; +import { deleteSubject } from '$lib/features/subjects/api/deleteSubject'; + +const API = 'http://test.classeo.local:18000/api'; + +function makeResponse(status: number, body?: Record): Response { + const init: ResponseInit = { status }; + if (body !== undefined) { + init.headers = { 'Content-Type': 'application/json' }; + } + return new Response(body !== undefined ? JSON.stringify(body) : null, init); +} + +describe('deleteSubject', () => { + it('envoie DELETE sans confirm quand hasGrades=false', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204)); + + const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API); + + expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc`, { method: 'DELETE' }); + expect(result).toEqual({ status: 'success' }); + }); + + it('envoie DELETE sans confirm quand hasGrades=null (pour laisser le backend décider)', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204)); + + await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API); + + expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc`, { method: 'DELETE' }); + }); + + it('ajoute ?confirm=true quand hasGrades=true', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(204)); + + await deleteSubject({ id: 'abc', hasGrades: true }, fetchFn, API); + + expect(fetchFn).toHaveBeenCalledWith(`${API}/subjects/abc?confirm=true`, { method: 'DELETE' }); + }); + + it('retourne un status conflict avec le message backend sur 409', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce( + makeResponse(409, { + 'hydra:description': + 'Cette matière est liée à 3 évaluation(s) et 12 note(s). Confirmez la suppression pour continuer.' + }) + ); + + const result = await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API); + + expect(result).toEqual({ + status: 'conflict', + message: + 'Cette matière est liée à 3 évaluation(s) et 12 note(s). Confirmez la suppression pour continuer.' + }); + }); + + it('retourne un status error sur autre code HTTP', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(makeResponse(500, { detail: 'Internal server error' })); + + const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API); + + expect(result).toEqual({ status: 'error', message: 'Internal server error' }); + }); + + it('fallback message si pas de payload JSON', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(403)); + + const result = await deleteSubject({ id: 'abc', hasGrades: false }, fetchFn, API); + + expect(result).toEqual({ status: 'error', message: 'Erreur lors de la suppression (403)' }); + }); + + it('extrait message depuis le champ `message`', async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(makeResponse(409, { message: 'Conflit' })); + + const result = await deleteSubject({ id: 'abc', hasGrades: null }, fetchFn, API); + + expect(result).toEqual({ status: 'conflict', message: 'Conflit' }); + }); +});