From 68179a929f899c3edd4bff607f9d08d8eb59c14b Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sun, 15 Mar 2026 14:20:48 +0100 Subject: [PATCH] feat: Permettre aux enseignants de dupliquer un devoir vers plusieurs classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un enseignant qui donne le même travail à plusieurs classes devait jusqu'ici recréer manuellement chaque devoir. La duplication permet de sélectionner les classes cibles, d'ajuster les dates d'échéance par classe, et de créer tous les devoirs en une seule opération atomique (transaction). La validation s'effectue par classe (affectation enseignant, date d'échéance) avec un rapport d'erreurs détaillé. L'infrastructure de warnings est prête pour les règles de timing de la Story 5.3. Le filtrage par classe dans la liste des devoirs passe côté serveur pour rester compatible avec la pagination. --- backend/config/services.yaml | 6 + .../DuplicateHomeworkCommand.php | 21 ++ .../DuplicateHomeworkHandler.php | 166 +++++++++ .../DuplicateHomework/DuplicationResult.php | 21 ++ .../DuplicationValidationException.php | 40 ++ .../HomeworkAttachmentRepository.php | 16 + .../Domain/Service/HomeworkDuplicator.php | 44 +++ .../ValueObject/ClassValidationResult.php | 28 ++ .../DuplicateHomeworkController.php | 137 +++++++ .../Provider/HomeworkCollectionProvider.php | 23 ++ .../DoctrineHomeworkAttachmentRepository.php | 80 ++++ .../InMemoryHomeworkAttachmentRepository.php | 28 ++ .../Scolarite/Api/HomeworkEndpointsTest.php | 126 ++++++- .../DuplicateHomeworkHandlerTest.php | 348 ++++++++++++++++++ .../UpdateHomeworkHandlerTest.php | 51 +++ .../Domain/Service/HomeworkDuplicatorTest.php | 157 ++++++++ frontend/e2e/homework.spec.ts | 196 ++++++++++ .../dashboard/teacher/homework/+page.svelte | 345 ++++++++++++++++- 18 files changed, 1831 insertions(+), 2 deletions(-) create mode 100644 backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkCommand.php create mode 100644 backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkHandler.php create mode 100644 backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicationResult.php create mode 100644 backend/src/Scolarite/Domain/Exception/DuplicationValidationException.php create mode 100644 backend/src/Scolarite/Domain/Repository/HomeworkAttachmentRepository.php create mode 100644 backend/src/Scolarite/Domain/Service/HomeworkDuplicator.php create mode 100644 backend/src/Scolarite/Domain/ValueObject/ClassValidationResult.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Controller/DuplicateHomeworkController.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php create mode 100644 backend/tests/Unit/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Service/HomeworkDuplicatorTest.php diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 0519e19..5ec6352 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -210,9 +210,15 @@ services: App\Scolarite\Domain\Repository\HomeworkRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRepository + App\Scolarite\Domain\Repository\HomeworkAttachmentRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository + App\Scolarite\Domain\Service\DueDateValidator: autowire: true + App\Scolarite\Domain\Service\HomeworkDuplicator: + autowire: true + App\Scolarite\Application\Port\FileStorage: alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage diff --git a/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkCommand.php b/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkCommand.php new file mode 100644 index 0000000..444efc0 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkCommand.php @@ -0,0 +1,21 @@ + $targetClassIds + * @param array $dueDates Clé = classId, valeur = date (Y-m-d) + */ + public function __construct( + public string $tenantId, + public string $homeworkId, + public string $teacherId, + public array $targetClassIds, + public array $dueDates = [], + ) { + } +} diff --git a/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkHandler.php b/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkHandler.php new file mode 100644 index 0000000..df39b21 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkHandler.php @@ -0,0 +1,166 @@ +tenantId); + $homeworkId = HomeworkId::fromString($command->homeworkId); + $teacherId = UserId::fromString($command->teacherId); + $now = $this->clock->now(); + + $source = $this->homeworkRepository->get($homeworkId, $tenantId); + + if ((string) $source->teacherId !== (string) $teacherId) { + throw NonProprietaireDuDevoirException::withId($homeworkId); + } + + $calendar = $this->calendarProvider->forCurrentYear($tenantId); + + // Dédupliquer et exclure la classe source + $uniqueClassIds = array_values(array_unique($command->targetClassIds)); + $sourceClassIdStr = (string) $source->classId; + + $targetClassIds = []; + $dueDates = []; + $validationResults = []; + $seen = []; + + foreach ($uniqueClassIds as $classIdStr) { + if ($classIdStr === $sourceClassIdStr) { + continue; + } + + if (in_array($classIdStr, $seen, true)) { + continue; + } + + $seen[] = $classIdStr; + $classId = ClassId::fromString($classIdStr); + + try { + $dueDate = isset($command->dueDates[$classIdStr]) + ? new DateTimeImmutable($command->dueDates[$classIdStr]) + : $source->dueDate; + } catch (Exception) { + $validationResults[] = ClassValidationResult::failure( + $classId, + "Date d'échéance invalide : {$command->dueDates[$classIdStr]}.", + ); + + continue; + } + + if (!$this->affectationChecker->estAffecte($teacherId, $classId, $source->subjectId, $tenantId)) { + $validationResults[] = ClassValidationResult::failure( + $classId, + "L'enseignant n'est pas affecté à cette classe pour cette matière.", + ); + + continue; + } + + try { + $this->dueDateValidator->valider($dueDate, $now, $calendar); + $validationResults[] = ClassValidationResult::success($classId); + $targetClassIds[] = $classId; + $dueDates[] = $dueDate; + } catch (DateEcheanceInvalideException $e) { + $validationResults[] = ClassValidationResult::failure($classId, $e->getMessage()); + } + } + + $failures = array_filter($validationResults, static fn (ClassValidationResult $r): bool => !$r->valid); + + if ($failures !== []) { + throw DuplicationValidationException::withResults($validationResults); + } + + $duplicates = $this->duplicator->dupliquer($source, $targetClassIds, $dueDates, $now); + + $sourceAttachments = $this->attachmentRepository->findByHomeworkId($source->id); + + $this->connection->beginTransaction(); + + try { + foreach ($duplicates as $duplicate) { + $this->homeworkRepository->save($duplicate); + + foreach ($sourceAttachments as $attachment) { + $this->attachmentRepository->save( + $duplicate->id, + new HomeworkAttachment( + id: HomeworkAttachmentId::generate(), + filename: $attachment->filename, + filePath: $attachment->filePath, + fileSize: $attachment->fileSize, + mimeType: $attachment->mimeType, + uploadedAt: $now, + ), + ); + } + } + + $this->connection->commit(); + } catch (Throwable $e) { + $this->connection->rollBack(); + + throw $e; + } + + $warnings = array_values(array_filter( + $validationResults, + static fn (ClassValidationResult $r): bool => $r->valid && $r->warning !== null, + )); + + return new DuplicationResult($duplicates, $warnings); + } +} diff --git a/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicationResult.php b/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicationResult.php new file mode 100644 index 0000000..41cc4fc --- /dev/null +++ b/backend/src/Scolarite/Application/Command/DuplicateHomework/DuplicationResult.php @@ -0,0 +1,21 @@ + $homeworks + * @param array $warnings + */ + public function __construct( + public array $homeworks, + public array $warnings, + ) { + } +} diff --git a/backend/src/Scolarite/Domain/Exception/DuplicationValidationException.php b/backend/src/Scolarite/Domain/Exception/DuplicationValidationException.php new file mode 100644 index 0000000..eb1f654 --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/DuplicationValidationException.php @@ -0,0 +1,40 @@ + $results */ + private function __construct( + string $message, + public readonly array $results, + ) { + parent::__construct($message); + } + + /** @param array $results */ + public static function withResults(array $results): self + { + $failures = array_filter($results, static fn (ClassValidationResult $r): bool => !$r->valid); + $messages = array_map( + static fn (ClassValidationResult $r): string => "Classe {$r->classId} : {$r->error}", + $failures, + ); + + return new self( + 'Validation échouée pour certaines classes : ' . implode('; ', $messages), + $results, + ); + } +} diff --git a/backend/src/Scolarite/Domain/Repository/HomeworkAttachmentRepository.php b/backend/src/Scolarite/Domain/Repository/HomeworkAttachmentRepository.php new file mode 100644 index 0000000..5646c81 --- /dev/null +++ b/backend/src/Scolarite/Domain/Repository/HomeworkAttachmentRepository.php @@ -0,0 +1,16 @@ + */ + public function findByHomeworkId(HomeworkId $homeworkId): array; + + public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void; +} diff --git a/backend/src/Scolarite/Domain/Service/HomeworkDuplicator.php b/backend/src/Scolarite/Domain/Service/HomeworkDuplicator.php new file mode 100644 index 0000000..71520ff --- /dev/null +++ b/backend/src/Scolarite/Domain/Service/HomeworkDuplicator.php @@ -0,0 +1,44 @@ + $targetClassIds + * @param array $dueDates Dates indexées par position (même ordre que $targetClassIds) + * + * @return array + */ + public function dupliquer( + Homework $source, + array $targetClassIds, + array $dueDates, + DateTimeImmutable $now, + ): array { + $duplicates = []; + + foreach ($targetClassIds as $index => $classId) { + $dueDate = $dueDates[$index] ?? $source->dueDate; + + $duplicates[] = Homework::creer( + tenantId: $source->tenantId, + classId: $classId, + subjectId: $source->subjectId, + teacherId: $source->teacherId, + title: $source->title, + description: $source->description, + dueDate: $dueDate, + now: $now, + ); + } + + return $duplicates; + } +} diff --git a/backend/src/Scolarite/Domain/ValueObject/ClassValidationResult.php b/backend/src/Scolarite/Domain/ValueObject/ClassValidationResult.php new file mode 100644 index 0000000..89a2e60 --- /dev/null +++ b/backend/src/Scolarite/Domain/ValueObject/ClassValidationResult.php @@ -0,0 +1,28 @@ +tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + $body = $request->toArray(); + + $targetClassIds = $body['targetClassIds'] ?? null; + + if (!is_array($targetClassIds) || $targetClassIds === []) { + throw new BadRequestHttpException('Au moins une classe cible est requise.'); + } + + /** @var array $dueDates */ + $dueDates = []; + $rawDueDates = $body['dueDates'] ?? []; + + if (is_array($rawDueDates)) { + foreach ($rawDueDates as $classId => $date) { + if (is_string($classId) && is_string($date)) { + $dueDates[$classId] = $date; + } + } + } + + $classIds = []; + + foreach ($targetClassIds as $item) { + if (!is_string($item)) { + throw new BadRequestHttpException('Les identifiants de classe doivent être des chaînes.'); + } + + $classIds[] = $item; + } + + try { + $command = new DuplicateHomeworkCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + homeworkId: $id, + teacherId: $user->userId(), + targetClassIds: $classIds, + dueDates: $dueDates, + ); + + $result = ($this->handler)($command); + + foreach ($result->homeworks as $homework) { + foreach ($homework->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + } + + $response = [ + 'data' => array_map(static fn (Homework $h): array => [ + 'id' => (string) $h->id, + 'classId' => (string) $h->classId, + 'title' => $h->title, + 'dueDate' => $h->dueDate->format('Y-m-d'), + 'status' => $h->status->value, + ], $result->homeworks), + ]; + + if ($result->warnings !== []) { + $response['warnings'] = array_map(static fn (ClassValidationResult $r): array => [ + 'classId' => (string) $r->classId, + 'warning' => $r->warning, + ], $result->warnings); + } + + return new JsonResponse($response, Response::HTTP_CREATED); + } catch (HomeworkNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (NonProprietaireDuDevoirException $e) { + throw new NotFoundHttpException($e->getMessage()); + } catch (DuplicationValidationException $e) { + return new JsonResponse([ + 'error' => $e->getMessage(), + 'results' => array_map(static fn (ClassValidationResult $r): array => [ + 'classId' => (string) $r->classId, + 'valid' => $r->valid, + 'error' => $r->error, + ], $e->results), + ], Response::HTTP_BAD_REQUEST); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage()); + } + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php index ee89598..3a22afb 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/HomeworkCollectionProvider.php @@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Provider; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; +use App\Administration\Domain\Model\SchoolClass\ClassId; use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Domain\Repository\SubjectRepository; @@ -15,10 +16,15 @@ use App\Scolarite\Domain\Repository\HomeworkRepository; use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource; use App\Shared\Infrastructure\Tenant\TenantContext; +use function array_filter; use function array_map; +use function array_values; +use function is_string; use Override; +use Ramsey\Uuid\Exception\InvalidUuidStringException; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; /** @@ -54,6 +60,23 @@ final readonly class HomeworkCollectionProvider implements ProviderInterface $homeworks = $this->homeworkRepository->findByTeacher($teacherId, $tenantId); + /** @var array $filters */ + $filters = $context['filters'] ?? []; + $classIdFilter = $filters['classId'] ?? null; + + if (is_string($classIdFilter) && $classIdFilter !== '') { + try { + $filterClassId = ClassId::fromString($classIdFilter); + } catch (InvalidUuidStringException $e) { + throw new BadRequestHttpException('UUID de classe invalide : ' . $e->getMessage()); + } + + $homeworks = array_values(array_filter( + $homeworks, + static fn (Homework $h): bool => $h->classId->equals($filterClassId), + )); + } + return array_map(fn (Homework $homework) => HomeworkResource::fromDomain( $homework, $this->resolveClassName($homework), diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php new file mode 100644 index 0000000..5ef635d --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkAttachmentRepository.php @@ -0,0 +1,80 @@ +connection->fetchAllAssociative( + 'SELECT * FROM homework_attachments WHERE homework_id = :homework_id', + ['homework_id' => (string) $homeworkId], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void + { + $this->connection->executeStatement( + 'INSERT INTO homework_attachments (id, homework_id, filename, file_path, file_size, mime_type, uploaded_at) + VALUES (:id, :homework_id, :filename, :file_path, :file_size, :mime_type, :uploaded_at)', + [ + 'id' => (string) $attachment->id, + 'homework_id' => (string) $homeworkId, + 'filename' => $attachment->filename, + 'file_path' => $attachment->filePath, + 'file_size' => $attachment->fileSize, + 'mime_type' => $attachment->mimeType, + 'uploaded_at' => $attachment->uploadedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + /** @param array $row */ + private function hydrate(array $row): HomeworkAttachment + { + /** @var string $id */ + $id = $row['id']; + /** @var string $filename */ + $filename = $row['filename']; + /** @var string $filePath */ + $filePath = $row['file_path']; + /** @var string|int $rawFileSize */ + $rawFileSize = $row['file_size']; + $fileSize = (int) $rawFileSize; + /** @var string $mimeType */ + $mimeType = $row['mime_type']; + /** @var string $uploadedAt */ + $uploadedAt = $row['uploaded_at']; + + return new HomeworkAttachment( + id: HomeworkAttachmentId::fromString($id), + filename: $filename, + filePath: $filePath, + fileSize: $fileSize, + mimeType: $mimeType, + uploadedAt: new DateTimeImmutable($uploadedAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php new file mode 100644 index 0000000..a0e2bb3 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkAttachmentRepository.php @@ -0,0 +1,28 @@ +> */ + private array $byHomeworkId = []; + + #[Override] + public function findByHomeworkId(HomeworkId $homeworkId): array + { + return $this->byHomeworkId[(string) $homeworkId] ?? []; + } + + #[Override] + public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void + { + $this->byHomeworkId[(string) $homeworkId][] = $attachment; + } +} diff --git a/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php b/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php index 6d71999..df5667a 100644 --- a/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php +++ b/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php @@ -39,6 +39,7 @@ final class HomeworkEndpointsTest extends ApiTestCase private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-886655440001'; private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-996655440001'; private const string DELETED_HOMEWORK_ID = '550e8400-e29b-41d4-a716-aa6655440001'; + private const string TARGET_CLASS_ID = '550e8400-e29b-41d4-a716-776655440002'; protected function setUp(): void { @@ -52,7 +53,7 @@ final class HomeworkEndpointsTest extends ApiTestCase /** @var Connection $connection */ $connection = $container->get(Connection::class); $connection->executeStatement('DELETE FROM homework WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); - $connection->executeStatement('DELETE FROM school_classes WHERE id = :id', ['id' => self::CLASS_ID]); + $connection->executeStatement('DELETE FROM school_classes WHERE id IN (:id1, :id2)', ['id1' => self::CLASS_ID, 'id2' => self::TARGET_CLASS_ID]); $connection->executeStatement('DELETE FROM subjects WHERE id = :id', ['id' => self::SUBJECT_ID]); $connection->executeStatement('DELETE FROM users WHERE id IN (:o, :t)', ['o' => self::OWNER_TEACHER_ID, 't' => self::OTHER_TEACHER_ID]); @@ -92,6 +93,12 @@ final class HomeworkEndpointsTest extends ApiTestCase ON CONFLICT (id) DO NOTHING", ['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], ); + $connection->executeStatement( + "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at) + VALUES (:id, :tid, :sid, :ayid, 'Test-HW-Target-Class', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::TARGET_CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId], + ); } // ========================================================================= @@ -382,6 +389,123 @@ final class HomeworkEndpointsTest extends ApiTestCase self::assertResponseStatusCodeSame(400); } + // ========================================================================= + // 5.2-FUNC-001 (P1) - POST /homework/{id}/duplicate without tenant -> 404 + // ========================================================================= + + #[Test] + public function duplicateHomeworkReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('POST', '/api/homework/' . self::HOMEWORK_ID . '/duplicate', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'targetClassIds' => [self::TARGET_CLASS_ID], + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // 5.2-FUNC-002 (P1) - POST /homework/{id}/duplicate without auth -> 401 + // ========================================================================= + + #[Test] + public function duplicateHomeworkReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'targetClassIds' => [self::TARGET_CLASS_ID], + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + // ========================================================================= + // 5.2-FUNC-003 (P1) - POST /homework/{id}/duplicate empty classes -> 400 + // ========================================================================= + + #[Test] + public function duplicateHomeworkReturns400WithEmptyTargetClasses(): void + { + $this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'targetClassIds' => [], + ], + ]); + + self::assertResponseStatusCodeSame(400); + } + + // ========================================================================= + // 5.2-FUNC-004 (P1) - POST /homework/{id}/duplicate not found -> 404 + // ========================================================================= + + #[Test] + public function duplicateHomeworkReturns404WhenHomeworkNotFound(): void + { + $nonExistentId = '00000000-0000-0000-0000-000000000099'; + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . $nonExistentId . '/duplicate', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'targetClassIds' => [self::TARGET_CLASS_ID], + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // 5.2-FUNC-005 (P1) - POST /homework/{id}/duplicate non-owner -> 404 + // ========================================================================= + + #[Test] + public function duplicateHomeworkReturns404WhenTeacherNotOwner(): void + { + $this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED); + + $client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'targetClassIds' => [self::TARGET_CLASS_ID], + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + // ========================================================================= // Helpers // ========================================================================= diff --git a/backend/tests/Unit/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkHandlerTest.php new file mode 100644 index 0000000..973a66e --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/DuplicateHomework/DuplicateHomeworkHandlerTest.php @@ -0,0 +1,348 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->attachmentRepository = new InMemoryHomeworkAttachmentRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-12 10:00:00'); + } + }; + } + + #[Test] + public function itDuplicatesHomeworkForOneClass(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: true); + + $command = new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1], + ); + + $result = $handler($command); + + self::assertCount(1, $result->homeworks); + self::assertSame($source->title, $result->homeworks[0]->title); + self::assertSame($source->description, $result->homeworks[0]->description); + self::assertTrue($result->homeworks[0]->classId->equals(ClassId::fromString(self::TARGET_CLASS_1))); + self::assertSame(HomeworkStatus::PUBLISHED, $result->homeworks[0]->status); + } + + #[Test] + public function itDuplicatesHomeworkForMultipleClasses(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: true); + + $command = new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2], + ); + + $result = $handler($command); + + self::assertCount(2, $result->homeworks); + self::assertTrue($result->homeworks[0]->classId->equals(ClassId::fromString(self::TARGET_CLASS_1))); + self::assertTrue($result->homeworks[1]->classId->equals(ClassId::fromString(self::TARGET_CLASS_2))); + } + + #[Test] + public function itPersistsDuplicatesInRepository(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: true); + + $command = new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1], + ); + + $result = $handler($command); + + $found = $this->homeworkRepository->get( + $result->homeworks[0]->id, + TenantId::fromString(self::TENANT_ID), + ); + + self::assertSame($source->title, $found->title); + } + + #[Test] + public function itAllowsCustomDueDatePerClass(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: true); + + $command = new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2], + dueDates: [ + self::TARGET_CLASS_1 => '2026-04-20', + self::TARGET_CLASS_2 => '2026-04-21', + ], + ); + + $result = $handler($command); + + self::assertSame('2026-04-20', $result->homeworks[0]->dueDate->format('Y-m-d')); + self::assertSame('2026-04-21', $result->homeworks[1]->dueDate->format('Y-m-d')); + } + + #[Test] + public function itUsesSourceDueDateWhenNotCustomized(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: true); + + $command = new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1], + ); + + $result = $handler($command); + + self::assertSame($source->dueDate->format('Y-m-d'), $result->homeworks[0]->dueDate->format('Y-m-d')); + } + + #[Test] + public function itThrowsWhenHomeworkNotFound(): void + { + $handler = $this->createHandler(affecte: true); + + $this->expectException(HomeworkNotFoundException::class); + + $handler(new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: '550e8400-e29b-41d4-a716-446655440099', + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1], + )); + } + + #[Test] + public function itThrowsWhenTeacherIsNotOwner(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: true); + + $this->expectException(NonProprietaireDuDevoirException::class); + + $handler(new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::OTHER_TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1], + )); + } + + #[Test] + public function itThrowsWhenTeacherNotAffectedToTargetClass(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: false); + + $this->expectException(DuplicationValidationException::class); + + $handler(new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1], + )); + } + + #[Test] + public function itThrowsWithPerClassValidationResults(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: false); + + try { + $handler(new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2], + )); + self::fail('Expected DuplicationValidationException'); + } catch (DuplicationValidationException $e) { + self::assertCount(2, $e->results); + self::assertFalse($e->results[0]->valid); + self::assertFalse($e->results[1]->valid); + } + } + + #[Test] + public function itDuplicatesAttachmentsForEachCopy(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: true); + + $attachment = new HomeworkAttachment( + id: HomeworkAttachmentId::generate(), + filename: 'exercice.pdf', + filePath: 'homework/tenant/source/exercice.pdf', + fileSize: 1024, + mimeType: 'application/pdf', + uploadedAt: new DateTimeImmutable('2026-03-12'), + ); + $this->attachmentRepository->save($source->id, $attachment); + + $command = new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2], + ); + + $result = $handler($command); + + foreach ($result->homeworks as $duplicate) { + $attachments = $this->attachmentRepository->findByHomeworkId($duplicate->id); + self::assertCount(1, $attachments); + self::assertSame('exercice.pdf', $attachments[0]->filename); + self::assertSame('homework/tenant/source/exercice.pdf', $attachments[0]->filePath); + self::assertFalse($attachments[0]->id->equals($attachment->id)); + } + } + + #[Test] + public function itCreatesIndependentDuplicates(): void + { + $source = $this->createAndSaveHomework(); + $handler = $this->createHandler(affecte: true); + + $command = new DuplicateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $source->id, + teacherId: self::TEACHER_ID, + targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2], + ); + + $result = $handler($command); + + self::assertFalse($result->homeworks[0]->id->equals($source->id)); + self::assertFalse($result->homeworks[1]->id->equals($source->id)); + self::assertFalse($result->homeworks[0]->id->equals($result->homeworks[1]->id)); + } + + private function createAndSaveHomework(): Homework + { + $homework = Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Exercices chapitre 5', + description: 'Faire les exercices 1 à 10', + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-10 10:00:00'), + ); + + $homework->pullDomainEvents(); + $this->homeworkRepository->save($homework); + + return $homework; + } + + private function createHandler(bool $affecte): DuplicateHomeworkHandler + { + $affectationChecker = new class($affecte) implements EnseignantAffectationChecker { + public function __construct(private readonly bool $affecte) + { + } + + public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool + { + return $this->affecte; + } + }; + + $calendarProvider = new class implements CurrentCalendarProvider { + public function forCurrentYear(TenantId $tenantId): SchoolCalendar + { + return SchoolCalendar::reconstitute( + tenantId: $tenantId, + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'), + zone: null, + entries: [], + ); + } + }; + + $connection = $this->createMock(Connection::class); + $connection->method('beginTransaction'); + $connection->method('commit'); + $connection->method('rollBack'); + + return new DuplicateHomeworkHandler( + $this->homeworkRepository, + $this->attachmentRepository, + $affectationChecker, + $calendarProvider, + new DueDateValidator(), + new HomeworkDuplicator(), + $this->clock, + $connection, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandlerTest.php index 7cd6167..88f93c2 100644 --- a/backend/tests/Unit/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Command/UpdateHomework/UpdateHomeworkHandlerTest.php @@ -121,6 +121,57 @@ final class UpdateHomeworkHandlerTest extends TestCase )); } + #[Test] + public function itUpdatesTitleOnly(): void + { + $handler = $this->createHandler(); + $command = new UpdateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->existingHomeworkId, + teacherId: '550e8400-e29b-41d4-a716-446655440010', + title: 'Nouveau titre', + description: null, + dueDate: '2026-04-15', + ); + + $homework = $handler($command); + + self::assertSame('Nouveau titre', $homework->title); + self::assertNull($homework->description); + self::assertSame('2026-04-15', $homework->dueDate->format('Y-m-d')); + } + + #[Test] + public function itUpdatesDescriptionFromNullToText(): void + { + $homeworkWithoutDescription = Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'), + subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'), + teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'), + title: 'Exercices sans description', + description: null, + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-10 10:00:00'), + ); + $this->homeworkRepository->save($homeworkWithoutDescription); + + $handler = $this->createHandler(); + $command = new UpdateHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $homeworkWithoutDescription->id, + teacherId: '550e8400-e29b-41d4-a716-446655440010', + title: 'Exercices sans description', + description: 'Nouvelle description ajoutée', + dueDate: '2026-04-15', + ); + + $homework = $handler($command); + + self::assertSame('Nouvelle description ajoutée', $homework->description); + self::assertSame('Exercices sans description', $homework->title); + } + #[Test] public function itThrowsWhenNotOwner(): void { diff --git a/backend/tests/Unit/Scolarite/Domain/Service/HomeworkDuplicatorTest.php b/backend/tests/Unit/Scolarite/Domain/Service/HomeworkDuplicatorTest.php new file mode 100644 index 0000000..da87293 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Service/HomeworkDuplicatorTest.php @@ -0,0 +1,157 @@ +duplicator = new HomeworkDuplicator(); + } + + #[Test] + public function itDuplicatesHomeworkForOneTargetClass(): void + { + $source = $this->createSourceHomework(); + $now = new DateTimeImmutable('2026-03-14 10:00:00'); + $targetClassId = ClassId::fromString(self::TARGET_CLASS_1); + $dueDate = new DateTimeImmutable('2026-04-20'); + + $duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [$dueDate], $now); + + self::assertCount(1, $duplicates); + $duplicate = $duplicates[0]; + self::assertSame($source->title, $duplicate->title); + self::assertSame($source->description, $duplicate->description); + self::assertTrue($duplicate->classId->equals($targetClassId)); + self::assertTrue($duplicate->subjectId->equals($source->subjectId)); + self::assertTrue($duplicate->teacherId->equals($source->teacherId)); + self::assertTrue($duplicate->tenantId->equals($source->tenantId)); + self::assertSame('2026-04-20', $duplicate->dueDate->format('Y-m-d')); + } + + #[Test] + public function itDuplicatesHomeworkForMultipleTargetClasses(): void + { + $source = $this->createSourceHomework(); + $now = new DateTimeImmutable('2026-03-14 10:00:00'); + $targetClasses = [ + ClassId::fromString(self::TARGET_CLASS_1), + ClassId::fromString(self::TARGET_CLASS_2), + ]; + $dueDates = [ + new DateTimeImmutable('2026-04-20'), + new DateTimeImmutable('2026-04-21'), + ]; + + $duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now); + + self::assertCount(2, $duplicates); + self::assertTrue($duplicates[0]->classId->equals($targetClasses[0])); + self::assertTrue($duplicates[1]->classId->equals($targetClasses[1])); + self::assertSame('2026-04-20', $duplicates[0]->dueDate->format('Y-m-d')); + self::assertSame('2026-04-21', $duplicates[1]->dueDate->format('Y-m-d')); + } + + #[Test] + public function itCreatesIndependentCopiesWithUniqueIds(): void + { + $source = $this->createSourceHomework(); + $now = new DateTimeImmutable('2026-03-14 10:00:00'); + $targetClasses = [ + ClassId::fromString(self::TARGET_CLASS_1), + ClassId::fromString(self::TARGET_CLASS_2), + ]; + $dueDates = [ + new DateTimeImmutable('2026-04-20'), + new DateTimeImmutable('2026-04-20'), + ]; + + $duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now); + + self::assertFalse($duplicates[0]->id->equals($source->id)); + self::assertFalse($duplicates[1]->id->equals($source->id)); + self::assertFalse($duplicates[0]->id->equals($duplicates[1]->id)); + } + + #[Test] + public function itRecordsDevoirCreeEventForEachDuplicate(): void + { + $source = $this->createSourceHomework(); + $now = new DateTimeImmutable('2026-03-14 10:00:00'); + $targetClasses = [ + ClassId::fromString(self::TARGET_CLASS_1), + ClassId::fromString(self::TARGET_CLASS_2), + ]; + $dueDates = [ + new DateTimeImmutable('2026-04-20'), + new DateTimeImmutable('2026-04-20'), + ]; + + $duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now); + + foreach ($duplicates as $duplicate) { + $events = $duplicate->pullDomainEvents(); + self::assertCount(1, $events); + } + } + + #[Test] + public function itUsesSourceDueDateWhenNoneProvided(): void + { + $source = $this->createSourceHomework(); + $now = new DateTimeImmutable('2026-03-14 10:00:00'); + $targetClassId = ClassId::fromString(self::TARGET_CLASS_1); + + $duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [], $now); + + self::assertSame($source->dueDate->format('Y-m-d'), $duplicates[0]->dueDate->format('Y-m-d')); + } + + #[Test] + public function itPreservesNullDescription(): void + { + $source = $this->createSourceHomework(description: null); + $now = new DateTimeImmutable('2026-03-14 10:00:00'); + $targetClassId = ClassId::fromString(self::TARGET_CLASS_1); + + $duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [], $now); + + self::assertNull($duplicates[0]->description); + } + + private function createSourceHomework(?string $description = 'Faire les exercices 1 à 10'): Homework + { + return Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Exercices chapitre 5', + description: $description, + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + } +} diff --git a/frontend/e2e/homework.spec.ts b/frontend/e2e/homework.spec.ts index fcc8153..400b1d5 100644 --- a/frontend/e2e/homework.spec.ts +++ b/frontend/e2e/homework.spec.ts @@ -401,6 +401,202 @@ test.describe('Homework Management (Story 5.1)', () => { }); }); + // ============================================================================ + // AC: Duplicate Homework (Story 5.2) + // ============================================================================ + test.describe('Story 5.2: Duplicate Homework', () => { + test.beforeAll(async () => { + // Ensure a second class exists for duplication + const { schoolId, academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + clearCache(); + }); + + async function createHomework(page: import('@playwright/test').Page, title: string) { + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); + await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill(title); + await page.locator('#hw-due-date').fill(getNextWeekday(5)); + await page.getByRole('button', { name: /créer le devoir/i }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(title)).toBeVisible({ timeout: 10000 }); + } + + test('duplicate button is visible on homework card', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir dupliquer test'); + + await expect(page.getByRole('button', { name: /dupliquer/i })).toBeVisible(); + }); + + test('opens duplicate modal with class selection', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir à dupliquer'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await expect(dialog.getByText(/dupliquer le devoir/i)).toBeVisible(); + await expect(dialog.getByText('Devoir à dupliquer')).toBeVisible(); + }); + + test('shows target classes checkboxes excluding source class', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir classes cibles'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Should show checkboxes for target classes + const checkboxes = dialog.locator('input[type="checkbox"]'); + await expect(checkboxes.first()).toBeVisible({ timeout: 5000 }); + }); + + test('can duplicate homework to a target class', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir duplication réussie'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Select first target class + const firstCheckbox = dialog.locator('input[type="checkbox"]').first(); + await firstCheckbox.check(); + + // Click duplicate button + await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Homework should now appear twice (original + duplicate) + await expect(page.getByText('Devoir duplication réussie')).toHaveCount(2, { timeout: 10000 }); + }); + + test('duplicate button is disabled when no class selected', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir sans sélection'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Duplicate button should be disabled + const duplicateBtn = dialog.getByRole('button', { name: /dupliquer/i }).last(); + await expect(duplicateBtn).toBeDisabled(); + }); + + test('cancel closes the duplicate modal', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir annulation dupli'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + await dialog.getByRole('button', { name: /annuler/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); + + test('can duplicate homework to multiple classes', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir multi-dupli'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Select ALL available checkboxes (at least 2 target classes) + const checkboxes = dialog.locator('input[type="checkbox"]'); + const count = await checkboxes.count(); + expect(count).toBeGreaterThanOrEqual(1); + + for (let i = 0; i < count; i++) { + await checkboxes.nth(i).check(); + } + + // Verify button text reflects the count + const duplicateButton = dialog.getByRole('button', { + name: new RegExp(`dupliquer \\(${count} classes?\\)`, 'i') + }); + await expect(duplicateButton).toBeVisible(); + await expect(duplicateButton).toBeEnabled(); + + // Click duplicate + await duplicateButton.click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Verify the homework title appears count+1 times (original + duplicates) + await expect(page.getByText('Devoir multi-dupli')).toHaveCount(count + 1, { timeout: 10000 }); + }); + + test('can customize due date per target class', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir date custom'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Select the first checkbox + const firstCheckbox = dialog.locator('input[type="checkbox"]').first(); + await firstCheckbox.check(); + + // A date input should appear after checking the checkbox + const dueDateInput = dialog.locator('input[type="date"]').first(); + await expect(dueDateInput).toBeVisible({ timeout: 5000 }); + + // Change the due date to a custom value + await dueDateInput.fill(getNextWeekday(10)); + + // Click duplicate + await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10000 }); + + // Verify the homework appears twice (original + 1 duplicate) + await expect(page.getByText('Devoir date custom')).toHaveCount(2, { timeout: 10000 }); + }); + + test('can filter homework list by class', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir filtre classe'); + + // Class filter should be visible + const classFilter = page.locator('select[aria-label="Filtrer par classe"]'); + await expect(classFilter).toBeVisible(); + + // Select a class to filter + await classFilter.selectOption({ index: 1 }); + + // Should still show homework (it matches the filter) + await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 }); + + // Select "Toutes les classes" to reset + await classFilter.selectOption({ index: 0 }); + await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 }); + }); + }); + // ============================================================================ // Partial Update // ============================================================================ diff --git a/frontend/src/routes/dashboard/teacher/homework/+page.svelte b/frontend/src/routes/dashboard/teacher/homework/+page.svelte index 8c2b3e8..d55e2fd 100644 --- a/frontend/src/routes/dashboard/teacher/homework/+page.svelte +++ b/frontend/src/routes/dashboard/teacher/homework/+page.svelte @@ -77,6 +77,19 @@ let homeworkToDelete = $state(null); let isDeleting = $state(false); + // Duplicate modal + let showDuplicateModal = $state(false); + let homeworkToDuplicate = $state(null); + let selectedTargetClassIds = $state([]); + let dueDatesByClass = $state>({}); + let isDuplicating = $state(false); + let duplicateError = $state(null); + let duplicateValidationResults = $state>([]); + let duplicateWarnings = $state>([]); + + // Class filter + let filterClassId = $state(page.url.searchParams.get('classId') ?? ''); + // Derived: available subjects for selected class let availableSubjectsForCreate = $derived.by(() => { if (!newClassId) return []; @@ -162,6 +175,7 @@ params.set('page', String(currentPage)); params.set('itemsPerPage', String(itemsPerPage)); if (searchTerm) params.set('search', searchTerm); + if (filterClassId) params.set('classId', filterClassId); const response = await authenticatedFetch(`${apiUrl}/homework?${params.toString()}`, { signal: controller.signal, @@ -186,6 +200,7 @@ const params = new URLSearchParams(); if (currentPage > 1) params.set('page', String(currentPage)); if (searchTerm) params.set('search', searchTerm); + if (filterClassId) params.set('classId', filterClassId); const query = params.toString(); goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true }); } @@ -237,6 +252,26 @@ return classes.filter((c) => assignedClassIds.includes(c.id)); }); + // Available target classes for duplication (same subject, exclude source class) + let availableTargetClasses = $derived.by(() => { + if (!homeworkToDuplicate) return []; + const sourceSubjectId = homeworkToDuplicate.subjectId; + const sourceClassId = homeworkToDuplicate.classId; + const assignedClassIds = assignments + .filter((a) => a.status === 'active' && a.subjectId === sourceSubjectId) + .map((a) => a.classId); + return classes.filter((c) => assignedClassIds.includes(c.id) && c.id !== sourceClassId); + }); + + function handleClassFilter(newClassId: string) { + filterClassId = newClassId; + currentPage = 1; + updateUrl(); + loadHomeworks().catch((e) => { + error = e instanceof Error ? e.message : 'Erreur inconnue'; + }); + } + // --- Create --- function openCreateModal() { showCreateModal = true; @@ -339,6 +374,86 @@ } } + // --- Duplicate --- + function openDuplicateModal(hw: Homework) { + homeworkToDuplicate = hw; + selectedTargetClassIds = []; + dueDatesByClass = {}; + duplicateError = null; + duplicateValidationResults = []; + duplicateWarnings = []; + showDuplicateModal = true; + } + + function closeDuplicateModal() { + showDuplicateModal = false; + homeworkToDuplicate = null; + } + + function toggleTargetClass(classId: string) { + if (selectedTargetClassIds.includes(classId)) { + selectedTargetClassIds = selectedTargetClassIds.filter((id) => id !== classId); + const { [classId]: _, ...rest } = dueDatesByClass; + dueDatesByClass = rest; + } else { + selectedTargetClassIds = [...selectedTargetClassIds, classId]; + } + } + + async function handleDuplicate() { + if (!homeworkToDuplicate || selectedTargetClassIds.length === 0) return; + + try { + isDuplicating = true; + duplicateError = null; + duplicateValidationResults = []; + const apiUrl = getApiBaseUrl(); + + const dueDates: Record = {}; + for (const classId of selectedTargetClassIds) { + if (dueDatesByClass[classId]) { + dueDates[classId] = dueDatesByClass[classId]; + } + } + + const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkToDuplicate.id}/duplicate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + targetClassIds: selectedTargetClassIds, + dueDates: Object.keys(dueDates).length > 0 ? dueDates : undefined, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + if (errorData?.results) { + duplicateValidationResults = errorData.results; + duplicateError = 'Certaines classes ne passent pas la validation.'; + return; + } + throw new Error( + errorData?.['hydra:description'] ?? + errorData?.error ?? + errorData?.message ?? + `Erreur lors de la duplication (${response.status})`, + ); + } + + const responseData = await response.json(); + if (responseData?.warnings?.length > 0) { + duplicateWarnings = responseData.warnings; + } + + closeDuplicateModal(); + await loadHomeworks(); + } catch (e) { + duplicateError = e instanceof Error ? e.message : 'Erreur lors de la duplication'; + } finally { + isDuplicating = false; + } + } + // --- Delete --- function openDeleteModal(hw: Homework) { homeworkToDelete = hw; @@ -405,7 +520,36 @@ {/if} - + {#if duplicateWarnings.length > 0} +
+ +
+ Duplication effectuée avec avertissements : +
    + {#each duplicateWarnings as w} +
  • {getClassName(w.classId)} : {w.warning}
  • + {/each} +
+
+ +
+ {/if} + +
+ +
+ +
+
{#if isLoading}
@@ -462,6 +606,9 @@ + @@ -699,6 +846,103 @@
{/if} + +{#if showDuplicateModal && homeworkToDuplicate} + + +{/if} +