feat: Permettre à l'élève de rendre un devoir avec réponse texte et pièces jointes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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.
This commit is contained in:
2026-03-25 19:38:25 +01:00
parent ab835e5c3d
commit df25a8cbb0
48 changed files with 4519 additions and 12 deletions

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\ClassStudentsReader;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
use App\Scolarite\Domain\Repository\SubmissionAttachmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_column;
use function array_diff;
use function array_filter;
use function array_map;
use function array_values;
use function count;
use DateTimeImmutable;
use function in_array;
use function realpath;
use function str_starts_with;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
final readonly class TeacherSubmissionController
{
public function __construct(
private Security $security,
private HomeworkRepository $homeworkRepository,
private HomeworkSubmissionRepository $submissionRepository,
private SubmissionAttachmentRepository $attachmentRepository,
private ClassStudentsReader $classStudentsReader,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $storageDir,
) {
}
#[Route('/api/homework/{id}/submissions', name: 'api_teacher_submission_list', methods: ['GET'])]
public function list(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);
$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;
}
}