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' });
+ });
+});