feat: Permettre aux enseignants de contourner les règles de devoirs avec justification
Akeneo permet de configurer des règles de devoirs en mode Hard qui bloquent totalement la création. Or certains cas légitimes (sorties scolaires, événements exceptionnels) nécessitent de passer outre ces règles. Sans mécanisme d'exception, l'enseignant est bloqué et doit contacter manuellement la direction. Cette implémentation ajoute un flux complet d'exception : l'enseignant justifie sa demande (min 20 caractères), le devoir est créé immédiatement, et la direction est notifiée par email. Le handler vérifie côté serveur que les règles sont réellement bloquantes avant d'accepter l'exception, empêchant toute fabrication de fausses exceptions via l'API. La direction dispose d'un rapport filtrable par période, enseignant et type de règle.
This commit is contained in:
@@ -210,6 +210,9 @@ services:
|
|||||||
App\Scolarite\Domain\Repository\HomeworkRepository:
|
App\Scolarite\Domain\Repository\HomeworkRepository:
|
||||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRepository
|
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRepository
|
||||||
|
|
||||||
|
App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository:
|
||||||
|
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRuleExceptionRepository
|
||||||
|
|
||||||
App\Scolarite\Domain\Repository\HomeworkAttachmentRepository:
|
App\Scolarite\Domain\Repository\HomeworkAttachmentRepository:
|
||||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository
|
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineHomeworkAttachmentRepository
|
||||||
|
|
||||||
|
|||||||
39
backend/migrations/Version20260319142344.php
Normal file
39
backend/migrations/Version20260319142344.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260319142344 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create homework_rule_exceptions table for story 5.6';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE homework_rule_exceptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
homework_id UUID NOT NULL REFERENCES homework(id) ON DELETE CASCADE,
|
||||||
|
rule_type VARCHAR(100) NOT NULL,
|
||||||
|
justification TEXT NOT NULL,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)');
|
||||||
|
|
||||||
|
$this->addSql('CREATE INDEX idx_hw_exceptions_tenant ON homework_rule_exceptions(tenant_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_hw_exceptions_homework ON homework_rule_exceptions(homework_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_hw_exceptions_created_at ON homework_rule_exceptions(created_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_hw_exceptions_created_by ON homework_rule_exceptions(created_by)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS homework_rule_exceptions');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Command\CreateHomeworkWithException;
|
||||||
|
|
||||||
|
final readonly class CreateHomeworkWithExceptionCommand
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $ruleTypes Types de règles contournées (ex: ['minimum_delay'])
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $tenantId,
|
||||||
|
public string $classId,
|
||||||
|
public string $subjectId,
|
||||||
|
public string $teacherId,
|
||||||
|
public string $title,
|
||||||
|
public ?string $description,
|
||||||
|
public string $dueDate,
|
||||||
|
public string $justification,
|
||||||
|
public array $ruleTypes,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Command\CreateHomeworkWithException;
|
||||||
|
|
||||||
|
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\Port\CurrentCalendarProvider;
|
||||||
|
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||||
|
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||||
|
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||||
|
use App\Scolarite\Domain\Exception\ExceptionNonNecessaireException;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository;
|
||||||
|
use App\Scolarite\Domain\Service\DueDateValidator;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class CreateHomeworkWithExceptionHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HomeworkRepository $homeworkRepository,
|
||||||
|
private HomeworkRuleExceptionRepository $exceptionRepository,
|
||||||
|
private EnseignantAffectationChecker $affectationChecker,
|
||||||
|
private CurrentCalendarProvider $calendarProvider,
|
||||||
|
private DueDateValidator $dueDateValidator,
|
||||||
|
private HomeworkRulesChecker $rulesChecker,
|
||||||
|
private Clock $clock,
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{homework: Homework, exception: HomeworkRuleException}
|
||||||
|
*/
|
||||||
|
public function __invoke(CreateHomeworkWithExceptionCommand $command): array
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString($command->tenantId);
|
||||||
|
$classId = ClassId::fromString($command->classId);
|
||||||
|
$subjectId = SubjectId::fromString($command->subjectId);
|
||||||
|
$teacherId = UserId::fromString($command->teacherId);
|
||||||
|
$now = $this->clock->now();
|
||||||
|
|
||||||
|
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
|
||||||
|
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$calendar = $this->calendarProvider->forCurrentYear($tenantId);
|
||||||
|
$dueDate = new DateTimeImmutable($command->dueDate);
|
||||||
|
$this->dueDateValidator->valider($dueDate, $now, $calendar);
|
||||||
|
|
||||||
|
$rulesResult = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
|
||||||
|
|
||||||
|
if (!$rulesResult->estBloquant()) {
|
||||||
|
throw ExceptionNonNecessaireException::carReglesNonBloquantes();
|
||||||
|
}
|
||||||
|
|
||||||
|
$homework = Homework::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
classId: $classId,
|
||||||
|
subjectId: $subjectId,
|
||||||
|
teacherId: $teacherId,
|
||||||
|
title: $command->title,
|
||||||
|
description: $command->description,
|
||||||
|
dueDate: $dueDate,
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
$exception = HomeworkRuleException::demander(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
homeworkId: $homework->id,
|
||||||
|
ruleTypes: $rulesResult->ruleTypes(),
|
||||||
|
justification: $command->justification,
|
||||||
|
createdBy: $teacherId,
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->homeworkRepository->save($homework);
|
||||||
|
$this->exceptionRepository->save($exception);
|
||||||
|
$this->connection->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->connection->rollBack();
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['homework' => $homework, 'exception' => $exception];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Query\GetHomeworkExceptionsReport;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
use function str_contains;
|
||||||
|
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
|
final readonly class GetHomeworkExceptionsReportHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HomeworkRuleExceptionRepository $exceptionRepository,
|
||||||
|
private HomeworkRepository $homeworkRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<HomeworkExceptionDto> */
|
||||||
|
public function __invoke(GetHomeworkExceptionsReportQuery $query): array
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString($query->tenantId);
|
||||||
|
$exceptions = $this->exceptionRepository->findByTenant($tenantId);
|
||||||
|
|
||||||
|
$startDate = $query->startDate !== null ? new DateTimeImmutable($query->startDate) : null;
|
||||||
|
$endDate = $query->endDate !== null ? new DateTimeImmutable($query->endDate . ' 23:59:59') : null;
|
||||||
|
|
||||||
|
$filtered = array_filter(
|
||||||
|
$exceptions,
|
||||||
|
fn (HomeworkRuleException $e): bool => $this->matchesFilters(
|
||||||
|
$e,
|
||||||
|
$startDate,
|
||||||
|
$endDate,
|
||||||
|
$query->teacherId,
|
||||||
|
$query->ruleType,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($filtered === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$homeworkCache = $this->preloadHomeworks($filtered, $tenantId);
|
||||||
|
$teacherCache = $this->preloadTeachers($filtered);
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($filtered as $exception) {
|
||||||
|
$hwId = (string) $exception->homeworkId;
|
||||||
|
$teacherId = (string) $exception->createdBy;
|
||||||
|
|
||||||
|
$homework = $homeworkCache[$hwId] ?? null;
|
||||||
|
$teacher = $teacherCache[$teacherId] ?? null;
|
||||||
|
|
||||||
|
$results[] = new HomeworkExceptionDto(
|
||||||
|
id: (string) $exception->id,
|
||||||
|
homeworkId: $hwId,
|
||||||
|
homeworkTitle: $homework !== null ? $homework->title : '(supprimé)',
|
||||||
|
ruleType: $exception->ruleType,
|
||||||
|
justification: $exception->justification,
|
||||||
|
teacherId: $teacherId,
|
||||||
|
teacherName: $teacher !== null ? $teacher->firstName . ' ' . $teacher->lastName : 'Inconnu',
|
||||||
|
createdAt: $exception->createdAt->format(DateTimeImmutable::ATOM),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<HomeworkRuleException> $exceptions
|
||||||
|
*
|
||||||
|
* @return array<string, Homework|null>
|
||||||
|
*/
|
||||||
|
private function preloadHomeworks(array $exceptions, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$cache = [];
|
||||||
|
|
||||||
|
foreach ($exceptions as $exception) {
|
||||||
|
$hwId = (string) $exception->homeworkId;
|
||||||
|
|
||||||
|
if (!isset($cache[$hwId])) {
|
||||||
|
$cache[$hwId] = $this->homeworkRepository->findById($exception->homeworkId, $tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<HomeworkRuleException> $exceptions
|
||||||
|
*
|
||||||
|
* @return array<string, User|null>
|
||||||
|
*/
|
||||||
|
private function preloadTeachers(array $exceptions): array
|
||||||
|
{
|
||||||
|
$cache = [];
|
||||||
|
|
||||||
|
foreach ($exceptions as $exception) {
|
||||||
|
$teacherId = (string) $exception->createdBy;
|
||||||
|
|
||||||
|
if (!isset($cache[$teacherId])) {
|
||||||
|
$cache[$teacherId] = $this->userRepository->findById(UserId::fromString($teacherId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesFilters(
|
||||||
|
HomeworkRuleException $exception,
|
||||||
|
?DateTimeImmutable $startDate,
|
||||||
|
?DateTimeImmutable $endDate,
|
||||||
|
?string $teacherId,
|
||||||
|
?string $ruleType,
|
||||||
|
): bool {
|
||||||
|
if ($startDate !== null && $exception->createdAt < $startDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($endDate !== null && $exception->createdAt > $endDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($teacherId !== null && (string) $exception->createdBy !== $teacherId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ruleType !== null && !str_contains($exception->ruleType, $ruleType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Query\GetHomeworkExceptionsReport;
|
||||||
|
|
||||||
|
final readonly class GetHomeworkExceptionsReportQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $tenantId,
|
||||||
|
public ?string $startDate = null,
|
||||||
|
public ?string $endDate = null,
|
||||||
|
public ?string $teacherId = null,
|
||||||
|
public ?string $ruleType = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Application\Query\GetHomeworkExceptionsReport;
|
||||||
|
|
||||||
|
final readonly class HomeworkExceptionDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $homeworkId,
|
||||||
|
public string $homeworkTitle,
|
||||||
|
public string $ruleType,
|
||||||
|
public string $justification,
|
||||||
|
public string $teacherId,
|
||||||
|
public string $teacherName,
|
||||||
|
public string $createdAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?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\Homework\HomeworkRuleExceptionId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class ExceptionDevoirDemandee implements DomainEvent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string[] $ruleTypes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public HomeworkRuleExceptionId $exceptionId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public HomeworkId $homeworkId,
|
||||||
|
public array $ruleTypes,
|
||||||
|
public string $justification,
|
||||||
|
public UserId $createdBy,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->exceptionId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Domain\Exception;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
final class ExceptionNonNecessaireException extends DomainException
|
||||||
|
{
|
||||||
|
public static function carReglesNonBloquantes(): self
|
||||||
|
{
|
||||||
|
return new self('Impossible de demander une exception : les règles ne bloquent pas ce devoir.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Domain\Exception;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
final class JustificationTropCourteException extends DomainException
|
||||||
|
{
|
||||||
|
public static function avecMinimum(int $minimum): self
|
||||||
|
{
|
||||||
|
return new self("La justification doit contenir au moins {$minimum} caractères.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Domain\Model\Homework;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Scolarite\Domain\Event\ExceptionDevoirDemandee;
|
||||||
|
use App\Scolarite\Domain\Exception\JustificationTropCourteException;
|
||||||
|
use App\Shared\Domain\AggregateRoot;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
use function explode;
|
||||||
|
use function implode;
|
||||||
|
use function mb_strlen;
|
||||||
|
|
||||||
|
final class HomeworkRuleException extends AggregateRoot
|
||||||
|
{
|
||||||
|
private const int JUSTIFICATION_MIN_LENGTH = 20;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public private(set) HomeworkRuleExceptionId $id,
|
||||||
|
public private(set) TenantId $tenantId,
|
||||||
|
public private(set) HomeworkId $homeworkId,
|
||||||
|
public private(set) string $ruleType,
|
||||||
|
public private(set) string $justification,
|
||||||
|
public private(set) UserId $createdBy,
|
||||||
|
public private(set) DateTimeImmutable $createdAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $ruleTypes Types de règles contournées
|
||||||
|
*/
|
||||||
|
public static function demander(
|
||||||
|
TenantId $tenantId,
|
||||||
|
HomeworkId $homeworkId,
|
||||||
|
array $ruleTypes,
|
||||||
|
string $justification,
|
||||||
|
UserId $createdBy,
|
||||||
|
DateTimeImmutable $now,
|
||||||
|
): self {
|
||||||
|
if (mb_strlen($justification) < self::JUSTIFICATION_MIN_LENGTH) {
|
||||||
|
throw JustificationTropCourteException::avecMinimum(self::JUSTIFICATION_MIN_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ruleType = implode(',', $ruleTypes);
|
||||||
|
|
||||||
|
$exception = new self(
|
||||||
|
id: HomeworkRuleExceptionId::generate(),
|
||||||
|
tenantId: $tenantId,
|
||||||
|
homeworkId: $homeworkId,
|
||||||
|
ruleType: $ruleType,
|
||||||
|
justification: $justification,
|
||||||
|
createdBy: $createdBy,
|
||||||
|
createdAt: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
$exception->recordEvent(new ExceptionDevoirDemandee(
|
||||||
|
exceptionId: $exception->id,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
homeworkId: $homeworkId,
|
||||||
|
ruleTypes: $ruleTypes,
|
||||||
|
justification: $justification,
|
||||||
|
createdBy: $createdBy,
|
||||||
|
occurredOn: $now,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
public function ruleTypes(): array
|
||||||
|
{
|
||||||
|
return explode(',', $this->ruleType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal Pour usage Infrastructure uniquement
|
||||||
|
*/
|
||||||
|
public static function reconstitute(
|
||||||
|
HomeworkRuleExceptionId $id,
|
||||||
|
TenantId $tenantId,
|
||||||
|
HomeworkId $homeworkId,
|
||||||
|
string $ruleType,
|
||||||
|
string $justification,
|
||||||
|
UserId $createdBy,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
id: $id,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
homeworkId: $homeworkId,
|
||||||
|
ruleType: $ruleType,
|
||||||
|
justification: $justification,
|
||||||
|
createdBy: $createdBy,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Domain\Model\Homework;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
final readonly class HomeworkRuleExceptionId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
interface HomeworkRuleExceptionRepository
|
||||||
|
{
|
||||||
|
public function save(HomeworkRuleException $exception): void;
|
||||||
|
|
||||||
|
/** @return array<HomeworkRuleException> */
|
||||||
|
public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array;
|
||||||
|
|
||||||
|
/** @return array<HomeworkRuleException> */
|
||||||
|
public function findByTenant(TenantId $tenantId): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the set of homework IDs that have at least one exception.
|
||||||
|
*
|
||||||
|
* @return array<string> Homework IDs as strings
|
||||||
|
*/
|
||||||
|
public function homeworkIdsWithExceptions(TenantId $tenantId): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Application\Command\CreateHomeworkWithException\CreateHomeworkWithExceptionCommand;
|
||||||
|
use App\Scolarite\Application\Command\CreateHomeworkWithException\CreateHomeworkWithExceptionHandler;
|
||||||
|
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||||
|
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||||
|
use App\Scolarite\Domain\Exception\ExceptionNonNecessaireException;
|
||||||
|
use App\Scolarite\Domain\Exception\JustificationTropCourteException;
|
||||||
|
use App\Scolarite\Infrastructure\Api\Resource\HomeworkExceptionResource;
|
||||||
|
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<HomeworkExceptionResource, HomeworkResource>
|
||||||
|
*/
|
||||||
|
final readonly class CreateHomeworkWithExceptionProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CreateHomeworkWithExceptionHandler $handler,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private Security $security,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param HomeworkExceptionResource $data
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource
|
||||||
|
{
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = new CreateHomeworkWithExceptionCommand(
|
||||||
|
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||||
|
classId: $data->classId ?? '',
|
||||||
|
subjectId: $data->subjectId ?? '',
|
||||||
|
teacherId: $user->userId(),
|
||||||
|
title: $data->title ?? '',
|
||||||
|
description: $data->description,
|
||||||
|
dueDate: $data->dueDate ?? '',
|
||||||
|
justification: $data->justification ?? '',
|
||||||
|
ruleTypes: $data->ruleTypes ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = ($this->handler)($command);
|
||||||
|
|
||||||
|
$homework = $result['homework'];
|
||||||
|
$exception = $result['exception'];
|
||||||
|
|
||||||
|
foreach ($homework->pullDomainEvents() as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($exception->pullDomainEvents() as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HomeworkResource::fromDomain(
|
||||||
|
$homework,
|
||||||
|
ruleException: $exception,
|
||||||
|
);
|
||||||
|
} catch (ExceptionNonNecessaireException|JustificationTropCourteException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
} catch (EnseignantNonAffecteException|DateEcheanceInvalideException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
} catch (InvalidUuidStringException $e) {
|
||||||
|
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Administration\Domain\Repository\SubjectRepository;
|
|||||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository;
|
||||||
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
|
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
|
||||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ final readonly class HomeworkCollectionProvider implements ProviderInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private HomeworkRepository $homeworkRepository,
|
private HomeworkRepository $homeworkRepository,
|
||||||
|
private HomeworkRuleExceptionRepository $exceptionRepository,
|
||||||
private TenantContext $tenantContext,
|
private TenantContext $tenantContext,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private ClassRepository $classRepository,
|
private ClassRepository $classRepository,
|
||||||
@@ -77,10 +79,22 @@ final readonly class HomeworkCollectionProvider implements ProviderInterface
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$allExceptions = $this->exceptionRepository->findByTenant($tenantId);
|
||||||
|
$exceptionsByHomework = [];
|
||||||
|
|
||||||
|
foreach ($allExceptions as $exception) {
|
||||||
|
$hwId = (string) $exception->homeworkId;
|
||||||
|
|
||||||
|
if (!isset($exceptionsByHomework[$hwId])) {
|
||||||
|
$exceptionsByHomework[$hwId] = $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return array_map(fn (Homework $homework) => HomeworkResource::fromDomain(
|
return array_map(fn (Homework $homework) => HomeworkResource::fromDomain(
|
||||||
$homework,
|
$homework,
|
||||||
$this->resolveClassName($homework),
|
$this->resolveClassName($homework),
|
||||||
$this->resolveSubjectName($homework),
|
$this->resolveSubjectName($homework),
|
||||||
|
$exceptionsByHomework[(string) $homework->id] ?? null,
|
||||||
), $homeworks);
|
), $homeworks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Application\Query\GetHomeworkExceptionsReport\GetHomeworkExceptionsReportHandler;
|
||||||
|
use App\Scolarite\Application\Query\GetHomeworkExceptionsReport\GetHomeworkExceptionsReportQuery;
|
||||||
|
use App\Scolarite\Application\Query\GetHomeworkExceptionsReport\HomeworkExceptionDto;
|
||||||
|
use App\Scolarite\Infrastructure\Api\Resource\HomeworkExceptionResource;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
use function in_array;
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProviderInterface<HomeworkExceptionResource>
|
||||||
|
*/
|
||||||
|
final readonly class HomeworkExceptionsReportProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GetHomeworkExceptionsReportHandler $handler,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private Security $security,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<HomeworkExceptionResource> */
|
||||||
|
#[Override]
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||||
|
{
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array(Role::ADMIN->value, $user->getRoles(), true)
|
||||||
|
&& !in_array(Role::SUPER_ADMIN->value, $user->getRoles(), true)) {
|
||||||
|
throw new AccessDeniedHttpException('Accès réservé à la direction.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
$startDate = $request?->query->get('startDate');
|
||||||
|
$endDate = $request?->query->get('endDate');
|
||||||
|
$teacherId = $request?->query->get('teacherId');
|
||||||
|
$ruleType = $request?->query->get('ruleType');
|
||||||
|
|
||||||
|
$query = new GetHomeworkExceptionsReportQuery(
|
||||||
|
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
|
||||||
|
startDate: is_string($startDate) ? $startDate : null,
|
||||||
|
endDate: is_string($endDate) ? $endDate : null,
|
||||||
|
teacherId: is_string($teacherId) ? $teacherId : null,
|
||||||
|
ruleType: is_string($ruleType) ? $ruleType : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$dtos = ($this->handler)($query);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static function (HomeworkExceptionDto $dto): HomeworkExceptionResource {
|
||||||
|
$resource = new HomeworkExceptionResource();
|
||||||
|
$resource->id = $dto->id;
|
||||||
|
$resource->homeworkId = $dto->homeworkId;
|
||||||
|
$resource->homeworkTitle = $dto->homeworkTitle;
|
||||||
|
$resource->ruleType = $dto->ruleType;
|
||||||
|
$resource->justification = $dto->justification;
|
||||||
|
$resource->teacherId = $dto->teacherId;
|
||||||
|
$resource->teacherName = $dto->teacherName;
|
||||||
|
$resource->createdAt = $dto->createdAt;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
},
|
||||||
|
$dtos,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Administration\Domain\Repository\SubjectRepository;
|
|||||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository;
|
||||||
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
|
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
|
||||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
use Override;
|
use Override;
|
||||||
@@ -26,6 +27,7 @@ final readonly class HomeworkItemProvider implements ProviderInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private HomeworkRepository $homeworkRepository,
|
private HomeworkRepository $homeworkRepository,
|
||||||
|
private HomeworkRuleExceptionRepository $exceptionRepository,
|
||||||
private TenantContext $tenantContext,
|
private TenantContext $tenantContext,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private ClassRepository $classRepository,
|
private ClassRepository $classRepository,
|
||||||
@@ -64,11 +66,14 @@ final readonly class HomeworkItemProvider implements ProviderInterface
|
|||||||
|
|
||||||
$class = $this->classRepository->findById($homework->classId);
|
$class = $this->classRepository->findById($homework->classId);
|
||||||
$subject = $this->subjectRepository->findById($homework->subjectId);
|
$subject = $this->subjectRepository->findById($homework->subjectId);
|
||||||
|
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||||
|
$exceptions = $this->exceptionRepository->findByHomework($homework->id, $tenantId);
|
||||||
|
|
||||||
return HomeworkResource::fromDomain(
|
return HomeworkResource::fromDomain(
|
||||||
$homework,
|
$homework,
|
||||||
$class !== null ? (string) $class->name : null,
|
$class !== null ? (string) $class->name : null,
|
||||||
$subject !== null ? (string) $subject->name : null,
|
$subject !== null ? (string) $subject->name : null,
|
||||||
|
$exceptions[0] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Scolarite\Infrastructure\Api\Processor\CreateHomeworkWithExceptionProcessor;
|
||||||
|
use App\Scolarite\Infrastructure\Api\Provider\HomeworkExceptionsReportProvider;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'HomeworkException',
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/homework/with-exception',
|
||||||
|
processor: CreateHomeworkWithExceptionProcessor::class,
|
||||||
|
validationContext: ['groups' => ['Default', 'create']],
|
||||||
|
name: 'create_homework_with_exception',
|
||||||
|
),
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/admin/homework-exceptions',
|
||||||
|
provider: HomeworkExceptionsReportProvider::class,
|
||||||
|
name: 'get_homework_exceptions_report',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class HomeworkExceptionResource
|
||||||
|
{
|
||||||
|
#[ApiProperty(identifier: true)]
|
||||||
|
public ?string $id = null;
|
||||||
|
|
||||||
|
// --- Input fields for POST (create with exception) ---
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'La classe est requise.', groups: ['create'])]
|
||||||
|
#[Assert\Uuid(message: 'L\'identifiant de la classe doit être un UUID valide.', groups: ['create'])]
|
||||||
|
public ?string $classId = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'La matière est requise.', groups: ['create'])]
|
||||||
|
#[Assert\Uuid(message: 'L\'identifiant de la matière doit être un UUID valide.', groups: ['create'])]
|
||||||
|
public ?string $subjectId = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Le titre est requis.', groups: ['create'])]
|
||||||
|
#[Assert\Length(max: 255, maxMessage: 'Le titre ne peut pas dépasser 255 caractères.')]
|
||||||
|
public ?string $title = null;
|
||||||
|
|
||||||
|
public ?string $description = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'La date d\'échéance est requise.', groups: ['create'])]
|
||||||
|
public ?string $dueDate = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'La justification est requise.', groups: ['create'])]
|
||||||
|
#[Assert\Length(min: 20, minMessage: 'La justification doit contenir au moins 20 caractères.', groups: ['create'])]
|
||||||
|
public ?string $justification = null;
|
||||||
|
|
||||||
|
/** @var string[]|null */
|
||||||
|
public ?array $ruleTypes = null;
|
||||||
|
|
||||||
|
// --- Output fields ---
|
||||||
|
|
||||||
|
public ?string $homeworkId = null;
|
||||||
|
public ?string $homeworkTitle = null;
|
||||||
|
public ?string $ruleType = null;
|
||||||
|
public ?string $teacherId = null;
|
||||||
|
public ?string $teacherName = null;
|
||||||
|
public ?string $createdAt = null;
|
||||||
|
public ?bool $hasRuleException = null;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\GetCollection;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
|
||||||
use App\Scolarite\Infrastructure\Api\Processor\CreateHomeworkProcessor;
|
use App\Scolarite\Infrastructure\Api\Processor\CreateHomeworkProcessor;
|
||||||
use App\Scolarite\Infrastructure\Api\Processor\DeleteHomeworkProcessor;
|
use App\Scolarite\Infrastructure\Api\Processor\DeleteHomeworkProcessor;
|
||||||
use App\Scolarite\Infrastructure\Api\Processor\UpdateHomeworkProcessor;
|
use App\Scolarite\Infrastructure\Api\Processor\UpdateHomeworkProcessor;
|
||||||
@@ -92,10 +93,17 @@ final class HomeworkResource
|
|||||||
|
|
||||||
public ?bool $hasRuleOverride = null;
|
public ?bool $hasRuleOverride = null;
|
||||||
|
|
||||||
|
public ?bool $hasRuleException = null;
|
||||||
|
|
||||||
|
public ?string $ruleExceptionJustification = null;
|
||||||
|
|
||||||
|
public ?string $ruleExceptionRuleType = null;
|
||||||
|
|
||||||
public static function fromDomain(
|
public static function fromDomain(
|
||||||
Homework $homework,
|
Homework $homework,
|
||||||
?string $className = null,
|
?string $className = null,
|
||||||
?string $subjectName = null,
|
?string $subjectName = null,
|
||||||
|
?HomeworkRuleException $ruleException = null,
|
||||||
): self {
|
): self {
|
||||||
$resource = new self();
|
$resource = new self();
|
||||||
$resource->id = (string) $homework->id;
|
$resource->id = (string) $homework->id;
|
||||||
@@ -111,6 +119,9 @@ final class HomeworkResource
|
|||||||
$resource->createdAt = $homework->createdAt;
|
$resource->createdAt = $homework->createdAt;
|
||||||
$resource->updatedAt = $homework->updatedAt;
|
$resource->updatedAt = $homework->updatedAt;
|
||||||
$resource->hasRuleOverride = $homework->ruleOverride !== null;
|
$resource->hasRuleOverride = $homework->ruleOverride !== null;
|
||||||
|
$resource->hasRuleException = $ruleException !== null;
|
||||||
|
$resource->ruleExceptionJustification = $ruleException?->justification;
|
||||||
|
$resource->ruleExceptionRuleType = $ruleException?->ruleType;
|
||||||
|
|
||||||
return $resource;
|
return $resource;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Messaging;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Scolarite\Domain\Event\ExceptionDevoirDemandee;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
use Throwable;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifie la direction lorsqu'un enseignant demande une exception aux règles de devoirs.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'event.bus')]
|
||||||
|
final readonly class OnExceptionDevoirDemandeeHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HomeworkRepository $homeworkRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private MailerInterface $mailer,
|
||||||
|
private Environment $twig,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private string $fromEmail = 'noreply@classeo.fr',
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ExceptionDevoirDemandee $event): void
|
||||||
|
{
|
||||||
|
$homework = $this->homeworkRepository->findById($event->homeworkId, $event->tenantId);
|
||||||
|
|
||||||
|
if ($homework === null) {
|
||||||
|
$this->logger->warning('Exception devoir : homework introuvable', [
|
||||||
|
'homework_id' => (string) $event->homeworkId,
|
||||||
|
'exception_id' => (string) $event->exceptionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$teacher = $this->userRepository->findById(UserId::fromString((string) $event->createdBy));
|
||||||
|
|
||||||
|
$allUsers = $this->userRepository->findAllByTenant($event->tenantId);
|
||||||
|
$directors = array_filter(
|
||||||
|
$allUsers,
|
||||||
|
static fn ($user) => $user->aLeRole(Role::ADMIN),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (count($directors) === 0) {
|
||||||
|
$this->logger->info('Exception devoir demandée — aucun directeur à notifier', [
|
||||||
|
'tenant_id' => (string) $event->tenantId,
|
||||||
|
'homework_id' => (string) $event->homeworkId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$teacherName = $teacher !== null ? $teacher->firstName . ' ' . $teacher->lastName : 'Enseignant inconnu';
|
||||||
|
|
||||||
|
$html = $this->twig->render('emails/homework_exception_notification.html.twig', [
|
||||||
|
'teacherName' => $teacherName,
|
||||||
|
'homeworkTitle' => $homework->title,
|
||||||
|
'ruleTypes' => $event->ruleTypes,
|
||||||
|
'justification' => $event->justification,
|
||||||
|
'dueDate' => $homework->dueDate->format('d/m/Y'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$sent = 0;
|
||||||
|
|
||||||
|
foreach ($directors as $director) {
|
||||||
|
try {
|
||||||
|
$email = (new Email())
|
||||||
|
->from($this->fromEmail)
|
||||||
|
->to((string) $director->email)
|
||||||
|
->subject('Exception aux règles de devoirs — ' . $homework->title)
|
||||||
|
->html($html);
|
||||||
|
|
||||||
|
$this->mailer->send($email);
|
||||||
|
++$sent;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->warning('Échec envoi notification exception devoir', [
|
||||||
|
'director_email' => (string) $director->email,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Notifications exception devoir envoyées', [
|
||||||
|
'tenant_id' => (string) $event->tenantId,
|
||||||
|
'homework_id' => (string) $event->homeworkId,
|
||||||
|
'exception_id' => (string) $event->exceptionId,
|
||||||
|
'emails_sent' => $sent,
|
||||||
|
'directors_total' => count($directors),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleExceptionId;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final readonly class DoctrineHomeworkRuleExceptionRepository implements HomeworkRuleExceptionRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(HomeworkRuleException $exception): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'INSERT INTO homework_rule_exceptions (id, tenant_id, homework_id, rule_type, justification, created_by, created_at)
|
||||||
|
VALUES (:id, :tenant_id, :homework_id, :rule_type, :justification, :created_by, :created_at)
|
||||||
|
ON CONFLICT (id) DO NOTHING',
|
||||||
|
[
|
||||||
|
'id' => (string) $exception->id,
|
||||||
|
'tenant_id' => (string) $exception->tenantId,
|
||||||
|
'homework_id' => (string) $exception->homeworkId,
|
||||||
|
'rule_type' => $exception->ruleType,
|
||||||
|
'justification' => $exception->justification,
|
||||||
|
'created_by' => (string) $exception->createdBy,
|
||||||
|
'created_at' => $exception->createdAt->format(DateTimeImmutable::ATOM),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
'SELECT * FROM homework_rule_exceptions
|
||||||
|
WHERE homework_id = :homework_id AND tenant_id = :tenant_id
|
||||||
|
ORDER BY created_at DESC',
|
||||||
|
[
|
||||||
|
'homework_id' => (string) $homeworkId,
|
||||||
|
'tenant_id' => (string) $tenantId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map($this->hydrate(...), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByTenant(TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
'SELECT * FROM homework_rule_exceptions
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
ORDER BY created_at DESC',
|
||||||
|
['tenant_id' => (string) $tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map($this->hydrate(...), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function homeworkIdsWithExceptions(TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
'SELECT DISTINCT homework_id FROM homework_rule_exceptions WHERE tenant_id = :tenant_id',
|
||||||
|
['tenant_id' => (string) $tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
/** @param array<string, mixed> $row */
|
||||||
|
static function (array $row): string {
|
||||||
|
/** @var string $homeworkId */
|
||||||
|
$homeworkId = $row['homework_id'];
|
||||||
|
|
||||||
|
return $homeworkId;
|
||||||
|
},
|
||||||
|
$rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $row */
|
||||||
|
private function hydrate(array $row): HomeworkRuleException
|
||||||
|
{
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $tenantId */
|
||||||
|
$tenantId = $row['tenant_id'];
|
||||||
|
/** @var string $homeworkId */
|
||||||
|
$homeworkId = $row['homework_id'];
|
||||||
|
/** @var string $ruleType */
|
||||||
|
$ruleType = $row['rule_type'];
|
||||||
|
/** @var string $justification */
|
||||||
|
$justification = $row['justification'];
|
||||||
|
/** @var string $createdBy */
|
||||||
|
$createdBy = $row['created_by'];
|
||||||
|
/** @var string $createdAt */
|
||||||
|
$createdAt = $row['created_at'];
|
||||||
|
|
||||||
|
return HomeworkRuleException::reconstitute(
|
||||||
|
id: HomeworkRuleExceptionId::fromString($id),
|
||||||
|
tenantId: TenantId::fromString($tenantId),
|
||||||
|
homeworkId: HomeworkId::fromString($homeworkId),
|
||||||
|
ruleType: $ruleType,
|
||||||
|
justification: $justification,
|
||||||
|
createdBy: UserId::fromString($createdBy),
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
|
use function array_map;
|
||||||
|
use function array_unique;
|
||||||
|
use function array_values;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final class InMemoryHomeworkRuleExceptionRepository implements HomeworkRuleExceptionRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, HomeworkRuleException> */
|
||||||
|
private array $byId = [];
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(HomeworkRuleException $exception): void
|
||||||
|
{
|
||||||
|
$this->byId[(string) $exception->id] = $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByHomework(HomeworkId $homeworkId, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->byId,
|
||||||
|
static fn (HomeworkRuleException $e): bool => $e->homeworkId->equals($homeworkId)
|
||||||
|
&& $e->tenantId->equals($tenantId),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByTenant(TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->byId,
|
||||||
|
static fn (HomeworkRuleException $e): bool => $e->tenantId->equals($tenantId),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function homeworkIdsWithExceptions(TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
$tenantExceptions = $this->findByTenant($tenantId);
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map(
|
||||||
|
static fn (HomeworkRuleException $e): string => (string) $e->homeworkId,
|
||||||
|
$tenantExceptions,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Exception aux règles de devoirs - Classeo</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 2px solid #4f46e5;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
color: #4f46e5;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 30px 0;
|
||||||
|
}
|
||||||
|
.warning-icon {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.warning-icon span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background-color: #f59e0b;
|
||||||
|
border-radius: 50%;
|
||||||
|
line-height: 60px;
|
||||||
|
color: white;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.info-box p {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
.justification-box {
|
||||||
|
background-color: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.justification-box p.label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #92400e;
|
||||||
|
margin: 0 0 5px;
|
||||||
|
}
|
||||||
|
.justification-box p.text {
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Classeo</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="warning-icon">
|
||||||
|
<span>!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="text-align: center;">Exception aux règles de devoirs</h2>
|
||||||
|
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
|
||||||
|
<p>Un enseignant a demandé une exception aux règles de devoirs de votre établissement.</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<p><strong>Enseignant :</strong> {{ teacherName }}</p>
|
||||||
|
<p><strong>Devoir :</strong> {{ homeworkTitle }}</p>
|
||||||
|
<p><strong>Date d'échéance :</strong> {{ dueDate }}</p>
|
||||||
|
<p><strong>Règle(s) contournée(s) :</strong> {{ ruleTypes|join(', ') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="justification-box">
|
||||||
|
<p class="label">Justification de l'enseignant :</p>
|
||||||
|
<p class="text">{{ justification }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Ce devoir a été créé immédiatement. Aucune action n'est requise de votre part, mais vous pouvez consulter le rapport des exceptions dans votre espace direction.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Cet email a été envoyé automatiquement par Classeo.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Application\Command\CreateHomeworkWithException;
|
||||||
|
|
||||||
|
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\CreateHomeworkWithException\CreateHomeworkWithExceptionCommand;
|
||||||
|
use App\Scolarite\Application\Command\CreateHomeworkWithException\CreateHomeworkWithExceptionHandler;
|
||||||
|
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\RuleWarning;
|
||||||
|
use App\Scolarite\Domain\Event\ExceptionDevoirDemandee;
|
||||||
|
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||||
|
use App\Scolarite\Domain\Exception\ExceptionNonNecessaireException;
|
||||||
|
use App\Scolarite\Domain\Exception\JustificationTropCourteException;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkStatus;
|
||||||
|
use App\Scolarite\Domain\Service\DueDateValidator;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRuleExceptionRepository;
|
||||||
|
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 CreateHomeworkWithExceptionHandlerTest 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 InMemoryHomeworkRepository $homeworkRepository;
|
||||||
|
private InMemoryHomeworkRuleExceptionRepository $exceptionRepository;
|
||||||
|
private Clock $clock;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->homeworkRepository = new InMemoryHomeworkRepository();
|
||||||
|
$this->exceptionRepository = new InMemoryHomeworkRuleExceptionRepository();
|
||||||
|
$this->clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-03-19 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesHomeworkAndExceptionWhenRulesAreBlocking(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
|
||||||
|
$command = $this->createCommand();
|
||||||
|
|
||||||
|
$result = $handler($command);
|
||||||
|
|
||||||
|
self::assertSame(HomeworkStatus::PUBLISHED, $result['homework']->status);
|
||||||
|
self::assertSame('Exercices chapitre 5', $result['homework']->title);
|
||||||
|
self::assertSame('minimum_delay', $result['exception']->ruleType);
|
||||||
|
self::assertSame('Sortie scolaire prévue, devoir urgent pour les élèves.', $result['exception']->justification);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPersistsBothInRepositories(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
|
||||||
|
$command = $this->createCommand();
|
||||||
|
|
||||||
|
$result = $handler($command);
|
||||||
|
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$savedHomework = $this->homeworkRepository->get($result['homework']->id, $tenantId);
|
||||||
|
$savedExceptions = $this->exceptionRepository->findByHomework($result['homework']->id, $tenantId);
|
||||||
|
|
||||||
|
self::assertSame('Exercices chapitre 5', $savedHomework->title);
|
||||||
|
self::assertCount(1, $savedExceptions);
|
||||||
|
self::assertTrue($savedExceptions[0]->homeworkId->equals($result['homework']->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUsesServerSideRuleTypesNotClientProvided(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
|
||||||
|
// Client sends ['fake_rule'] but server should use the actual rule types
|
||||||
|
$command = $this->createCommand(ruleTypes: ['fake_rule']);
|
||||||
|
|
||||||
|
$result = $handler($command);
|
||||||
|
|
||||||
|
// ruleType comes from server-side check, not from client
|
||||||
|
self::assertSame('minimum_delay', $result['exception']->ruleType);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRecordsDomainEvents(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
|
||||||
|
$result = $handler($this->createCommand());
|
||||||
|
|
||||||
|
$exceptionEvents = $result['exception']->pullDomainEvents();
|
||||||
|
self::assertCount(1, $exceptionEvents);
|
||||||
|
self::assertInstanceOf(ExceptionDevoirDemandee::class, $exceptionEvents[0]);
|
||||||
|
self::assertSame(['minimum_delay'], $exceptionEvents[0]->ruleTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsWhenRulesAreNotBlocking(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(affecte: true, rulesResult: HomeworkRulesCheckResult::ok());
|
||||||
|
|
||||||
|
$this->expectException(ExceptionNonNecessaireException::class);
|
||||||
|
|
||||||
|
$handler($this->createCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsWhenRulesAreSoftWarningOnly(): void
|
||||||
|
{
|
||||||
|
$softResult = new HomeworkRulesCheckResult(
|
||||||
|
warnings: [new RuleWarning('minimum_delay', 'Délai trop court', ['days' => 3])],
|
||||||
|
bloquant: false,
|
||||||
|
);
|
||||||
|
$handler = $this->createHandler(affecte: true, rulesResult: $softResult);
|
||||||
|
|
||||||
|
$this->expectException(ExceptionNonNecessaireException::class);
|
||||||
|
|
||||||
|
$handler($this->createCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsWhenTeacherNotAffected(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(affecte: false, rulesResult: $this->blockingResult());
|
||||||
|
|
||||||
|
$this->expectException(EnseignantNonAffecteException::class);
|
||||||
|
|
||||||
|
$handler($this->createCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsWhenJustificationTooShort(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
|
||||||
|
|
||||||
|
$this->expectException(JustificationTropCourteException::class);
|
||||||
|
|
||||||
|
$handler($this->createCommand(justification: 'Trop court'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itStoresMultipleRuleTypesFromServerCheck(): void
|
||||||
|
{
|
||||||
|
$multiRuleResult = new HomeworkRulesCheckResult(
|
||||||
|
warnings: [
|
||||||
|
new RuleWarning('minimum_delay', 'Délai trop court', ['days' => 7]),
|
||||||
|
new RuleWarning('no_monday_after', 'Pas de lundi', ['day' => 'friday', 'time' => '12:00']),
|
||||||
|
],
|
||||||
|
bloquant: true,
|
||||||
|
);
|
||||||
|
$handler = $this->createHandler(affecte: true, rulesResult: $multiRuleResult);
|
||||||
|
|
||||||
|
$result = $handler($this->createCommand());
|
||||||
|
|
||||||
|
self::assertSame('minimum_delay,no_monday_after', $result['exception']->ruleType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function blockingResult(): HomeworkRulesCheckResult
|
||||||
|
{
|
||||||
|
return new HomeworkRulesCheckResult(
|
||||||
|
warnings: [new RuleWarning('minimum_delay', 'Le devoir doit être créé au moins 7 jours avant.', ['days' => 7])],
|
||||||
|
bloquant: true,
|
||||||
|
suggestedDates: ['2026-03-30'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createHandler(bool $affecte, HomeworkRulesCheckResult $rulesResult): CreateHomeworkWithExceptionHandler
|
||||||
|
{
|
||||||
|
$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) implements HomeworkRulesChecker {
|
||||||
|
public function __construct(private readonly HomeworkRulesCheckResult $result)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult
|
||||||
|
{
|
||||||
|
return $this->result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
|
||||||
|
return new CreateHomeworkWithExceptionHandler(
|
||||||
|
$this->homeworkRepository,
|
||||||
|
$this->exceptionRepository,
|
||||||
|
$affectationChecker,
|
||||||
|
$calendarProvider,
|
||||||
|
new DueDateValidator(),
|
||||||
|
$rulesChecker,
|
||||||
|
$this->clock,
|
||||||
|
$connection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[]|null $ruleTypes
|
||||||
|
*/
|
||||||
|
private function createCommand(
|
||||||
|
?string $justification = null,
|
||||||
|
?array $ruleTypes = null,
|
||||||
|
): CreateHomeworkWithExceptionCommand {
|
||||||
|
return new CreateHomeworkWithExceptionCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
classId: self::CLASS_ID,
|
||||||
|
subjectId: self::SUBJECT_ID,
|
||||||
|
teacherId: self::TEACHER_ID,
|
||||||
|
title: 'Exercices chapitre 5',
|
||||||
|
description: 'Faire les exercices 1 à 10',
|
||||||
|
dueDate: '2026-04-15',
|
||||||
|
justification: $justification ?? 'Sortie scolaire prévue, devoir urgent pour les élèves.',
|
||||||
|
ruleTypes: $ruleTypes ?? ['minimum_delay'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Application\Query\GetHomeworkExceptionsReport;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Scolarite\Application\Query\GetHomeworkExceptionsReport\GetHomeworkExceptionsReportHandler;
|
||||||
|
use App\Scolarite\Application\Query\GetHomeworkExceptionsReport\GetHomeworkExceptionsReportQuery;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkStatus;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRuleExceptionRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class GetHomeworkExceptionsReportHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||||
|
private const string TEACHER2_ID = '550e8400-e29b-41d4-a716-446655440011';
|
||||||
|
|
||||||
|
private InMemoryHomeworkRepository $homeworkRepository;
|
||||||
|
private InMemoryHomeworkRuleExceptionRepository $exceptionRepository;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->homeworkRepository = new InMemoryHomeworkRepository();
|
||||||
|
$this->exceptionRepository = new InMemoryHomeworkRuleExceptionRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsAllExceptionsForTenant(): void
|
||||||
|
{
|
||||||
|
$this->seedData();
|
||||||
|
$handler = $this->createHandler();
|
||||||
|
|
||||||
|
$results = $handler(new GetHomeworkExceptionsReportQuery(tenantId: self::TENANT_ID));
|
||||||
|
|
||||||
|
self::assertCount(2, $results);
|
||||||
|
self::assertSame('Exercices chapitre 5', $results[0]->homeworkTitle);
|
||||||
|
self::assertSame('Jean Dupont', $results[0]->teacherName);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itFiltersByDateRange(): void
|
||||||
|
{
|
||||||
|
$this->seedData();
|
||||||
|
$handler = $this->createHandler();
|
||||||
|
|
||||||
|
$results = $handler(new GetHomeworkExceptionsReportQuery(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
startDate: '2026-03-20',
|
||||||
|
endDate: '2026-03-25',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $results);
|
||||||
|
self::assertSame('Devoir histoire', $results[0]->homeworkTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itFiltersByTeacher(): void
|
||||||
|
{
|
||||||
|
$this->seedData();
|
||||||
|
$handler = $this->createHandler();
|
||||||
|
|
||||||
|
$results = $handler(new GetHomeworkExceptionsReportQuery(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
teacherId: self::TEACHER2_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $results);
|
||||||
|
self::assertSame(self::TEACHER2_ID, $results[0]->teacherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itFiltersByRuleType(): void
|
||||||
|
{
|
||||||
|
$this->seedData();
|
||||||
|
$handler = $this->createHandler();
|
||||||
|
|
||||||
|
$results = $handler(new GetHomeworkExceptionsReportQuery(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
ruleType: 'no_monday_after',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $results);
|
||||||
|
self::assertStringContainsString('no_monday_after', $results[0]->ruleType);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsEmptyWhenNoExceptions(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler();
|
||||||
|
|
||||||
|
$results = $handler(new GetHomeworkExceptionsReportQuery(tenantId: self::TENANT_ID));
|
||||||
|
|
||||||
|
self::assertCount(0, $results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedData(): void
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
|
||||||
|
$hw1 = Homework::reconstitute(
|
||||||
|
id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'),
|
||||||
|
tenantId: $tenantId,
|
||||||
|
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||||
|
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
title: 'Exercices chapitre 5',
|
||||||
|
description: null,
|
||||||
|
dueDate: new DateTimeImmutable('2026-04-15'),
|
||||||
|
status: HomeworkStatus::PUBLISHED,
|
||||||
|
createdAt: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
updatedAt: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->homeworkRepository->save($hw1);
|
||||||
|
|
||||||
|
$hw2 = Homework::reconstitute(
|
||||||
|
id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440051'),
|
||||||
|
tenantId: $tenantId,
|
||||||
|
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||||
|
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER2_ID),
|
||||||
|
title: 'Devoir histoire',
|
||||||
|
description: null,
|
||||||
|
dueDate: new DateTimeImmutable('2026-04-20'),
|
||||||
|
status: HomeworkStatus::PUBLISHED,
|
||||||
|
createdAt: new DateTimeImmutable('2026-03-21 10:00:00'),
|
||||||
|
updatedAt: new DateTimeImmutable('2026-03-21 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->homeworkRepository->save($hw2);
|
||||||
|
|
||||||
|
$ex1 = HomeworkRuleException::demander(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
homeworkId: $hw1->id,
|
||||||
|
ruleTypes: ['minimum_delay'],
|
||||||
|
justification: 'Sortie scolaire prévue, devoir urgent pour les élèves.',
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->exceptionRepository->save($ex1);
|
||||||
|
|
||||||
|
$ex2 = HomeworkRuleException::demander(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
homeworkId: $hw2->id,
|
||||||
|
ruleTypes: ['no_monday_after'],
|
||||||
|
justification: 'Rentrée après les vacances, devoir préparatoire.',
|
||||||
|
createdBy: UserId::fromString(self::TEACHER2_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-21 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->exceptionRepository->save($ex2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createHandler(): GetHomeworkExceptionsReportHandler
|
||||||
|
{
|
||||||
|
$userRepository = $this->createMock(UserRepository::class);
|
||||||
|
$userRepository->method('findById')
|
||||||
|
->willReturnCallback(static function (UserId $id): ?User {
|
||||||
|
$map = [
|
||||||
|
self::TEACHER_ID => ['Jean', 'Dupont'],
|
||||||
|
self::TEACHER2_ID => ['Marie', 'Martin'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$data = $map[(string) $id] ?? null;
|
||||||
|
|
||||||
|
if ($data === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::reconstitute(
|
||||||
|
id: $id,
|
||||||
|
email: new Email('test@test.fr'),
|
||||||
|
roles: [Role::PROF],
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolName: 'École Test',
|
||||||
|
statut: StatutCompte::ACTIF,
|
||||||
|
dateNaissance: null,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-01'),
|
||||||
|
hashedPassword: 'hashed',
|
||||||
|
activatedAt: new DateTimeImmutable('2026-01-02'),
|
||||||
|
consentementParental: null,
|
||||||
|
firstName: $data[0],
|
||||||
|
lastName: $data[1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new GetHomeworkExceptionsReportHandler(
|
||||||
|
$this->exceptionRepository,
|
||||||
|
$this->homeworkRepository,
|
||||||
|
$userRepository,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Domain\Model\Homework;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Scolarite\Domain\Event\ExceptionDevoirDemandee;
|
||||||
|
use App\Scolarite\Domain\Exception\JustificationTropCourteException;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleExceptionId;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
use function str_repeat;
|
||||||
|
|
||||||
|
final class HomeworkRuleExceptionTest 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 TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function demanderCreatesExceptionWithAllFields(): void
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$homeworkId = HomeworkId::fromString(self::HOMEWORK_ID);
|
||||||
|
$teacherId = UserId::fromString(self::TEACHER_ID);
|
||||||
|
$now = new DateTimeImmutable('2026-03-19 10:00:00');
|
||||||
|
|
||||||
|
$exception = HomeworkRuleException::demander(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
homeworkId: $homeworkId,
|
||||||
|
ruleTypes: ['minimum_delay'],
|
||||||
|
justification: 'Sortie scolaire prévue, les élèves doivent préparer leur dossier.',
|
||||||
|
createdBy: $teacherId,
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotEmpty((string) $exception->id);
|
||||||
|
self::assertTrue($exception->tenantId->equals($tenantId));
|
||||||
|
self::assertTrue($exception->homeworkId->equals($homeworkId));
|
||||||
|
self::assertSame('minimum_delay', $exception->ruleType);
|
||||||
|
self::assertSame(['minimum_delay'], $exception->ruleTypes());
|
||||||
|
self::assertSame('Sortie scolaire prévue, les élèves doivent préparer leur dossier.', $exception->justification);
|
||||||
|
self::assertTrue($exception->createdBy->equals($teacherId));
|
||||||
|
self::assertEquals($now, $exception->createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function demanderStoresMultipleRuleTypesCommaSeparated(): void
|
||||||
|
{
|
||||||
|
$exception = HomeworkRuleException::demander(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
|
||||||
|
ruleTypes: ['minimum_delay', 'no_monday_after'],
|
||||||
|
justification: 'Sortie scolaire et lundi exceptionnel.',
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('minimum_delay,no_monday_after', $exception->ruleType);
|
||||||
|
self::assertSame(['minimum_delay', 'no_monday_after'], $exception->ruleTypes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function demanderRecordsExceptionDevoirDemandeeEvent(): void
|
||||||
|
{
|
||||||
|
$exception = $this->createException();
|
||||||
|
|
||||||
|
$events = $exception->pullDomainEvents();
|
||||||
|
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(ExceptionDevoirDemandee::class, $events[0]);
|
||||||
|
self::assertSame($exception->id, $events[0]->exceptionId);
|
||||||
|
self::assertSame(['minimum_delay'], $events[0]->ruleTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function demanderThrowsWhenJustificationTooShort(): void
|
||||||
|
{
|
||||||
|
$this->expectException(JustificationTropCourteException::class);
|
||||||
|
$this->expectExceptionMessage('au moins 20 caractères');
|
||||||
|
|
||||||
|
HomeworkRuleException::demander(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
|
||||||
|
ruleTypes: ['minimum_delay'],
|
||||||
|
justification: 'Trop court',
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function demanderAcceptsExactlyMinimumLength(): void
|
||||||
|
{
|
||||||
|
$justification = str_repeat('x', 20);
|
||||||
|
|
||||||
|
$exception = HomeworkRuleException::demander(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
|
||||||
|
ruleTypes: ['minimum_delay'],
|
||||||
|
justification: $justification,
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame($justification, $exception->justification);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function demanderRejectsNineteenCharacters(): void
|
||||||
|
{
|
||||||
|
$this->expectException(JustificationTropCourteException::class);
|
||||||
|
|
||||||
|
HomeworkRuleException::demander(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
|
||||||
|
ruleTypes: ['minimum_delay'],
|
||||||
|
justification: str_repeat('x', 19),
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
|
||||||
|
{
|
||||||
|
$id = HomeworkRuleExceptionId::generate();
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$homeworkId = HomeworkId::fromString(self::HOMEWORK_ID);
|
||||||
|
$teacherId = UserId::fromString(self::TEACHER_ID);
|
||||||
|
$createdAt = new DateTimeImmutable('2026-03-19 10:00:00');
|
||||||
|
|
||||||
|
$exception = HomeworkRuleException::reconstitute(
|
||||||
|
id: $id,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
homeworkId: $homeworkId,
|
||||||
|
ruleType: 'minimum_delay',
|
||||||
|
justification: 'Justification suffisamment longue pour passer.',
|
||||||
|
createdBy: $teacherId,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($exception->id->equals($id));
|
||||||
|
self::assertTrue($exception->tenantId->equals($tenantId));
|
||||||
|
self::assertTrue($exception->homeworkId->equals($homeworkId));
|
||||||
|
self::assertSame('minimum_delay', $exception->ruleType);
|
||||||
|
self::assertSame('Justification suffisamment longue pour passer.', $exception->justification);
|
||||||
|
self::assertTrue($exception->createdBy->equals($teacherId));
|
||||||
|
self::assertEquals($createdAt, $exception->createdAt);
|
||||||
|
self::assertEmpty($exception->pullDomainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createException(): HomeworkRuleException
|
||||||
|
{
|
||||||
|
return HomeworkRuleException::demander(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
|
||||||
|
ruleTypes: ['minimum_delay'],
|
||||||
|
justification: 'Sortie scolaire prévue, les élèves doivent préparer leur dossier.',
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Messaging;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Scolarite\Domain\Event\ExceptionDevoirDemandee;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||||
|
use App\Scolarite\Domain\Model\Homework\HomeworkRuleExceptionId;
|
||||||
|
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Messaging\OnExceptionDevoirDemandeeHandler;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
final class OnExceptionDevoirDemandeeHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itSendsEmailToAllDirectorsInTenant(): void
|
||||||
|
{
|
||||||
|
$mailer = $this->createMock(MailerInterface::class);
|
||||||
|
$twig = $this->createMock(Environment::class);
|
||||||
|
$homeworkRepo = $this->createMock(HomeworkRepository::class);
|
||||||
|
$userRepo = $this->createMock(UserRepository::class);
|
||||||
|
|
||||||
|
$homework = $this->createHomework();
|
||||||
|
$homeworkRepo->method('findById')->willReturn($homework);
|
||||||
|
|
||||||
|
$teacher = $this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont');
|
||||||
|
$userRepo->method('findById')->willReturn($teacher);
|
||||||
|
|
||||||
|
$userRepo->method('findAllByTenant')->willReturn([
|
||||||
|
$this->createUser('director@school.fr', [Role::ADMIN], 'Marie', 'Martin'),
|
||||||
|
$this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont'),
|
||||||
|
$this->createUser('parent@school.fr', [Role::PARENT], 'Pierre', 'Durand'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$twig->method('render')->willReturn('<html>notification</html>');
|
||||||
|
|
||||||
|
$mailer->expects(self::exactly(1))->method('send');
|
||||||
|
|
||||||
|
$handler = new OnExceptionDevoirDemandeeHandler(
|
||||||
|
$homeworkRepo,
|
||||||
|
$userRepo,
|
||||||
|
$mailer,
|
||||||
|
$twig,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
($handler)($this->createEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itSkipsWhenNoDirectorsInTenant(): void
|
||||||
|
{
|
||||||
|
$mailer = $this->createMock(MailerInterface::class);
|
||||||
|
$twig = $this->createMock(Environment::class);
|
||||||
|
$homeworkRepo = $this->createMock(HomeworkRepository::class);
|
||||||
|
$userRepo = $this->createMock(UserRepository::class);
|
||||||
|
|
||||||
|
$homeworkRepo->method('findById')->willReturn($this->createHomework());
|
||||||
|
$userRepo->method('findById')->willReturn($this->createUser('teacher@school.fr', [Role::PROF]));
|
||||||
|
$userRepo->method('findAllByTenant')->willReturn([
|
||||||
|
$this->createUser('teacher@school.fr', [Role::PROF]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mailer->expects(self::never())->method('send');
|
||||||
|
|
||||||
|
$handler = new OnExceptionDevoirDemandeeHandler(
|
||||||
|
$homeworkRepo,
|
||||||
|
$userRepo,
|
||||||
|
$mailer,
|
||||||
|
$twig,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
($handler)($this->createEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itHandlesMailerFailureGracefully(): void
|
||||||
|
{
|
||||||
|
$mailer = $this->createMock(MailerInterface::class);
|
||||||
|
$twig = $this->createMock(Environment::class);
|
||||||
|
$homeworkRepo = $this->createMock(HomeworkRepository::class);
|
||||||
|
$userRepo = $this->createMock(UserRepository::class);
|
||||||
|
|
||||||
|
$homeworkRepo->method('findById')->willReturn($this->createHomework());
|
||||||
|
$userRepo->method('findById')->willReturn($this->createUser('teacher@school.fr', [Role::PROF]));
|
||||||
|
$userRepo->method('findAllByTenant')->willReturn([
|
||||||
|
$this->createUser('director@school.fr', [Role::ADMIN]),
|
||||||
|
]);
|
||||||
|
$twig->method('render')->willReturn('<html>notification</html>');
|
||||||
|
|
||||||
|
$mailer->method('send')->willThrowException(new RuntimeException('SMTP error'));
|
||||||
|
|
||||||
|
$handler = new OnExceptionDevoirDemandeeHandler(
|
||||||
|
$homeworkRepo,
|
||||||
|
$userRepo,
|
||||||
|
$mailer,
|
||||||
|
$twig,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
($handler)($this->createEvent());
|
||||||
|
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPassesCorrectDataToTemplate(): void
|
||||||
|
{
|
||||||
|
$mailer = $this->createMock(MailerInterface::class);
|
||||||
|
$twig = $this->createMock(Environment::class);
|
||||||
|
$homeworkRepo = $this->createMock(HomeworkRepository::class);
|
||||||
|
$userRepo = $this->createMock(UserRepository::class);
|
||||||
|
|
||||||
|
$homeworkRepo->method('findById')->willReturn($this->createHomework());
|
||||||
|
$teacher = $this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont');
|
||||||
|
$userRepo->method('findById')->willReturn($teacher);
|
||||||
|
$userRepo->method('findAllByTenant')->willReturn([
|
||||||
|
$this->createUser('director@school.fr', [Role::ADMIN]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$twig->expects(self::once())
|
||||||
|
->method('render')
|
||||||
|
->with(
|
||||||
|
'emails/homework_exception_notification.html.twig',
|
||||||
|
self::callback(static function (array $params): bool {
|
||||||
|
return $params['teacherName'] === 'Jean Dupont'
|
||||||
|
&& $params['homeworkTitle'] === 'Exercices chapitre 5'
|
||||||
|
&& $params['ruleTypes'] === ['minimum_delay']
|
||||||
|
&& $params['justification'] === 'Sortie scolaire prévue, devoir urgent.'
|
||||||
|
&& $params['dueDate'] === '15/04/2026';
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
->willReturn('<html>notification</html>');
|
||||||
|
|
||||||
|
$mailer->expects(self::once())->method('send');
|
||||||
|
|
||||||
|
$handler = new OnExceptionDevoirDemandeeHandler(
|
||||||
|
$homeworkRepo,
|
||||||
|
$userRepo,
|
||||||
|
$mailer,
|
||||||
|
$twig,
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
($handler)($this->createEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createEvent(): ExceptionDevoirDemandee
|
||||||
|
{
|
||||||
|
return new ExceptionDevoirDemandee(
|
||||||
|
exceptionId: HomeworkRuleExceptionId::generate(),
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
homeworkId: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'),
|
||||||
|
ruleTypes: ['minimum_delay'],
|
||||||
|
justification: 'Sortie scolaire prévue, devoir urgent.',
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
occurredOn: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createHomework(): Homework
|
||||||
|
{
|
||||||
|
return Homework::reconstitute(
|
||||||
|
id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'),
|
||||||
|
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(self::TEACHER_ID),
|
||||||
|
title: 'Exercices chapitre 5',
|
||||||
|
description: 'Faire les exercices 1 à 10',
|
||||||
|
dueDate: new DateTimeImmutable('2026-04-15'),
|
||||||
|
status: \App\Scolarite\Domain\Model\Homework\HomeworkStatus::PUBLISHED,
|
||||||
|
createdAt: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
updatedAt: new DateTimeImmutable('2026-03-19 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Role[] $roles
|
||||||
|
*/
|
||||||
|
private function createUser(
|
||||||
|
string $email,
|
||||||
|
array $roles,
|
||||||
|
string $firstName = 'Test',
|
||||||
|
string $lastName = 'User',
|
||||||
|
): User {
|
||||||
|
return User::reconstitute(
|
||||||
|
id: UserId::generate(),
|
||||||
|
email: new Email($email),
|
||||||
|
roles: $roles,
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolName: 'École Test',
|
||||||
|
statut: StatutCompte::ACTIF,
|
||||||
|
dateNaissance: null,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-01'),
|
||||||
|
hashedPassword: 'hashed',
|
||||||
|
activatedAt: new DateTimeImmutable('2026-01-02'),
|
||||||
|
consentementParental: null,
|
||||||
|
firstName: $firstName,
|
||||||
|
lastName: $lastName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
365
frontend/e2e/homework-exception.spec.ts
Normal file
365
frontend/e2e/homework-exception.spec.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
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 TEACHER_EMAIL = 'e2e-exception-teacher@example.com';
|
||||||
|
const TEACHER_PASSWORD = 'Exception123';
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Table may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedHardRules() {
|
||||||
|
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]';
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` +
|
||||||
|
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRules() {
|
||||||
|
try {
|
||||||
|
runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`);
|
||||||
|
} catch {
|
||||||
|
// Table may not exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function openCreateAndFillForm(page: import('@playwright/test').Page, title: string, daysFromNow: number) {
|
||||||
|
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const nearDate = getNextWeekday(daysFromNow);
|
||||||
|
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||||
|
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
|
||||||
|
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||||
|
await page.locator('#hw-title').fill(title);
|
||||||
|
await page.locator('#hw-due-date').fill(nearDate);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Homework Exception Request (Story 5.6)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
// 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 --firstName=Jean --lastName=Exception 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-EX-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-EX-Maths', 'E2EEXM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// May already exist
|
||||||
|
}
|
||||||
|
|
||||||
|
seedTeacherAssignments();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
try {
|
||||||
|
runSql(
|
||||||
|
`DELETE FROM homework_rule_exceptions WHERE tenant_id = '${TENANT_ID}'`,
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
// Tables may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRules();
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC1: Exception request form
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC1: Exception request form', () => {
|
||||||
|
test('shows "Demander une exception" button in blocking modal', async ({ page }) => {
|
||||||
|
seedHardRules();
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
await openCreateAndFillForm(page, 'Devoir exception test', 2);
|
||||||
|
|
||||||
|
const blockedDialog = page.getByRole('alertdialog');
|
||||||
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking exception button opens justification form', async ({ page }) => {
|
||||||
|
seedHardRules();
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
await openCreateAndFillForm(page, 'Devoir exception form', 2);
|
||||||
|
|
||||||
|
const blockedDialog = page.getByRole('alertdialog');
|
||||||
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||||
|
|
||||||
|
// Exception request dialog appears
|
||||||
|
const exceptionDialog = page.getByRole('dialog');
|
||||||
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(exceptionDialog.getByText(/demander une exception/i)).toBeVisible();
|
||||||
|
await expect(exceptionDialog.locator('#exception-justification')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC2: Justification required (min 20 chars)
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC2: Justification validation', () => {
|
||||||
|
test('submit button disabled when justification too short', async ({ page }) => {
|
||||||
|
seedHardRules();
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
await openCreateAndFillForm(page, 'Devoir justif short', 2);
|
||||||
|
|
||||||
|
const blockedDialog = page.getByRole('alertdialog');
|
||||||
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||||
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||||
|
|
||||||
|
const exceptionDialog = page.getByRole('dialog');
|
||||||
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Type less than 20 characters
|
||||||
|
await exceptionDialog.locator('#exception-justification').fill('Court');
|
||||||
|
|
||||||
|
// Submit button should be disabled
|
||||||
|
await expect(exceptionDialog.getByRole('button', { name: /créer avec exception/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homework created immediately after valid justification', async ({ page }) => {
|
||||||
|
seedHardRules();
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
await openCreateAndFillForm(page, 'Devoir exception créé', 2);
|
||||||
|
|
||||||
|
const blockedDialog = page.getByRole('alertdialog');
|
||||||
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||||
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||||
|
|
||||||
|
const exceptionDialog = page.getByRole('dialog');
|
||||||
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Type valid justification (>= 20 chars)
|
||||||
|
await exceptionDialog
|
||||||
|
.locator('#exception-justification')
|
||||||
|
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||||
|
|
||||||
|
// Homework appears in the list
|
||||||
|
await expect(page.getByText('Devoir exception créé')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC4: Exception badge
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC4: Exception marking', () => {
|
||||||
|
test('homework with exception shows exception badge', async ({ page }) => {
|
||||||
|
seedHardRules();
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
await openCreateAndFillForm(page, 'Devoir avec badge', 2);
|
||||||
|
|
||||||
|
const blockedDialog = page.getByRole('alertdialog');
|
||||||
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||||
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||||
|
|
||||||
|
const exceptionDialog = page.getByRole('dialog');
|
||||||
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||||
|
await exceptionDialog
|
||||||
|
.locator('#exception-justification')
|
||||||
|
.fill('Justification suffisamment longue pour être valide.');
|
||||||
|
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||||
|
|
||||||
|
// Wait for homework to appear
|
||||||
|
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Exception badge visible (⚠ Exception text or rule override badge)
|
||||||
|
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
|
||||||
|
await expect(card.locator('.badge-rule-exception, .badge-rule-override')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exception badge tooltip describes the exception for justification viewing', async ({ page }) => {
|
||||||
|
seedHardRules();
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
await openCreateAndFillForm(page, 'Devoir tooltip test', 2);
|
||||||
|
|
||||||
|
const blockedDialog = page.getByRole('alertdialog');
|
||||||
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||||
|
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||||
|
|
||||||
|
const exceptionDialog = page.getByRole('dialog');
|
||||||
|
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||||
|
await exceptionDialog
|
||||||
|
.locator('#exception-justification')
|
||||||
|
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
|
||||||
|
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Devoir tooltip test')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Badge has descriptive title attribute for justification consultation
|
||||||
|
const card = page.locator('.homework-card', { hasText: 'Devoir tooltip test' });
|
||||||
|
const badge = card.locator('.badge-rule-exception');
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toHaveAttribute('title', /exception/i);
|
||||||
|
await expect(badge).toContainText('Exception');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC5: Direction exceptions report
|
||||||
|
// ============================================================================
|
||||||
|
// AC5 (Direction exceptions report) is covered by unit tests
|
||||||
|
// (GetHomeworkExceptionsReportHandlerTest) because E2E testing requires
|
||||||
|
// multi-tenant admin login which is not reliably testable in parallel mode.
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC6: Soft mode - no justification needed
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC6: Soft mode without justification', () => {
|
||||||
|
test('soft mode does not show exception request button', async ({ page }) => {
|
||||||
|
// Configure soft mode
|
||||||
|
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]';
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'soft', true, NOW(), NOW()) ` +
|
||||||
|
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()`,
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
|
||||||
|
await loginAsTeacher(page);
|
||||||
|
await navigateToHomework(page);
|
||||||
|
await openCreateAndFillForm(page, 'Devoir soft mode', 2);
|
||||||
|
|
||||||
|
// Warning modal appears (not blocking)
|
||||||
|
const warningModal = page.getByRole('alertdialog');
|
||||||
|
await expect(warningModal).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(warningModal.getByText(/avertissement/i)).toBeVisible();
|
||||||
|
|
||||||
|
// "Continuer malgré tout" visible (soft mode allows bypass)
|
||||||
|
await expect(warningModal.getByRole('button', { name: /continuer malgré tout/i })).toBeVisible();
|
||||||
|
|
||||||
|
// No exception request button
|
||||||
|
await expect(warningModal.getByRole('button', { name: /demander une exception/i })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -276,7 +276,7 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => {
|
|||||||
// AC3: Exception request information
|
// AC3: Exception request information
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
test.describe('AC3: Exception request information', () => {
|
test.describe('AC3: Exception request information', () => {
|
||||||
test('shows exception contact information in blocking modal', async ({ page }) => {
|
test('shows exception request button in blocking modal', async ({ page }) => {
|
||||||
seedHardRules();
|
seedHardRules();
|
||||||
clearCache();
|
clearCache();
|
||||||
|
|
||||||
@@ -287,8 +287,8 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => {
|
|||||||
const blockedDialog = page.getByRole('alertdialog');
|
const blockedDialog = page.getByRole('alertdialog');
|
||||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Exception information visible
|
// Exception request button visible (Story 5.6 replaced static text with a real button)
|
||||||
await expect(blockedDialog.getByText(/exception.*contactez/i)).toBeVisible();
|
await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface RuleWarning {
|
||||||
|
ruleType: string;
|
||||||
|
message: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
warnings,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
isSubmitting = false,
|
||||||
|
}: {
|
||||||
|
warnings: RuleWarning[];
|
||||||
|
onSubmit: (justification: string, ruleTypes: string[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let justification = $state('');
|
||||||
|
let charCount = $derived(justification.length);
|
||||||
|
let isValid = $derived(charCount >= 20);
|
||||||
|
|
||||||
|
let modalElement = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (modalElement) {
|
||||||
|
modalElement.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!isValid || isSubmitting) return;
|
||||||
|
const ruleTypes = warnings.map((w) => w.ruleType);
|
||||||
|
onSubmit(justification, ruleTypes);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="modal-overlay" onclick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
bind:this={modalElement}
|
||||||
|
class="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="exception-request-title"
|
||||||
|
aria-describedby="exception-request-description"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header class="modal-header modal-header-exception">
|
||||||
|
<h2 id="exception-request-title">Demander une exception</h2>
|
||||||
|
<button class="modal-close" onclick={onClose} aria-label="Fermer">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="modal-body"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p id="exception-request-description">
|
||||||
|
Ce devoir enfreint les règles suivantes. Veuillez justifier votre demande d'exception :
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="rule-list">
|
||||||
|
{#each warnings as warning}
|
||||||
|
<li class="rule-item">
|
||||||
|
<span class="rule-icon">🚫</span>
|
||||||
|
<span>{warning.message}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="exception-justification">
|
||||||
|
Justification <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="exception-justification"
|
||||||
|
bind:value={justification}
|
||||||
|
placeholder="Expliquez pourquoi ce devoir nécessite une exception aux règles..."
|
||||||
|
rows="4"
|
||||||
|
minlength="20"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<div class="char-counter" class:char-counter-valid={isValid} class:char-counter-invalid={charCount > 0 && !isValid}>
|
||||||
|
{charCount}/20 caractères minimum
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="exception-notice">
|
||||||
|
Le devoir sera créé immédiatement. La direction sera notifiée de cette exception.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick={onClose} disabled={isSubmitting}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-primary" disabled={!isValid || isSubmitting}>
|
||||||
|
{#if isSubmitting}
|
||||||
|
Création...
|
||||||
|
{:else}
|
||||||
|
Créer avec exception
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-exception {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.75rem 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-exception h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter-valid {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter-invalid {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exception-notice {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,11 +10,13 @@
|
|||||||
suggestedDates = [],
|
suggestedDates = [],
|
||||||
onSelectDate,
|
onSelectDate,
|
||||||
onClose,
|
onClose,
|
||||||
|
onRequestException,
|
||||||
}: {
|
}: {
|
||||||
warnings: RuleWarning[];
|
warnings: RuleWarning[];
|
||||||
suggestedDates: string[];
|
suggestedDates: string[];
|
||||||
onSelectDate: (date: string) => void;
|
onSelectDate: (date: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onRequestException?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let modalElement = $state<HTMLDivElement | null>(null);
|
let modalElement = $state<HTMLDivElement | null>(null);
|
||||||
@@ -86,11 +88,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<p class="rule-blocked-exception">
|
{#if onRequestException}
|
||||||
<span class="exception-link-placeholder">
|
<div class="rule-blocked-exception">
|
||||||
Besoin d'une exception ? Contactez votre administration.
|
<button type="button" class="btn-exception" onclick={onRequestException}>
|
||||||
</span>
|
Demander une exception
|
||||||
</p>
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -202,10 +206,39 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.exception-link-placeholder {
|
.btn-exception {
|
||||||
color: #6b7280;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-style: italic;
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-exception:hover {
|
||||||
|
background: #fef3c7;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
|
|||||||
@@ -68,7 +68,8 @@
|
|||||||
{ href: '/admin/image-rights', label: "Droit à l'image" },
|
{ href: '/admin/image-rights', label: "Droit à l'image" },
|
||||||
{ href: '/admin/pedagogy', label: 'Pédagogie' },
|
{ href: '/admin/pedagogy', label: 'Pédagogie' },
|
||||||
{ href: '/admin/branding', label: 'Identité visuelle' },
|
{ href: '/admin/branding', label: 'Identité visuelle' },
|
||||||
{ href: '/admin/homework-rules', label: 'Règles de devoirs' }
|
{ href: '/admin/homework-rules', label: 'Règles de devoirs' },
|
||||||
|
{ href: '/admin/homework-exceptions', label: 'Exceptions devoirs' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
468
frontend/src/routes/admin/homework-exceptions/+page.svelte
Normal file
468
frontend/src/routes/admin/homework-exceptions/+page.svelte
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
|
interface HomeworkException {
|
||||||
|
id: string;
|
||||||
|
homeworkId: string;
|
||||||
|
homeworkTitle: string;
|
||||||
|
ruleType: string;
|
||||||
|
justification: string;
|
||||||
|
teacherId: string;
|
||||||
|
teacherName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exceptions = $state<HomeworkException[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let filterTeacherId = $state(page.url.searchParams.get('teacherId') ?? '');
|
||||||
|
let filterRuleType = $state(page.url.searchParams.get('ruleType') ?? '');
|
||||||
|
let filterStartDate = $state(page.url.searchParams.get('startDate') ?? '');
|
||||||
|
let filterEndDate = $state(page.url.searchParams.get('endDate') ?? '');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
untrack(() => loadExceptions());
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractCollection<T>(data: Record<string, unknown>): T[] {
|
||||||
|
const members = data['hydra:member'] ?? data['member'];
|
||||||
|
if (Array.isArray(members)) return members as T[];
|
||||||
|
if (Array.isArray(data)) return data as T[];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExceptions() {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterStartDate) params.set('startDate', filterStartDate);
|
||||||
|
if (filterEndDate) params.set('endDate', filterEndDate);
|
||||||
|
if (filterTeacherId) params.set('teacherId', filterTeacherId);
|
||||||
|
if (filterRuleType) params.set('ruleType', filterRuleType);
|
||||||
|
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/admin/homework-exceptions?${params.toString()}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erreur lors du chargement des exceptions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
exceptions = extractCollection<HomeworkException>(data);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUrl() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterStartDate) params.set('startDate', filterStartDate);
|
||||||
|
if (filterEndDate) params.set('endDate', filterEndDate);
|
||||||
|
if (filterTeacherId) params.set('teacherId', filterTeacherId);
|
||||||
|
if (filterRuleType) params.set('ruleType', filterRuleType);
|
||||||
|
const query = params.toString();
|
||||||
|
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilter() {
|
||||||
|
updateUrl();
|
||||||
|
loadExceptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearFilters() {
|
||||||
|
filterStartDate = '';
|
||||||
|
filterEndDate = '';
|
||||||
|
filterTeacherId = '';
|
||||||
|
filterRuleType = '';
|
||||||
|
updateUrl();
|
||||||
|
loadExceptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 formatRuleType(ruleType: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
minimum_delay: 'Délai minimum',
|
||||||
|
no_monday_after: 'Pas de lundi après',
|
||||||
|
};
|
||||||
|
return ruleType
|
||||||
|
.split(',')
|
||||||
|
.map((t) => labels[t] ?? t)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive unique teachers from loaded exceptions for the filter dropdown
|
||||||
|
let uniqueTeachers = $derived.by(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const ex of exceptions) {
|
||||||
|
if (!seen.has(ex.teacherId)) {
|
||||||
|
seen.set(ex.teacherId, ex.teacherName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasFilters = $derived(
|
||||||
|
filterStartDate !== '' || filterEndDate !== '' || filterTeacherId !== '' || filterRuleType !== '',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Exceptions aux règles de devoirs - Classeo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="exceptions-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>Exceptions aux règles de devoirs</h1>
|
||||||
|
<p class="subtitle">Rapport des contournements de règles par les enseignants</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span class="alert-icon">⚠</span>
|
||||||
|
{error}
|
||||||
|
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="filters-section">
|
||||||
|
<div class="filters-row">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-start">Du</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="filter-start"
|
||||||
|
bind:value={filterStartDate}
|
||||||
|
onchange={handleFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-end">Au</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="filter-end"
|
||||||
|
bind:value={filterEndDate}
|
||||||
|
onchange={handleFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-teacher">Enseignant</label>
|
||||||
|
<select id="filter-teacher" bind:value={filterTeacherId} onchange={handleFilter}>
|
||||||
|
<option value="">Tous les enseignants</option>
|
||||||
|
{#each uniqueTeachers as teacher (teacher.id)}
|
||||||
|
<option value={teacher.id}>{teacher.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="filter-rule">Règle</label>
|
||||||
|
<select id="filter-rule" bind:value={filterRuleType} onchange={handleFilter}>
|
||||||
|
<option value="">Toutes les règles</option>
|
||||||
|
<option value="minimum_delay">Délai minimum</option>
|
||||||
|
<option value="no_monday_after">Pas de lundi après</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{#if hasFilters}
|
||||||
|
<button class="btn-clear" onclick={handleClearFilters}>Effacer les filtres</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state" aria-live="polite" role="status">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Chargement des exceptions...</p>
|
||||||
|
</div>
|
||||||
|
{:else if exceptions.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<span class="empty-icon">✅</span>
|
||||||
|
<h2>Aucune exception</h2>
|
||||||
|
<p>
|
||||||
|
{#if hasFilters}
|
||||||
|
Aucune exception ne correspond à vos critères de recherche.
|
||||||
|
{:else}
|
||||||
|
Aucun enseignant n'a demandé d'exception aux règles de devoirs.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="results-summary">
|
||||||
|
{exceptions.length} exception{exceptions.length > 1 ? 's' : ''} trouvée{exceptions.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="exceptions-list">
|
||||||
|
{#each exceptions as ex (ex.id)}
|
||||||
|
<div class="exception-card">
|
||||||
|
<div class="exception-header">
|
||||||
|
<h3 class="exception-homework">{ex.homeworkTitle}</h3>
|
||||||
|
<span class="exception-rule">{formatRuleType(ex.ruleType)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="exception-meta">
|
||||||
|
<span class="meta-item" title="Enseignant">
|
||||||
|
<span class="meta-icon">👤</span>
|
||||||
|
{ex.teacherName}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item" title="Date de l'exception">
|
||||||
|
<span class="meta-icon">📅</span>
|
||||||
|
{formatDate(ex.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="exception-justification">
|
||||||
|
<span class="justification-label">Justification :</span>
|
||||||
|
<p class="justification-text">{ex.justification}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.exceptions-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-section {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group input,
|
||||||
|
.filter-group select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 2px dashed #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-summary {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exceptions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exception-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exception-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exception-homework {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exception-rule {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exception-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-icon {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exception-justification {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-text {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { getApiBaseUrl } from '$lib/api/config';
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
|
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
|
||||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
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 RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
||||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
@@ -20,6 +21,9 @@
|
|||||||
className: string | null;
|
className: string | null;
|
||||||
subjectName: string | null;
|
subjectName: string | null;
|
||||||
hasRuleOverride: boolean;
|
hasRuleOverride: boolean;
|
||||||
|
hasRuleException?: boolean;
|
||||||
|
ruleExceptionJustification?: string | null;
|
||||||
|
ruleExceptionRuleType?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -105,6 +109,15 @@
|
|||||||
let ruleBlockedWarnings = $state<RuleWarning[]>([]);
|
let ruleBlockedWarnings = $state<RuleWarning[]>([]);
|
||||||
let ruleBlockedSuggestedDates = $state<string[]>([]);
|
let ruleBlockedSuggestedDates = $state<string[]>([]);
|
||||||
|
|
||||||
|
// Exception justification viewing
|
||||||
|
let showJustificationModal = $state(false);
|
||||||
|
let justificationHomework = $state<Homework | null>(null);
|
||||||
|
|
||||||
|
// Exception request modal
|
||||||
|
let showExceptionModal = $state(false);
|
||||||
|
let exceptionWarnings = $state<RuleWarning[]>([]);
|
||||||
|
let isSubmittingException = $state(false);
|
||||||
|
|
||||||
// Inline date validation for hard mode
|
// Inline date validation for hard mode
|
||||||
let dueDateError = $state<string | null>(null);
|
let dueDateError = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -452,6 +465,55 @@
|
|||||||
showCreateModal = true;
|
showCreateModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRequestException() {
|
||||||
|
exceptionWarnings = ruleBlockedWarnings;
|
||||||
|
showRuleBlockedModal = false;
|
||||||
|
showExceptionModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExceptionSubmit(justification: string, ruleTypes: string[]) {
|
||||||
|
if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSubmittingException = true;
|
||||||
|
error = null;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/homework/with-exception`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
classId: newClassId,
|
||||||
|
subjectId: newSubjectId,
|
||||||
|
title: newTitle.trim(),
|
||||||
|
description: newDescription.trim() || null,
|
||||||
|
dueDate: newDueDate,
|
||||||
|
justification,
|
||||||
|
ruleTypes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
const msg =
|
||||||
|
errorData?.['hydra:description'] ??
|
||||||
|
errorData?.message ??
|
||||||
|
errorData?.detail ??
|
||||||
|
`Erreur lors de la création avec exception (${response.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
showExceptionModal = false;
|
||||||
|
exceptionWarnings = [];
|
||||||
|
ruleBlockedWarnings = [];
|
||||||
|
ruleBlockedSuggestedDates = [];
|
||||||
|
await loadHomeworks();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors de la création avec exception';
|
||||||
|
} finally {
|
||||||
|
isSubmittingException = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleBlockedClose() {
|
function handleBlockedClose() {
|
||||||
const firstSuggested = ruleBlockedSuggestedDates[0];
|
const firstSuggested = ruleBlockedSuggestedDates[0];
|
||||||
const conformDate = firstSuggested ?? computeConformMinDate(ruleBlockedWarnings);
|
const conformDate = firstSuggested ?? computeConformMinDate(ruleBlockedWarnings);
|
||||||
@@ -717,7 +779,14 @@
|
|||||||
<div class="homework-header">
|
<div class="homework-header">
|
||||||
<h3 class="homework-title">{hw.title}</h3>
|
<h3 class="homework-title">{hw.title}</h3>
|
||||||
<div class="homework-badges">
|
<div class="homework-badges">
|
||||||
{#if hw.hasRuleOverride}
|
{#if hw.hasRuleException}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="badge-rule-exception"
|
||||||
|
title="Créé avec une exception aux règles — cliquer pour voir la justification"
|
||||||
|
onclick={() => { justificationHomework = hw; showJustificationModal = true; }}
|
||||||
|
>⚠ Exception</button>
|
||||||
|
{:else if hw.hasRuleOverride}
|
||||||
<span class="badge-rule-override" title="Créé malgré un avertissement de règle">⚠</span>
|
<span class="badge-rule-override" title="Créé malgré un avertissement de règle">⚠</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="homework-status" class:status-published={hw.status === 'published'} class:status-deleted={hw.status === 'deleted'}>
|
<span class="homework-status" class:status-published={hw.status === 'published'} class:status-deleted={hw.status === 'deleted'}>
|
||||||
@@ -1158,9 +1227,63 @@
|
|||||||
suggestedDates={ruleBlockedSuggestedDates}
|
suggestedDates={ruleBlockedSuggestedDates}
|
||||||
onSelectDate={handleBlockedSelectDate}
|
onSelectDate={handleBlockedSelectDate}
|
||||||
onClose={handleBlockedClose}
|
onClose={handleBlockedClose}
|
||||||
|
onRequestException={handleRequestException}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Exception Request Modal -->
|
||||||
|
{#if showExceptionModal && exceptionWarnings.length > 0}
|
||||||
|
<ExceptionRequestModal
|
||||||
|
warnings={exceptionWarnings}
|
||||||
|
onSubmit={handleExceptionSubmit}
|
||||||
|
onClose={() => {
|
||||||
|
showExceptionModal = false;
|
||||||
|
exceptionWarnings = [];
|
||||||
|
}}
|
||||||
|
isSubmitting={isSubmittingException}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Justification Viewing Modal -->
|
||||||
|
{#if showJustificationModal && justificationHomework}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="modal-overlay" onclick={() => { showJustificationModal = false; justificationHomework = null; }} role="presentation">
|
||||||
|
<div
|
||||||
|
class="modal modal-confirm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="justification-title"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') { showJustificationModal = false; justificationHomework = null; } }}
|
||||||
|
>
|
||||||
|
<header class="modal-header modal-header-exception-view">
|
||||||
|
<h2 id="justification-title">Exception aux règles</h2>
|
||||||
|
<button class="modal-close" onclick={() => { showJustificationModal = false; justificationHomework = null; }} aria-label="Fermer">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="justification-info">
|
||||||
|
<span class="justification-info-label">Devoir :</span>
|
||||||
|
<span>{justificationHomework.title}</span>
|
||||||
|
<span class="justification-info-label">Règle contournée :</span>
|
||||||
|
<span>{justificationHomework.ruleExceptionRuleType ?? 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="justification-content">
|
||||||
|
<span class="justification-content-label">Justification :</span>
|
||||||
|
<p class="justification-content-text">{justificationHomework.ruleExceptionJustification ?? 'Non disponible'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick={() => { showJustificationModal = false; justificationHomework = null; }}>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.homework-page {
|
.homework-page {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -1713,6 +1836,73 @@
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-rule-exception {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #92400e;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-rule-exception:hover {
|
||||||
|
background: #fef3c7;
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Justification viewing modal */
|
||||||
|
.modal-header-exception-view {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.75rem 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-exception-view h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.25rem 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-content {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-content-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #92400e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justification-content-text {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Rule Warning Modal */
|
/* Rule Warning Modal */
|
||||||
.modal-header-warning {
|
.modal-header-warning {
|
||||||
border-bottom: 3px solid #f59e0b;
|
border-bottom: 3px solid #f59e0b;
|
||||||
|
|||||||
Reference in New Issue
Block a user