diff --git a/backend/config/services.yaml b/backend/config/services.yaml index b316339..2a93425 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -216,6 +216,16 @@ services: App\Scolarite\Domain\Repository\HomeworkAttachmentRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository + # Homework Submissions (Story 5.10 - Rendu de devoir par l'élève) + App\Scolarite\Domain\Repository\HomeworkSubmissionRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkSubmissionRepository + + App\Scolarite\Domain\Repository\SubmissionAttachmentRepository: + alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineSubmissionAttachmentRepository + + App\Scolarite\Application\Port\ClassStudentsReader: + alias: App\Scolarite\Infrastructure\Service\DoctrineClassStudentsReader + App\Scolarite\Domain\Service\DueDateValidator: autowire: true diff --git a/backend/migrations/Version20260324162229.php b/backend/migrations/Version20260324162229.php new file mode 100644 index 0000000..8d875f9 --- /dev/null +++ b/backend/migrations/Version20260324162229.php @@ -0,0 +1,51 @@ +addSql('CREATE TABLE homework_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + homework_id UUID NOT NULL REFERENCES homework(id), + student_id UUID NOT NULL REFERENCES users(id), + response_html TEXT, + status VARCHAR(20) NOT NULL DEFAULT \'draft\', + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (homework_id, student_id) + )'); + + $this->addSql('CREATE TABLE submission_attachments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submission_id UUID NOT NULL REFERENCES homework_submissions(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size INT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )'); + + $this->addSql('CREATE INDEX idx_submission_homework_tenant ON homework_submissions(homework_id, tenant_id)'); + $this->addSql('CREATE INDEX idx_submission_lookup ON homework_submissions(homework_id, student_id, tenant_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS submission_attachments'); + $this->addSql('DROP TABLE IF EXISTS homework_submissions'); + } +} diff --git a/backend/src/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionCommand.php b/backend/src/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionCommand.php new file mode 100644 index 0000000..c60c07d --- /dev/null +++ b/backend/src/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionCommand.php @@ -0,0 +1,16 @@ +tenantId); + $homeworkId = HomeworkId::fromString($command->homeworkId); + $studentId = UserId::fromString($command->studentId); + + $homework = $this->homeworkRepository->get($homeworkId, $tenantId); + + $classId = $this->studentClassReader->currentClassId($command->studentId, $tenantId); + + if ($classId === null || $classId !== (string) $homework->classId) { + throw EleveNonAffecteAuDevoirException::pourEleve($studentId, $homeworkId); + } + + $sanitizedHtml = $command->responseHtml !== null + ? $this->htmlSanitizer->sanitize($command->responseHtml) + : null; + + $now = $this->clock->now(); + + $existing = $this->submissionRepository->findByHomeworkAndStudent($homeworkId, $studentId, $tenantId); + + if ($existing !== null) { + $existing->modifierBrouillon($sanitizedHtml, $now); + $this->submissionRepository->save($existing); + + return $existing; + } + + $submission = HomeworkSubmission::creerBrouillon( + tenantId: $tenantId, + homeworkId: $homeworkId, + studentId: $studentId, + responseHtml: $sanitizedHtml, + now: $now, + ); + + $this->submissionRepository->save($submission); + + return $submission; + } +} diff --git a/backend/src/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkCommand.php b/backend/src/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkCommand.php new file mode 100644 index 0000000..3683c88 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkCommand.php @@ -0,0 +1,15 @@ +tenantId); + $homeworkId = HomeworkId::fromString($command->homeworkId); + $studentId = UserId::fromString($command->studentId); + + $homework = $this->homeworkRepository->get($homeworkId, $tenantId); + + $classId = $this->studentClassReader->currentClassId($command->studentId, $tenantId); + + if ($classId === null || $classId !== (string) $homework->classId) { + throw EleveNonAffecteAuDevoirException::pourEleve($studentId, $homeworkId); + } + + $submission = $this->submissionRepository->findByHomeworkAndStudent($homeworkId, $studentId, $tenantId); + + if ($submission === null) { + throw RenduNonTrouveException::pourDevoirEtEleve($homeworkId, $studentId); + } + + $now = $this->clock->now(); + $submission->soumettre(dueDate: $homework->dueDate, now: $now); + + $this->submissionRepository->save($submission); + + return $submission; + } +} diff --git a/backend/src/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentCommand.php b/backend/src/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentCommand.php new file mode 100644 index 0000000..edf4fa9 --- /dev/null +++ b/backend/src/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentCommand.php @@ -0,0 +1,18 @@ +tenantId); + $submissionId = HomeworkSubmissionId::fromString($command->submissionId); + + $submission = $this->submissionRepository->get($submissionId, $tenantId); + + if (!$submission->status->estModifiable()) { + throw RenduDejaSoumisException::pourRendu($submissionId); + } + + $attachmentId = SubmissionAttachmentId::generate(); + $storagePath = sprintf( + 'submissions/%s/%s/%s/%s', + $command->tenantId, + $command->submissionId, + (string) $attachmentId, + $command->filename, + ); + + $content = file_get_contents($command->tempFilePath); + + if ($content === false) { + throw PieceJointeInvalideException::lectureFichierImpossible($command->filename); + } + + $this->fileStorage->upload($storagePath, $content, $command->mimeType); + + return new SubmissionAttachment( + id: $attachmentId, + filename: $command->filename, + filePath: $storagePath, + fileSize: $command->fileSize, + mimeType: $command->mimeType, + uploadedAt: $this->clock->now(), + ); + } +} diff --git a/backend/src/Scolarite/Application/Port/ClassStudentsReader.php b/backend/src/Scolarite/Application/Port/ClassStudentsReader.php new file mode 100644 index 0000000..8d6fcbe --- /dev/null +++ b/backend/src/Scolarite/Application/Port/ClassStudentsReader.php @@ -0,0 +1,18 @@ + Liste des élèves avec ID et nom complet + */ + public function studentsInClass(string $classId, TenantId $tenantId): array; +} diff --git a/backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandler.php b/backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandler.php index 6a39de3..2836c5c 100644 --- a/backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandler.php +++ b/backend/src/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandler.php @@ -5,12 +5,14 @@ declare(strict_types=1); namespace App\Scolarite\Application\Query\GetStudentHomework; use App\Administration\Domain\Model\SchoolClass\ClassId; +use App\Administration\Domain\Model\User\UserId; use App\Scolarite\Application\Port\ScheduleDisplayReader; use App\Scolarite\Application\Port\StudentClassReader; use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository; use App\Scolarite\Domain\Repository\HomeworkRepository; +use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository; use App\Shared\Domain\Tenant\TenantId; use function array_filter; @@ -29,6 +31,7 @@ final readonly class GetStudentHomeworkHandler private StudentClassReader $studentClassReader, private HomeworkRepository $homeworkRepository, private HomeworkAttachmentRepository $attachmentRepository, + private HomeworkSubmissionRepository $submissionRepository, private ScheduleDisplayReader $displayReader, ) { } @@ -56,7 +59,7 @@ final readonly class GetStudentHomeworkHandler usort($homeworks, static fn (Homework $a, Homework $b): int => $a->dueDate <=> $b->dueDate); - return $this->enrichHomeworks($homeworks, $query->tenantId); + return $this->enrichHomeworks($homeworks, $query->studentId, $query->tenantId); } /** @@ -64,7 +67,7 @@ final readonly class GetStudentHomeworkHandler * * @return array */ - private function enrichHomeworks(array $homeworks, string $tenantId): array + private function enrichHomeworks(array $homeworks, string $studentId, string $tenantId): array { if ($homeworks === []) { return []; @@ -83,6 +86,14 @@ final readonly class GetStudentHomeworkHandler $homeworkIds = array_map(static fn (Homework $h): HomeworkId => $h->id, $homeworks); $attachmentMap = $this->attachmentRepository->hasAttachments(...$homeworkIds); + $studentUserId = UserId::fromString($studentId); + $tenantIdObj = TenantId::fromString($tenantId); + $submissionStatusMap = $this->submissionRepository->findStatusesByStudent( + $studentUserId, + $tenantIdObj, + ...$homeworkIds, + ); + return array_map( static fn (Homework $h): StudentHomeworkDto => StudentHomeworkDto::fromDomain( $h, @@ -90,6 +101,7 @@ final readonly class GetStudentHomeworkHandler $subjects[(string) $h->subjectId]['color'] ?? null, $teacherNames[(string) $h->teacherId] ?? '', $attachmentMap[(string) $h->id] ?? false, + $submissionStatusMap[(string) $h->id] ?? null, ), $homeworks, ); diff --git a/backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDto.php b/backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDto.php index 9101334..c85a334 100644 --- a/backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDto.php +++ b/backend/src/Scolarite/Application/Query/GetStudentHomework/StudentHomeworkDto.php @@ -20,6 +20,7 @@ final readonly class StudentHomeworkDto public string $dueDate, public string $createdAt, public bool $hasAttachments, + public ?string $submissionStatus = null, ) { } @@ -29,6 +30,7 @@ final readonly class StudentHomeworkDto ?string $subjectColor, string $teacherName, bool $hasAttachments, + ?string $submissionStatus = null, ): self { return new self( id: (string) $homework->id, @@ -42,6 +44,7 @@ final readonly class StudentHomeworkDto dueDate: $homework->dueDate->format('Y-m-d'), createdAt: $homework->createdAt->format('Y-m-d\TH:i:sP'), hasAttachments: $hasAttachments, + submissionStatus: $submissionStatus, ); } } diff --git a/backend/src/Scolarite/Domain/Event/DevoirRendu.php b/backend/src/Scolarite/Domain/Event/DevoirRendu.php new file mode 100644 index 0000000..1010df0 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/DevoirRendu.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->submissionId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Event/DevoirRenduEnRetard.php b/backend/src/Scolarite/Domain/Event/DevoirRenduEnRetard.php new file mode 100644 index 0000000..a9a0382 --- /dev/null +++ b/backend/src/Scolarite/Domain/Event/DevoirRenduEnRetard.php @@ -0,0 +1,38 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->submissionId->value; + } +} diff --git a/backend/src/Scolarite/Domain/Exception/EleveNonAffecteAuDevoirException.php b/backend/src/Scolarite/Domain/Exception/EleveNonAffecteAuDevoirException.php new file mode 100644 index 0000000..d116aaa --- /dev/null +++ b/backend/src/Scolarite/Domain/Exception/EleveNonAffecteAuDevoirException.php @@ -0,0 +1,23 @@ +updatedAt = $createdAt; + } + + public static function creerBrouillon( + TenantId $tenantId, + HomeworkId $homeworkId, + UserId $studentId, + ?string $responseHtml, + DateTimeImmutable $now, + ): self { + return new self( + id: HomeworkSubmissionId::generate(), + tenantId: $tenantId, + homeworkId: $homeworkId, + studentId: $studentId, + responseHtml: $responseHtml, + status: SubmissionStatus::DRAFT, + submittedAt: null, + createdAt: $now, + ); + } + + public function modifierBrouillon(?string $responseHtml, DateTimeImmutable $now): void + { + if (!$this->status->estModifiable()) { + throw RenduDejaSoumisException::pourRendu($this->id); + } + + $this->responseHtml = $responseHtml; + $this->updatedAt = $now; + } + + public function soumettre(DateTimeImmutable $dueDate, DateTimeImmutable $now): void + { + if (!$this->status->estModifiable()) { + throw RenduDejaSoumisException::pourRendu($this->id); + } + + $this->submittedAt = $now; + $this->updatedAt = $now; + + $estEnRetard = $now > $dueDate; + + $this->status = $estEnRetard + ? SubmissionStatus::LATE + : SubmissionStatus::SUBMITTED; + + if ($estEnRetard) { + $this->recordEvent(new DevoirRenduEnRetard( + submissionId: $this->id, + homeworkId: $this->homeworkId, + studentId: $this->studentId, + tenantId: $this->tenantId, + occurredOn: $now, + )); + } else { + $this->recordEvent(new DevoirRendu( + submissionId: $this->id, + homeworkId: $this->homeworkId, + studentId: $this->studentId, + tenantId: $this->tenantId, + occurredOn: $now, + )); + } + } + + /** + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + HomeworkSubmissionId $id, + TenantId $tenantId, + HomeworkId $homeworkId, + UserId $studentId, + ?string $responseHtml, + SubmissionStatus $status, + ?DateTimeImmutable $submittedAt, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ): self { + $submission = new self( + id: $id, + tenantId: $tenantId, + homeworkId: $homeworkId, + studentId: $studentId, + responseHtml: $responseHtml, + status: $status, + submittedAt: $submittedAt, + createdAt: $createdAt, + ); + + $submission->updatedAt = $updatedAt; + + return $submission; + } +} diff --git a/backend/src/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmissionId.php b/backend/src/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmissionId.php new file mode 100644 index 0000000..b87e253 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmissionId.php @@ -0,0 +1,11 @@ + self::MAX_FILE_SIZE) { + throw PieceJointeInvalideException::fichierTropGros($fileSize, self::MAX_FILE_SIZE); + } + $this->fileSize = $fileSize; + } + }, + public private(set) string $mimeType { + set(string $mimeType) { + if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { + throw PieceJointeInvalideException::typeFichierNonAutorise($mimeType); + } + $this->mimeType = $mimeType; + } + }, + public private(set) DateTimeImmutable $uploadedAt, + ) { + } +} diff --git a/backend/src/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachmentId.php b/backend/src/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachmentId.php new file mode 100644 index 0000000..ba9ba5c --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachmentId.php @@ -0,0 +1,11 @@ + */ + public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array; + + /** + * @return array Map homeworkId => submission status value (or null) + */ + public function findStatusesByStudent(UserId $studentId, TenantId $tenantId, HomeworkId ...$homeworkIds): array; +} diff --git a/backend/src/Scolarite/Domain/Repository/SubmissionAttachmentRepository.php b/backend/src/Scolarite/Domain/Repository/SubmissionAttachmentRepository.php new file mode 100644 index 0000000..bed0f24 --- /dev/null +++ b/backend/src/Scolarite/Domain/Repository/SubmissionAttachmentRepository.php @@ -0,0 +1,18 @@ + */ + public function findBySubmissionId(HomeworkSubmissionId $submissionId): array; + + public function save(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void; + + public function delete(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void; +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php index 8ccc3ac..e2ecad1 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentHomeworkController.php @@ -176,6 +176,7 @@ final readonly class StudentHomeworkController 'dueDate' => $dto->dueDate, 'createdAt' => $dto->createdAt, 'hasAttachments' => $dto->hasAttachments, + 'submissionStatus' => $dto->submissionStatus, ]; } diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/StudentSubmissionController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentSubmissionController.php new file mode 100644 index 0000000..e445679 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/StudentSubmissionController.php @@ -0,0 +1,259 @@ +getSecurityUser(); + $this->assertHomeworkBelongsToStudentClass($user, $id); + + /** @var array{responseHtml?: string|null} $data */ + $data = $request->toArray(); + + try { + $submission = ($this->saveDraftHandler)(new SaveDraftSubmissionCommand( + tenantId: $user->tenantId(), + homeworkId: $id, + studentId: $user->userId(), + responseHtml: isset($data['responseHtml']) && is_string($data['responseHtml']) ? $data['responseHtml'] : null, + )); + } catch (EleveNonAffecteAuDevoirException $e) { + throw new NotFoundHttpException('Devoir non trouvé.', $e); + } catch (RenduDejaSoumisException $e) { + throw new ConflictHttpException($e->getMessage(), $e); + } + + return new JsonResponse( + ['data' => $this->serializeSubmission($submission)], + Response::HTTP_CREATED, + ); + } + + #[Route('/api/me/homework/{id}/submission/submit', name: 'api_student_submission_submit', methods: ['POST'])] + public function submit(string $id): JsonResponse + { + $user = $this->getSecurityUser(); + $this->assertHomeworkBelongsToStudentClass($user, $id); + + try { + $submission = ($this->submitHandler)(new SubmitHomeworkCommand( + tenantId: $user->tenantId(), + homeworkId: $id, + studentId: $user->userId(), + )); + } catch (EleveNonAffecteAuDevoirException $e) { + throw new NotFoundHttpException('Devoir non trouvé.', $e); + } catch (RenduNonTrouveException $e) { + throw new NotFoundHttpException('Aucun brouillon à soumettre.', $e); + } catch (RenduDejaSoumisException $e) { + throw new ConflictHttpException($e->getMessage(), $e); + } + + foreach ($submission->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return new JsonResponse(['data' => $this->serializeSubmission($submission)]); + } + + #[Route('/api/me/homework/{id}/submission/attachments', name: 'api_student_submission_upload_attachment', methods: ['POST'])] + public function uploadAttachment(string $id, Request $request): JsonResponse + { + $user = $this->getSecurityUser(); + $this->assertHomeworkBelongsToStudentClass($user, $id); + + $tenantId = TenantId::fromString($user->tenantId()); + $homeworkId = HomeworkId::fromString($id); + + $submission = $this->submissionRepository->findByHomeworkAndStudent( + $homeworkId, + UserId::fromString($user->userId()), + $tenantId, + ); + + if ($submission === null) { + throw new NotFoundHttpException('Aucun brouillon trouvé. Veuillez d\'abord créer un brouillon.'); + } + + if (!$submission->status->estModifiable()) { + throw new ConflictHttpException('Le rendu a déjà été soumis.'); + } + + $file = $request->files->get('file'); + + if ($file === null) { + throw new BadRequestHttpException('Aucun fichier fourni.'); + } + + /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */ + $attachment = ($this->uploadHandler)(new UploadSubmissionAttachmentCommand( + tenantId: $user->tenantId(), + submissionId: (string) $submission->id, + filename: $file->getClientOriginalName(), + mimeType: $file->getMimeType() ?? 'application/octet-stream', + fileSize: $file->getSize(), + tempFilePath: $file->getPathname(), + )); + + $this->attachmentRepository->save($submission->id, $attachment); + + return new JsonResponse( + ['data' => $this->serializeAttachment($attachment)], + Response::HTTP_CREATED, + ); + } + + #[Route('/api/me/homework/{id}/submission', name: 'api_student_submission_get', methods: ['GET'])] + public function getSubmission(string $id): JsonResponse + { + $user = $this->getSecurityUser(); + $this->assertHomeworkBelongsToStudentClass($user, $id); + + $tenantId = TenantId::fromString($user->tenantId()); + $homeworkId = HomeworkId::fromString($id); + + $submission = $this->submissionRepository->findByHomeworkAndStudent( + $homeworkId, + UserId::fromString($user->userId()), + $tenantId, + ); + + if ($submission === null) { + return new JsonResponse(['data' => null]); + } + + $attachments = $this->attachmentRepository->findBySubmissionId($submission->id); + + return new JsonResponse([ + 'data' => $this->serializeSubmissionWithAttachments($submission, $attachments), + ]); + } + + private function assertHomeworkBelongsToStudentClass(SecurityUser $user, string $homeworkId): void + { + $tenantId = TenantId::fromString($user->tenantId()); + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + $classId = $this->studentClassReader->currentClassId($user->userId(), $tenantId); + + if ($classId === null || $classId !== (string) $homework->classId) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + } + + private function getSecurityUser(): SecurityUser + { + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new AccessDeniedHttpException('Authentification requise.'); + } + + return $user; + } + + /** + * @return array + */ + private function serializeSubmission(HomeworkSubmission $submission): array + { + return [ + 'id' => (string) $submission->id, + 'homeworkId' => (string) $submission->homeworkId, + 'studentId' => (string) $submission->studentId, + 'responseHtml' => $submission->responseHtml, + 'status' => $submission->status->value, + 'submittedAt' => $submission->submittedAt?->format(DateTimeImmutable::ATOM), + 'createdAt' => $submission->createdAt->format(DateTimeImmutable::ATOM), + 'updatedAt' => $submission->updatedAt->format(DateTimeImmutable::ATOM), + ]; + } + + /** + * @param array $attachments + * + * @return array + */ + private function serializeSubmissionWithAttachments(HomeworkSubmission $submission, array $attachments): array + { + $data = $this->serializeSubmission($submission); + $data['attachments'] = array_map($this->serializeAttachment(...), $attachments); + + return $data; + } + + /** + * @return array + */ + private function serializeAttachment(SubmissionAttachment $attachment): array + { + return [ + 'id' => (string) $attachment->id, + 'filename' => $attachment->filename, + 'fileSize' => $attachment->fileSize, + 'mimeType' => $attachment->mimeType, + ]; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Controller/TeacherSubmissionController.php b/backend/src/Scolarite/Infrastructure/Api/Controller/TeacherSubmissionController.php new file mode 100644 index 0000000..93d3c33 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Controller/TeacherSubmissionController.php @@ -0,0 +1,302 @@ +getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + $homeworkId = HomeworkId::fromString($id); + + $homework = $this->homeworkRepository->findById($homeworkId, $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Accès non autorisé.'); + } + + $submissions = $this->submissionRepository->findByHomework($homeworkId, $tenantId); + $students = $this->classStudentsReader->studentsInClass((string) $homework->classId, $tenantId); + + $studentNameMap = []; + foreach ($students as $student) { + /** @var string $studentId */ + $studentId = $student['id']; + /** @var string $studentName */ + $studentName = $student['name']; + $studentNameMap[$studentId] = $studentName; + } + + $submittedStudentIds = array_map( + static fn (HomeworkSubmission $s): string => (string) $s->studentId, + $submissions, + ); + + $rows = array_map( + static fn (HomeworkSubmission $s): array => [ + 'id' => (string) $s->id, + 'studentId' => (string) $s->studentId, + 'studentName' => $studentNameMap[(string) $s->studentId] ?? '', + 'status' => $s->status->value, + 'submittedAt' => $s->submittedAt?->format(DateTimeImmutable::ATOM), + 'createdAt' => $s->createdAt->format(DateTimeImmutable::ATOM), + ], + $submissions, + ); + + foreach ($students as $student) { + /** @var string $sId */ + $sId = $student['id']; + + if (!in_array($sId, $submittedStudentIds, true)) { + /** @var string $sName */ + $sName = $student['name']; + $rows[] = [ + 'id' => null, + 'studentId' => $sId, + 'studentName' => $sName, + 'status' => 'not_submitted', + 'submittedAt' => null, + 'createdAt' => null, + ]; + } + } + + return new JsonResponse(['data' => $rows]); + } + + #[Route('/api/homework/{id}/submissions/stats', name: 'api_teacher_submission_stats', methods: ['GET'])] + public function stats(string $id): JsonResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + $homeworkId = HomeworkId::fromString($id); + + $homework = $this->homeworkRepository->findById($homeworkId, $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Accès non autorisé.'); + } + + $submissions = $this->submissionRepository->findByHomework($homeworkId, $tenantId); + $students = $this->classStudentsReader->studentsInClass((string) $homework->classId, $tenantId); + + $submittedStudentIds = array_map( + static fn (HomeworkSubmission $s): string => (string) $s->studentId, + array_filter( + $submissions, + static fn (HomeworkSubmission $s): bool => !$s->status->estModifiable(), + ), + ); + + $allStudentIds = array_column($students, 'id'); + $missingStudentIds = array_diff($allStudentIds, $submittedStudentIds); + + $studentNameMap = []; + foreach ($students as $student) { + /** @var string $sId */ + $sId = $student['id']; + /** @var string $sName */ + $sName = $student['name']; + $studentNameMap[$sId] = $sName; + } + + $missingStudents = array_values(array_map( + static fn (string $studentId): array => [ + 'id' => $studentId, + 'name' => $studentNameMap[$studentId] ?? '', + ], + array_filter( + $missingStudentIds, + static fn (string $studentId): bool => !in_array($studentId, $submittedStudentIds, true), + ), + )); + + return new JsonResponse([ + 'data' => [ + 'totalStudents' => count($students), + 'submittedCount' => count($submittedStudentIds), + 'missingStudents' => $missingStudents, + ], + ]); + } + + #[Route('/api/homework/{id}/submissions/{submissionId}', name: 'api_teacher_submission_detail', methods: ['GET'])] + public function detail(string $id, string $submissionId): JsonResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + $homeworkId = HomeworkId::fromString($id); + + $homework = $this->homeworkRepository->findById($homeworkId, $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Accès non autorisé.'); + } + + $submission = $this->submissionRepository->findById( + HomeworkSubmissionId::fromString($submissionId), + $tenantId, + ); + + if ($submission === null || !$submission->homeworkId->equals($homeworkId)) { + throw new NotFoundHttpException('Rendu non trouvé.'); + } + + $attachments = $this->attachmentRepository->findBySubmissionId($submission->id); + $students = $this->classStudentsReader->studentsInClass((string) $homework->classId, $tenantId); + + $studentName = ''; + foreach ($students as $student) { + if ($student['id'] === (string) $submission->studentId) { + /** @var string $name */ + $name = $student['name']; + $studentName = $name; + + break; + } + } + + return new JsonResponse([ + 'data' => [ + 'id' => (string) $submission->id, + 'studentId' => (string) $submission->studentId, + 'studentName' => $studentName, + 'responseHtml' => $submission->responseHtml, + 'status' => $submission->status->value, + 'submittedAt' => $submission->submittedAt?->format(DateTimeImmutable::ATOM), + 'createdAt' => $submission->createdAt->format(DateTimeImmutable::ATOM), + 'attachments' => array_map( + static fn (SubmissionAttachment $a): array => [ + 'id' => (string) $a->id, + 'filename' => $a->filename, + 'fileSize' => $a->fileSize, + 'mimeType' => $a->mimeType, + ], + $attachments, + ), + ], + ]); + } + + #[Route('/api/homework/{homeworkId}/submissions/{submissionId}/attachments/{attachmentId}', name: 'api_teacher_submission_attachment_download', methods: ['GET'])] + public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): BinaryFileResponse + { + $user = $this->getSecurityUser(); + $tenantId = TenantId::fromString($user->tenantId()); + + $homework = $this->homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tenantId); + + if ($homework === null) { + throw new NotFoundHttpException('Devoir non trouvé.'); + } + + if ((string) $homework->teacherId !== $user->userId()) { + throw new AccessDeniedHttpException('Accès non autorisé.'); + } + + $submission = $this->submissionRepository->findById( + HomeworkSubmissionId::fromString($submissionId), + $tenantId, + ); + + if ($submission === null || !$submission->homeworkId->equals(HomeworkId::fromString($homeworkId))) { + throw new NotFoundHttpException('Rendu non trouvé.'); + } + + $attachments = $this->attachmentRepository->findBySubmissionId($submission->id); + + foreach ($attachments as $attachment) { + if ((string) $attachment->id === $attachmentId) { + $fullPath = $this->storageDir . '/' . $attachment->filePath; + $realPath = realpath($fullPath); + $realStorageDir = realpath($this->storageDir); + + if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) { + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + $response = new BinaryFileResponse($realPath); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_INLINE, + $attachment->filename, + ); + + return $response; + } + } + + throw new NotFoundHttpException('Pièce jointe non trouvée.'); + } + + private function getSecurityUser(): SecurityUser + { + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new AccessDeniedHttpException('Authentification requise.'); + } + + return $user; + } +} diff --git a/backend/src/Scolarite/Infrastructure/EventListener/NotifierEnseignantDevoirRenduListener.php b/backend/src/Scolarite/Infrastructure/EventListener/NotifierEnseignantDevoirRenduListener.php new file mode 100644 index 0000000..e373c87 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/EventListener/NotifierEnseignantDevoirRenduListener.php @@ -0,0 +1,36 @@ +logger->info('Devoir rendu par un élève (notification enseignant à implémenter — Epic 9)', [ + 'submissionId' => (string) $event->submissionId, + 'homeworkId' => (string) $event->homeworkId, + 'studentId' => (string) $event->studentId, + 'tenantId' => (string) $event->tenantId, + 'late' => $event instanceof DevoirRenduEnRetard, + ]); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkSubmissionRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkSubmissionRepository.php new file mode 100644 index 0000000..b9cf6d4 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkSubmissionRepository.php @@ -0,0 +1,195 @@ +connection->executeStatement( + 'INSERT INTO homework_submissions (id, tenant_id, homework_id, student_id, response_html, status, submitted_at, created_at, updated_at) + VALUES (:id, :tenant_id, :homework_id, :student_id, :response_html, :status, :submitted_at, :created_at, :updated_at) + ON CONFLICT (id) DO UPDATE SET + response_html = EXCLUDED.response_html, + status = EXCLUDED.status, + submitted_at = EXCLUDED.submitted_at, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $submission->id, + 'tenant_id' => (string) $submission->tenantId, + 'homework_id' => (string) $submission->homeworkId, + 'student_id' => (string) $submission->studentId, + 'response_html' => $submission->responseHtml, + 'status' => $submission->status->value, + 'submitted_at' => $submission->submittedAt?->format(DateTimeImmutable::ATOM), + 'created_at' => $submission->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $submission->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function get(HomeworkSubmissionId $id, TenantId $tenantId): HomeworkSubmission + { + $submission = $this->findById($id, $tenantId); + + if ($submission === null) { + throw RenduNonTrouveException::withId($id); + } + + return $submission; + } + + #[Override] + public function findById(HomeworkSubmissionId $id, TenantId $tenantId): ?HomeworkSubmission + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM homework_submissions WHERE id = :id AND tenant_id = :tenant_id', + ['id' => (string) $id, 'tenant_id' => (string) $tenantId], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findByHomeworkAndStudent( + HomeworkId $homeworkId, + UserId $studentId, + TenantId $tenantId, + ): ?HomeworkSubmission { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM homework_submissions + WHERE homework_id = :homework_id + AND student_id = :student_id + AND tenant_id = :tenant_id', + [ + 'homework_id' => (string) $homeworkId, + 'student_id' => (string) $studentId, + 'tenant_id' => (string) $tenantId, + ], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM homework_submissions + WHERE homework_id = :homework_id + AND tenant_id = :tenant_id + ORDER BY submitted_at ASC NULLS LAST, created_at ASC', + [ + 'homework_id' => (string) $homeworkId, + 'tenant_id' => (string) $tenantId, + ], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function findStatusesByStudent(UserId $studentId, TenantId $tenantId, HomeworkId ...$homeworkIds): array + { + if ($homeworkIds === []) { + return []; + } + + $ids = array_map(static fn (HomeworkId $id): string => (string) $id, $homeworkIds); + + /** @var array $rows */ + $rows = $this->connection->fetchAllAssociative( + 'SELECT homework_id, status FROM homework_submissions + WHERE student_id = :student_id + AND tenant_id = :tenant_id + AND homework_id IN (:homework_ids)', + [ + 'student_id' => (string) $studentId, + 'tenant_id' => (string) $tenantId, + 'homework_ids' => $ids, + ], + ['homework_ids' => ArrayParameterType::STRING], + ); + + $result = array_fill_keys($ids, null); + + foreach ($rows as $row) { + /** @var string $hwId */ + $hwId = $row['homework_id']; + /** @var string $status */ + $status = $row['status']; + $result[$hwId] = $status; + } + + return $result; + } + + /** @param array $row */ + private function hydrate(array $row): HomeworkSubmission + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $homeworkId */ + $homeworkId = $row['homework_id']; + /** @var string $studentId */ + $studentId = $row['student_id']; + /** @var string|null $responseHtml */ + $responseHtml = $row['response_html']; + /** @var string $status */ + $status = $row['status']; + /** @var string|null $submittedAt */ + $submittedAt = $row['submitted_at']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return HomeworkSubmission::reconstitute( + id: HomeworkSubmissionId::fromString($id), + tenantId: TenantId::fromString($tenantId), + homeworkId: HomeworkId::fromString($homeworkId), + studentId: UserId::fromString($studentId), + responseHtml: $responseHtml, + status: SubmissionStatus::from($status), + submittedAt: $submittedAt !== null ? new DateTimeImmutable($submittedAt) : null, + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineSubmissionAttachmentRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineSubmissionAttachmentRepository.php new file mode 100644 index 0000000..d69fe1a --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineSubmissionAttachmentRepository.php @@ -0,0 +1,92 @@ +connection->fetchAllAssociative( + 'SELECT * FROM submission_attachments WHERE submission_id = :submission_id', + ['submission_id' => (string) $submissionId], + ); + + return array_map($this->hydrate(...), $rows); + } + + #[Override] + public function save(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void + { + $this->connection->executeStatement( + 'INSERT INTO submission_attachments (id, submission_id, filename, file_path, file_size, mime_type, uploaded_at) + VALUES (:id, :submission_id, :filename, :file_path, :file_size, :mime_type, :uploaded_at)', + [ + 'id' => (string) $attachment->id, + 'submission_id' => (string) $submissionId, + 'filename' => $attachment->filename, + 'file_path' => $attachment->filePath, + 'file_size' => $attachment->fileSize, + 'mime_type' => $attachment->mimeType, + 'uploaded_at' => $attachment->uploadedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function delete(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void + { + $this->connection->executeStatement( + 'DELETE FROM submission_attachments WHERE id = :id AND submission_id = :submission_id', + [ + 'id' => (string) $attachment->id, + 'submission_id' => (string) $submissionId, + ], + ); + } + + /** @param array $row */ + private function hydrate(array $row): SubmissionAttachment + { + /** @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 SubmissionAttachment( + id: SubmissionAttachmentId::fromString($id), + filename: $filename, + filePath: $filePath, + fileSize: $fileSize, + mimeType: $mimeType, + uploadedAt: new DateTimeImmutable($uploadedAt), + ); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkSubmissionRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkSubmissionRepository.php new file mode 100644 index 0000000..f1cb2aa --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkSubmissionRepository.php @@ -0,0 +1,108 @@ + */ + private array $byId = []; + + #[Override] + public function save(HomeworkSubmission $submission): void + { + $this->byId[(string) $submission->id] = $submission; + } + + #[Override] + public function get(HomeworkSubmissionId $id, TenantId $tenantId): HomeworkSubmission + { + $submission = $this->findById($id, $tenantId); + + if ($submission === null) { + throw RenduNonTrouveException::withId($id); + } + + return $submission; + } + + #[Override] + public function findById(HomeworkSubmissionId $id, TenantId $tenantId): ?HomeworkSubmission + { + $submission = $this->byId[(string) $id] ?? null; + + if ($submission === null || !$submission->tenantId->equals($tenantId)) { + return null; + } + + return $submission; + } + + #[Override] + public function findByHomeworkAndStudent( + HomeworkId $homeworkId, + UserId $studentId, + TenantId $tenantId, + ): ?HomeworkSubmission { + foreach ($this->byId as $submission) { + if ( + $submission->homeworkId->equals($homeworkId) + && $submission->studentId->equals($studentId) + && $submission->tenantId->equals($tenantId) + ) { + return $submission; + } + } + + return null; + } + + #[Override] + public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array + { + return array_values(array_filter( + $this->byId, + static fn (HomeworkSubmission $s): bool => $s->homeworkId->equals($homeworkId) + && $s->tenantId->equals($tenantId), + )); + } + + #[Override] + public function findStatusesByStudent(UserId $studentId, TenantId $tenantId, HomeworkId ...$homeworkIds): array + { + $ids = array_map(static fn (HomeworkId $id): string => (string) $id, $homeworkIds); + $result = array_fill_keys($ids, null); + + foreach ($this->byId as $submission) { + if ( + $submission->studentId->equals($studentId) + && $submission->tenantId->equals($tenantId) + ) { + $hwId = (string) $submission->homeworkId; + + if (array_key_exists($hwId, $result)) { + $result[$hwId] = $submission->status->value; + } + } + } + + return $result; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemorySubmissionAttachmentRepository.php b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemorySubmissionAttachmentRepository.php new file mode 100644 index 0000000..d7a9d70 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemorySubmissionAttachmentRepository.php @@ -0,0 +1,47 @@ +> */ + private array $bySubmissionId = []; + + #[Override] + public function findBySubmissionId(HomeworkSubmissionId $submissionId): array + { + return $this->bySubmissionId[(string) $submissionId] ?? []; + } + + #[Override] + public function save(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void + { + $this->bySubmissionId[(string) $submissionId][] = $attachment; + } + + #[Override] + public function delete(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void + { + $key = (string) $submissionId; + + if (!isset($this->bySubmissionId[$key])) { + return; + } + + $this->bySubmissionId[$key] = array_values(array_filter( + $this->bySubmissionId[$key], + static fn (SubmissionAttachment $a): bool => (string) $a->id !== (string) $attachment->id, + )); + } +} diff --git a/backend/src/Scolarite/Infrastructure/Service/DoctrineClassStudentsReader.php b/backend/src/Scolarite/Infrastructure/Service/DoctrineClassStudentsReader.php new file mode 100644 index 0000000..a59faac --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Service/DoctrineClassStudentsReader.php @@ -0,0 +1,47 @@ + $rows */ + $rows = $this->connection->fetchAllAssociative( + 'SELECT u.id, u.first_name, u.last_name + FROM class_assignments ca + JOIN users u ON u.id = ca.user_id + WHERE ca.school_class_id = :class_id + AND ca.tenant_id = :tenant_id + ORDER BY u.last_name ASC, u.first_name ASC', + [ + 'class_id' => $classId, + 'tenant_id' => (string) $tenantId, + ], + ); + + return array_map( + static fn (array $row): array => [ + 'id' => $row['id'], + 'name' => $row['first_name'] . ' ' . $row['last_name'], + ], + $rows, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionHandlerTest.php new file mode 100644 index 0000000..f4a873b --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionHandlerTest.php @@ -0,0 +1,220 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->submissionRepository = new InMemoryHomeworkSubmissionRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-24 10:00:00'); + } + }; + + $this->homework = Homework::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + classId: ClassId::fromString(self::CLASS_ID), + subjectId: \App\Administration\Domain\Model\Subject\SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Exercices chapitre 5', + description: null, + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + $this->homeworkRepository->save($this->homework); + } + + #[Test] + public function itCreatesNewDraftSubmission(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + $submission = $handler($command); + + self::assertSame(SubmissionStatus::DRAFT, $submission->status); + self::assertSame('

