feat: Permettre à l'élève de consulter ses devoirs
L'élève n'avait aucun moyen de voir les devoirs assignés à sa classe. Cette fonctionnalité ajoute la consultation complète : liste triée par échéance, détail avec pièces jointes, filtrage par matière, et marquage personnel « fait » en localStorage. Le dashboard élève affiche désormais les devoirs à venir avec ouverture du détail en modale, et un lien vers la page complète. L'accès API est sécurisé par vérification de la classe de l'élève (pas d'IDOR) et validation du chemin des pièces jointes (pas de path traversal).
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
|
||||
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkQuery;
|
||||
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDetailDto;
|
||||
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDto;
|
||||
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\Infrastructure\Security\HomeworkStudentVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function is_string;
|
||||
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\Request;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted(HomeworkStudentVoter::VIEW)]
|
||||
final readonly class StudentHomeworkController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private GetStudentHomeworkHandler $handler,
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private HomeworkAttachmentRepository $attachmentRepository,
|
||||
private ScheduleDisplayReader $displayReader,
|
||||
private StudentClassReader $studentClassReader,
|
||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||
private string $uploadsDir,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/me/homework', name: 'api_student_homework_list', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
$subjectId = $request->query->get('subjectId');
|
||||
|
||||
$result = ($this->handler)(new GetStudentHomeworkQuery(
|
||||
studentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null,
|
||||
));
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => array_map($this->serializeListItem(...), $result),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/me/homework/{id}', name: 'api_student_homework_detail', methods: ['GET'])]
|
||||
public function detail(string $id): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
|
||||
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||
|
||||
if ($homework === null) {
|
||||
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||
}
|
||||
|
||||
$this->assertHomeworkBelongsToStudentClass($user, $homework);
|
||||
|
||||
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
|
||||
|
||||
$subjects = $this->displayReader->subjectDisplay($user->tenantId(), (string) $homework->subjectId);
|
||||
$teacherNames = $this->displayReader->teacherNames($user->tenantId(), (string) $homework->teacherId);
|
||||
|
||||
$detail = StudentHomeworkDetailDto::fromDomain(
|
||||
$homework,
|
||||
$subjects[(string) $homework->subjectId]['name'] ?? '',
|
||||
$subjects[(string) $homework->subjectId]['color'] ?? null,
|
||||
$teacherNames[(string) $homework->teacherId] ?? '',
|
||||
$attachments,
|
||||
);
|
||||
|
||||
return new JsonResponse(['data' => $this->serializeDetail($detail)]);
|
||||
}
|
||||
|
||||
#[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])]
|
||||
public function downloadAttachment(string $homeworkId, 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é.');
|
||||
}
|
||||
|
||||
$this->assertHomeworkBelongsToStudentClass($user, $homework);
|
||||
|
||||
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if ((string) $attachment->id === $attachmentId) {
|
||||
$realPath = realpath($attachment->filePath);
|
||||
$realUploadsDir = realpath($this->uploadsDir);
|
||||
|
||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
||||
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 assertHomeworkBelongsToStudentClass(SecurityUser $user, Homework $homework): void
|
||||
{
|
||||
$classId = $this->studentClassReader->currentClassId(
|
||||
$user->userId(),
|
||||
TenantId::fromString($user->tenantId()),
|
||||
);
|
||||
|
||||
if ($classId === null || (string) $homework->classId !== $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<string, mixed>
|
||||
*/
|
||||
private function serializeListItem(StudentHomeworkDto $dto): array
|
||||
{
|
||||
return [
|
||||
'id' => $dto->id,
|
||||
'subjectId' => $dto->subjectId,
|
||||
'subjectName' => $dto->subjectName,
|
||||
'subjectColor' => $dto->subjectColor,
|
||||
'teacherId' => $dto->teacherId,
|
||||
'teacherName' => $dto->teacherName,
|
||||
'title' => $dto->title,
|
||||
'description' => $dto->description,
|
||||
'dueDate' => $dto->dueDate,
|
||||
'createdAt' => $dto->createdAt,
|
||||
'hasAttachments' => $dto->hasAttachments,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeDetail(StudentHomeworkDetailDto $dto): array
|
||||
{
|
||||
return [
|
||||
'id' => $dto->id,
|
||||
'subjectId' => $dto->subjectId,
|
||||
'subjectName' => $dto->subjectName,
|
||||
'subjectColor' => $dto->subjectColor,
|
||||
'teacherId' => $dto->teacherId,
|
||||
'teacherName' => $dto->teacherName,
|
||||
'title' => $dto->title,
|
||||
'description' => $dto->description,
|
||||
'dueDate' => $dto->dueDate,
|
||||
'createdAt' => $dto->createdAt,
|
||||
'attachments' => array_map(
|
||||
static fn ($a): array => [
|
||||
'id' => $a->id,
|
||||
'filename' => $a->filename,
|
||||
'fileSize' => $a->fileSize,
|
||||
'mimeType' => $a->mimeType,
|
||||
],
|
||||
$dto->attachments,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user