feat: Permettre aux enseignants de dupliquer un devoir vers plusieurs classes
Un enseignant qui donne le même travail à plusieurs classes devait jusqu'ici recréer manuellement chaque devoir. La duplication permet de sélectionner les classes cibles, d'ajuster les dates d'échéance par classe, et de créer tous les devoirs en une seule opération atomique (transaction). La validation s'effectue par classe (affectation enseignant, date d'échéance) avec un rapport d'erreurs détaillé. L'infrastructure de warnings est prête pour les règles de timing de la Story 5.3. Le filtrage par classe dans la liste des devoirs passe côté serveur pour rester compatible avec la pagination.
This commit is contained in:
@@ -210,9 +210,15 @@ services:
|
||||
App\Scolarite\Domain\Repository\HomeworkRepository:
|
||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRepository
|
||||
|
||||
App\Scolarite\Domain\Repository\HomeworkAttachmentRepository:
|
||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository
|
||||
|
||||
App\Scolarite\Domain\Service\DueDateValidator:
|
||||
autowire: true
|
||||
|
||||
App\Scolarite\Domain\Service\HomeworkDuplicator:
|
||||
autowire: true
|
||||
|
||||
App\Scolarite\Application\Port\FileStorage:
|
||||
alias: App\Scolarite\Infrastructure\Storage\LocalFileStorage
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DuplicateHomework;
|
||||
|
||||
final readonly class DuplicateHomeworkCommand
|
||||
{
|
||||
/**
|
||||
* @param array<string> $targetClassIds
|
||||
* @param array<string, string> $dueDates Clé = classId, valeur = date (Y-m-d)
|
||||
*/
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $homeworkId,
|
||||
public string $teacherId,
|
||||
public array $targetClassIds,
|
||||
public array $dueDates = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DuplicateHomework;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||
use App\Scolarite\Domain\Exception\DuplicationValidationException;
|
||||
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
|
||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Scolarite\Domain\Service\DueDateValidator;
|
||||
use App\Scolarite\Domain\Service\HomeworkDuplicator;
|
||||
use App\Scolarite\Domain\ValueObject\ClassValidationResult;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_unique;
|
||||
use function array_values;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Exception;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Throwable;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class DuplicateHomeworkHandler
|
||||
{
|
||||
public function __construct(
|
||||
private HomeworkRepository $homeworkRepository,
|
||||
private HomeworkAttachmentRepository $attachmentRepository,
|
||||
private EnseignantAffectationChecker $affectationChecker,
|
||||
private CurrentCalendarProvider $calendarProvider,
|
||||
private DueDateValidator $dueDateValidator,
|
||||
private HomeworkDuplicator $duplicator,
|
||||
private Clock $clock,
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(DuplicateHomeworkCommand $command): DuplicationResult
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$homeworkId = HomeworkId::fromString($command->homeworkId);
|
||||
$teacherId = UserId::fromString($command->teacherId);
|
||||
$now = $this->clock->now();
|
||||
|
||||
$source = $this->homeworkRepository->get($homeworkId, $tenantId);
|
||||
|
||||
if ((string) $source->teacherId !== (string) $teacherId) {
|
||||
throw NonProprietaireDuDevoirException::withId($homeworkId);
|
||||
}
|
||||
|
||||
$calendar = $this->calendarProvider->forCurrentYear($tenantId);
|
||||
|
||||
// Dédupliquer et exclure la classe source
|
||||
$uniqueClassIds = array_values(array_unique($command->targetClassIds));
|
||||
$sourceClassIdStr = (string) $source->classId;
|
||||
|
||||
$targetClassIds = [];
|
||||
$dueDates = [];
|
||||
$validationResults = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($uniqueClassIds as $classIdStr) {
|
||||
if ($classIdStr === $sourceClassIdStr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($classIdStr, $seen, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[] = $classIdStr;
|
||||
$classId = ClassId::fromString($classIdStr);
|
||||
|
||||
try {
|
||||
$dueDate = isset($command->dueDates[$classIdStr])
|
||||
? new DateTimeImmutable($command->dueDates[$classIdStr])
|
||||
: $source->dueDate;
|
||||
} catch (Exception) {
|
||||
$validationResults[] = ClassValidationResult::failure(
|
||||
$classId,
|
||||
"Date d'échéance invalide : {$command->dueDates[$classIdStr]}.",
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $source->subjectId, $tenantId)) {
|
||||
$validationResults[] = ClassValidationResult::failure(
|
||||
$classId,
|
||||
"L'enseignant n'est pas affecté à cette classe pour cette matière.",
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->dueDateValidator->valider($dueDate, $now, $calendar);
|
||||
$validationResults[] = ClassValidationResult::success($classId);
|
||||
$targetClassIds[] = $classId;
|
||||
$dueDates[] = $dueDate;
|
||||
} catch (DateEcheanceInvalideException $e) {
|
||||
$validationResults[] = ClassValidationResult::failure($classId, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$failures = array_filter($validationResults, static fn (ClassValidationResult $r): bool => !$r->valid);
|
||||
|
||||
if ($failures !== []) {
|
||||
throw DuplicationValidationException::withResults($validationResults);
|
||||
}
|
||||
|
||||
$duplicates = $this->duplicator->dupliquer($source, $targetClassIds, $dueDates, $now);
|
||||
|
||||
$sourceAttachments = $this->attachmentRepository->findByHomeworkId($source->id);
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($duplicates as $duplicate) {
|
||||
$this->homeworkRepository->save($duplicate);
|
||||
|
||||
foreach ($sourceAttachments as $attachment) {
|
||||
$this->attachmentRepository->save(
|
||||
$duplicate->id,
|
||||
new HomeworkAttachment(
|
||||
id: HomeworkAttachmentId::generate(),
|
||||
filename: $attachment->filename,
|
||||
filePath: $attachment->filePath,
|
||||
fileSize: $attachment->fileSize,
|
||||
mimeType: $attachment->mimeType,
|
||||
uploadedAt: $now,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$warnings = array_values(array_filter(
|
||||
$validationResults,
|
||||
static fn (ClassValidationResult $r): bool => $r->valid && $r->warning !== null,
|
||||
));
|
||||
|
||||
return new DuplicationResult($duplicates, $warnings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Command\DuplicateHomework;
|
||||
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\ValueObject\ClassValidationResult;
|
||||
|
||||
final readonly class DuplicationResult
|
||||
{
|
||||
/**
|
||||
* @param array<Homework> $homeworks
|
||||
* @param array<ClassValidationResult> $warnings
|
||||
*/
|
||||
public function __construct(
|
||||
public array $homeworks,
|
||||
public array $warnings,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use App\Scolarite\Domain\ValueObject\ClassValidationResult;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function implode;
|
||||
|
||||
final class DuplicationValidationException extends DomainException
|
||||
{
|
||||
/** @param array<ClassValidationResult> $results */
|
||||
private function __construct(
|
||||
string $message,
|
||||
public readonly array $results,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
/** @param array<ClassValidationResult> $results */
|
||||
public static function withResults(array $results): self
|
||||
{
|
||||
$failures = array_filter($results, static fn (ClassValidationResult $r): bool => !$r->valid);
|
||||
$messages = array_map(
|
||||
static fn (ClassValidationResult $r): string => "Classe {$r->classId} : {$r->error}",
|
||||
$failures,
|
||||
);
|
||||
|
||||
return new self(
|
||||
'Validation échouée pour certaines classes : ' . implode('; ', $messages),
|
||||
$results,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
|
||||
interface HomeworkAttachmentRepository
|
||||
{
|
||||
/** @return array<HomeworkAttachment> */
|
||||
public function findByHomeworkId(HomeworkId $homeworkId): array;
|
||||
|
||||
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
|
||||
}
|
||||
44
backend/src/Scolarite/Domain/Service/HomeworkDuplicator.php
Normal file
44
backend/src/Scolarite/Domain/Service/HomeworkDuplicator.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class HomeworkDuplicator
|
||||
{
|
||||
/**
|
||||
* @param array<ClassId> $targetClassIds
|
||||
* @param array<DateTimeImmutable> $dueDates Dates indexées par position (même ordre que $targetClassIds)
|
||||
*
|
||||
* @return array<Homework>
|
||||
*/
|
||||
public function dupliquer(
|
||||
Homework $source,
|
||||
array $targetClassIds,
|
||||
array $dueDates,
|
||||
DateTimeImmutable $now,
|
||||
): array {
|
||||
$duplicates = [];
|
||||
|
||||
foreach ($targetClassIds as $index => $classId) {
|
||||
$dueDate = $dueDates[$index] ?? $source->dueDate;
|
||||
|
||||
$duplicates[] = Homework::creer(
|
||||
tenantId: $source->tenantId,
|
||||
classId: $classId,
|
||||
subjectId: $source->subjectId,
|
||||
teacherId: $source->teacherId,
|
||||
title: $source->title,
|
||||
description: $source->description,
|
||||
dueDate: $dueDate,
|
||||
now: $now,
|
||||
);
|
||||
}
|
||||
|
||||
return $duplicates;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\ValueObject;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
|
||||
final readonly class ClassValidationResult
|
||||
{
|
||||
private function __construct(
|
||||
public ClassId $classId,
|
||||
public bool $valid,
|
||||
public ?string $error,
|
||||
public ?string $warning,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function success(ClassId $classId, ?string $warning = null): self
|
||||
{
|
||||
return new self($classId, true, null, $warning);
|
||||
}
|
||||
|
||||
public static function failure(ClassId $classId, string $error): self
|
||||
{
|
||||
return new self($classId, false, $error, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\DuplicateHomework\DuplicateHomeworkCommand;
|
||||
use App\Scolarite\Application\Command\DuplicateHomework\DuplicateHomeworkHandler;
|
||||
use App\Scolarite\Domain\Exception\DuplicationValidationException;
|
||||
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
|
||||
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\ValueObject\ClassValidationResult;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
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\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final readonly class DuplicateHomeworkController
|
||||
{
|
||||
public function __construct(
|
||||
private DuplicateHomeworkHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private Security $security,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/homework/{id}/duplicate', name: 'api_homework_duplicate', methods: ['POST'])]
|
||||
public function __invoke(string $id, Request $request): JsonResponse
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||
}
|
||||
|
||||
$body = $request->toArray();
|
||||
|
||||
$targetClassIds = $body['targetClassIds'] ?? null;
|
||||
|
||||
if (!is_array($targetClassIds) || $targetClassIds === []) {
|
||||
throw new BadRequestHttpException('Au moins une classe cible est requise.');
|
||||
}
|
||||
|
||||
/** @var array<string, string> $dueDates */
|
||||
$dueDates = [];
|
||||
$rawDueDates = $body['dueDates'] ?? [];
|
||||
|
||||
if (is_array($rawDueDates)) {
|
||||
foreach ($rawDueDates as $classId => $date) {
|
||||
if (is_string($classId) && is_string($date)) {
|
||||
$dueDates[$classId] = $date;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$classIds = [];
|
||||
|
||||
foreach ($targetClassIds as $item) {
|
||||
if (!is_string($item)) {
|
||||
throw new BadRequestHttpException('Les identifiants de classe doivent être des chaînes.');
|
||||
}
|
||||
|
||||
$classIds[] = $item;
|
||||
}
|
||||
|
||||
try {
|
||||
$command = new DuplicateHomeworkCommand(
|
||||
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||
homeworkId: $id,
|
||||
teacherId: $user->userId(),
|
||||
targetClassIds: $classIds,
|
||||
dueDates: $dueDates,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
|
||||
foreach ($result->homeworks as $homework) {
|
||||
foreach ($homework->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
|
||||
$response = [
|
||||
'data' => array_map(static fn (Homework $h): array => [
|
||||
'id' => (string) $h->id,
|
||||
'classId' => (string) $h->classId,
|
||||
'title' => $h->title,
|
||||
'dueDate' => $h->dueDate->format('Y-m-d'),
|
||||
'status' => $h->status->value,
|
||||
], $result->homeworks),
|
||||
];
|
||||
|
||||
if ($result->warnings !== []) {
|
||||
$response['warnings'] = array_map(static fn (ClassValidationResult $r): array => [
|
||||
'classId' => (string) $r->classId,
|
||||
'warning' => $r->warning,
|
||||
], $result->warnings);
|
||||
}
|
||||
|
||||
return new JsonResponse($response, Response::HTTP_CREATED);
|
||||
} catch (HomeworkNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
} catch (NonProprietaireDuDevoirException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
} catch (DuplicationValidationException $e) {
|
||||
return new JsonResponse([
|
||||
'error' => $e->getMessage(),
|
||||
'results' => array_map(static fn (ClassValidationResult $r): array => [
|
||||
'classId' => (string) $r->classId,
|
||||
'valid' => $r->valid,
|
||||
'error' => $r->error,
|
||||
], $e->results),
|
||||
], Response::HTTP_BAD_REQUEST);
|
||||
} catch (InvalidUuidStringException $e) {
|
||||
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
@@ -15,10 +16,15 @@ use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
@@ -54,6 +60,23 @@ final readonly class HomeworkCollectionProvider implements ProviderInterface
|
||||
|
||||
$homeworks = $this->homeworkRepository->findByTeacher($teacherId, $tenantId);
|
||||
|
||||
/** @var array<string, mixed> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
$classIdFilter = $filters['classId'] ?? null;
|
||||
|
||||
if (is_string($classIdFilter) && $classIdFilter !== '') {
|
||||
try {
|
||||
$filterClassId = ClassId::fromString($classIdFilter);
|
||||
} catch (InvalidUuidStringException $e) {
|
||||
throw new BadRequestHttpException('UUID de classe invalide : ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$homeworks = array_values(array_filter(
|
||||
$homeworks,
|
||||
static fn (Homework $h): bool => $h->classId->equals($filterClassId),
|
||||
));
|
||||
}
|
||||
|
||||
return array_map(fn (Homework $homework) => HomeworkResource::fromDomain(
|
||||
$homework,
|
||||
$this->resolveClassName($homework),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAttachmentRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByHomeworkId(HomeworkId $homeworkId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM homework_attachments WHERE homework_id = :homework_id',
|
||||
['homework_id' => (string) $homeworkId],
|
||||
);
|
||||
|
||||
return array_map($this->hydrate(...), $rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO homework_attachments (id, homework_id, filename, file_path, file_size, mime_type, uploaded_at)
|
||||
VALUES (:id, :homework_id, :filename, :file_path, :file_size, :mime_type, :uploaded_at)',
|
||||
[
|
||||
'id' => (string) $attachment->id,
|
||||
'homework_id' => (string) $homeworkId,
|
||||
'filename' => $attachment->filename,
|
||||
'file_path' => $attachment->filePath,
|
||||
'file_size' => $attachment->fileSize,
|
||||
'mime_type' => $attachment->mimeType,
|
||||
'uploaded_at' => $attachment->uploadedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function hydrate(array $row): HomeworkAttachment
|
||||
{
|
||||
/** @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 HomeworkAttachment(
|
||||
id: HomeworkAttachmentId::fromString($id),
|
||||
filename: $filename,
|
||||
filePath: $filePath,
|
||||
fileSize: $fileSize,
|
||||
mimeType: $mimeType,
|
||||
uploadedAt: new DateTimeImmutable($uploadedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
|
||||
use Override;
|
||||
|
||||
final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRepository
|
||||
{
|
||||
/** @var array<string, array<HomeworkAttachment>> */
|
||||
private array $byHomeworkId = [];
|
||||
|
||||
#[Override]
|
||||
public function findByHomeworkId(HomeworkId $homeworkId): array
|
||||
{
|
||||
return $this->byHomeworkId[(string) $homeworkId] ?? [];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
|
||||
{
|
||||
$this->byHomeworkId[(string) $homeworkId][] = $attachment;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-886655440001';
|
||||
private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-996655440001';
|
||||
private const string DELETED_HOMEWORK_ID = '550e8400-e29b-41d4-a716-aa6655440001';
|
||||
private const string TARGET_CLASS_ID = '550e8400-e29b-41d4-a716-776655440002';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -52,7 +53,7 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
/** @var Connection $connection */
|
||||
$connection = $container->get(Connection::class);
|
||||
$connection->executeStatement('DELETE FROM homework WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM school_classes WHERE id = :id', ['id' => self::CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM school_classes WHERE id IN (:id1, :id2)', ['id1' => self::CLASS_ID, 'id2' => self::TARGET_CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM subjects WHERE id = :id', ['id' => self::SUBJECT_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id IN (:o, :t)', ['o' => self::OWNER_TEACHER_ID, 't' => self::OTHER_TEACHER_ID]);
|
||||
|
||||
@@ -92,6 +93,12 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||
);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at)
|
||||
VALUES (:id, :tid, :sid, :ayid, 'Test-HW-Target-Class', 'active', NOW(), NOW())
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
['id' => self::TARGET_CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId],
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -382,6 +389,123 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.2-FUNC-001 (P1) - POST /homework/{id}/duplicate without tenant -> 404
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function duplicateHomeworkReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', '/api/homework/' . self::HOMEWORK_ID . '/duplicate', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'targetClassIds' => [self::TARGET_CLASS_ID],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.2-FUNC-002 (P1) - POST /homework/{id}/duplicate without auth -> 401
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function duplicateHomeworkReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'targetClassIds' => [self::TARGET_CLASS_ID],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.2-FUNC-003 (P1) - POST /homework/{id}/duplicate empty classes -> 400
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function duplicateHomeworkReturns400WithEmptyTargetClasses(): void
|
||||
{
|
||||
$this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED);
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'targetClassIds' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.2-FUNC-004 (P1) - POST /homework/{id}/duplicate not found -> 404
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function duplicateHomeworkReturns404WhenHomeworkNotFound(): void
|
||||
{
|
||||
$nonExistentId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . $nonExistentId . '/duplicate', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'targetClassIds' => [self::TARGET_CLASS_ID],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.2-FUNC-005 (P1) - POST /homework/{id}/duplicate non-owner -> 404
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function duplicateHomeworkReturns404WhenTeacherNotOwner(): void
|
||||
{
|
||||
$this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED);
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OTHER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID . '/duplicate', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'targetClassIds' => [self::TARGET_CLASS_ID],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Command\DuplicateHomework;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
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\DuplicateHomework\DuplicateHomeworkCommand;
|
||||
use App\Scolarite\Application\Command\DuplicateHomework\DuplicateHomeworkHandler;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Domain\Exception\DuplicationValidationException;
|
||||
use App\Scolarite\Domain\Exception\HomeworkNotFoundException;
|
||||
use App\Scolarite\Domain\Exception\NonProprietaireDuDevoirException;
|
||||
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\Homework\HomeworkStatus;
|
||||
use App\Scolarite\Domain\Service\DueDateValidator;
|
||||
use App\Scolarite\Domain\Service\HomeworkDuplicator;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DuplicateHomeworkHandlerTest 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 OTHER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
private const string TARGET_CLASS_1 = '550e8400-e29b-41d4-a716-446655440021';
|
||||
private const string TARGET_CLASS_2 = '550e8400-e29b-41d4-a716-446655440022';
|
||||
|
||||
private InMemoryHomeworkRepository $homeworkRepository;
|
||||
private InMemoryHomeworkAttachmentRepository $attachmentRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->homeworkRepository = new InMemoryHomeworkRepository();
|
||||
$this->attachmentRepository = new InMemoryHomeworkAttachmentRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-12 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDuplicatesHomeworkForOneClass(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$command = new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1],
|
||||
);
|
||||
|
||||
$result = $handler($command);
|
||||
|
||||
self::assertCount(1, $result->homeworks);
|
||||
self::assertSame($source->title, $result->homeworks[0]->title);
|
||||
self::assertSame($source->description, $result->homeworks[0]->description);
|
||||
self::assertTrue($result->homeworks[0]->classId->equals(ClassId::fromString(self::TARGET_CLASS_1)));
|
||||
self::assertSame(HomeworkStatus::PUBLISHED, $result->homeworks[0]->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDuplicatesHomeworkForMultipleClasses(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$command = new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
|
||||
);
|
||||
|
||||
$result = $handler($command);
|
||||
|
||||
self::assertCount(2, $result->homeworks);
|
||||
self::assertTrue($result->homeworks[0]->classId->equals(ClassId::fromString(self::TARGET_CLASS_1)));
|
||||
self::assertTrue($result->homeworks[1]->classId->equals(ClassId::fromString(self::TARGET_CLASS_2)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPersistsDuplicatesInRepository(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$command = new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1],
|
||||
);
|
||||
|
||||
$result = $handler($command);
|
||||
|
||||
$found = $this->homeworkRepository->get(
|
||||
$result->homeworks[0]->id,
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertSame($source->title, $found->title);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsCustomDueDatePerClass(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$command = new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
|
||||
dueDates: [
|
||||
self::TARGET_CLASS_1 => '2026-04-20',
|
||||
self::TARGET_CLASS_2 => '2026-04-21',
|
||||
],
|
||||
);
|
||||
|
||||
$result = $handler($command);
|
||||
|
||||
self::assertSame('2026-04-20', $result->homeworks[0]->dueDate->format('Y-m-d'));
|
||||
self::assertSame('2026-04-21', $result->homeworks[1]->dueDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUsesSourceDueDateWhenNotCustomized(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$command = new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1],
|
||||
);
|
||||
|
||||
$result = $handler($command);
|
||||
|
||||
self::assertSame($source->dueDate->format('Y-m-d'), $result->homeworks[0]->dueDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenHomeworkNotFound(): void
|
||||
{
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$this->expectException(HomeworkNotFoundException::class);
|
||||
|
||||
$handler(new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: '550e8400-e29b-41d4-a716-446655440099',
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1],
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTeacherIsNotOwner(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$this->expectException(NonProprietaireDuDevoirException::class);
|
||||
|
||||
$handler(new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::OTHER_TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1],
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenTeacherNotAffectedToTargetClass(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: false);
|
||||
|
||||
$this->expectException(DuplicationValidationException::class);
|
||||
|
||||
$handler(new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1],
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWithPerClassValidationResults(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: false);
|
||||
|
||||
try {
|
||||
$handler(new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
|
||||
));
|
||||
self::fail('Expected DuplicationValidationException');
|
||||
} catch (DuplicationValidationException $e) {
|
||||
self::assertCount(2, $e->results);
|
||||
self::assertFalse($e->results[0]->valid);
|
||||
self::assertFalse($e->results[1]->valid);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDuplicatesAttachmentsForEachCopy(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$attachment = new HomeworkAttachment(
|
||||
id: HomeworkAttachmentId::generate(),
|
||||
filename: 'exercice.pdf',
|
||||
filePath: 'homework/tenant/source/exercice.pdf',
|
||||
fileSize: 1024,
|
||||
mimeType: 'application/pdf',
|
||||
uploadedAt: new DateTimeImmutable('2026-03-12'),
|
||||
);
|
||||
$this->attachmentRepository->save($source->id, $attachment);
|
||||
|
||||
$command = new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
|
||||
);
|
||||
|
||||
$result = $handler($command);
|
||||
|
||||
foreach ($result->homeworks as $duplicate) {
|
||||
$attachments = $this->attachmentRepository->findByHomeworkId($duplicate->id);
|
||||
self::assertCount(1, $attachments);
|
||||
self::assertSame('exercice.pdf', $attachments[0]->filename);
|
||||
self::assertSame('homework/tenant/source/exercice.pdf', $attachments[0]->filePath);
|
||||
self::assertFalse($attachments[0]->id->equals($attachment->id));
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesIndependentDuplicates(): void
|
||||
{
|
||||
$source = $this->createAndSaveHomework();
|
||||
$handler = $this->createHandler(affecte: true);
|
||||
|
||||
$command = new DuplicateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $source->id,
|
||||
teacherId: self::TEACHER_ID,
|
||||
targetClassIds: [self::TARGET_CLASS_1, self::TARGET_CLASS_2],
|
||||
);
|
||||
|
||||
$result = $handler($command);
|
||||
|
||||
self::assertFalse($result->homeworks[0]->id->equals($source->id));
|
||||
self::assertFalse($result->homeworks[1]->id->equals($source->id));
|
||||
self::assertFalse($result->homeworks[0]->id->equals($result->homeworks[1]->id));
|
||||
}
|
||||
|
||||
private function createAndSaveHomework(): Homework
|
||||
{
|
||||
$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: 'Faire les exercices 1 à 10',
|
||||
dueDate: new DateTimeImmutable('2026-04-15'),
|
||||
now: new DateTimeImmutable('2026-03-10 10:00:00'),
|
||||
);
|
||||
|
||||
$homework->pullDomainEvents();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
return $homework;
|
||||
}
|
||||
|
||||
private function createHandler(bool $affecte): DuplicateHomeworkHandler
|
||||
{
|
||||
$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: [],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('beginTransaction');
|
||||
$connection->method('commit');
|
||||
$connection->method('rollBack');
|
||||
|
||||
return new DuplicateHomeworkHandler(
|
||||
$this->homeworkRepository,
|
||||
$this->attachmentRepository,
|
||||
$affectationChecker,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
new HomeworkDuplicator(),
|
||||
$this->clock,
|
||||
$connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,57 @@ final class UpdateHomeworkHandlerTest extends TestCase
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUpdatesTitleOnly(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = new UpdateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $this->existingHomeworkId,
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
title: 'Nouveau titre',
|
||||
description: null,
|
||||
dueDate: '2026-04-15',
|
||||
);
|
||||
|
||||
$homework = $handler($command);
|
||||
|
||||
self::assertSame('Nouveau titre', $homework->title);
|
||||
self::assertNull($homework->description);
|
||||
self::assertSame('2026-04-15', $homework->dueDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUpdatesDescriptionFromNullToText(): void
|
||||
{
|
||||
$homeworkWithoutDescription = Homework::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||
teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'),
|
||||
title: 'Exercices sans description',
|
||||
description: null,
|
||||
dueDate: new DateTimeImmutable('2026-04-15'),
|
||||
now: new DateTimeImmutable('2026-03-10 10:00:00'),
|
||||
);
|
||||
$this->homeworkRepository->save($homeworkWithoutDescription);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = new UpdateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
homeworkId: (string) $homeworkWithoutDescription->id,
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
title: 'Exercices sans description',
|
||||
description: 'Nouvelle description ajoutée',
|
||||
dueDate: '2026-04-15',
|
||||
);
|
||||
|
||||
$homework = $handler($command);
|
||||
|
||||
self::assertSame('Nouvelle description ajoutée', $homework->description);
|
||||
self::assertSame('Exercices sans description', $homework->title);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenNotOwner(): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\Service\HomeworkDuplicator;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HomeworkDuplicatorTest 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 TARGET_CLASS_1 = '550e8400-e29b-41d4-a716-446655440021';
|
||||
private const string TARGET_CLASS_2 = '550e8400-e29b-41d4-a716-446655440022';
|
||||
|
||||
private HomeworkDuplicator $duplicator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->duplicator = new HomeworkDuplicator();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDuplicatesHomeworkForOneTargetClass(): void
|
||||
{
|
||||
$source = $this->createSourceHomework();
|
||||
$now = new DateTimeImmutable('2026-03-14 10:00:00');
|
||||
$targetClassId = ClassId::fromString(self::TARGET_CLASS_1);
|
||||
$dueDate = new DateTimeImmutable('2026-04-20');
|
||||
|
||||
$duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [$dueDate], $now);
|
||||
|
||||
self::assertCount(1, $duplicates);
|
||||
$duplicate = $duplicates[0];
|
||||
self::assertSame($source->title, $duplicate->title);
|
||||
self::assertSame($source->description, $duplicate->description);
|
||||
self::assertTrue($duplicate->classId->equals($targetClassId));
|
||||
self::assertTrue($duplicate->subjectId->equals($source->subjectId));
|
||||
self::assertTrue($duplicate->teacherId->equals($source->teacherId));
|
||||
self::assertTrue($duplicate->tenantId->equals($source->tenantId));
|
||||
self::assertSame('2026-04-20', $duplicate->dueDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDuplicatesHomeworkForMultipleTargetClasses(): void
|
||||
{
|
||||
$source = $this->createSourceHomework();
|
||||
$now = new DateTimeImmutable('2026-03-14 10:00:00');
|
||||
$targetClasses = [
|
||||
ClassId::fromString(self::TARGET_CLASS_1),
|
||||
ClassId::fromString(self::TARGET_CLASS_2),
|
||||
];
|
||||
$dueDates = [
|
||||
new DateTimeImmutable('2026-04-20'),
|
||||
new DateTimeImmutable('2026-04-21'),
|
||||
];
|
||||
|
||||
$duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now);
|
||||
|
||||
self::assertCount(2, $duplicates);
|
||||
self::assertTrue($duplicates[0]->classId->equals($targetClasses[0]));
|
||||
self::assertTrue($duplicates[1]->classId->equals($targetClasses[1]));
|
||||
self::assertSame('2026-04-20', $duplicates[0]->dueDate->format('Y-m-d'));
|
||||
self::assertSame('2026-04-21', $duplicates[1]->dueDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesIndependentCopiesWithUniqueIds(): void
|
||||
{
|
||||
$source = $this->createSourceHomework();
|
||||
$now = new DateTimeImmutable('2026-03-14 10:00:00');
|
||||
$targetClasses = [
|
||||
ClassId::fromString(self::TARGET_CLASS_1),
|
||||
ClassId::fromString(self::TARGET_CLASS_2),
|
||||
];
|
||||
$dueDates = [
|
||||
new DateTimeImmutable('2026-04-20'),
|
||||
new DateTimeImmutable('2026-04-20'),
|
||||
];
|
||||
|
||||
$duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now);
|
||||
|
||||
self::assertFalse($duplicates[0]->id->equals($source->id));
|
||||
self::assertFalse($duplicates[1]->id->equals($source->id));
|
||||
self::assertFalse($duplicates[0]->id->equals($duplicates[1]->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsDevoirCreeEventForEachDuplicate(): void
|
||||
{
|
||||
$source = $this->createSourceHomework();
|
||||
$now = new DateTimeImmutable('2026-03-14 10:00:00');
|
||||
$targetClasses = [
|
||||
ClassId::fromString(self::TARGET_CLASS_1),
|
||||
ClassId::fromString(self::TARGET_CLASS_2),
|
||||
];
|
||||
$dueDates = [
|
||||
new DateTimeImmutable('2026-04-20'),
|
||||
new DateTimeImmutable('2026-04-20'),
|
||||
];
|
||||
|
||||
$duplicates = $this->duplicator->dupliquer($source, $targetClasses, $dueDates, $now);
|
||||
|
||||
foreach ($duplicates as $duplicate) {
|
||||
$events = $duplicate->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUsesSourceDueDateWhenNoneProvided(): void
|
||||
{
|
||||
$source = $this->createSourceHomework();
|
||||
$now = new DateTimeImmutable('2026-03-14 10:00:00');
|
||||
$targetClassId = ClassId::fromString(self::TARGET_CLASS_1);
|
||||
|
||||
$duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [], $now);
|
||||
|
||||
self::assertSame($source->dueDate->format('Y-m-d'), $duplicates[0]->dueDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPreservesNullDescription(): void
|
||||
{
|
||||
$source = $this->createSourceHomework(description: null);
|
||||
$now = new DateTimeImmutable('2026-03-14 10:00:00');
|
||||
$targetClassId = ClassId::fromString(self::TARGET_CLASS_1);
|
||||
|
||||
$duplicates = $this->duplicator->dupliquer($source, [$targetClassId], [], $now);
|
||||
|
||||
self::assertNull($duplicates[0]->description);
|
||||
}
|
||||
|
||||
private function createSourceHomework(?string $description = 'Faire les exercices 1 à 10'): Homework
|
||||
{
|
||||
return 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: $description,
|
||||
dueDate: new DateTimeImmutable('2026-04-15'),
|
||||
now: new DateTimeImmutable('2026-03-12 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -401,6 +401,202 @@ test.describe('Homework Management (Story 5.1)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC: Duplicate Homework (Story 5.2)
|
||||
// ============================================================================
|
||||
test.describe('Story 5.2: Duplicate Homework', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Ensure a second class exists for duplication
|
||||
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-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
seedTeacherAssignments();
|
||||
clearCache();
|
||||
});
|
||||
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
|
||||
test('duplicate button is visible on homework card', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir dupliquer test');
|
||||
|
||||
await expect(page.getByRole('button', { name: /dupliquer/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens duplicate modal with class selection', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir à dupliquer');
|
||||
|
||||
await page.getByRole('button', { name: /dupliquer/i }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(dialog.getByText(/dupliquer le devoir/i)).toBeVisible();
|
||||
await expect(dialog.getByText('Devoir à dupliquer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows target classes checkboxes excluding source class', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir classes cibles');
|
||||
|
||||
await page.getByRole('button', { name: /dupliquer/i }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show checkboxes for target classes
|
||||
const checkboxes = dialog.locator('input[type="checkbox"]');
|
||||
await expect(checkboxes.first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can duplicate homework to a target class', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir duplication réussie');
|
||||
|
||||
await page.getByRole('button', { name: /dupliquer/i }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select first target class
|
||||
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
|
||||
await firstCheckbox.check();
|
||||
|
||||
// Click duplicate button
|
||||
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Homework should now appear twice (original + duplicate)
|
||||
await expect(page.getByText('Devoir duplication réussie')).toHaveCount(2, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('duplicate button is disabled when no class selected', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir sans sélection');
|
||||
|
||||
await page.getByRole('button', { name: /dupliquer/i }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Duplicate button should be disabled
|
||||
const duplicateBtn = dialog.getByRole('button', { name: /dupliquer/i }).last();
|
||||
await expect(duplicateBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
test('cancel closes the duplicate modal', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir annulation dupli');
|
||||
|
||||
await page.getByRole('button', { name: /dupliquer/i }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await dialog.getByRole('button', { name: /annuler/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('can duplicate homework to multiple classes', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir multi-dupli');
|
||||
|
||||
await page.getByRole('button', { name: /dupliquer/i }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select ALL available checkboxes (at least 2 target classes)
|
||||
const checkboxes = dialog.locator('input[type="checkbox"]');
|
||||
const count = await checkboxes.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await checkboxes.nth(i).check();
|
||||
}
|
||||
|
||||
// Verify button text reflects the count
|
||||
const duplicateButton = dialog.getByRole('button', {
|
||||
name: new RegExp(`dupliquer \\(${count} classes?\\)`, 'i')
|
||||
});
|
||||
await expect(duplicateButton).toBeVisible();
|
||||
await expect(duplicateButton).toBeEnabled();
|
||||
|
||||
// Click duplicate
|
||||
await duplicateButton.click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify the homework title appears count+1 times (original + duplicates)
|
||||
await expect(page.getByText('Devoir multi-dupli')).toHaveCount(count + 1, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('can customize due date per target class', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir date custom');
|
||||
|
||||
await page.getByRole('button', { name: /dupliquer/i }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select the first checkbox
|
||||
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
|
||||
await firstCheckbox.check();
|
||||
|
||||
// A date input should appear after checking the checkbox
|
||||
const dueDateInput = dialog.locator('input[type="date"]').first();
|
||||
await expect(dueDateInput).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Change the due date to a custom value
|
||||
await dueDateInput.fill(getNextWeekday(10));
|
||||
|
||||
// Click duplicate
|
||||
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify the homework appears twice (original + 1 duplicate)
|
||||
await expect(page.getByText('Devoir date custom')).toHaveCount(2, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('can filter homework list by class', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await createHomework(page, 'Devoir filtre classe');
|
||||
|
||||
// Class filter should be visible
|
||||
const classFilter = page.locator('select[aria-label="Filtrer par classe"]');
|
||||
await expect(classFilter).toBeVisible();
|
||||
|
||||
// Select a class to filter
|
||||
await classFilter.selectOption({ index: 1 });
|
||||
|
||||
// Should still show homework (it matches the filter)
|
||||
await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Select "Toutes les classes" to reset
|
||||
await classFilter.selectOption({ index: 0 });
|
||||
await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Partial Update
|
||||
// ============================================================================
|
||||
|
||||
@@ -77,6 +77,19 @@
|
||||
let homeworkToDelete = $state<Homework | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// Duplicate modal
|
||||
let showDuplicateModal = $state(false);
|
||||
let homeworkToDuplicate = $state<Homework | null>(null);
|
||||
let selectedTargetClassIds = $state<string[]>([]);
|
||||
let dueDatesByClass = $state<Record<string, string>>({});
|
||||
let isDuplicating = $state(false);
|
||||
let duplicateError = $state<string | null>(null);
|
||||
let duplicateValidationResults = $state<Array<{ classId: string; valid: boolean; error: string | null }>>([]);
|
||||
let duplicateWarnings = $state<Array<{ classId: string; warning: string }>>([]);
|
||||
|
||||
// Class filter
|
||||
let filterClassId = $state(page.url.searchParams.get('classId') ?? '');
|
||||
|
||||
// Derived: available subjects for selected class
|
||||
let availableSubjectsForCreate = $derived.by(() => {
|
||||
if (!newClassId) return [];
|
||||
@@ -162,6 +175,7 @@
|
||||
params.set('page', String(currentPage));
|
||||
params.set('itemsPerPage', String(itemsPerPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
if (filterClassId) params.set('classId', filterClassId);
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
@@ -186,6 +200,7 @@
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 1) params.set('page', String(currentPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
if (filterClassId) params.set('classId', filterClassId);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
@@ -237,6 +252,26 @@
|
||||
return classes.filter((c) => assignedClassIds.includes(c.id));
|
||||
});
|
||||
|
||||
// Available target classes for duplication (same subject, exclude source class)
|
||||
let availableTargetClasses = $derived.by(() => {
|
||||
if (!homeworkToDuplicate) return [];
|
||||
const sourceSubjectId = homeworkToDuplicate.subjectId;
|
||||
const sourceClassId = homeworkToDuplicate.classId;
|
||||
const assignedClassIds = assignments
|
||||
.filter((a) => a.status === 'active' && a.subjectId === sourceSubjectId)
|
||||
.map((a) => a.classId);
|
||||
return classes.filter((c) => assignedClassIds.includes(c.id) && c.id !== sourceClassId);
|
||||
});
|
||||
|
||||
function handleClassFilter(newClassId: string) {
|
||||
filterClassId = newClassId;
|
||||
currentPage = 1;
|
||||
updateUrl();
|
||||
loadHomeworks().catch((e) => {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
});
|
||||
}
|
||||
|
||||
// --- Create ---
|
||||
function openCreateModal() {
|
||||
showCreateModal = true;
|
||||
@@ -339,6 +374,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Duplicate ---
|
||||
function openDuplicateModal(hw: Homework) {
|
||||
homeworkToDuplicate = hw;
|
||||
selectedTargetClassIds = [];
|
||||
dueDatesByClass = {};
|
||||
duplicateError = null;
|
||||
duplicateValidationResults = [];
|
||||
duplicateWarnings = [];
|
||||
showDuplicateModal = true;
|
||||
}
|
||||
|
||||
function closeDuplicateModal() {
|
||||
showDuplicateModal = false;
|
||||
homeworkToDuplicate = null;
|
||||
}
|
||||
|
||||
function toggleTargetClass(classId: string) {
|
||||
if (selectedTargetClassIds.includes(classId)) {
|
||||
selectedTargetClassIds = selectedTargetClassIds.filter((id) => id !== classId);
|
||||
const { [classId]: _, ...rest } = dueDatesByClass;
|
||||
dueDatesByClass = rest;
|
||||
} else {
|
||||
selectedTargetClassIds = [...selectedTargetClassIds, classId];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
if (!homeworkToDuplicate || selectedTargetClassIds.length === 0) return;
|
||||
|
||||
try {
|
||||
isDuplicating = true;
|
||||
duplicateError = null;
|
||||
duplicateValidationResults = [];
|
||||
const apiUrl = getApiBaseUrl();
|
||||
|
||||
const dueDates: Record<string, string> = {};
|
||||
for (const classId of selectedTargetClassIds) {
|
||||
if (dueDatesByClass[classId]) {
|
||||
dueDates[classId] = dueDatesByClass[classId];
|
||||
}
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkToDuplicate.id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
targetClassIds: selectedTargetClassIds,
|
||||
dueDates: Object.keys(dueDates).length > 0 ? dueDates : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
if (errorData?.results) {
|
||||
duplicateValidationResults = errorData.results;
|
||||
duplicateError = 'Certaines classes ne passent pas la validation.';
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
errorData?.['hydra:description'] ??
|
||||
errorData?.error ??
|
||||
errorData?.message ??
|
||||
`Erreur lors de la duplication (${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
if (responseData?.warnings?.length > 0) {
|
||||
duplicateWarnings = responseData.warnings;
|
||||
}
|
||||
|
||||
closeDuplicateModal();
|
||||
await loadHomeworks();
|
||||
} catch (e) {
|
||||
duplicateError = e instanceof Error ? e.message : 'Erreur lors de la duplication';
|
||||
} finally {
|
||||
isDuplicating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
function openDeleteModal(hw: Homework) {
|
||||
homeworkToDelete = hw;
|
||||
@@ -405,7 +520,36 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if duplicateWarnings.length > 0}
|
||||
<div class="alert alert-warning">
|
||||
<span class="alert-icon">⚠</span>
|
||||
<div>
|
||||
<strong>Duplication effectuée avec avertissements :</strong>
|
||||
<ul class="warning-list">
|
||||
{#each duplicateWarnings as w}
|
||||
<li>{getClassName(w.classId)} : {w.warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<button class="alert-close" onclick={() => (duplicateWarnings = [])}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="filters-row">
|
||||
<SearchInput value={searchTerm} onSearch={handleSearch} placeholder="Rechercher par titre..." />
|
||||
<div class="class-filter">
|
||||
<select
|
||||
value={filterClassId}
|
||||
aria-label="Filtrer par classe"
|
||||
onchange={(e) => handleClassFilter((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Toutes les classes</option>
|
||||
{#each availableClasses as cls (cls.id)}
|
||||
<option value={cls.id}>{cls.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
@@ -462,6 +606,9 @@
|
||||
<button class="btn-secondary btn-sm" onclick={() => openEditModal(hw)}>
|
||||
Modifier
|
||||
</button>
|
||||
<button class="btn-secondary btn-sm" onclick={() => openDuplicateModal(hw)}>
|
||||
Dupliquer
|
||||
</button>
|
||||
<button class="btn-danger btn-sm" onclick={() => openDeleteModal(hw)}>
|
||||
Supprimer
|
||||
</button>
|
||||
@@ -699,6 +846,103 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Duplicate Modal -->
|
||||
{#if showDuplicateModal && homeworkToDuplicate}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onclick={closeDuplicateModal} role="presentation">
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="duplicate-modal-title"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') closeDuplicateModal(); }}
|
||||
>
|
||||
<header class="modal-header">
|
||||
<h2 id="duplicate-modal-title">Dupliquer le devoir</h2>
|
||||
<button class="modal-close" onclick={closeDuplicateModal} aria-label="Fermer">×</button>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-info">
|
||||
<span class="info-label">Devoir :</span>
|
||||
<span>{homeworkToDuplicate.title}</span>
|
||||
<span class="info-label">Classe source :</span>
|
||||
<span>{homeworkToDuplicate.className ?? getClassName(homeworkToDuplicate.classId)}</span>
|
||||
<span class="info-label">Matière :</span>
|
||||
<span>{homeworkToDuplicate.subjectName ?? getSubjectName(homeworkToDuplicate.subjectId)}</span>
|
||||
</div>
|
||||
|
||||
{#if duplicateError}
|
||||
<div class="alert alert-error" style="margin-bottom: 1rem;">
|
||||
<span class="alert-icon">⚠</span>
|
||||
{duplicateError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if availableTargetClasses.length === 0}
|
||||
<p class="empty-target-classes">Aucune autre classe disponible pour cette matière.</p>
|
||||
{:else}
|
||||
<div class="form-group">
|
||||
<label>Classes cibles *</label>
|
||||
<div class="checkbox-list">
|
||||
{#each availableTargetClasses as cls (cls.id)}
|
||||
{@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)}
|
||||
<div class="checkbox-item" class:validation-error={validationResult && !validationResult.valid}>
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTargetClassIds.includes(cls.id)}
|
||||
onchange={() => toggleTargetClass(cls.id)}
|
||||
/>
|
||||
{cls.name}
|
||||
</label>
|
||||
{#if selectedTargetClassIds.includes(cls.id)}
|
||||
<div class="due-date-inline">
|
||||
<label for="due-{cls.id}">Date :</label>
|
||||
<input
|
||||
type="date"
|
||||
id="due-{cls.id}"
|
||||
value={dueDatesByClass[cls.id] ?? homeworkToDuplicate.dueDate}
|
||||
min={minDueDate}
|
||||
onchange={(e) => {
|
||||
dueDatesByClass = { ...dueDatesByClass, [cls.id]: (e.target as HTMLInputElement).value };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if validationResult && !validationResult.valid}
|
||||
<small class="form-hint form-hint-warning">{validationResult.error}</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={closeDuplicateModal} disabled={isDuplicating}>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick={handleDuplicate}
|
||||
disabled={isDuplicating || selectedTargetClassIds.length === 0}
|
||||
>
|
||||
{#if isDuplicating}
|
||||
Duplication...
|
||||
{:else}
|
||||
Dupliquer ({selectedTargetClassIds.length} classe{selectedTargetClassIds.length > 1 ? 's' : ''})
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.homework-page {
|
||||
padding: 1.5rem;
|
||||
@@ -826,6 +1070,18 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.warning-list {
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Loading & Empty states */
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
@@ -1125,4 +1381,91 @@
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Filters row */
|
||||
.filters-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters-row > :first-child {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.class-filter select {
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.class-filter select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Duplicate modal */
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.checkbox-item.validation-error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkbox-label input[type='checkbox'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.due-date-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.due-date-inline label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.due-date-inline input[type='date'] {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-target-classes {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user