Ma réponse

', $submission->responseHtml); + self::assertNull($submission->submittedAt); + } + + #[Test] + public function itPersistsDraftInRepository(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(); + + $created = $handler($command); + + $found = $this->submissionRepository->findByHomeworkAndStudent( + HomeworkId::fromString((string) $this->homework->id), + UserId::fromString(self::STUDENT_ID), + TenantId::fromString(self::TENANT_ID), + ); + + self::assertNotNull($found); + self::assertTrue($found->id->equals($created->id)); + } + + #[Test] + public function itUpdatesExistingDraft(): void + { + $handler = $this->createHandler(); + + $handler($this->createCommand(responseHtml: '

Première version

')); + $updated = $handler($this->createCommand(responseHtml: '

Version modifiée

')); + + self::assertSame('

Version modifiée

', $updated->responseHtml); + self::assertSame(SubmissionStatus::DRAFT, $updated->status); + } + + #[Test] + public function itThrowsWhenStudentNotInClass(): void + { + $handler = $this->createHandler(studentClassId: null); + + $this->expectException(EleveNonAffecteAuDevoirException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itThrowsWhenStudentInDifferentClass(): void + { + $handler = $this->createHandler(studentClassId: '550e8400-e29b-41d4-a716-446655440099'); + + $this->expectException(EleveNonAffecteAuDevoirException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itThrowsWhenUpdatingSubmittedSubmission(): void + { + $handler = $this->createHandler(); + + $handler($this->createCommand()); + + // Soumettre le rendu + $submission = $this->submissionRepository->findByHomeworkAndStudent( + HomeworkId::fromString((string) $this->homework->id), + UserId::fromString(self::STUDENT_ID), + TenantId::fromString(self::TENANT_ID), + ); + self::assertNotNull($submission); + $submission->soumettre( + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-24 11:00:00'), + ); + $this->submissionRepository->save($submission); + + $this->expectException(RenduDejaSoumisException::class); + + $handler($this->createCommand(responseHtml: '

Tentative de modification

')); + } + + #[Test] + public function itSanitizesHtmlResponse(): void + { + $sanitizer = new class implements HtmlSanitizer { + public function sanitize(string $html): string + { + return strip_tags($html, '

'); + } + }; + + $handler = $this->createHandler(htmlSanitizer: $sanitizer); + $command = $this->createCommand(responseHtml: '

Texte

'); + + $submission = $handler($command); + + self::assertSame('

Texte

alert("xss")', $submission->responseHtml); + } + + #[Test] + public function itAllowsNullResponse(): void + { + $handler = $this->createHandler(); + $command = $this->createCommand(responseHtml: null); + + $submission = $handler($command); + + self::assertNull($submission->responseHtml); + } + + private function createHandler( + ?string $studentClassId = self::CLASS_ID, + ?HtmlSanitizer $htmlSanitizer = null, + ): SaveDraftSubmissionHandler { + $studentClassReader = new class($studentClassId) implements StudentClassReader { + public function __construct(private readonly ?string $classId) + { + } + + public function currentClassId(string $studentId, TenantId $tenantId): ?string + { + return $this->classId; + } + }; + + $sanitizer = $htmlSanitizer ?? new class implements HtmlSanitizer { + public function sanitize(string $html): string + { + return $html; + } + }; + + return new SaveDraftSubmissionHandler( + $this->homeworkRepository, + $this->submissionRepository, + $studentClassReader, + $sanitizer, + $this->clock, + ); + } + + private function createCommand(?string $responseHtml = '

Ma réponse

'): SaveDraftSubmissionCommand + { + return new SaveDraftSubmissionCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->homework->id, + studentId: self::STUDENT_ID, + responseHtml: $responseHtml, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkHandlerTest.php new file mode 100644 index 0000000..b022998 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkHandlerTest.php @@ -0,0 +1,171 @@ +homeworkRepository = new InMemoryHomeworkRepository(); + $this->submissionRepository = new InMemoryHomeworkSubmissionRepository(); + + $this->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: null, + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-12 10:00:00'), + ); + $this->homeworkRepository->save($this->homework); + } + + #[Test] + public function itSubmitsOnTimeSuccessfully(): void + { + $this->createDraft(); + + $handler = $this->createHandler(now: '2026-04-10 14:00:00'); + $command = $this->createCommand(); + + $submission = $handler($command); + + self::assertSame(SubmissionStatus::SUBMITTED, $submission->status); + self::assertNotNull($submission->submittedAt); + } + + #[Test] + public function itSubmitsLateWhenAfterDueDate(): void + { + $this->createDraft(); + + $handler = $this->createHandler(now: '2026-04-20 14:00:00'); + $command = $this->createCommand(); + + $submission = $handler($command); + + self::assertSame(SubmissionStatus::LATE, $submission->status); + } + + #[Test] + public function itThrowsWhenNoDraftExists(): void + { + $handler = $this->createHandler(now: '2026-04-10 14:00:00'); + + $this->expectException(RenduNonTrouveException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itThrowsWhenAlreadySubmitted(): void + { + $this->createDraft(); + + $handler = $this->createHandler(now: '2026-04-10 14:00:00'); + $handler($this->createCommand()); + + $this->expectException(RenduDejaSoumisException::class); + + $handler($this->createCommand()); + } + + #[Test] + public function itThrowsWhenStudentNotInClass(): void + { + $this->createDraft(); + + $handler = $this->createHandler(now: '2026-04-10 14:00:00', studentClassId: null); + + $this->expectException(EleveNonAffecteAuDevoirException::class); + + $handler($this->createCommand()); + } + + private function createDraft(): void + { + $submission = HomeworkSubmission::creerBrouillon( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: $this->homework->id, + studentId: UserId::fromString(self::STUDENT_ID), + responseHtml: '

