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

L'élève peut désormais répondre à un devoir via un éditeur WYSIWYG,
joindre des fichiers (PDF, JPEG, PNG, DOCX), sauvegarder un brouillon
et soumettre définitivement son rendu. Le système détecte automatiquement
les soumissions en retard par rapport à la date d'échéance.

Côté enseignant, une page dédiée affiche la liste complète des élèves
avec leur statut (soumis, en retard, brouillon, non rendu), le détail
de chaque rendu avec ses pièces jointes téléchargeables, et les
statistiques de rendus par classe.
This commit is contained in:
2026-03-25 19:38:25 +01:00
parent ab835e5c3d
commit df25a8cbb0
48 changed files with 4519 additions and 12 deletions

View File

@@ -0,0 +1,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;
}
}

View 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;
}
}

View File

@@ -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,
));
}
}

View File

@@ -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,
));
}
}

View File

@@ -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,
));
}
}

View File

@@ -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;
}
}

View File

@@ -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
{
}

View File

@@ -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,
) {
}
}

View File

@@ -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
{
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}