Compare commits
2 Commits
93baeb1eaa
...
df25a8cbb0
| Author | SHA1 | Date | |
|---|---|---|---|
| df25a8cbb0 | |||
| ab835e5c3d |
@@ -28,6 +28,7 @@
|
||||
"symfony/dotenv": "^8.0",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "^8.0",
|
||||
"symfony/html-sanitizer": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/lock": "8.0.*",
|
||||
"symfony/mailer": "8.0.*",
|
||||
|
||||
256
backend/composer.lock
generated
256
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "cf5f7c77977031afccfa7da74ed52205",
|
||||
"content-hash": "92b9472c96a59c314d96372c4094f185",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/core",
|
||||
@@ -1785,6 +1785,188 @@
|
||||
],
|
||||
"time": "2025-10-17T11:30:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/uri",
|
||||
"version": "7.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/uri.git",
|
||||
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
|
||||
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"league/uri-interfaces": "^7.8.1",
|
||||
"php": "^8.1",
|
||||
"psr/http-factory": "^1"
|
||||
},
|
||||
"conflict": {
|
||||
"league/uri-schemes": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "to improve IPV4 host parsing",
|
||||
"ext-dom": "to convert the URI into an HTML anchor tag",
|
||||
"ext-fileinfo": "to create Data URI from file contennts",
|
||||
"ext-gmp": "to improve IPV4 host parsing",
|
||||
"ext-intl": "to handle IDN host with the best performance",
|
||||
"ext-uri": "to use the PHP native URI class",
|
||||
"jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
|
||||
"league/uri-components": "to provide additional tools to manipulate URI objects components",
|
||||
"league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
|
||||
"php-64bit": "to improve IPV4 host parsing",
|
||||
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
|
||||
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "7.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Uri\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ignace Nyamagana Butera",
|
||||
"email": "nyamsprod@gmail.com",
|
||||
"homepage": "https://nyamsprod.com"
|
||||
}
|
||||
],
|
||||
"description": "URI manipulation library",
|
||||
"homepage": "https://uri.thephpleague.com",
|
||||
"keywords": [
|
||||
"URN",
|
||||
"data-uri",
|
||||
"file-uri",
|
||||
"ftp",
|
||||
"hostname",
|
||||
"http",
|
||||
"https",
|
||||
"middleware",
|
||||
"parse_str",
|
||||
"parse_url",
|
||||
"psr-7",
|
||||
"query-string",
|
||||
"querystring",
|
||||
"rfc2141",
|
||||
"rfc3986",
|
||||
"rfc3987",
|
||||
"rfc6570",
|
||||
"rfc8141",
|
||||
"uri",
|
||||
"uri-template",
|
||||
"url",
|
||||
"ws"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://uri.thephpleague.com",
|
||||
"forum": "https://thephpleague.slack.com",
|
||||
"issues": "https://github.com/thephpleague/uri-src/issues",
|
||||
"source": "https://github.com/thephpleague/uri/tree/7.8.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/nyamsprod",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-15T20:22:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/uri-interfaces",
|
||||
"version": "7.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/uri-interfaces.git",
|
||||
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
|
||||
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-filter": "*",
|
||||
"php": "^8.1",
|
||||
"psr/http-message": "^1.1 || ^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-bcmath": "to improve IPV4 host parsing",
|
||||
"ext-gmp": "to improve IPV4 host parsing",
|
||||
"ext-intl": "to handle IDN host with the best performance",
|
||||
"php-64bit": "to improve IPV4 host parsing",
|
||||
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
|
||||
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "7.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Uri\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ignace Nyamagana Butera",
|
||||
"email": "nyamsprod@gmail.com",
|
||||
"homepage": "https://nyamsprod.com"
|
||||
}
|
||||
],
|
||||
"description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
|
||||
"homepage": "https://uri.thephpleague.com",
|
||||
"keywords": [
|
||||
"data-uri",
|
||||
"file-uri",
|
||||
"ftp",
|
||||
"hostname",
|
||||
"http",
|
||||
"https",
|
||||
"parse_str",
|
||||
"parse_url",
|
||||
"psr-7",
|
||||
"query-string",
|
||||
"querystring",
|
||||
"rfc3986",
|
||||
"rfc3987",
|
||||
"rfc6570",
|
||||
"uri",
|
||||
"url",
|
||||
"ws"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://uri.thephpleague.com",
|
||||
"forum": "https://thephpleague.slack.com",
|
||||
"issues": "https://github.com/thephpleague/uri-src/issues",
|
||||
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/nyamsprod",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-08T20:05:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lexik/jwt-authentication-bundle",
|
||||
"version": "v3.2.0",
|
||||
@@ -4841,6 +5023,78 @@
|
||||
],
|
||||
"time": "2026-01-27T09:06:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/html-sanitizer",
|
||||
"version": "v8.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/html-sanitizer.git",
|
||||
"reference": "555b37caeee3d07af33471e02377d5ff561f8ac2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/555b37caeee3d07af33471e02377d5ff561f8ac2",
|
||||
"reference": "555b37caeee3d07af33471e02377d5ff561f8ac2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"league/uri": "^6.5|^7.0",
|
||||
"php": ">=8.4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HtmlSanitizer\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Titouan Galopin",
|
||||
"email": "galopintitouan@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"Purifier",
|
||||
"html",
|
||||
"sanitizer"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/html-sanitizer/tree/v8.0.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-06T13:17:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.5",
|
||||
|
||||
13
backend/config/packages/html_sanitizer.yaml
Normal file
13
backend/config/packages/html_sanitizer.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
framework:
|
||||
html_sanitizer:
|
||||
sanitizers:
|
||||
homework_sanitizer:
|
||||
allow_elements:
|
||||
p: []
|
||||
br: []
|
||||
strong: []
|
||||
em: []
|
||||
ul: []
|
||||
ol: []
|
||||
li: []
|
||||
a: ['href', 'target', 'rel']
|
||||
@@ -216,12 +216,29 @@ 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
|
||||
|
||||
App\Scolarite\Domain\Service\HomeworkDuplicator:
|
||||
autowire: true
|
||||
|
||||
App\Scolarite\Application\Port\HtmlSanitizer:
|
||||
alias: App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer
|
||||
|
||||
App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer:
|
||||
arguments:
|
||||
$homeworkSanitizer: '@html_sanitizer.sanitizer.homework_sanitizer'
|
||||
|
||||
App\Scolarite\Application\Port\FileStorage:
|
||||
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
@@ -29,6 +30,7 @@ final readonly class CreateHomeworkHandler
|
||||
private CurrentCalendarProvider $calendarProvider,
|
||||
private DueDateValidator $dueDateValidator,
|
||||
private HomeworkRulesChecker $rulesChecker,
|
||||
private HtmlSanitizer $htmlSanitizer,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -63,13 +65,17 @@ final readonly class CreateHomeworkHandler
|
||||
throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray());
|
||||
}
|
||||
|
||||
$description = $command->description !== null
|
||||
? $this->htmlSanitizer->sanitize($command->description)
|
||||
: null;
|
||||
|
||||
$homework = Homework::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
teacherId: $teacherId,
|
||||
title: $command->title,
|
||||
description: $command->description,
|
||||
description: $description,
|
||||
dueDate: $dueDate,
|
||||
now: $now,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Scolarite\Application\Command\UpdateHomework;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
@@ -23,6 +24,7 @@ final readonly class UpdateHomeworkHandler
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private CurrentCalendarProvider $calendarProvider,
|
||||
private DueDateValidator $dueDateValidator,
|
||||
private HtmlSanitizer $htmlSanitizer,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -44,9 +46,13 @@ final readonly class UpdateHomeworkHandler
|
||||
$dueDate = new DateTimeImmutable($command->dueDate);
|
||||
$this->dueDateValidator->valider($dueDate, $now, $calendar);
|
||||
|
||||
$description = $command->description !== null
|
||||
? $this->htmlSanitizer->sanitize($command->description)
|
||||
: null;
|
||||
|
||||
$homework->modifier(
|
||||
title: $command->title,
|
||||
description: $command->description,
|
||||
description: $description,
|
||||
dueDate: $dueDate,
|
||||
now: $now,
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ final readonly class UploadHomeworkAttachmentHandler
|
||||
$this->homeworkRepository->get($homeworkId, $tenantId);
|
||||
|
||||
$attachmentId = HomeworkAttachmentId::generate();
|
||||
$storagePath = sprintf('homework/%s/%s/%s', $command->tenantId, $command->homeworkId, $command->filename);
|
||||
$storagePath = sprintf('homework/%s/%s/%s/%s', $command->tenantId, $command->homeworkId, (string) $attachmentId, $command->filename);
|
||||
|
||||
$content = file_get_contents($command->tempFilePath);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
10
backend/src/Scolarite/Application/Port/HtmlSanitizer.php
Normal file
10
backend/src/Scolarite/Application/Port/HtmlSanitizer.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
interface HtmlSanitizer
|
||||
{
|
||||
public function sanitize(string $html): string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,6 @@ interface HomeworkAttachmentRepository
|
||||
public function hasAttachments(HomeworkId ...$homeworkIds): array;
|
||||
|
||||
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
|
||||
|
||||
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentCommand;
|
||||
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentHandler;
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use App\Scolarite\Domain\Exception\PieceJointeInvalideException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
|
||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final readonly class HomeworkAttachmentController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private HomeworkAttachmentRepository $attachmentRepository,
|
||||
private UploadHomeworkAttachmentHandler $uploadHandler,
|
||||
private FileStorage $fileStorage,
|
||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||
private string $storageDir,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/homework/{id}/attachments', name: 'api_homework_attachment_list', methods: ['GET'])]
|
||||
public function list(string $id): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
|
||||
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||
|
||||
if ($homework === null) {
|
||||
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||
}
|
||||
|
||||
if ((string) $homework->teacherId !== $user->userId()) {
|
||||
throw new AccessDeniedHttpException('Accès non autorisé.');
|
||||
}
|
||||
|
||||
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
|
||||
|
||||
return new JsonResponse(array_map(
|
||||
static fn (HomeworkAttachment $a): array => [
|
||||
'id' => (string) $a->id,
|
||||
'filename' => $a->filename,
|
||||
'fileSize' => $a->fileSize,
|
||||
'mimeType' => $a->mimeType,
|
||||
],
|
||||
$attachments,
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/homework/{id}/attachments', name: 'api_homework_attachment_upload', methods: ['POST'])]
|
||||
public function upload(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
|
||||
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||
|
||||
if ($homework === null) {
|
||||
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||
}
|
||||
|
||||
if ((string) $homework->teacherId !== $user->userId()) {
|
||||
throw new AccessDeniedHttpException('Seul le propriétaire peut ajouter des pièces jointes.');
|
||||
}
|
||||
|
||||
$file = $request->files->get('file');
|
||||
|
||||
if ($file === null) {
|
||||
throw new BadRequestHttpException('Aucun fichier envoyé.');
|
||||
}
|
||||
|
||||
/** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$mimeType = $file->getMimeType() ?? $file->getClientMimeType() ?? '';
|
||||
$fileSize = $file->getSize();
|
||||
|
||||
try {
|
||||
$attachment = ($this->uploadHandler)(new UploadHomeworkAttachmentCommand(
|
||||
tenantId: $user->tenantId(),
|
||||
homeworkId: $id,
|
||||
filename: $originalName,
|
||||
mimeType: $mimeType,
|
||||
fileSize: (int) $fileSize,
|
||||
tempFilePath: $file->getPathname(),
|
||||
));
|
||||
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
} catch (PieceJointeInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => (string) $attachment->id,
|
||||
'filename' => $attachment->filename,
|
||||
'fileSize' => $attachment->fileSize,
|
||||
'mimeType' => $attachment->mimeType,
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_download', methods: ['GET'])]
|
||||
public function download(string $id, string $attachmentId): BinaryFileResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
|
||||
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||
|
||||
if ($homework === null) {
|
||||
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||
}
|
||||
|
||||
if ((string) $homework->teacherId !== $user->userId()) {
|
||||
throw new AccessDeniedHttpException('Accès non autorisé.');
|
||||
}
|
||||
|
||||
$attachments = $this->attachmentRepository->findByHomeworkId($homework->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.');
|
||||
}
|
||||
|
||||
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_delete', methods: ['DELETE'])]
|
||||
public function delete(string $id, string $attachmentId): Response
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
|
||||
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
|
||||
|
||||
if ($homework === null) {
|
||||
throw new NotFoundHttpException('Devoir non trouvé.');
|
||||
}
|
||||
|
||||
if ((string) $homework->teacherId !== $user->userId()) {
|
||||
throw new AccessDeniedHttpException('Seul le propriétaire peut supprimer des pièces jointes.');
|
||||
}
|
||||
|
||||
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if ((string) $attachment->id === $attachmentId) {
|
||||
$this->fileStorage->delete($attachment->filePath);
|
||||
$this->attachmentRepository->delete($homework->id, $attachment);
|
||||
|
||||
return new Response(status: Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ final readonly class ParentHomeworkController
|
||||
private GetChildrenHomeworkDetailHandler $detailHandler,
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private HomeworkAttachmentRepository $attachmentRepository,
|
||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||
private string $uploadsDir,
|
||||
) {
|
||||
}
|
||||
@@ -138,7 +138,8 @@ final readonly class ParentHomeworkController
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if ((string) $attachment->id === $attachmentId) {
|
||||
$realPath = realpath($attachment->filePath);
|
||||
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
|
||||
$realPath = realpath($fullPath);
|
||||
$realUploadsDir = realpath($this->uploadsDir);
|
||||
|
||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
||||
|
||||
@@ -44,7 +44,7 @@ final readonly class StudentHomeworkController
|
||||
private HomeworkAttachmentRepository $attachmentRepository,
|
||||
private ScheduleDisplayReader $displayReader,
|
||||
private StudentClassReader $studentClassReader,
|
||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||
#[Autowire('%kernel.project_dir%/var/storage')]
|
||||
private string $uploadsDir,
|
||||
) {
|
||||
}
|
||||
@@ -115,7 +115,8 @@ final readonly class StudentHomeworkController
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if ((string) $attachment->id === $attachmentId) {
|
||||
$realPath = realpath($attachment->filePath);
|
||||
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
|
||||
$realPath = realpath($fullPath);
|
||||
$realUploadsDir = realpath($this->uploadsDir);
|
||||
|
||||
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
|
||||
@@ -175,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,18 @@ final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAtt
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM homework_attachments WHERE id = :id AND homework_id = :homework_id',
|
||||
[
|
||||
'id' => (string) $attachment->id,
|
||||
'homework_id' => (string) $homeworkId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function hydrate(array $row): HomeworkAttachment
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
|
||||
|
||||
use function array_fill_keys;
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
|
||||
use Override;
|
||||
|
||||
@@ -42,4 +44,19 @@ final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRe
|
||||
{
|
||||
$this->byHomeworkId[(string) $homeworkId][] = $attachment;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
|
||||
{
|
||||
$key = (string) $homeworkId;
|
||||
|
||||
if (!isset($this->byHomeworkId[$key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->byHomeworkId[$key] = array_values(array_filter(
|
||||
$this->byHomeworkId[$key],
|
||||
static fn (HomeworkAttachment $a): bool => (string) $a->id !== (string) $attachment->id,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use Override;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
|
||||
|
||||
final readonly class HomeworkHtmlSanitizer implements HtmlSanitizer
|
||||
{
|
||||
public function __construct(
|
||||
private HtmlSanitizerInterface $homeworkSanitizer,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return $this->homeworkSanitizer->sanitize($html);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use App\Scolarite\Application\Port\RuleWarning;
|
||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
@@ -110,6 +111,46 @@ final class CreateHomeworkHandlerTest extends TestCase
|
||||
self::assertNull($homework->description);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSanitizesHtmlDescription(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
|
||||
$command = $this->createCommand(description: '<p>Texte <strong>gras</strong></p><script>alert("xss")</script>');
|
||||
|
||||
$homework = $handler($command);
|
||||
|
||||
self::assertSame('<p>Texte <strong>gras</strong></p>alert("xss")', $homework->description);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotSanitizeNullDescription(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public bool $called = false;
|
||||
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
$this->called = true;
|
||||
|
||||
return $html;
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandlerWithSanitizer(affecte: true, htmlSanitizer: $sanitizer);
|
||||
$command = $this->createCommand(description: null);
|
||||
|
||||
$handler($command);
|
||||
|
||||
self::assertFalse($sanitizer->called);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void
|
||||
{
|
||||
@@ -269,12 +310,67 @@ final class CreateHomeworkHandlerTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
$htmlSanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return $html;
|
||||
}
|
||||
};
|
||||
|
||||
return new CreateHomeworkHandler(
|
||||
$this->homeworkRepository,
|
||||
$affectationChecker,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$rulesChecker,
|
||||
$htmlSanitizer,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createHandlerWithSanitizer(bool $affecte, HtmlSanitizer $htmlSanitizer, ?HomeworkRulesCheckResult $rulesResult = null): CreateHomeworkHandler
|
||||
{
|
||||
$affectationChecker = new class($affecte) implements EnseignantAffectationChecker {
|
||||
public function __construct(private readonly bool $affecte)
|
||||
{
|
||||
}
|
||||
|
||||
public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool
|
||||
{
|
||||
return $this->affecte;
|
||||
}
|
||||
};
|
||||
|
||||
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
return SchoolCalendar::reconstitute(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
zone: null,
|
||||
entries: [],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$rulesChecker = new class($rulesResult ?? HomeworkRulesCheckResult::ok()) implements HomeworkRulesChecker {
|
||||
public function __construct(private readonly HomeworkRulesCheckResult $result)
|
||||
{
|
||||
}
|
||||
|
||||
public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
};
|
||||
|
||||
return new CreateHomeworkHandler(
|
||||
$this->homeworkRepository,
|
||||
$affectationChecker,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$rulesChecker,
|
||||
$htmlSanitizer,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkCommand;
|
||||
use App\Scolarite\Application\Command\UpdateHomework\UpdateHomeworkHandler;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\HtmlSanitizer;
|
||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||
use App\Scolarite\Domain\Exception\DevoirDejaSupprimeException;
|
||||
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
|
||||
@@ -172,6 +173,60 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
||||
self::assertSame('Exercices sans description', $homework->title);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSanitizesHtmlDescription(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return strip_tags($html, '<p><strong><em><ul><ol><li><a>');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandlerWithSanitizer($sanitizer);
|
||||
$command = new UpdateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $this->existingHomeworkId,
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
title: 'Test sanitize',
|
||||
description: '<p>Texte</p><script>alert("xss")</script>',
|
||||
dueDate: '2026-04-20',
|
||||
);
|
||||
|
||||
$homework = $handler($command);
|
||||
|
||||
self::assertSame('<p>Texte</p>alert("xss")', $homework->description);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotSanitizeNullDescription(): void
|
||||
{
|
||||
$sanitizer = new class implements HtmlSanitizer {
|
||||
public bool $called = false;
|
||||
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
$this->called = true;
|
||||
|
||||
return $html;
|
||||
}
|
||||
};
|
||||
|
||||
$handler = $this->createHandlerWithSanitizer($sanitizer);
|
||||
$command = new UpdateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $this->existingHomeworkId,
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
title: 'Test null desc',
|
||||
description: null,
|
||||
dueDate: '2026-04-20',
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
self::assertFalse($sanitizer->called);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenNotOwner(): void
|
||||
{
|
||||
@@ -206,7 +261,7 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
||||
$this->homeworkRepository->save($homework);
|
||||
}
|
||||
|
||||
private function createHandler(): UpdateHomeworkHandler
|
||||
private function createHandlerWithSanitizer(HtmlSanitizer $htmlSanitizer): UpdateHomeworkHandler
|
||||
{
|
||||
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
@@ -224,6 +279,37 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
||||
$this->homeworkRepository,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$htmlSanitizer,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createHandler(): UpdateHomeworkHandler
|
||||
{
|
||||
$calendarProvider = new class implements CurrentCalendarProvider {
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
return SchoolCalendar::reconstitute(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
zone: null,
|
||||
entries: [],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$htmlSanitizer = new class implements HtmlSanitizer {
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
return $html;
|
||||
}
|
||||
};
|
||||
|
||||
return new UpdateHomeworkHandler(
|
||||
$this->homeworkRepository,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$htmlSanitizer,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Infrastructure\Service\HomeworkHtmlSanitizer;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
|
||||
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
|
||||
|
||||
final class HomeworkHtmlSanitizerTest extends TestCase
|
||||
{
|
||||
private HomeworkHtmlSanitizer $sanitizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$config = (new HtmlSanitizerConfig())
|
||||
->allowElement('p')
|
||||
->allowElement('br')
|
||||
->allowElement('strong')
|
||||
->allowElement('em')
|
||||
->allowElement('ul')
|
||||
->allowElement('ol')
|
||||
->allowElement('li')
|
||||
->allowElement('a', ['href', 'target', 'rel']);
|
||||
|
||||
$this->sanitizer = new HomeworkHtmlSanitizer(new HtmlSanitizer($config));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsBoldText(): void
|
||||
{
|
||||
$html = '<p>Texte <strong>en gras</strong></p>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsItalicText(): void
|
||||
{
|
||||
$html = '<p>Texte <em>en italique</em></p>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsUnorderedLists(): void
|
||||
{
|
||||
$html = '<ul><li>Élément 1</li><li>Élément 2</li></ul>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsOrderedLists(): void
|
||||
{
|
||||
$html = '<ol><li>Premier</li><li>Deuxième</li></ol>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsLinksWithSafeAttributes(): void
|
||||
{
|
||||
$html = '<p><a href="https://example.com" target="_blank" rel="noopener noreferrer">Lien</a></p>';
|
||||
|
||||
self::assertSame($html, $this->sanitizer->sanitize($html));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsScriptTags(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<p>Texte</p><script>alert("xss")</script>');
|
||||
|
||||
self::assertStringNotContainsString('<script>', $result);
|
||||
self::assertStringNotContainsString('alert', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsEventHandlers(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<p onclick="alert(1)">Texte</p>');
|
||||
|
||||
self::assertStringNotContainsString('onclick', $result);
|
||||
self::assertStringContainsString('Texte', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsStyleTags(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<style>body{display:none}</style><p>Texte</p>');
|
||||
|
||||
self::assertStringNotContainsString('<style>', $result);
|
||||
self::assertStringContainsString('Texte', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsIframeTags(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<p>Texte</p><iframe src="evil.com"></iframe>');
|
||||
|
||||
self::assertStringNotContainsString('<iframe>', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsImgTags(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<p>Texte</p><img src="x" onerror="alert(1)">');
|
||||
|
||||
self::assertStringNotContainsString('<img', $result);
|
||||
self::assertStringNotContainsString('onerror', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesEmptyString(): void
|
||||
{
|
||||
self::assertSame('', $this->sanitizer->sanitize(''));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesPlainText(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('Texte simple sans HTML');
|
||||
|
||||
self::assertSame('Texte simple sans HTML', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStripsJavascriptLinks(): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize('<a href="javascript:alert(1)">Clic</a>');
|
||||
|
||||
self::assertStringNotContainsString('javascript:', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('richContentProvider')]
|
||||
public function itPreservesRichContent(string $input, string $expectedSubstring): void
|
||||
{
|
||||
$result = $this->sanitizer->sanitize($input);
|
||||
|
||||
self::assertStringContainsString($expectedSubstring, $result);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function richContentProvider(): iterable
|
||||
{
|
||||
yield 'gras' => ['<strong>gras</strong>', '<strong>gras</strong>'];
|
||||
yield 'italique' => ['<em>italique</em>', '<em>italique</em>'];
|
||||
yield 'liste à puces' => ['<ul><li>item</li></ul>', '<li>item</li>'];
|
||||
yield 'liste numérotée' => ['<ol><li>item</li></ol>', '<ol>'];
|
||||
yield 'paragraphe' => ['<p>texte</p>', '<p>texte</p>'];
|
||||
yield 'lien' => ['<a href="https://x.com">lien</a>', 'href="https://x.com"'];
|
||||
}
|
||||
}
|
||||
467
frontend/e2e/homework-richtext-attachments.spec.ts
Normal file
467
frontend/e2e/homework-richtext-attachments.spec.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
||||
|
||||
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}`;
|
||||
|
||||
// Réutilise le même enseignant que homework.spec.ts pour partager le setup
|
||||
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||
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 seedTeacherAssignments() {
|
||||
const { academicYearId } = resolveDeterministicIds();
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
]);
|
||||
}
|
||||
|
||||
async function navigateToHomework(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
|
||||
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
async function createHomework(page: import('@playwright/test').Page, title: string) {
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill(title);
|
||||
|
||||
// Type in WYSIWYG editor
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Consignes du devoir');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(title)).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
function createTempPdf(): string {
|
||||
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const filePath = join(tmpDir, 'test-attachment.pdf');
|
||||
const pdfContent = `%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
190
|
||||
%%EOF`;
|
||||
writeFileSync(filePath, pdfContent);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createTempTxt(): string {
|
||||
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const filePath = join(tmpDir, 'test-invalid.txt');
|
||||
writeFileSync(filePath, 'This is a plain text file that should be rejected.');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function cleanupTempFiles() {
|
||||
const tmpDir = join(__dirname, '..', 'tmp-test-files');
|
||||
for (const name of ['test-attachment.pdf', 'test-invalid.txt']) {
|
||||
try {
|
||||
unlinkSync(join(tmpDir, name));
|
||||
} catch {
|
||||
// May not exist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Ensure teacher user exists (same as homework.spec.ts)
|
||||
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();
|
||||
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-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
seedTeacherAssignments();
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
cleanupTempFiles();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
try {
|
||||
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.1 : WYSIWYG Editor
|
||||
// ============================================================================
|
||||
test.describe('WYSIWYG Editor', () => {
|
||||
test('create form shows rich text editor with toolbar', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Rich text editor with toolbar should be visible
|
||||
const editor = page.locator('.rich-text-editor');
|
||||
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.toolbar')).toBeVisible();
|
||||
|
||||
// Toolbar buttons
|
||||
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create homework with rich text description', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir texte riche');
|
||||
|
||||
// Type in rich text editor
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Consignes importantes');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('bold formatting works in editor', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir gras test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Normal ');
|
||||
|
||||
// Apply bold via keyboard shortcut (more reliable than toolbar click)
|
||||
await page.keyboard.press('Control+b');
|
||||
await page.keyboard.type('en gras');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify bold is rendered in the description
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('strong')).toContainText('en gras');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.2 : Upload attachment
|
||||
// ============================================================================
|
||||
test.describe('Attachments', () => {
|
||||
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Create homework
|
||||
await createHomework(page, 'Devoir avec PJ');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload file
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
|
||||
// File appears in list
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
// T4.3 : Delete attachment
|
||||
test('can delete an uploaded attachment', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir suppr PJ');
|
||||
|
||||
// Open edit modal and upload
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const pdfPath = createTempPdf();
|
||||
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Delete the attachment
|
||||
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
|
||||
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.1 : Invalid file type rejection (P1)
|
||||
// ============================================================================
|
||||
test.describe('Invalid File Type Rejection', () => {
|
||||
test('rejects a .txt file with an error message', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir rejet fichier');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Try to upload a .txt file
|
||||
const txtPath = createTempTxt();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(txtPath);
|
||||
|
||||
// Error message should appear
|
||||
const errorAlert = page.locator('[role="alert"]');
|
||||
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
||||
await expect(errorAlert).toContainText('Type de fichier non accepté');
|
||||
|
||||
// The .txt file should NOT appear in the file list
|
||||
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.2 : Attachment persistence after save (P1)
|
||||
// ============================================================================
|
||||
test.describe('Attachment Persistence', () => {
|
||||
test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir persistance PJ');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload a PDF
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Save the changes
|
||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Reopen the edit modal
|
||||
const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||
await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The attachment should still be there
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.3 : File size display after upload (P2)
|
||||
// ============================================================================
|
||||
test.describe('File Size Display', () => {
|
||||
test('shows formatted file size after uploading a PDF', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir taille fichier');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload a PDF
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko")
|
||||
const fileSize = page.locator('.file-size');
|
||||
await expect(fileSize).toBeVisible({ timeout: 5000 });
|
||||
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.4 : Backward compatibility
|
||||
// ============================================================================
|
||||
test.describe('Backward Compatibility', () => {
|
||||
test('existing plain text homework displays correctly', async ({ page }) => {
|
||||
// Create homework with plain text description via SQL
|
||||
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, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Plain text description displays correctly
|
||||
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
|
||||
});
|
||||
|
||||
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
|
||||
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, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// WYSIWYG editor contains the old text
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -217,7 +217,7 @@ test.describe('Homework Management (Story 5.1)', () => {
|
||||
await page.locator('#hw-title').fill('Exercices chapitre 5');
|
||||
|
||||
// Fill description
|
||||
await page.locator('#hw-description').fill('Pages 42-45, exercices 1 à 10');
|
||||
await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Pages 42-45, exercices 1 à 10');
|
||||
|
||||
// Set due date (next weekday, at least 2 days from now)
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(3));
|
||||
@@ -389,7 +389,7 @@ test.describe('Homework Management (Story 5.1)', () => {
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir date passée');
|
||||
await page.locator('#hw-description').fill('Test validation');
|
||||
await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Test validation');
|
||||
|
||||
// Set a past date — fill() works with Svelte 5 bind:value
|
||||
const yesterday = new Date();
|
||||
@@ -686,7 +686,7 @@ test.describe('Homework Management (Story 5.1)', () => {
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Titre original');
|
||||
await page.locator('#hw-description').fill('Description inchangée');
|
||||
await page.locator('.modal .rich-text-content').click(); await page.locator('.modal .rich-text-content').pressSequentially('Description inchangée');
|
||||
await page.locator('#hw-due-date').fill(dueDate);
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
@@ -698,7 +698,7 @@ test.describe('Homework Management (Story 5.1)', () => {
|
||||
await expect(editDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify pre-filled values
|
||||
await expect(page.locator('#edit-description')).toHaveValue('Description inchangée');
|
||||
await expect(page.locator('.modal .rich-text-content')).toContainText('Description inchangée');
|
||||
await expect(page.locator('#edit-due-date')).toHaveValue(dueDate);
|
||||
|
||||
// Change only the title
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"svelte-eslint-parser": "^1.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.4.0",
|
||||
@@ -42,6 +41,7 @@
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"svelte": "^5.15.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"svelte-eslint-parser": "^1.0.0",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
@@ -51,6 +51,10 @@
|
||||
"dependencies": {
|
||||
"@sentry/sveltekit": "^8.50.0",
|
||||
"@tanstack/svelte-query": "^5.66.0",
|
||||
"@tiptap/core": "^3.20.4",
|
||||
"@tiptap/extension-link": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"@vite-pwa/sveltekit": "^0.6.8",
|
||||
"web-vitals": "^4.2.0",
|
||||
"workbox-window": "^7.3.0"
|
||||
|
||||
535
frontend/pnpm-lock.yaml
generated
535
frontend/pnpm-lock.yaml
generated
@@ -14,6 +14,18 @@ importers:
|
||||
'@tanstack/svelte-query':
|
||||
specifier: ^5.66.0
|
||||
version: 5.90.2(svelte@5.49.1)
|
||||
'@tiptap/core':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@tiptap/pm@3.20.4)
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4
|
||||
'@vite-pwa/sveltekit':
|
||||
specifier: ^0.6.8
|
||||
version: 0.6.8(@sveltejs/kit@2.50.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.49.1)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(svelte@5.49.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)))(vite-plugin-pwa@0.21.2(vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0))
|
||||
@@ -1315,6 +1327,9 @@ packages:
|
||||
'@prisma/instrumentation@5.22.0':
|
||||
resolution: {integrity: sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==}
|
||||
|
||||
'@remirror/core-constants@3.0.0':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
'@rollup/plugin-babel@5.3.1':
|
||||
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -1741,6 +1756,132 @@ packages:
|
||||
vitest:
|
||||
optional: true
|
||||
|
||||
'@tiptap/core@3.20.4':
|
||||
resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/extension-blockquote@3.20.4':
|
||||
resolution: {integrity: sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-bold@3.20.4':
|
||||
resolution: {integrity: sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-bullet-list@3.20.4':
|
||||
resolution: {integrity: sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.4
|
||||
|
||||
'@tiptap/extension-code-block@3.20.4':
|
||||
resolution: {integrity: sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/extension-code@3.20.4':
|
||||
resolution: {integrity: sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-document@3.20.4':
|
||||
resolution: {integrity: sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-dropcursor@3.20.4':
|
||||
resolution: {integrity: sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.4
|
||||
|
||||
'@tiptap/extension-gapcursor@3.20.4':
|
||||
resolution: {integrity: sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': ^3.20.4
|
||||
|
||||
'@tiptap/extension-hard-break@3.20.4':
|
||||
resolution: {integrity: sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-heading@3.20.4':
|
||||
resolution: {integrity: sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.20.4':
|
||||
resolution: {integrity: sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/extension-italic@3.20.4':
|
||||
resolution: {integrity: sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-link@3.20.4':
|
||||
resolution: {integrity: sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/extension-list-item@3.20.4':
|
||||
resolution: {integrity: sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.4
|
||||
|
||||
'@tiptap/extension-list-keymap@3.20.4':
|
||||
resolution: {integrity: sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.4
|
||||
|
||||
'@tiptap/extension-list@3.20.4':
|
||||
resolution: {integrity: sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/extension-ordered-list@3.20.4':
|
||||
resolution: {integrity: sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': ^3.20.4
|
||||
|
||||
'@tiptap/extension-paragraph@3.20.4':
|
||||
resolution: {integrity: sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-strike@3.20.4':
|
||||
resolution: {integrity: sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-text@3.20.4':
|
||||
resolution: {integrity: sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extension-underline@3.20.4':
|
||||
resolution: {integrity: sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
|
||||
'@tiptap/extensions@3.20.4':
|
||||
resolution: {integrity: sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.20.4
|
||||
'@tiptap/pm': ^3.20.4
|
||||
|
||||
'@tiptap/pm@3.20.4':
|
||||
resolution: {integrity: sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==}
|
||||
|
||||
'@tiptap/starter-kit@3.20.4':
|
||||
resolution: {integrity: sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==}
|
||||
|
||||
'@types/aria-query@5.0.4':
|
||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||
|
||||
@@ -1759,6 +1900,15 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||
|
||||
'@types/mdurl@2.0.0':
|
||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||
|
||||
'@types/mysql@2.15.26':
|
||||
resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==}
|
||||
|
||||
@@ -2137,6 +2287,9 @@ packages:
|
||||
core-js-compat@3.48.0:
|
||||
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2246,6 +2399,10 @@ packages:
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -2864,6 +3021,12 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
linkifyjs@4.3.2:
|
||||
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||
|
||||
locate-character@3.0.0:
|
||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||
|
||||
@@ -2924,6 +3087,10 @@ packages:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2931,6 +3098,9 @@ packages:
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -3038,6 +3208,9 @@ packages:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
|
||||
own-keys@1.0.1:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3317,9 +3490,71 @@ packages:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
prosemirror-changeset@2.4.0:
|
||||
resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
|
||||
|
||||
prosemirror-collab@1.3.1:
|
||||
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
|
||||
prosemirror-markdown@1.13.4:
|
||||
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
|
||||
|
||||
prosemirror-menu@1.3.0:
|
||||
resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==}
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
|
||||
|
||||
prosemirror-schema-basic@1.2.4:
|
||||
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
|
||||
prosemirror-trailing-node@3.0.0:
|
||||
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
|
||||
peerDependencies:
|
||||
prosemirror-model: ^1.22.1
|
||||
prosemirror-state: ^1.4.2
|
||||
prosemirror-view: ^1.33.8
|
||||
|
||||
prosemirror-transform@1.11.0:
|
||||
resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
|
||||
|
||||
prosemirror-view@1.41.7:
|
||||
resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3405,6 +3640,9 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -3748,6 +3986,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3919,6 +4160,9 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5368,6 +5612,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@remirror/core-constants@3.0.0': {}
|
||||
|
||||
'@rollup/plugin-babel@5.3.1(@babel/core@7.28.6)(rollup@2.79.2)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.6
|
||||
@@ -5815,6 +6061,152 @@ snapshots:
|
||||
vite: 6.4.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)
|
||||
vitest: 2.1.9(@types/node@22.19.7)(jsdom@27.4.0)(terser@5.46.0)
|
||||
|
||||
'@tiptap/core@3.20.4(@tiptap/pm@3.20.4)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@tiptap/extension-blockquote@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-bold@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-bullet-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@tiptap/extension-code@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-document@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-dropcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-gapcursor@3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-hard-break@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-heading@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-horizontal-rule@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@tiptap/extension-italic@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-link@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
'@tiptap/pm': 3.20.4
|
||||
linkifyjs: 4.3.2
|
||||
|
||||
'@tiptap/extension-list-item@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-list-keymap@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@tiptap/extension-ordered-list@3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-paragraph@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-strike@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-text@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extension-underline@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
|
||||
'@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@tiptap/pm@3.20.4':
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.0
|
||||
prosemirror-collab: 1.3.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.4.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-markdown: 1.13.4
|
||||
prosemirror-menu: 1.3.0
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-schema-basic: 1.2.4
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
'@tiptap/starter-kit@3.20.4':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||
'@tiptap/extension-blockquote': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-bold': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-bullet-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-code': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-code-block': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
'@tiptap/extension-document': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-dropcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-gapcursor': 3.20.4(@tiptap/extensions@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-hard-break': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-heading': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-horizontal-rule': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
'@tiptap/extension-italic': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-link': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
'@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
'@tiptap/extension-list-item': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-list-keymap': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-ordered-list': 3.20.4(@tiptap/extension-list@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-paragraph': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-strike': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-text': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extension-underline': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))
|
||||
'@tiptap/extensions': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
|
||||
'@tiptap/pm': 3.20.4
|
||||
|
||||
'@types/aria-query@5.0.4': {}
|
||||
|
||||
'@types/connect@3.4.36':
|
||||
@@ -5829,6 +6221,15 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
dependencies:
|
||||
'@types/linkify-it': 5.0.0
|
||||
'@types/mdurl': 2.0.0
|
||||
|
||||
'@types/mdurl@2.0.0': {}
|
||||
|
||||
'@types/mysql@2.15.26':
|
||||
dependencies:
|
||||
'@types/node': 22.19.7
|
||||
@@ -6257,6 +6658,8 @@ snapshots:
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -6356,6 +6759,8 @@ snapshots:
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
@@ -7107,6 +7512,12 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
linkifyjs@4.3.2: {}
|
||||
|
||||
locate-character@3.0.0: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
@@ -7165,10 +7576,21 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
markdown-it@14.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
@@ -7256,6 +7678,8 @@ snapshots:
|
||||
type-check: 0.4.0
|
||||
word-wrap: 1.2.5
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
own-keys@1.0.1:
|
||||
dependencies:
|
||||
get-intrinsic: 1.3.0
|
||||
@@ -7431,8 +7855,113 @@ snapshots:
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
prosemirror-changeset@2.4.0:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-collab@1.3.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
|
||||
prosemirror-commands@1.7.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-dropcursor@1.8.2:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-gapcursor@1.4.1:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-history@1.5.0:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
rope-sequence: 1.3.4
|
||||
|
||||
prosemirror-inputrules@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-keymap@1.2.3:
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
prosemirror-markdown@1.13.4:
|
||||
dependencies:
|
||||
'@types/markdown-it': 14.1.2
|
||||
markdown-it: 14.1.1
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-menu@1.3.0:
|
||||
dependencies:
|
||||
crelt: 1.0.6
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-state: 1.4.4
|
||||
|
||||
prosemirror-model@1.25.4:
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
|
||||
prosemirror-schema-basic@1.2.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-schema-list@1.5.1:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
prosemirror-state@1.4.4:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-tables@1.8.5:
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7):
|
||||
dependencies:
|
||||
'@remirror/core-constants': 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.7
|
||||
|
||||
prosemirror-transform@1.11.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
|
||||
prosemirror-view@1.41.7:
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.4
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
@@ -7557,6 +8086,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.57.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -7994,6 +8525,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -8134,6 +8667,8 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<script lang="ts">
|
||||
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 {
|
||||
id: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
let {
|
||||
existingFiles = [],
|
||||
onUpload,
|
||||
onDelete,
|
||||
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);
|
||||
let pendingFiles = $state<{ name: string; size: number }[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
$effect(() => {
|
||||
files = existingFiles;
|
||||
});
|
||||
|
||||
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 getFileIcon(mimeType: string): string {
|
||||
if (mimeType === 'application/pdf') return '📄';
|
||||
if (mimeType.startsWith('image/')) return '🖼️';
|
||||
return '📎';
|
||||
}
|
||||
|
||||
function validateFile(file: File): string | null {
|
||||
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)}).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selectedFiles = input.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
error = null;
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
error = validationError;
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingFiles = [...pendingFiles, { name: file.name, size: file.size }];
|
||||
|
||||
try {
|
||||
const uploaded = await onUpload(file);
|
||||
files = [...files, uploaded];
|
||||
} catch {
|
||||
error = `Erreur lors de l'envoi de "${file.name}".`;
|
||||
} finally {
|
||||
pendingFiles = pendingFiles.filter((p) => p.name !== file.name);
|
||||
}
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function handleDelete(fileId: string) {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await onDelete(fileId);
|
||||
files = files.filter((f) => f.id !== fileId);
|
||||
} catch {
|
||||
error = 'Erreur lors de la suppression du fichier.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="file-upload" class:disabled>
|
||||
{#if error}
|
||||
<p class="upload-error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if files.length > 0 || pendingFiles.length > 0}
|
||||
<ul class="file-list">
|
||||
{#each files as file}
|
||||
<li class="file-item">
|
||||
<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 && showDelete}
|
||||
<button
|
||||
type="button"
|
||||
class="file-remove"
|
||||
onclick={() => handleDelete(file.id)}
|
||||
title="Supprimer {file.filename}"
|
||||
aria-label="Supprimer {file.filename}"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{#each pendingFiles as pending}
|
||||
<li class="file-item file-pending">
|
||||
<span class="file-icon">⏳</span>
|
||||
<span class="file-name">{pending.name}</span>
|
||||
<span class="file-size">{formatFileSize(pending.size)}</span>
|
||||
<span class="file-uploading">Envoi...</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if !disabled}
|
||||
<button type="button" class="upload-btn" onclick={() => fileInput.click()}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
Ajouter un fichier
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
onchange={handleFileSelect}
|
||||
class="file-input-hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<p class="upload-hint">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-upload.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.upload-error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fee2e2;
|
||||
border-radius: 0.375rem;
|
||||
color: #991b1b;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.file-pending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-uploading {
|
||||
color: #3b82f6;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 0.625rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.file-remove:hover {
|
||||
background: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Editor as EditorType } from '@tiptap/core';
|
||||
|
||||
let {
|
||||
content = '',
|
||||
onUpdate,
|
||||
placeholder = 'Saisissez votre texte...',
|
||||
disabled = false
|
||||
}: {
|
||||
content?: string;
|
||||
onUpdate: (html: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
|
||||
let editorElement: HTMLDivElement;
|
||||
let editor: EditorType | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
initEditor();
|
||||
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
async function initEditor() {
|
||||
const [{ Editor }, { default: StarterKit }, { default: Link }] = await Promise.all([
|
||||
import('@tiptap/core'),
|
||||
import('@tiptap/starter-kit'),
|
||||
import('@tiptap/extension-link')
|
||||
]);
|
||||
|
||||
editor = new Editor({
|
||||
element: editorElement,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
horizontalRule: false,
|
||||
code: false
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
target: '_blank'
|
||||
}
|
||||
})
|
||||
],
|
||||
content,
|
||||
editable: !disabled,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
const html = e.isEmpty ? '' : e.getHTML();
|
||||
onUpdate(html);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'rich-text-content',
|
||||
'aria-label': placeholder,
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (editor && disabled !== undefined) {
|
||||
editor.setEditable(!disabled);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleBold() {
|
||||
editor?.chain().focus().toggleBold().run();
|
||||
}
|
||||
|
||||
function toggleItalic() {
|
||||
editor?.chain().focus().toggleItalic().run();
|
||||
}
|
||||
|
||||
function toggleBulletList() {
|
||||
editor?.chain().focus().toggleBulletList().run();
|
||||
}
|
||||
|
||||
function toggleOrderedList() {
|
||||
editor?.chain().focus().toggleOrderedList().run();
|
||||
}
|
||||
|
||||
function toggleLink() {
|
||||
if (!editor) return;
|
||||
|
||||
if (editor.isActive('link')) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = window.prompt('URL du lien :');
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rich-text-editor" class:disabled>
|
||||
{#if editor && !disabled}
|
||||
<div class="toolbar" role="toolbar" aria-label="Formatage du texte">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('bold')}
|
||||
onclick={toggleBold}
|
||||
title="Gras"
|
||||
aria-label="Gras"
|
||||
aria-pressed={editor.isActive('bold')}
|
||||
>
|
||||
<strong>G</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('italic')}
|
||||
onclick={toggleItalic}
|
||||
title="Italique"
|
||||
aria-label="Italique"
|
||||
aria-pressed={editor.isActive('italic')}
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<span class="toolbar-separator" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('bulletList')}
|
||||
onclick={toggleBulletList}
|
||||
title="Liste à puces"
|
||||
aria-label="Liste à puces"
|
||||
aria-pressed={editor.isActive('bulletList')}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 000 2h10a1 1 0 100-2H7zm0 5a1 1 0 000 2h10a1 1 0 100-2H7zm0 5a1 1 0 000 2h10a1 1 0 100-2H7zM3 9a1 1 0 100 2 1 1 0 000-2zm0 5a1 1 0 100 2 1 1 0 000-2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('orderedList')}
|
||||
onclick={toggleOrderedList}
|
||||
title="Liste numérotée"
|
||||
aria-label="Liste numérotée"
|
||||
aria-pressed={editor.isActive('orderedList')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" aria-hidden="true">
|
||||
<line x1="10" y1="6" x2="21" y2="6" />
|
||||
<line x1="10" y1="12" x2="21" y2="12" />
|
||||
<line x1="10" y1="18" x2="21" y2="18" />
|
||||
<text x="4" y="7.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">1</text>
|
||||
<text x="4" y="13.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">2</text>
|
||||
<text x="4" y="19.5" font-size="7" font-weight="700" fill="currentColor" stroke="none" text-anchor="middle">3</text>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="toolbar-separator" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn"
|
||||
class:active={editor.isActive('link')}
|
||||
onclick={toggleLink}
|
||||
title="Lien"
|
||||
aria-label="Lien"
|
||||
aria-pressed={editor.isActive('link')}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div bind:this={editorElement} class="editor-container"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rich-text-editor {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.rich-text-editor:focus-within {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.rich-text-editor.disabled {
|
||||
opacity: 0.6;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
background: #d1d5db;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content) {
|
||||
padding: 0.75rem;
|
||||
min-height: 8rem;
|
||||
outline: none;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content li) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content a) {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-container :global(.rich-text-content .is-empty::before) {
|
||||
content: attr(data-placeholder);
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
float: left;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
@@ -93,10 +95,18 @@
|
||||
{#if detail.description}
|
||||
<section class="detail-description">
|
||||
<h3>Description</h3>
|
||||
<p>{detail.description}</p>
|
||||
<div class="description-content">{@html detail.description}</div>
|
||||
</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>
|
||||
@@ -183,11 +193,34 @@
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.detail-description p {
|
||||
margin: 0;
|
||||
.detail-description :global(.description-content) {
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.detail-description :global(.description-content a) {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.download-error {
|
||||
@@ -237,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}`;
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
|
||||
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
||||
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
|
||||
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
@@ -28,6 +30,13 @@
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface HomeworkAttachmentFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface RuleWarning {
|
||||
ruleType: string;
|
||||
message: string;
|
||||
@@ -75,6 +84,10 @@
|
||||
let newDescription = $state('');
|
||||
let newDueDate = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let newPendingFiles = $state<File[]>([]);
|
||||
|
||||
// Attachments
|
||||
let editAttachments = $state<HomeworkAttachmentFile[]>([]);
|
||||
|
||||
// Edit modal
|
||||
let showEditModal = $state(false);
|
||||
@@ -321,6 +334,7 @@
|
||||
newTitle = '';
|
||||
newDescription = '';
|
||||
newDueDate = '';
|
||||
newPendingFiles = [];
|
||||
ruleConformMinDate = '';
|
||||
dueDateError = null;
|
||||
}
|
||||
@@ -382,6 +396,22 @@
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// Upload pending files if any
|
||||
if (newPendingFiles.length > 0) {
|
||||
const created = await response.json().catch(() => null);
|
||||
const homeworkId = created?.id;
|
||||
if (homeworkId) {
|
||||
for (const file of newPendingFiles) {
|
||||
try {
|
||||
await uploadAttachment(homeworkId, file);
|
||||
} catch {
|
||||
// Best effort — file upload failure doesn't block creation
|
||||
}
|
||||
}
|
||||
}
|
||||
newPendingFiles = [];
|
||||
}
|
||||
|
||||
closeCreateModal();
|
||||
showRuleWarningModal = false;
|
||||
ruleWarnings = [];
|
||||
@@ -525,18 +555,63 @@
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
// --- Attachments ---
|
||||
async function uploadAttachment(homeworkId: string, file: File): Promise<HomeworkAttachmentFile> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const formData = new window.FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/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.');
|
||||
}
|
||||
|
||||
return response.json() as Promise<HomeworkAttachmentFile>;
|
||||
}
|
||||
|
||||
async function deleteAttachment(homeworkId: string, attachmentId: string): Promise<void> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/attachments/${attachmentId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la suppression du fichier.');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAttachments(homeworkId: string): Promise<HomeworkAttachmentFile[]> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/attachments`);
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = await response.json();
|
||||
return (data as HomeworkAttachmentFile[]) ?? [];
|
||||
}
|
||||
|
||||
// --- Edit ---
|
||||
function openEditModal(hw: Homework) {
|
||||
async function openEditModal(hw: Homework) {
|
||||
editHomework = hw;
|
||||
editTitle = hw.title;
|
||||
editDescription = hw.description ?? '';
|
||||
editDueDate = hw.dueDate;
|
||||
editAttachments = [];
|
||||
showEditModal = true;
|
||||
|
||||
// Charger les pièces jointes existantes en arrière-plan
|
||||
editAttachments = await fetchAttachments(hw.id);
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
showEditModal = false;
|
||||
editHomework = null;
|
||||
editAttachments = [];
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
@@ -811,7 +886,7 @@
|
||||
</div>
|
||||
|
||||
{#if hw.description}
|
||||
<p class="homework-description">{hw.description}</p>
|
||||
<div class="homework-description">{@html hw.description}</div>
|
||||
{/if}
|
||||
|
||||
{#if hw.status === 'published'}
|
||||
@@ -901,13 +976,13 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hw-description">Description</label>
|
||||
<textarea
|
||||
id="hw-description"
|
||||
bind:value={newDescription}
|
||||
<label>Description</label>
|
||||
<RichTextEditor
|
||||
content={newDescription}
|
||||
onUpdate={(html) => (newDescription = html)}
|
||||
placeholder="Consignes, pages à lire, liens utiles..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -931,6 +1006,27 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Pièces jointes</label>
|
||||
<FileUpload
|
||||
existingFiles={[]}
|
||||
onUpload={async (file) => {
|
||||
newPendingFiles = [...newPendingFiles, file];
|
||||
const pendingId = `pending-${file.name}-${file.size}`;
|
||||
return { id: pendingId, filename: file.name, fileSize: file.size, mimeType: file.type };
|
||||
}}
|
||||
onDelete={async (fileId) => {
|
||||
newPendingFiles = newPendingFiles.filter(
|
||||
(f) => `pending-${f.name}-${f.size}` !== fileId
|
||||
);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{#if newPendingFiles.length > 0}
|
||||
<small class="form-hint">Les fichiers seront envoyés après la création du devoir.</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
|
||||
Annuler
|
||||
@@ -997,13 +1093,13 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-description">Description</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
bind:value={editDescription}
|
||||
<label>Description</label>
|
||||
<RichTextEditor
|
||||
content={editDescription}
|
||||
onUpdate={(html) => (editDescription = html)}
|
||||
placeholder="Consignes, pages à lire, liens utiles..."
|
||||
rows="4"
|
||||
></textarea>
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -1011,6 +1107,18 @@
|
||||
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
|
||||
</div>
|
||||
|
||||
{#if editHomework}
|
||||
<div class="form-group">
|
||||
<label>Pièces jointes</label>
|
||||
<FileUpload
|
||||
existingFiles={editAttachments}
|
||||
onUpload={(file) => uploadAttachment(editHomework!.id, file)}
|
||||
onDelete={(attachmentId) => deleteAttachment(editHomework!.id, attachmentId)}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={closeEditModal} disabled={isUpdating}>
|
||||
Annuler
|
||||
@@ -1553,7 +1661,16 @@
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.homework-description :global(ul) {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.homework-description :global(ol) {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.homework-actions {
|
||||
|
||||
@@ -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