Ma réponse

', + now: new DateTimeImmutable('2026-03-24 10:00:00'), + ); + $this->submissionRepository->save($submission); + } + + private function createHandler(string $now, ?string $studentClassId = self::CLASS_ID): SubmitHomeworkHandler + { + $clock = new class($now) implements Clock { + public function __construct(private readonly string $time) + { + } + + public function now(): DateTimeImmutable + { + return new DateTimeImmutable($this->time); + } + }; + + $studentClassReader = new class($studentClassId) implements StudentClassReader { + public function __construct(private readonly ?string $classId) + { + } + + public function currentClassId(string $studentId, TenantId $tenantId): ?string + { + return $this->classId; + } + }; + + return new SubmitHomeworkHandler( + $this->homeworkRepository, + $this->submissionRepository, + $studentClassReader, + $clock, + ); + } + + private function createCommand(): SubmitHomeworkCommand + { + return new SubmitHomeworkCommand( + tenantId: self::TENANT_ID, + homeworkId: (string) $this->homework->id, + studentId: self::STUDENT_ID, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandlerTest.php new file mode 100644 index 0000000..1231d9b --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandlerTest.php @@ -0,0 +1,130 @@ +submissionRepository = new InMemoryHomeworkSubmissionRepository(); + $this->tempFile = tempnam(sys_get_temp_dir(), 'e2e_upload_'); + file_put_contents($this->tempFile, 'fake PDF content'); + } + + protected function tearDown(): void + { + if (file_exists($this->tempFile)) { + unlink($this->tempFile); + } + } + + #[Test] + public function itUploadsAttachmentForDraftSubmission(): void + { + $submission = $this->createDraftSubmission(); + $this->submissionRepository->save($submission); + + $handler = $this->createHandler(); + $command = new UploadSubmissionAttachmentCommand( + tenantId: self::TENANT_ID, + submissionId: (string) $submission->id, + filename: 'devoir.pdf', + mimeType: 'application/pdf', + fileSize: 1024, + tempFilePath: $this->tempFile, + ); + + $attachment = $handler($command); + + self::assertSame('devoir.pdf', $attachment->filename); + self::assertSame('application/pdf', $attachment->mimeType); + self::assertSame(1024, $attachment->fileSize); + self::assertStringContainsString('submissions/', $attachment->filePath); + } + + #[Test] + public function itRejectsUploadOnSubmittedSubmission(): void + { + $submission = $this->createDraftSubmission(); + $submission->soumettre( + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-24 11:00:00'), + ); + $this->submissionRepository->save($submission); + + $handler = $this->createHandler(); + $command = new UploadSubmissionAttachmentCommand( + tenantId: self::TENANT_ID, + submissionId: (string) $submission->id, + filename: 'devoir.pdf', + mimeType: 'application/pdf', + fileSize: 1024, + tempFilePath: $this->tempFile, + ); + + $this->expectException(RenduDejaSoumisException::class); + + $handler($command); + } + + private function createDraftSubmission(): HomeworkSubmission + { + return HomeworkSubmission::creerBrouillon( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString(self::HOMEWORK_ID), + studentId: UserId::fromString(self::STUDENT_ID), + responseHtml: '

Ma réponse

', + now: new DateTimeImmutable('2026-03-24 10:00:00'), + ); + } + + private function createHandler(): UploadSubmissionAttachmentHandler + { + $fileStorage = new class implements FileStorage { + public function upload(string $path, mixed $content, string $mimeType): string + { + return $path; + } + + public function delete(string $path): void + { + } + }; + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-24 10:00:00'); + } + }; + + return new UploadSubmissionAttachmentHandler( + $this->submissionRepository, + $fileStorage, + $clock, + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandlerTest.php index ebb7555..964f687 100644 --- a/backend/tests/Unit/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Query/GetStudentHomework/GetStudentHomeworkHandlerTest.php @@ -14,8 +14,10 @@ use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkQuery; use App\Scolarite\Domain\Model\Homework\Homework; use App\Scolarite\Domain\Model\Homework\HomeworkAttachment; use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId; +use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository; use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository; +use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkSubmissionRepository; use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; @@ -32,11 +34,13 @@ final class GetStudentHomeworkHandlerTest extends TestCase private InMemoryHomeworkRepository $homeworkRepository; private InMemoryHomeworkAttachmentRepository $attachmentRepository; + private InMemoryHomeworkSubmissionRepository $submissionRepository; protected function setUp(): void { $this->homeworkRepository = new InMemoryHomeworkRepository(); $this->attachmentRepository = new InMemoryHomeworkAttachmentRepository(); + $this->submissionRepository = new InMemoryHomeworkSubmissionRepository(); } #[Test] @@ -190,6 +194,73 @@ final class GetStudentHomeworkHandlerTest extends TestCase self::assertFalse($result[0]->hasAttachments); } + #[Test] + public function itReturnsNullSubmissionStatusWhenNoSubmission(): void + { + $handler = $this->createHandler(); + $this->givenHomework('Devoir sans rendu', '2026-04-15'); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertNull($result[0]->submissionStatus); + } + + #[Test] + public function itReturnsSubmissionStatusWhenDraftExists(): void + { + $handler = $this->createHandler(); + $homework = $this->givenHomework('Devoir avec brouillon', '2026-04-15'); + + $submission = HomeworkSubmission::creerBrouillon( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: $homework->id, + studentId: UserId::fromString(self::STUDENT_ID), + responseHtml: '

Brouillon

', + now: new DateTimeImmutable('2026-03-24 10:00:00'), + ); + $this->submissionRepository->save($submission); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame('draft', $result[0]->submissionStatus); + } + + #[Test] + public function itReturnsSubmittedStatusAfterSubmission(): void + { + $handler = $this->createHandler(); + $homework = $this->givenHomework('Devoir soumis', '2026-04-15'); + + $submission = HomeworkSubmission::creerBrouillon( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: $homework->id, + studentId: UserId::fromString(self::STUDENT_ID), + responseHtml: '

Réponse

', + now: new DateTimeImmutable('2026-03-24 10:00:00'), + ); + $submission->soumettre( + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-24 11:00:00'), + ); + $this->submissionRepository->save($submission); + + $result = $handler(new GetStudentHomeworkQuery( + studentId: self::STUDENT_ID, + tenantId: self::TENANT_ID, + )); + + self::assertCount(1, $result); + self::assertSame('submitted', $result[0]->submissionStatus); + } + private function createHandler(?string $classId = self::CLASS_ID): GetStudentHomeworkHandler { $studentClassReader = new class($classId) implements StudentClassReader { @@ -231,6 +302,7 @@ final class GetStudentHomeworkHandlerTest extends TestCase $studentClassReader, $this->homeworkRepository, $this->attachmentRepository, + $this->submissionRepository, $displayReader, ); } diff --git a/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmissionTest.php b/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmissionTest.php new file mode 100644 index 0000000..8045aa0 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmissionTest.php @@ -0,0 +1,198 @@ +createDraft(); + + self::assertSame(SubmissionStatus::DRAFT, $submission->status); + self::assertNull($submission->submittedAt); + self::assertEmpty($submission->pullDomainEvents()); + } + + #[Test] + public function creerBrouillonSetsAllProperties(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $homeworkId = HomeworkId::fromString(self::HOMEWORK_ID); + $studentId = UserId::fromString(self::STUDENT_ID); + $now = new DateTimeImmutable('2026-03-24 10:00:00'); + + $submission = HomeworkSubmission::creerBrouillon( + tenantId: $tenantId, + homeworkId: $homeworkId, + studentId: $studentId, + responseHtml: '

