feat: Permettre à l'élève de rendre un devoir avec réponse texte et pièces jointes
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:
@@ -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
|
||||
|
||||
|
||||
51
backend/migrations/Version20260324162229.php
Normal file
51
backend/migrations/Version20260324162229.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260324162229 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create homework_submissions and submission_attachments tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\SaveDraftSubmission;
|
||||
|
||||
final readonly class SaveDraftSubmissionCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $homeworkId,
|
||||
public string $studentId,
|
||||
public ?string $responseHtml,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\SaveDraftSubmission;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class SaveDraftSubmissionHandler
|
||||
{
|
||||
public function __construct(
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private HomeworkSubmissionRepository $submissionRepository,
|
||||
private StudentClassReader $studentClassReader,
|
||||
private HtmlSanitizer $htmlSanitizer,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(SaveDraftSubmissionCommand $command): HomeworkSubmission
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\SubmitHomework;
|
||||
|
||||
final readonly class SubmitHomeworkCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $homeworkId,
|
||||
public string $studentId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\SubmitHomework;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
|
||||
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class SubmitHomeworkHandler
|
||||
{
|
||||
public function __construct(
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private HomeworkSubmissionRepository $submissionRepository,
|
||||
private StudentClassReader $studentClassReader,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(SubmitHomeworkCommand $command): HomeworkSubmission
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UploadSubmissionAttachment;
|
||||
|
||||
final readonly class UploadSubmissionAttachmentCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $submissionId,
|
||||
public string $filename,
|
||||
public string $mimeType,
|
||||
public int $fileSize,
|
||||
public string $tempFilePath,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\UploadSubmissionAttachment;
|
||||
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
|
||||
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachmentId;
|
||||
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function file_get_contents;
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UploadSubmissionAttachmentHandler
|
||||
{
|
||||
public function __construct(
|
||||
private HomeworkSubmissionRepository $submissionRepository,
|
||||
private FileStorage $fileStorage,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UploadSubmissionAttachmentCommand $command): SubmissionAttachment
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour lire les élèves affectés à une classe.
|
||||
*/
|
||||
interface ClassStudentsReader
|
||||
{
|
||||
/**
|
||||
* @return array<array{id: string, name: string}> Liste des élèves avec ID et nom complet
|
||||
*/
|
||||
public function studentsInClass(string $classId, TenantId $tenantId): array;
|
||||
}
|
||||
@@ -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<StudentHomeworkDto>
|
||||
*/
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
38
backend/src/Scolarite/Domain/Event/DevoirRendu.php
Normal file
38
backend/src/Scolarite/Domain/Event/DevoirRendu.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class DevoirRendu implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public HomeworkSubmissionId $submissionId,
|
||||
public HomeworkId $homeworkId,
|
||||
public UserId $studentId,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->submissionId->value;
|
||||
}
|
||||
}
|
||||
38
backend/src/Scolarite/Domain/Event/DevoirRenduEnRetard.php
Normal file
38
backend/src/Scolarite/Domain/Event/DevoirRenduEnRetard.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class DevoirRenduEnRetard implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public HomeworkSubmissionId $submissionId,
|
||||
public HomeworkId $homeworkId,
|
||||
public UserId $studentId,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->submissionId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class EleveNonAffecteAuDevoirException extends DomainException
|
||||
{
|
||||
public static function pourEleve(UserId $studentId, HomeworkId $homeworkId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'élève "%s" n\'appartient pas à la classe du devoir "%s".',
|
||||
$studentId,
|
||||
$homeworkId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class RenduDejaSoumisException extends DomainException
|
||||
{
|
||||
public static function pourRendu(HomeworkSubmissionId $id): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le rendu "%s" a déjà été soumis et ne peut plus être modifié.',
|
||||
$id,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class RenduNonTrouveException extends DomainException
|
||||
{
|
||||
public static function withId(HomeworkSubmissionId $id): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le rendu avec l\'ID "%s" n\'a pas été trouvé.',
|
||||
$id,
|
||||
));
|
||||
}
|
||||
|
||||
public static function pourDevoirEtEleve(HomeworkId $homeworkId, UserId $studentId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Aucun rendu trouvé pour le devoir "%s" et l\'élève "%s".',
|
||||
$homeworkId,
|
||||
$studentId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\HomeworkSubmission;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Event\DevoirRendu;
|
||||
use App\Scolarite\Domain\Event\DevoirRenduEnRetard;
|
||||
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final class HomeworkSubmission extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
private function __construct(
|
||||
public private(set) HomeworkSubmissionId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) HomeworkId $homeworkId,
|
||||
public private(set) UserId $studentId,
|
||||
public private(set) ?string $responseHtml,
|
||||
public private(set) SubmissionStatus $status,
|
||||
public private(set) ?DateTimeImmutable $submittedAt,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\HomeworkSubmission;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class HomeworkSubmissionId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\HomeworkSubmission;
|
||||
|
||||
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function in_array;
|
||||
|
||||
final class SubmissionAttachment
|
||||
{
|
||||
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
|
||||
|
||||
private const array ALLOWED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
public private(set) SubmissionAttachmentId $id,
|
||||
public private(set) string $filename,
|
||||
public private(set) string $filePath,
|
||||
public private(set) int $fileSize {
|
||||
set(int $fileSize) {
|
||||
if ($fileSize > 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\HomeworkSubmission;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class SubmissionAttachmentId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model\HomeworkSubmission;
|
||||
|
||||
enum SubmissionStatus: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case SUBMITTED = 'submitted';
|
||||
case LATE = 'late';
|
||||
|
||||
public function estModifiable(): bool
|
||||
{
|
||||
return $this === self::DRAFT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface HomeworkSubmissionRepository
|
||||
{
|
||||
public function save(HomeworkSubmission $submission): void;
|
||||
|
||||
/** @throws RenduNonTrouveException */
|
||||
public function get(HomeworkSubmissionId $id, TenantId $tenantId): HomeworkSubmission;
|
||||
|
||||
public function findById(HomeworkSubmissionId $id, TenantId $tenantId): ?HomeworkSubmission;
|
||||
|
||||
public function findByHomeworkAndStudent(
|
||||
HomeworkId $homeworkId,
|
||||
UserId $studentId,
|
||||
TenantId $tenantId,
|
||||
): ?HomeworkSubmission;
|
||||
|
||||
/** @return array<HomeworkSubmission> */
|
||||
public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array;
|
||||
|
||||
/**
|
||||
* @return array<string, string|null> Map homeworkId => submission status value (or null)
|
||||
*/
|
||||
public function findStatusesByStudent(UserId $studentId, TenantId $tenantId, HomeworkId ...$homeworkIds): array;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
|
||||
|
||||
interface SubmissionAttachmentRepository
|
||||
{
|
||||
/** @return array<SubmissionAttachment> */
|
||||
public function findBySubmissionId(HomeworkSubmissionId $submissionId): array;
|
||||
|
||||
public function save(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void;
|
||||
|
||||
public function delete(HomeworkSubmissionId $submissionId, SubmissionAttachment $attachment): void;
|
||||
}
|
||||
@@ -176,6 +176,7 @@ final readonly class StudentHomeworkController
|
||||
'dueDate' => $dto->dueDate,
|
||||
'createdAt' => $dto->createdAt,
|
||||
'hasAttachments' => $dto->hasAttachments,
|
||||
'submissionStatus' => $dto->submissionStatus,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\SaveDraftSubmission\SaveDraftSubmissionCommand;
|
||||
use App\Scolarite\Application\Command\SaveDraftSubmission\SaveDraftSubmissionHandler;
|
||||
use App\Scolarite\Application\Command\SubmitHomework\SubmitHomeworkCommand;
|
||||
use App\Scolarite\Application\Command\SubmitHomework\SubmitHomeworkHandler;
|
||||
use App\Scolarite\Application\Command\UploadSubmissionAttachment\UploadSubmissionAttachmentCommand;
|
||||
use App\Scolarite\Application\Command\UploadSubmissionAttachment\UploadSubmissionAttachmentHandler;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
|
||||
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
|
||||
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||
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\Scolarite\Infrastructure\Security\HomeworkStudentVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function is_string;
|
||||
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted(HomeworkStudentVoter::VIEW)]
|
||||
final readonly class StudentSubmissionController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private SaveDraftSubmissionHandler $saveDraftHandler,
|
||||
private SubmitHomeworkHandler $submitHandler,
|
||||
private UploadSubmissionAttachmentHandler $uploadHandler,
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private HomeworkSubmissionRepository $submissionRepository,
|
||||
private SubmissionAttachmentRepository $attachmentRepository,
|
||||
private StudentClassReader $studentClassReader,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/me/homework/{id}/submission', name: 'api_student_submission_save_draft', methods: ['POST'])]
|
||||
public function saveDraft(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->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<string, mixed>
|
||||
*/
|
||||
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<SubmissionAttachment> $attachments
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeSubmissionWithAttachments(HomeworkSubmission $submission, array $attachments): array
|
||||
{
|
||||
$data = $this->serializeSubmission($submission);
|
||||
$data['attachments'] = array_map($this->serializeAttachment(...), $attachments);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeAttachment(SubmissionAttachment $attachment): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $attachment->id,
|
||||
'filename' => $attachment->filename,
|
||||
'fileSize' => $attachment->fileSize,
|
||||
'mimeType' => $attachment->mimeType,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\EventListener;
|
||||
|
||||
use App\Scolarite\Domain\Event\DevoirRendu;
|
||||
use App\Scolarite\Domain\Event\DevoirRenduEnRetard;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Placeholder : notifiera l'enseignant lorsqu'un élève rend un devoir.
|
||||
*
|
||||
* L'implémentation réelle dépend de l'Epic 9 (Communication & Notifications).
|
||||
* En attendant, on log l'événement pour la traçabilité.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'event.bus')]
|
||||
final readonly class NotifierEnseignantDevoirRenduListener
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(DevoirRendu|DevoirRenduEnRetard $event): void
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
|
||||
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\SubmissionStatus;
|
||||
use App\Scolarite\Domain\Repository\HomeworkSubmissionRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_fill_keys;
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineHomeworkSubmissionRepository implements HomeworkSubmissionRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(HomeworkSubmission $submission): void
|
||||
{
|
||||
$this->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<array{homework_id: string, status: string}> $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<string, mixed> $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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachmentId;
|
||||
use App\Scolarite\Domain\Repository\SubmissionAttachmentRepository;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineSubmissionAttachmentRepository implements SubmissionAttachmentRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findBySubmissionId(HomeworkSubmissionId $submissionId): array
|
||||
{
|
||||
$rows = $this->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<string, mixed> $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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
|
||||
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\Repository\HomeworkSubmissionRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_fill_keys;
|
||||
use function array_filter;
|
||||
use function array_key_exists;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
|
||||
use Override;
|
||||
|
||||
final class InMemoryHomeworkSubmissionRepository implements HomeworkSubmissionRepository
|
||||
{
|
||||
/** @var array<string, HomeworkSubmission> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
|
||||
use App\Scolarite\Domain\Repository\SubmissionAttachmentRepository;
|
||||
|
||||
use function array_filter;
|
||||
use function array_values;
|
||||
|
||||
use Override;
|
||||
|
||||
final class InMemorySubmissionAttachmentRepository implements SubmissionAttachmentRepository
|
||||
{
|
||||
/** @var array<string, array<SubmissionAttachment>> */
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Application\Port\ClassStudentsReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineClassStudentsReader implements ClassStudentsReader
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function studentsInClass(string $classId, TenantId $tenantId): array
|
||||
{
|
||||
/** @var array<array{id: string, first_name: string, last_name: string}> $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\SaveDraftSubmission;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\SaveDraftSubmission\SaveDraftSubmissionCommand;
|
||||
use App\Scolarite\Application\Command\SaveDraftSubmission\SaveDraftSubmissionHandler;
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
|
||||
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkSubmissionRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SaveDraftSubmissionHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
|
||||
private InMemoryHomeworkRepository $homeworkRepository;
|
||||
private InMemoryHomeworkSubmissionRepository $submissionRepository;
|
||||
private Clock $clock;
|
||||
private Homework $homework;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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('<p>Ma réponse</p>', $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: '<p>Première version</p>'));
|
||||
$updated = $handler($this->createCommand(responseHtml: '<p>Version modifiée</p>'));
|
||||
|
||||
self::assertSame('<p>Version modifiée</p>', $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: '<p>Tentative de modification</p>'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSanitizesHtmlResponse(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return strip_tags($html, '<p><strong><em>');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandler(htmlSanitizer: $sanitizer);
|
||||
$command = $this->createCommand(responseHtml: '<p>Texte</p><script>alert("xss")</script>');
|
||||
|
||||
$submission = $handler($command);
|
||||
|
||||
self::assertSame('<p>Texte</p>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 = '<p>Ma réponse</p>'): SaveDraftSubmissionCommand
|
||||
{
|
||||
return new SaveDraftSubmissionCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $this->homework->id,
|
||||
studentId: self::STUDENT_ID,
|
||||
responseHtml: $responseHtml,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\SubmitHomework;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\SubmitHomework\SubmitHomeworkCommand;
|
||||
use App\Scolarite\Application\Command\SubmitHomework\SubmitHomeworkHandler;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Domain\Exception\EleveNonAffecteAuDevoirException;
|
||||
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
|
||||
use App\Scolarite\Domain\Exception\RenduNonTrouveException;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkSubmissionRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SubmitHomeworkHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
|
||||
private InMemoryHomeworkRepository $homeworkRepository;
|
||||
private InMemoryHomeworkSubmissionRepository $submissionRepository;
|
||||
private Homework $homework;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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: '<p>Ma réponse</p>',
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\UploadSubmissionAttachment;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\UploadSubmissionAttachment\UploadSubmissionAttachmentCommand;
|
||||
use App\Scolarite\Application\Command\UploadSubmissionAttachment\UploadSubmissionAttachmentHandler;
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkSubmissionRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class UploadSubmissionAttachmentHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
|
||||
private InMemoryHomeworkSubmissionRepository $submissionRepository;
|
||||
private string $tempFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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: '<p>Ma réponse</p>',
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: '<p>Brouillon</p>',
|
||||
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: '<p>Réponse</p>',
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\HomeworkSubmission;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Event\DevoirRendu;
|
||||
use App\Scolarite\Domain\Event\DevoirRenduEnRetard;
|
||||
use App\Scolarite\Domain\Exception\RenduDejaSoumisException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HomeworkSubmissionTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
|
||||
#[Test]
|
||||
public function creerBrouillonCreatesDraftSubmission(): void
|
||||
{
|
||||
$submission = $this->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: '<p>Ma réponse</p>',
|
||||
now: $now,
|
||||
);
|
||||
|
||||
self::assertTrue($submission->tenantId->equals($tenantId));
|
||||
self::assertTrue($submission->homeworkId->equals($homeworkId));
|
||||
self::assertTrue($submission->studentId->equals($studentId));
|
||||
self::assertSame('<p>Ma réponse</p>', $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('<p>Réponse modifiée</p>', $updatedAt);
|
||||
|
||||
self::assertSame('<p>Réponse modifiée</p>', $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('<p>Modification</p>', 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: '<p>Réponse</p>',
|
||||
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('<p>Réponse</p>', $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: '<p>Ma réponse</p>',
|
||||
now: new DateTimeImmutable('2026-03-24 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\HomeworkSubmission;
|
||||
|
||||
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachment;
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionAttachmentId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SubmissionAttachmentTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
#[DataProvider('validMimeTypes')]
|
||||
public function acceptsAllowedMimeTypes(string $mimeType): void
|
||||
{
|
||||
$attachment = new SubmissionAttachment(
|
||||
id: SubmissionAttachmentId::generate(),
|
||||
filename: 'test.pdf',
|
||||
filePath: 'submissions/tenant/hw/sub/test.pdf',
|
||||
fileSize: 1024,
|
||||
mimeType: $mimeType,
|
||||
uploadedAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
self::assertSame($mimeType, $attachment->mimeType);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Model\HomeworkSubmission;
|
||||
|
||||
use App\Scolarite\Domain\Model\HomeworkSubmission\SubmissionStatus;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SubmissionStatusTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function draftEstModifiable(): void
|
||||
{
|
||||
self::assertTrue(SubmissionStatus::DRAFT->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);
|
||||
}
|
||||
}
|
||||
394
frontend/e2e/homework-submission.spec.ts
Normal file
394
frontend/e2e/homework-submission.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
const DEFAULT_ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
const DEFAULT_ACCEPT_ATTR = '.pdf,.jpg,.jpeg,.png';
|
||||
const DEFAULT_HINT = 'PDF, JPEG ou PNG — 10 Mo max par fichier';
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
|
||||
|
||||
interface UploadedFile {
|
||||
@@ -13,12 +15,20 @@
|
||||
existingFiles = [],
|
||||
onUpload,
|
||||
onDelete,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
acceptedTypes = DEFAULT_ACCEPTED_TYPES,
|
||||
acceptAttr = DEFAULT_ACCEPT_ATTR,
|
||||
hint = DEFAULT_HINT,
|
||||
showDelete = true
|
||||
}: {
|
||||
existingFiles?: UploadedFile[];
|
||||
onUpload: (file: File) => Promise<UploadedFile>;
|
||||
onDelete: (fileId: string) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
acceptedTypes?: string[];
|
||||
acceptAttr?: string;
|
||||
hint?: string;
|
||||
showDelete?: boolean;
|
||||
} = $props();
|
||||
|
||||
let files = $state<UploadedFile[]>(existingFiles);
|
||||
@@ -43,8 +53,8 @@
|
||||
}
|
||||
|
||||
function validateFile(file: File): string | null {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
return `Type de fichier non accepté : ${file.type}. Types autorisés : PDF, JPEG, PNG.`;
|
||||
if (!acceptedTypes.includes(file.type)) {
|
||||
return `Type de fichier non accepté : ${file.type}.`;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return `Le fichier dépasse la taille maximale de 10 Mo (${formatFileSize(file.size)}).`;
|
||||
@@ -105,7 +115,7 @@
|
||||
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
|
||||
<span class="file-name">{file.filename}</span>
|
||||
<span class="file-size">{formatFileSize(file.fileSize)}</span>
|
||||
{#if !disabled}
|
||||
{#if !disabled && showDelete}
|
||||
<button
|
||||
type="button"
|
||||
class="file-remove"
|
||||
@@ -139,13 +149,13 @@
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
accept={acceptAttr}
|
||||
onchange={handleFileSelect}
|
||||
class="file-input-hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<p class="upload-hint">PDF, JPEG ou PNG — 10 Mo max par fichier</p>
|
||||
<p class="upload-hint">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
HomeworkSubmission,
|
||||
HomeworkAttachment,
|
||||
StudentHomeworkDetail
|
||||
} from '$lib/features/homework/api/studentHomework';
|
||||
import {
|
||||
fetchSubmission,
|
||||
saveDraftSubmission,
|
||||
submitHomework,
|
||||
uploadSubmissionAttachment
|
||||
} from '$lib/features/homework/api/studentHomework';
|
||||
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
|
||||
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
|
||||
|
||||
let {
|
||||
detail,
|
||||
onBack,
|
||||
onSubmitted
|
||||
}: {
|
||||
detail: StudentHomeworkDetail;
|
||||
onBack: () => void;
|
||||
onSubmitted?: () => void;
|
||||
} = $props();
|
||||
|
||||
let submission = $state<HomeworkSubmission | null>(null);
|
||||
let responseHtml = $state<string>('');
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let submitting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
let showConfirmDialog = $state(false);
|
||||
let attachments = $state<HomeworkAttachment[]>([]);
|
||||
|
||||
let isSubmitted = $derived(
|
||||
submission?.status === 'submitted' || submission?.status === 'late'
|
||||
);
|
||||
|
||||
let statusLabel = $derived(() => {
|
||||
if (!submission) return null;
|
||||
switch (submission.status) {
|
||||
case 'draft':
|
||||
return { text: 'Brouillon', className: 'status-draft' };
|
||||
case 'submitted':
|
||||
return { text: 'Soumis', className: 'status-submitted' };
|
||||
case 'late':
|
||||
return { text: 'En retard', className: 'status-late' };
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSubmission() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
submission = await fetchSubmission(detail.id);
|
||||
if (submission) {
|
||||
responseHtml = submission.responseHtml ?? '';
|
||||
attachments = submission.attachments ?? [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
saving = true;
|
||||
error = null;
|
||||
successMessage = null;
|
||||
|
||||
try {
|
||||
submission = await saveDraftSubmission(detail.id, responseHtml || null);
|
||||
successMessage = 'Brouillon sauvegardé';
|
||||
window.setTimeout(() => {
|
||||
successMessage = null;
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors de la sauvegarde';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmitClick() {
|
||||
showConfirmDialog = true;
|
||||
}
|
||||
|
||||
async function handleConfirmSubmit() {
|
||||
showConfirmDialog = false;
|
||||
submitting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Save draft first if needed
|
||||
if (!submission) {
|
||||
submission = await saveDraftSubmission(detail.id, responseHtml || null);
|
||||
} else if (responseHtml !== (submission.responseHtml ?? '')) {
|
||||
submission = await saveDraftSubmission(detail.id, responseHtml || null);
|
||||
}
|
||||
|
||||
submission = await submitHomework(detail.id);
|
||||
successMessage = 'Devoir rendu avec succès !';
|
||||
onSubmitted?.();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors de la soumission';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadAttachment(file: File): Promise<HomeworkAttachment> {
|
||||
// Ensure a draft exists first
|
||||
if (!submission) {
|
||||
submission = await saveDraftSubmission(detail.id, responseHtml || null);
|
||||
}
|
||||
|
||||
return uploadSubmissionAttachment(detail.id, file);
|
||||
}
|
||||
|
||||
async function handleDeleteAttachment(_fileId: string): Promise<void> {
|
||||
// Suppression non supportée — le bouton est masqué via showDelete={false}
|
||||
}
|
||||
|
||||
function formatDueDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadSubmission();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="submission-form">
|
||||
<button class="back-button" onclick={onBack}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au devoir
|
||||
</button>
|
||||
|
||||
<header class="form-header" style:border-left-color={detail.subjectColor ?? '#3b82f6'}>
|
||||
<span class="subject-name" style:color={detail.subjectColor ?? '#3b82f6'}>
|
||||
{detail.subjectName}
|
||||
</span>
|
||||
<h2 class="form-title">{detail.title}</h2>
|
||||
<div class="form-meta">
|
||||
<span class="due-date">Pour le {formatDueDate(detail.dueDate)}</span>
|
||||
{#if statusLabel()}
|
||||
<span class="status-badge {statusLabel()?.className}">{statusLabel()?.text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-banner" role="status">{successMessage}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-state">Chargement...</div>
|
||||
{:else if isSubmitted}
|
||||
<section class="submitted-view">
|
||||
<div class="submitted-icon">✓</div>
|
||||
<p class="submitted-message">
|
||||
{#if submission?.status === 'late'}
|
||||
Votre devoir a été rendu en retard le {submission?.submittedAt
|
||||
? new Date(submission.submittedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: ''}.
|
||||
{:else}
|
||||
Votre devoir a été rendu le {submission?.submittedAt
|
||||
? new Date(submission.submittedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: ''}.
|
||||
{/if}
|
||||
</p>
|
||||
{#if submission?.responseHtml}
|
||||
<section class="response-view">
|
||||
<h3>Votre réponse</h3>
|
||||
<div class="response-content">{@html submission.responseHtml}</div>
|
||||
</section>
|
||||
{/if}
|
||||
{#if attachments.length > 0}
|
||||
<section class="attachments-view">
|
||||
<h3>Pièces jointes</h3>
|
||||
<ul class="attachments-list">
|
||||
{#each attachments as attachment}
|
||||
<li class="attachment-item">
|
||||
<span class="attachment-name">{attachment.filename}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="editor-section">
|
||||
<h3>Votre réponse</h3>
|
||||
<RichTextEditor
|
||||
content={responseHtml}
|
||||
onUpdate={(html) => {
|
||||
responseHtml = html;
|
||||
}}
|
||||
placeholder="Rédigez votre réponse ici..."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="attachments-section">
|
||||
<h3>Pièces jointes</h3>
|
||||
<FileUpload
|
||||
existingFiles={attachments}
|
||||
onUpload={handleUploadAttachment}
|
||||
onDelete={handleDeleteAttachment}
|
||||
disabled={saving || submitting}
|
||||
acceptedTypes={['application/pdf', 'image/jpeg', 'image/png', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']}
|
||||
acceptAttr=".pdf,.jpg,.jpeg,.png,.docx"
|
||||
hint="PDF, JPEG, PNG ou DOCX — 10 Mo max par fichier"
|
||||
showDelete={false}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="btn-draft"
|
||||
onclick={handleSaveDraft}
|
||||
disabled={saving || submitting}
|
||||
>
|
||||
{saving ? 'Sauvegarde...' : 'Sauvegarder le brouillon'}
|
||||
</button>
|
||||
<button
|
||||
class="btn-submit"
|
||||
onclick={handleSubmitClick}
|
||||
disabled={saving || submitting}
|
||||
>
|
||||
{submitting ? 'Soumission...' : 'Soumettre mon devoir'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showConfirmDialog}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="dialog-overlay" role="presentation" onclick={() => (showConfirmDialog = false)}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div class="dialog" role="alertdialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>Confirmer la soumission</h3>
|
||||
<p>
|
||||
Êtes-vous sûr de vouloir soumettre votre devoir ? Vous ne pourrez plus le modifier
|
||||
après soumission.
|
||||
</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-cancel" onclick={() => (showConfirmDialog = false)}>Annuler</button>
|
||||
<button class="btn-confirm" onclick={handleConfirmSubmit}>Confirmer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.submission-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.subject-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.form-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.status-submitted {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-late {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fee2e2;
|
||||
border-radius: 0.375rem;
|
||||
color: #991b1b;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #dcfce7;
|
||||
border-radius: 0.375rem;
|
||||
color: #166534;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.editor-section h3,
|
||||
.attachments-section h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-draft {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-draft:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-draft:disabled,
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Submitted view */
|
||||
.submitted-view {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.submitted-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 0 auto 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.submitted-message {
|
||||
color: #374151;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.response-view,
|
||||
.attachments-view {
|
||||
text-align: left;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.response-view h3,
|
||||
.attachments-view h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.attachments-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Confirmation Dialog */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 24rem;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dialog h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.dialog p {
|
||||
margin: 0 0 1.25rem;
|
||||
color: #4b5563;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-confirm:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
@@ -78,6 +78,13 @@
|
||||
<span class="status-badge" class:done={isDone}>
|
||||
{isDone ? 'Fait' : 'À faire'}
|
||||
</span>
|
||||
{#if homework.submissionStatus === 'submitted'}
|
||||
<span class="submission-badge submission-submitted">Rendu</span>
|
||||
{:else if homework.submissionStatus === 'late'}
|
||||
<span class="submission-badge submission-late">Rendu en retard</span>
|
||||
{:else if homework.submissionStatus === 'draft'}
|
||||
<span class="submission-badge submission-draft">Brouillon</span>
|
||||
{/if}
|
||||
{#if homework.hasAttachments}
|
||||
<span class="attachment-indicator" title="Pièce(s) jointe(s)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
@@ -97,6 +99,14 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if onSubmit}
|
||||
<div class="submit-section">
|
||||
<button class="btn-submit-homework" onclick={onSubmit}>
|
||||
Rendre mon devoir
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if detail.attachments.length > 0}
|
||||
<section class="detail-attachments">
|
||||
<h3>Pièces jointes</h3>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<StudentHomework[]>([]);
|
||||
@@ -13,6 +14,7 @@
|
||||
let error = $state<string | null>(null);
|
||||
let selectedSubjectId = $state<string | null>(null);
|
||||
let selectedDetail = $state<HomeworkDetailType | null>(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 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if selectedDetail}
|
||||
<HomeworkDetail detail={selectedDetail} onBack={handleBack} />
|
||||
{#if selectedDetail && showSubmissionForm}
|
||||
<HomeworkSubmissionForm detail={selectedDetail} onBack={handleSubmissionBack} onSubmitted={handleSubmitted} />
|
||||
{:else if selectedDetail}
|
||||
<HomeworkDetail detail={selectedDetail} onBack={handleBack} onSubmit={handleOpenSubmissionForm} />
|
||||
{:else}
|
||||
<div class="student-homework">
|
||||
{#if isOffline()}
|
||||
|
||||
@@ -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<HomeworkSubmission | null> {
|
||||
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<HomeworkSubmission> {
|
||||
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<HomeworkSubmission> {
|
||||
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<HomeworkAttachment> {
|
||||
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;
|
||||
}
|
||||
|
||||
97
frontend/src/lib/features/homework/api/teacherSubmissions.ts
Normal file
97
frontend/src/lib/features/homework/api/teacherSubmissions.ts
Normal file
@@ -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<TeacherSubmission[]> {
|
||||
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<TeacherSubmissionDetail> {
|
||||
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<SubmissionStats> {
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import type {
|
||||
TeacherSubmission,
|
||||
TeacherSubmissionDetail,
|
||||
SubmissionStats
|
||||
} from '$lib/features/homework/api/teacherSubmissions';
|
||||
import {
|
||||
fetchSubmissions,
|
||||
fetchSubmissionDetail,
|
||||
fetchSubmissionStats,
|
||||
getSubmissionAttachmentUrl
|
||||
} from '$lib/features/homework/api/teacherSubmissions';
|
||||
|
||||
let homeworkId = $derived(page.params.id ?? '');
|
||||
let submissions = $state<TeacherSubmission[]>([]);
|
||||
let stats = $state<SubmissionStats | null>(null);
|
||||
let selectedDetail = $state<TeacherSubmissionDetail | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let downloadError = $state<string | null>(null);
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} o`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string): { text: string; className: string } {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return { text: 'Soumis', className: 'badge-submitted' };
|
||||
case 'late':
|
||||
return { text: 'En retard', className: 'badge-late' };
|
||||
case 'draft':
|
||||
return { text: 'Brouillon', className: 'badge-draft' };
|
||||
case 'not_submitted':
|
||||
return { text: 'Non rendu', className: 'badge-not-submitted' };
|
||||
default:
|
||||
return { text: status, className: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const [subs, st] = await Promise.all([
|
||||
fetchSubmissions(homeworkId),
|
||||
fetchSubmissionStats(homeworkId)
|
||||
]);
|
||||
submissions = subs;
|
||||
stats = st;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewDetail(submissionId: string) {
|
||||
try {
|
||||
selectedDetail = await fetchSubmissionDetail(homeworkId, submissionId);
|
||||
} catch {
|
||||
error = 'Erreur lors du chargement du détail.';
|
||||
}
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
selectedDetail = null;
|
||||
}
|
||||
|
||||
function shouldOpenInline(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf' || mimeType.startsWith('image/') || mimeType.startsWith('text/');
|
||||
}
|
||||
|
||||
async function downloadAttachment(submissionId: string, attachmentId: string, filename: string, mimeType: string) {
|
||||
downloadError = null;
|
||||
const url = getSubmissionAttachmentUrl(homeworkId, submissionId, attachmentId);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
downloadError = `Impossible de télécharger "${filename}".`;
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
|
||||
if (shouldOpenInline(mimeType)) {
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
} else {
|
||||
link.download = filename;
|
||||
}
|
||||
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
} catch {
|
||||
downloadError = `Impossible de télécharger "${filename}".`;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void homeworkId;
|
||||
void loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="submissions-page">
|
||||
<button class="back-link" onclick={() => goto('/dashboard/teacher/homework')}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour aux devoirs
|
||||
</button>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Chargement des rendus...</div>
|
||||
{:else if error}
|
||||
<div class="error-message" role="alert">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => void loadData()}>Réessayer</button>
|
||||
</div>
|
||||
{:else if selectedDetail}
|
||||
<div class="detail-view">
|
||||
<button class="back-link" onclick={handleBack}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour à la liste
|
||||
</button>
|
||||
|
||||
<header class="detail-header">
|
||||
<h2>{selectedDetail.studentName}</h2>
|
||||
<div class="detail-meta">
|
||||
<span class="badge {statusLabel(selectedDetail.status).className}">
|
||||
{statusLabel(selectedDetail.status).text}
|
||||
</span>
|
||||
{#if selectedDetail.submittedAt}
|
||||
<span class="submitted-date">Soumis le {formatDate(selectedDetail.submittedAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if selectedDetail.responseHtml}
|
||||
<section class="detail-response">
|
||||
<h3>Réponse</h3>
|
||||
<div class="response-content">{@html selectedDetail.responseHtml}</div>
|
||||
</section>
|
||||
{:else}
|
||||
<p class="no-response">Aucune réponse textuelle.</p>
|
||||
{/if}
|
||||
|
||||
{#if selectedDetail.attachments.length > 0}
|
||||
<section class="detail-attachments">
|
||||
<h3>Pièces jointes</h3>
|
||||
{#if downloadError}
|
||||
<p class="download-error" role="alert">{downloadError}</p>
|
||||
{/if}
|
||||
<ul class="attachments-list">
|
||||
{#each selectedDetail.attachments as attachment}
|
||||
<li>
|
||||
<button
|
||||
class="attachment-item"
|
||||
onclick={() => downloadAttachment(selectedDetail!.id, attachment.id, attachment.filename, attachment.mimeType)}
|
||||
>
|
||||
<span class="attachment-name">{attachment.filename}</span>
|
||||
<span class="attachment-size">{formatFileSize(attachment.fileSize)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if stats}
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{stats.submittedCount}</span>
|
||||
<span class="stat-label">/ {stats.totalStudents} rendus</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if submissions.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Aucun rendu pour ce devoir.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="submissions-list">
|
||||
<h3>Rendus ({submissions.length})</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Élève</th>
|
||||
<th>Statut</th>
|
||||
<th>Date de soumission</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each submissions as sub}
|
||||
<tr>
|
||||
<td>{sub.studentName}</td>
|
||||
<td>
|
||||
<span class="badge {statusLabel(sub.status).className}">
|
||||
{statusLabel(sub.status).text}
|
||||
</span>
|
||||
</td>
|
||||
<td>{sub.submittedAt ? formatDate(sub.submittedAt) : '—'}</td>
|
||||
<td>
|
||||
{#if sub.id}
|
||||
<button class="btn-view" onclick={() => handleViewDetail(sub.id!)}>
|
||||
Voir
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.submissions-page {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-message button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.submissions-list h3,
|
||||
.missing-students h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-submitted {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.badge-late {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-draft {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.badge-not-submitted {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #3b82f6;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Detail view */
|
||||
.detail-header h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.submitted-date {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.detail-response h3,
|
||||
.detail-attachments h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.no-response {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.download-error {
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fee2e2;
|
||||
border-radius: 0.375rem;
|
||||
color: #991b1b;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.attachments-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
flex: 1;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.attachment-size {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user