feat: Permettre aux enseignants de contourner les règles de devoirs avec justification
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-03-19 21:58:56 +01:00
parent d34d31976f
commit 14c7849179
35 changed files with 3477 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository;
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
@@ -34,6 +35,7 @@ final readonly class HomeworkCollectionProvider implements ProviderInterface
{
public function __construct(
private HomeworkRepository $homeworkRepository,
private HomeworkRuleExceptionRepository $exceptionRepository,
private TenantContext $tenantContext,
private Security $security,
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(
$homework,
$this->resolveClassName($homework),
$this->resolveSubjectName($homework),
$exceptionsByHomework[(string) $homework->id] ?? null,
), $homeworks);
}

View File

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

View File

@@ -11,6 +11,7 @@ use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Domain\Repository\HomeworkRuleExceptionRepository;
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
@@ -26,6 +27,7 @@ final readonly class HomeworkItemProvider implements ProviderInterface
{
public function __construct(
private HomeworkRepository $homeworkRepository,
private HomeworkRuleExceptionRepository $exceptionRepository,
private TenantContext $tenantContext,
private Security $security,
private ClassRepository $classRepository,
@@ -64,11 +66,14 @@ final readonly class HomeworkItemProvider implements ProviderInterface
$class = $this->classRepository->findById($homework->classId);
$subject = $this->subjectRepository->findById($homework->subjectId);
$tenantId = $this->tenantContext->getCurrentTenantId();
$exceptions = $this->exceptionRepository->findByHomework($homework->id, $tenantId);
return HomeworkResource::fromDomain(
$homework,
$class !== null ? (string) $class->name : null,
$subject !== null ? (string) $subject->name : null,
$exceptions[0] ?? null,
);
}
}

View File

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

View File

@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
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\DeleteHomeworkProcessor;
use App\Scolarite\Infrastructure\Api\Processor\UpdateHomeworkProcessor;
@@ -92,10 +93,17 @@ final class HomeworkResource
public ?bool $hasRuleOverride = null;
public ?bool $hasRuleException = null;
public ?string $ruleExceptionJustification = null;
public ?string $ruleExceptionRuleType = null;
public static function fromDomain(
Homework $homework,
?string $className = null,
?string $subjectName = null,
?HomeworkRuleException $ruleException = null,
): self {
$resource = new self();
$resource->id = (string) $homework->id;
@@ -111,6 +119,9 @@ final class HomeworkResource
$resource->createdAt = $homework->createdAt;
$resource->updatedAt = $homework->updatedAt;
$resource->hasRuleOverride = $homework->ruleOverride !== null;
$resource->hasRuleException = $ruleException !== null;
$resource->ruleExceptionJustification = $ruleException?->justification;
$resource->ruleExceptionRuleType = $ruleException?->ruleType;
return $resource;
}

View File

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

View File

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

View File

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