Ma réponse

', + now: $now, + ); + + self::assertTrue($submission->tenantId->equals($tenantId)); + self::assertTrue($submission->homeworkId->equals($homeworkId)); + self::assertTrue($submission->studentId->equals($studentId)); + self::assertSame('

Ma réponse

', $submission->responseHtml); + self::assertSame(SubmissionStatus::DRAFT, $submission->status); + self::assertEquals($now, $submission->createdAt); + self::assertEquals($now, $submission->updatedAt); + } + + #[Test] + public function creerBrouillonAllowsNullResponse(): void + { + $submission = HomeworkSubmission::creerBrouillon( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString(self::HOMEWORK_ID), + studentId: UserId::fromString(self::STUDENT_ID), + responseHtml: null, + now: new DateTimeImmutable('2026-03-24 10:00:00'), + ); + + self::assertNull($submission->responseHtml); + } + + #[Test] + public function modifierBrouillonUpdatesResponse(): void + { + $submission = $this->createDraft(); + $updatedAt = new DateTimeImmutable('2026-03-24 11:00:00'); + + $submission->modifierBrouillon('

Réponse modifiée

', $updatedAt); + + self::assertSame('

Réponse modifiée

', $submission->responseHtml); + self::assertEquals($updatedAt, $submission->updatedAt); + } + + #[Test] + public function modifierBrouillonThrowsWhenAlreadySubmitted(): void + { + $submission = $this->createDraft(); + $submission->soumettre( + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-24 12:00:00'), + ); + + $this->expectException(RenduDejaSoumisException::class); + + $submission->modifierBrouillon('

Modification

', new DateTimeImmutable('2026-03-24 13:00:00')); + } + + #[Test] + public function soumettreOnTimeRecordsDevoirRenduEvent(): void + { + $submission = $this->createDraft(); + $dueDate = new DateTimeImmutable('2026-04-15 23:59:59'); + $now = new DateTimeImmutable('2026-04-10 14:00:00'); + + $submission->soumettre(dueDate: $dueDate, now: $now); + + self::assertSame(SubmissionStatus::SUBMITTED, $submission->status); + self::assertEquals($now, $submission->submittedAt); + + $events = $submission->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(DevoirRendu::class, $events[0]); + self::assertSame($submission->id, $events[0]->submissionId); + } + + #[Test] + public function soumettreLateSetsLateStatusAndRecordsEvent(): void + { + $submission = $this->createDraft(); + $dueDate = new DateTimeImmutable('2026-03-20 23:59:59'); + $now = new DateTimeImmutable('2026-03-24 14:00:00'); + + $submission->soumettre(dueDate: $dueDate, now: $now); + + self::assertSame(SubmissionStatus::LATE, $submission->status); + self::assertEquals($now, $submission->submittedAt); + + $events = $submission->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(DevoirRenduEnRetard::class, $events[0]); + self::assertSame($submission->id, $events[0]->submissionId); + } + + #[Test] + public function soumettreThrowsWhenAlreadySubmitted(): void + { + $submission = $this->createDraft(); + $submission->soumettre( + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-24 12:00:00'), + ); + + $this->expectException(RenduDejaSoumisException::class); + + $submission->soumettre( + dueDate: new DateTimeImmutable('2026-04-15'), + now: new DateTimeImmutable('2026-03-24 13:00:00'), + ); + } + + #[Test] + public function reconstituteRestoresAllPropertiesWithoutEvents(): void + { + $id = \App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId::generate(); + $tenantId = TenantId::fromString(self::TENANT_ID); + $homeworkId = HomeworkId::fromString(self::HOMEWORK_ID); + $studentId = UserId::fromString(self::STUDENT_ID); + $createdAt = new DateTimeImmutable('2026-03-24 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-03-24 12:00:00'); + $submittedAt = new DateTimeImmutable('2026-03-24 11:30:00'); + + $submission = HomeworkSubmission::reconstitute( + id: $id, + tenantId: $tenantId, + homeworkId: $homeworkId, + studentId: $studentId, + responseHtml: '

