From df25a8cbb04323c2e4d72b88cf37032d97cbcff4 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 25 Mar 2026 19:38:25 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20=C3=A0=20l'=C3=A9l=C3=A8ve?= =?UTF-8?q?=20de=20rendre=20un=20devoir=20avec=20r=C3=A9ponse=20texte=20et?= =?UTF-8?q?=20pi=C3=A8ces=20jointes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'élève peut désormais répondre à un devoir via un éditeur WYSIWYG, joindre des fichiers (PDF, JPEG, PNG, DOCX), sauvegarder un brouillon et soumettre définitivement son rendu. Le système détecte automatiquement les soumissions en retard par rapport à la date d'échéance. Côté enseignant, une page dédiée affiche la liste complète des élèves avec leur statut (soumis, en retard, brouillon, non rendu), le détail de chaque rendu avec ses pièces jointes téléchargeables, et les statistiques de rendus par classe. --- backend/config/services.yaml | 10 + backend/migrations/Version20260324162229.php | 51 ++ .../SaveDraftSubmissionCommand.php | 16 + .../SaveDraftSubmissionHandler.php | 72 +++ .../SubmitHomework/SubmitHomeworkCommand.php | 15 + .../SubmitHomework/SubmitHomeworkHandler.php | 57 ++ .../UploadSubmissionAttachmentCommand.php | 18 + .../UploadSubmissionAttachmentHandler.php | 69 +++ .../Application/Port/ClassStudentsReader.php | 18 + .../GetStudentHomeworkHandler.php | 16 +- .../GetStudentHomework/StudentHomeworkDto.php | 3 + .../Scolarite/Domain/Event/DevoirRendu.php | 38 ++ .../Domain/Event/DevoirRenduEnRetard.php | 38 ++ .../EleveNonAffecteAuDevoirException.php | 23 + .../Exception/RenduDejaSoumisException.php | 21 + .../Exception/RenduNonTrouveException.php | 32 + .../HomeworkSubmission/HomeworkSubmission.php | 125 ++++ .../HomeworkSubmissionId.php | 11 + .../SubmissionAttachment.php | 46 ++ .../SubmissionAttachmentId.php | 11 + .../HomeworkSubmission/SubmissionStatus.php | 17 + .../HomeworkSubmissionRepository.php | 36 ++ .../SubmissionAttachmentRepository.php | 18 + .../Controller/StudentHomeworkController.php | 1 + .../StudentSubmissionController.php | 259 ++++++++ .../TeacherSubmissionController.php | 302 ++++++++++ .../NotifierEnseignantDevoirRenduListener.php | 36 ++ .../DoctrineHomeworkSubmissionRepository.php | 195 ++++++ ...DoctrineSubmissionAttachmentRepository.php | 92 +++ .../InMemoryHomeworkSubmissionRepository.php | 108 ++++ ...InMemorySubmissionAttachmentRepository.php | 47 ++ .../Service/DoctrineClassStudentsReader.php | 47 ++ .../SaveDraftSubmissionHandlerTest.php | 220 +++++++ .../SubmitHomeworkHandlerTest.php | 171 ++++++ .../UploadSubmissionAttachmentHandlerTest.php | 130 ++++ .../GetStudentHomeworkHandlerTest.php | 72 +++ .../HomeworkSubmissionTest.php | 198 ++++++ .../SubmissionAttachmentTest.php | 86 +++ .../SubmissionStatusTest.php | 38 ++ frontend/e2e/homework-submission.spec.ts | 394 ++++++++++++ .../molecules/FileUpload/FileUpload.svelte | 24 +- .../HomeworkSubmissionForm.svelte | 569 ++++++++++++++++++ .../StudentHomework/HomeworkCard.svelte | 29 + .../StudentHomework/HomeworkDetail.svelte | 35 +- .../StudentHomeworkList.svelte | 26 +- .../features/homework/api/studentHomework.ts | 97 +++ .../homework/api/teacherSubmissions.ts | 97 +++ .../homework/[id]/submissions/+page.svelte | 497 +++++++++++++++ 48 files changed, 4519 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/Version20260324162229.php create mode 100644 backend/src/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionCommand.php create mode 100644 backend/src/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionHandler.php create mode 100644 backend/src/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkCommand.php create mode 100644 backend/src/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkHandler.php create mode 100644 backend/src/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentCommand.php create mode 100644 backend/src/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandler.php create mode 100644 backend/src/Scolarite/Application/Port/ClassStudentsReader.php create mode 100644 backend/src/Scolarite/Domain/Event/DevoirRendu.php create mode 100644 backend/src/Scolarite/Domain/Event/DevoirRenduEnRetard.php create mode 100644 backend/src/Scolarite/Domain/Exception/EleveNonAffecteAuDevoirException.php create mode 100644 backend/src/Scolarite/Domain/Exception/RenduDejaSoumisException.php create mode 100644 backend/src/Scolarite/Domain/Exception/RenduNonTrouveException.php create mode 100644 backend/src/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmission.php create mode 100644 backend/src/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmissionId.php create mode 100644 backend/src/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachment.php create mode 100644 backend/src/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachmentId.php create mode 100644 backend/src/Scolarite/Domain/Model/HomeworkSubmission/SubmissionStatus.php create mode 100644 backend/src/Scolarite/Domain/Repository/HomeworkSubmissionRepository.php create mode 100644 backend/src/Scolarite/Domain/Repository/SubmissionAttachmentRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Controller/StudentSubmissionController.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Controller/TeacherSubmissionController.php create mode 100644 backend/src/Scolarite/Infrastructure/EventListener/NotifierEnseignantDevoirRenduListener.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineHomeworkSubmissionRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/Doctrine/DoctrineSubmissionAttachmentRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemoryHomeworkSubmissionRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Persistence/InMemory/InMemorySubmissionAttachmentRepository.php create mode 100644 backend/src/Scolarite/Infrastructure/Service/DoctrineClassStudentsReader.php create mode 100644 backend/tests/Unit/Scolarite/Application/Command/SaveDraftSubmission/SaveDraftSubmissionHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Command/SubmitHomework/SubmitHomeworkHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Application/Command/UploadSubmissionAttachment/UploadSubmissionAttachmentHandlerTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/HomeworkSubmissionTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/SubmissionAttachmentTest.php create mode 100644 backend/tests/Unit/Scolarite/Domain/Model/HomeworkSubmission/SubmissionStatusTest.php create mode 100644 frontend/e2e/homework-submission.spec.ts create mode 100644 frontend/src/lib/components/organisms/HomeworkSubmission/HomeworkSubmissionForm.svelte create mode 100644 frontend/src/lib/features/homework/api/teacherSubmissions.ts create mode 100644 frontend/src/routes/dashboard/teacher/homework/[id]/submissions/+page.svelte 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} +
+ +