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} +