Réponse

', + status: SubmissionStatus::SUBMITTED, + submittedAt: $submittedAt, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertTrue($submission->id->equals($id)); + self::assertTrue($submission->tenantId->equals($tenantId)); + self::assertTrue($submission->homeworkId->equals($homeworkId)); + self::assertTrue($submission->studentId->equals($studentId)); + self::assertSame('

Réponse

', $submission->responseHtml); + self::assertSame(SubmissionStatus::SUBMITTED, $submission->status); + self::assertEquals($submittedAt, $submission->submittedAt); + self::assertEquals($createdAt, $submission->createdAt); + self::assertEquals($updatedAt, $submission->updatedAt); + self::assertEmpty($submission->pullDomainEvents()); + } + + private function createDraft(): HomeworkSubmission + { + return HomeworkSubmission::creerBrouillon( + tenantId: TenantId::fromString(self::TENANT_ID), + homeworkId: HomeworkId::fromString(self::HOMEWORK_ID), + studentId: UserId::fromString(self::STUDENT_ID), + responseHtml: '

Ma réponse

', + now: new DateTimeImmutable('2026-03-24 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachmentTest.php b/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachmentTest.php new file mode 100644 index 0000000..d4ddc73 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachmentTest.php @@ -0,0 +1,86 @@ +mimeType); + } + + /** @return iterable */ + public static function validMimeTypes(): iterable + { + yield 'pdf' => ['application/pdf']; + yield 'jpeg' => ['image/jpeg']; + yield 'png' => ['image/png']; + yield 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']; + } + + #[Test] + public function rejectsInvalidMimeType(): void + { + $this->expectException(PieceJointeInvalideException::class); + + new SubmissionAttachment( + id: SubmissionAttachmentId::generate(), + filename: 'test.exe', + filePath: 'submissions/tenant/hw/sub/test.exe', + fileSize: 1024, + mimeType: 'application/x-msdownload', + uploadedAt: new DateTimeImmutable(), + ); + } + + #[Test] + public function rejectsFileTooLarge(): void + { + $this->expectException(PieceJointeInvalideException::class); + + new SubmissionAttachment( + id: SubmissionAttachmentId::generate(), + filename: 'large.pdf', + filePath: 'submissions/tenant/hw/sub/large.pdf', + fileSize: 11 * 1024 * 1024, // 11 Mo + mimeType: 'application/pdf', + uploadedAt: new DateTimeImmutable(), + ); + } + + #[Test] + public function acceptsFileAtMaxSize(): void + { + $attachment = new SubmissionAttachment( + id: SubmissionAttachmentId::generate(), + filename: 'max.pdf', + filePath: 'submissions/tenant/hw/sub/max.pdf', + fileSize: 10 * 1024 * 1024, // exactly 10 Mo + mimeType: 'application/pdf', + uploadedAt: new DateTimeImmutable(), + ); + + self::assertSame(10 * 1024 * 1024, $attachment->fileSize); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/SubmissionStatusTest.php b/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/SubmissionStatusTest.php new file mode 100644 index 0000000..4c357e2 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/SubmissionStatusTest.php @@ -0,0 +1,38 @@ +estModifiable()); + } + + #[Test] + public function submittedNestPasModifiable(): void + { + self::assertFalse(SubmissionStatus::SUBMITTED->estModifiable()); + } + + #[Test] + public function lateNestPasModifiable(): void + { + self::assertFalse(SubmissionStatus::LATE->estModifiable()); + } + + #[Test] + public function backingValues(): void + { + self::assertSame('draft', SubmissionStatus::DRAFT->value); + self::assertSame('submitted', SubmissionStatus::SUBMITTED->value); + self::assertSame('late', SubmissionStatus::LATE->value); + } +} diff --git a/frontend/e2e/homework-submission.spec.ts b/frontend/e2e/homework-submission.spec.ts new file mode 100644 index 0000000..be58aec --- /dev/null +++ b/frontend/e2e/homework-submission.spec.ts @@ -0,0 +1,394 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const STUDENT_EMAIL = 'e2e-sub-student@example.com'; +const STUDENT_PASSWORD = 'SubStudent123'; +const TEACHER_EMAIL = 'e2e-sub-teacher@example.com'; +const TEACHER_PASSWORD = 'SubTeacher123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +function runSql(sql: string) { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, + { encoding: 'utf-8' } + ); +} + +function clearCache() { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pool may not exist + } +} + +function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { + const output = execSync( + `docker compose -f "${composeFile}" exec -T php php -r '` + + `require "/app/vendor/autoload.php"; ` + + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + + `' 2>&1`, + { encoding: 'utf-8' } + ).trim(); + const [schoolId, academicYearId] = output.split('\n'); + return { schoolId: schoolId!, academicYearId: academicYearId! }; +} + +function getNextWeekday(daysFromNow: number): string { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + const day = date.getDay(); + if (day === 0) date.setDate(date.getDate() + 1); + if (day === 6) date.setDate(date.getDate() + 2); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function getPastDate(daysAgo: number): string { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +async function loginAsStudent(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(STUDENT_EMAIL); + await page.locator('#password').fill(STUDENT_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +async function loginAsTeacher(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TEACHER_EMAIL); + await page.locator('#password').fill(TEACHER_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); +} + +test.describe('Homework Submission (Story 5.10)', () => { + test.describe.configure({ mode: 'serial' }); + + const dueDate = getNextWeekday(7); + const pastDueDate = getPastDate(3); + + test.beforeAll(async () => { + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pools may not exist + } + + // Create student user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`, + { encoding: 'utf-8' } + ); + + // Create teacher user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + const { schoolId, academicYearId } = resolveDeterministicIds(); + + // Ensure class exists + 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-Sub-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Ensure subject exists + try { + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Sub-Maths', 'E2ESUBMAT', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); + } catch { + // May already exist + } + + // Assign student to class + runSql( + `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + + `FROM users u, school_classes c ` + + `WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING` + ); + + // Clean up submissions and homework + try { + runSql( + `DELETE FROM submission_attachments WHERE submission_id IN ` + + `(SELECT hs.id FROM homework_submissions hs JOIN homework h ON h.id = hs.homework_id ` + + `WHERE h.tenant_id = '${TENANT_ID}' AND h.class_id IN ` + + `(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))` + ); + runSql( + `DELETE FROM homework_submissions WHERE homework_id IN ` + + `(SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` + + `(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))` + ); + runSql( + `DELETE FROM homework_attachments WHERE homework_id IN ` + + `(SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` + + `(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))` + ); + runSql( + `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` + + `(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}')` + ); + } catch { + // Tables may not exist + } + + // Seed homework (future due date) + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'E2E Devoir à rendre', 'Rédigez un texte libre.', '${dueDate}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2ESUBMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + // Seed homework (past due date for late submission test) + runSql( + `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'E2E Devoir en retard', 'Devoir déjà dû.', '${pastDueDate}', 'published', NOW(), NOW() ` + + `FROM school_classes c, ` + + `(SELECT id FROM subjects WHERE code = 'E2ESUBMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + + `WHERE c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}'` + ); + + clearCache(); + }); + + // ====================================================================== + // AC1 + AC3: Draft and Submission + // ====================================================================== + test.describe('AC1+AC3: Write and submit homework', () => { + test('student sees "Rendre mon devoir" button in homework detail', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 }); + + // Click on the homework + await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click(); + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + + // Verify "Rendre mon devoir" button is visible + await expect(page.getByRole('button', { name: /rendre mon devoir/i })).toBeVisible(); + }); + + test('student can save a draft', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click(); + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + + // Click "Rendre mon devoir" + await page.getByRole('button', { name: /rendre mon devoir/i }).click(); + await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 }); + + // Wait for editor and type + const editor = page.locator('.ProseMirror'); + await expect(editor).toBeVisible({ timeout: 10000 }); + await editor.click(); + await page.keyboard.type('Mon brouillon de reponse'); + + // Save draft + await page.getByRole('button', { name: /sauvegarder le brouillon/i }).click(); + await expect(page.locator('.success-banner')).toContainText('Brouillon sauvegardé', { + timeout: 10000 + }); + }); + + test('student can upload a file attachment', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click(); + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + + await page.getByRole('button', { name: /rendre mon devoir/i }).click(); + await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 }); + + // Upload a PDF file + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'devoir-eleve.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('Fake PDF content for E2E test') + }); + + // Wait for file to appear in the list + await expect(page.locator('.file-item')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('devoir-eleve.pdf')).toBeVisible(); + }); + + test('student can submit homework with confirmation', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click(); + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + + await page.getByRole('button', { name: /rendre mon devoir/i }).click(); + await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 }); + + // Click submit + await page.getByRole('button', { name: /soumettre mon devoir/i }).click(); + + // Confirmation dialog should appear + await expect(page.locator('[role="alertdialog"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('[role="alertdialog"]')).toContainText( + 'Confirmer la soumission' + ); + + // Confirm submission + await page.getByRole('button', { name: /confirmer/i }).click(); + + // Should show success + await expect(page.locator('.success-banner')).toContainText('rendu avec succès', { + timeout: 10000 + }); + }); + }); + + // ====================================================================== + // AC4: Status in homework list + // ====================================================================== + test.describe('AC4: Submission status in list', () => { + test('student sees submitted status in homework list', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 }); + + const card = page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }); + await expect(card.locator('.submission-submitted')).toContainText('Rendu'); + }); + }); + + // ====================================================================== + // AC4: Late submission + // ====================================================================== + test.describe('AC4: Late submission', () => { + test('late submission shows en retard status', async ({ page }) => { + await loginAsStudent(page); + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 }); + await page.locator('.homework-card', { hasText: 'E2E Devoir en retard' }).click(); + await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); + + await page.getByRole('button', { name: /rendre mon devoir/i }).click(); + await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 }); + + // Wait for editor to be ready then type + const editor = page.locator('.ProseMirror'); + await expect(editor).toBeVisible({ timeout: 10000 }); + await editor.click(); + await page.keyboard.type('Rendu en retard'); + + // Save draft first + await page.getByRole('button', { name: /sauvegarder le brouillon/i }).click(); + await expect(page.locator('.success-banner')).toBeVisible({ timeout: 10000 }); + + // Wait for success to appear then submit + await page.getByRole('button', { name: /soumettre mon devoir/i }).click(); + await expect(page.locator('[role="alertdialog"]')).toBeVisible({ timeout: 5000 }); + await page.getByRole('button', { name: /confirmer/i }).click(); + await expect(page.locator('.success-banner')).toContainText('rendu', { + timeout: 15000 + }); + + // Go back to list and check status + await page.goto(`${ALPHA_URL}/dashboard/homework`); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 }); + const card = page.locator('.homework-card', { hasText: 'E2E Devoir en retard' }); + await expect(card.locator('.submission-late')).toContainText('retard', { + timeout: 15000 + }); + }); + }); + + // ====================================================================== + // AC5 + AC6: Teacher views submissions and stats + // ====================================================================== + test.describe('AC5+AC6: Teacher submission views', () => { + test('teacher can view submissions list and stats', async ({ page }) => { + await loginAsTeacher(page); + + // Get the homework ID from the database + const homeworkIdOutput = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM homework WHERE title = 'E2E Devoir à rendre' AND tenant_id = '${TENANT_ID}' LIMIT 1" 2>&1`, + { encoding: 'utf-8' } + ); + + const idMatch = homeworkIdOutput.match( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ); + if (!idMatch) { + throw new Error('Could not find homework ID'); + } + const homeworkId = idMatch[0]; + + // Navigate to submissions page + await page.goto( + `${ALPHA_URL}/dashboard/teacher/homework/${homeworkId}/submissions` + ); + + // Should see stats + await expect(page.locator('.stat-value')).toContainText('1', { timeout: 15000 }); + + // Should see the submission in the table + await expect(page.locator('tbody tr')).toHaveCount(1, { timeout: 10000 }); + + // Should see "Voir" button + await expect(page.getByRole('button', { name: 'Voir', exact: true })).toBeVisible(); + + // Click to view detail + await page.getByRole('button', { name: 'Voir', exact: true }).click(); + await expect(page.locator('.detail-header')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.response-content')).toBeVisible(); + }); + }); +}); diff --git a/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte b/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte index 96eb6e1..7c4d466 100644 --- a/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte +++ b/frontend/src/lib/components/molecules/FileUpload/FileUpload.svelte @@ -1,5 +1,7 @@ + +
+ + +
+ + {detail.subjectName} + +

{detail.title}

+
+ Pour le {formatDueDate(detail.dueDate)} + {#if statusLabel()} + {statusLabel()?.text} + {/if} +
+
+ + {#if error} + + {/if} + + {#if successMessage} +
{successMessage}
+ {/if} + + {#if loading} +
Chargement...
+ {:else if isSubmitted} + + {:else} +
+

Votre réponse

+ { + responseHtml = html; + }} + placeholder="Rédigez votre réponse ici..." + /> +
+ +
+

Pièces jointes

+ +
+ +
+ + +
+ {/if} +
+ +{#if showConfirmDialog} + + + +{/if} + + diff --git a/frontend/src/lib/components/organisms/StudentHomework/HomeworkCard.svelte b/frontend/src/lib/components/organisms/StudentHomework/HomeworkCard.svelte index a46ad92..0bc2c78 100644 --- a/frontend/src/lib/components/organisms/StudentHomework/HomeworkCard.svelte +++ b/frontend/src/lib/components/organisms/StudentHomework/HomeworkCard.svelte @@ -78,6 +78,13 @@ {isDone ? 'Fait' : 'À faire'} + {#if homework.submissionStatus === 'submitted'} + + {:else if homework.submissionStatus === 'late'} + Rendu en retard + {:else if homework.submissionStatus === 'draft'} + Brouillon + {/if} {#if homework.hasAttachments} @@ -178,4 +185,26 @@ display: flex; align-items: center; } + + .submission-badge { + font-size: 0.75rem; + font-weight: 600; + border-radius: 999px; + padding: 0.125rem 0.5rem; + } + + .submission-submitted { + color: #166534; + background: #dcfce7; + } + + .submission-late { + color: #991b1b; + background: #fee2e2; + } + + .submission-draft { + color: #3730a3; + background: #e0e7ff; + } diff --git a/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte b/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte index 04cc364..7b78476 100644 --- a/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte +++ b/frontend/src/lib/components/organisms/StudentHomework/HomeworkDetail.svelte @@ -6,10 +6,12 @@ let { detail, onBack, + onSubmit, getAttachmentUrl = defaultGetAttachmentUrl }: { detail: StudentHomeworkDetail; onBack: () => void; + onSubmit?: () => void; getAttachmentUrl?: (homeworkId: string, attachmentId: string) => string; } = $props(); @@ -45,7 +47,7 @@ } link.click(); - setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); } let downloadError = $state(null); @@ -97,6 +99,14 @@ {/if} + {#if onSubmit} +
+ +
+ {/if} + {#if detail.attachments.length > 0}

Pièces jointes

@@ -260,4 +270,27 @@ color: #9ca3af; font-size: 0.75rem; } + + .submit-section { + padding-top: 0.5rem; + } + + .btn-submit-homework { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.625rem 1.25rem; + border: none; + border-radius: 0.375rem; + background: #3b82f6; + color: white; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; + } + + .btn-submit-homework:hover { + background: #2563eb; + } diff --git a/frontend/src/lib/components/organisms/StudentHomework/StudentHomeworkList.svelte b/frontend/src/lib/components/organisms/StudentHomework/StudentHomeworkList.svelte index 1f934a5..d4b05c7 100644 --- a/frontend/src/lib/components/organisms/StudentHomework/StudentHomeworkList.svelte +++ b/frontend/src/lib/components/organisms/StudentHomework/StudentHomeworkList.svelte @@ -5,6 +5,7 @@ import { isOffline } from '$lib/features/schedule/stores/scheduleCache.svelte'; import HomeworkCard from './HomeworkCard.svelte'; import HomeworkDetail from './HomeworkDetail.svelte'; + import HomeworkSubmissionForm from '$lib/components/organisms/HomeworkSubmission/HomeworkSubmissionForm.svelte'; import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte'; let homeworks = $state([]); @@ -13,6 +14,7 @@ let error = $state(null); let selectedSubjectId = $state(null); let selectedDetail = $state(null); + let showSubmissionForm = $state(false); let detailLoading = $state(false); let statuses = $derived(getHomeworkStatuses()); @@ -69,6 +71,24 @@ function handleBack() { selectedDetail = null; + showSubmissionForm = false; + } + + function handleOpenSubmissionForm() { + showSubmissionForm = true; + } + + function handleSubmissionBack() { + showSubmissionForm = false; + } + + function handleSubmitted() { + // Laisser le message de succès visible brièvement avant de revenir à la liste + window.setTimeout(() => { + showSubmissionForm = false; + selectedDetail = null; + void loadHomeworks(); + }, 1500); } function handleToggleDone(homeworkId: string) { @@ -81,8 +101,10 @@ }); -{#if selectedDetail} - +{#if selectedDetail && showSubmissionForm} + +{:else if selectedDetail} + {:else}
{#if isOffline()} diff --git a/frontend/src/lib/features/homework/api/studentHomework.ts b/frontend/src/lib/features/homework/api/studentHomework.ts index b92c4ab..9e000fe 100644 --- a/frontend/src/lib/features/homework/api/studentHomework.ts +++ b/frontend/src/lib/features/homework/api/studentHomework.ts @@ -13,6 +13,7 @@ export interface StudentHomework { dueDate: string; createdAt: string; hasAttachments: boolean; + submissionStatus: 'draft' | 'submitted' | 'late' | null; } export interface HomeworkAttachment { @@ -36,6 +37,18 @@ export interface StudentHomeworkDetail { attachments: HomeworkAttachment[]; } +export interface HomeworkSubmission { + id: string; + homeworkId: string; + studentId: string; + responseHtml: string | null; + status: 'draft' | 'submitted' | 'late'; + submittedAt: string | null; + createdAt: string; + updatedAt: string; + attachments?: HomeworkAttachment[]; +} + /** * Récupère la liste des devoirs pour l'élève connecté. */ @@ -74,3 +87,87 @@ export function getAttachmentUrl(homeworkId: string, attachmentId: string): stri const apiUrl = getApiBaseUrl(); return `${apiUrl}/me/homework/${homeworkId}/attachments/${attachmentId}`; } + +/** + * Récupère le rendu de l'élève pour un devoir (ou null si aucun brouillon). + */ +export async function fetchSubmission(homeworkId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement du rendu (${response.status})`); + } + + const json = await response.json(); + return json.data ?? null; +} + +/** + * Sauvegarde un brouillon de rendu. + */ +export async function saveDraftSubmission( + homeworkId: string, + responseHtml: string | null +): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ responseHtml }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail ?? `Erreur lors de la sauvegarde (${response.status})`); + } + + const json = await response.json(); + return json.data; +} + +/** + * Soumet définitivement le rendu. + */ +export async function submitHomework(homeworkId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission/submit`, { + method: 'POST' + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail ?? `Erreur lors de la soumission (${response.status})`); + } + + const json = await response.json(); + return json.data; +} + +/** + * Upload une pièce jointe au rendu de l'élève. + */ +export async function uploadSubmissionAttachment( + homeworkId: string, + file: File +): Promise { + const apiUrl = getApiBaseUrl(); + const formData = new FormData(); + formData.append('file', file); + + const response = await authenticatedFetch( + `${apiUrl}/me/homework/${homeworkId}/submission/attachments`, + { + method: 'POST', + body: formData + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail ?? "Erreur lors de l'envoi du fichier."); + } + + const json = await response.json(); + return json.data; +} diff --git a/frontend/src/lib/features/homework/api/teacherSubmissions.ts b/frontend/src/lib/features/homework/api/teacherSubmissions.ts new file mode 100644 index 0000000..ea57321 --- /dev/null +++ b/frontend/src/lib/features/homework/api/teacherSubmissions.ts @@ -0,0 +1,97 @@ +import { getApiBaseUrl } from '$lib/api'; +import { authenticatedFetch } from '$lib/auth'; + +export interface TeacherSubmission { + id: string | null; + studentId: string; + studentName: string; + status: 'draft' | 'submitted' | 'late' | 'not_submitted'; + submittedAt: string | null; + createdAt: string | null; +} + +export interface TeacherSubmissionDetail { + id: string; + studentId: string; + studentName: string; + responseHtml: string | null; + status: 'draft' | 'submitted' | 'late'; + submittedAt: string | null; + createdAt: string; + attachments: { + id: string; + filename: string; + fileSize: number; + mimeType: string; + }[]; +} + +export interface SubmissionStats { + totalStudents: number; + submittedCount: number; + missingStudents: { id: string; name: string }[]; +} + +/** + * Récupère la liste des rendus pour un devoir. + */ +export async function fetchSubmissions(homeworkId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/submissions`); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement des rendus (${response.status})`); + } + + const json = await response.json(); + return json.data ?? []; +} + +/** + * Récupère le détail d'un rendu. + */ +export async function fetchSubmissionDetail( + homeworkId: string, + submissionId: string +): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch( + `${apiUrl}/homework/${homeworkId}/submissions/${submissionId}` + ); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement du rendu (${response.status})`); + } + + const json = await response.json(); + return json.data; +} + +/** + * Récupère les statistiques de rendus pour un devoir. + */ +export async function fetchSubmissionStats(homeworkId: string): Promise { + const apiUrl = getApiBaseUrl(); + const response = await authenticatedFetch( + `${apiUrl}/homework/${homeworkId}/submissions/stats` + ); + + if (!response.ok) { + throw new Error(`Erreur lors du chargement des statistiques (${response.status})`); + } + + const json = await response.json(); + return json.data; +} + +/** + * Retourne l'URL de téléchargement d'une pièce jointe de rendu. + */ +export function getSubmissionAttachmentUrl( + homeworkId: string, + submissionId: string, + attachmentId: string +): string { + const apiUrl = getApiBaseUrl(); + return `${apiUrl}/homework/${homeworkId}/submissions/${submissionId}/attachments/${attachmentId}`; +} diff --git a/frontend/src/routes/dashboard/teacher/homework/[id]/submissions/+page.svelte b/frontend/src/routes/dashboard/teacher/homework/[id]/submissions/+page.svelte new file mode 100644 index 0000000..d1ebcbc --- /dev/null +++ b/frontend/src/routes/dashboard/teacher/homework/[id]/submissions/+page.svelte @@ -0,0 +1,497 @@ + + +
+ + + {#if loading} +
Chargement des rendus...
+ {:else if error} + + {:else if selectedDetail} +
+ + +
+

{selectedDetail.studentName}

+
+ + {statusLabel(selectedDetail.status).text} + + {#if selectedDetail.submittedAt} + + {/if} +
+
+ + {#if selectedDetail.responseHtml} +
+

Réponse

+
{@html selectedDetail.responseHtml}
+
+ {:else} +

Aucune réponse textuelle.

+ {/if} + + {#if selectedDetail.attachments.length > 0} +
+

Pièces jointes

+ {#if downloadError} + + {/if} +
    + {#each selectedDetail.attachments as attachment} +
  • + +
  • + {/each} +
+
+ {/if} +
+ {:else} + {#if stats} +
+
+ {stats.submittedCount} + / {stats.totalStudents} rendus +
+
+ {/if} + + {#if submissions.length === 0} +
+

Aucun rendu pour ce devoir.

+
+ {:else} +
+

Rendus ({submissions.length})

+ + + + + + + + + + + {#each submissions as sub} + + + + + + + {/each} + +
ÉlèveStatutDate de soumission
{sub.studentName} + + {statusLabel(sub.status).text} + + {sub.submittedAt ? formatDate(sub.submittedAt) : '—'} + {#if sub.id} + + {/if} +
+
+ {/if} + + {/if} +
+ +