feat: Gestion des classes scolaires
Permet aux administrateurs de créer, modifier et supprimer des classes pour organiser les élèves par niveau. L'archivage soft-delete préserve l'historique tout en masquant les classes obsolètes. Inclut la validation des noms (2-50 caractères), les niveaux scolaires du CP à la Terminale, et les contrôles d'accès par rôle.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ArchiveClass;
|
||||
|
||||
/**
|
||||
* Command pour archiver (soft delete) une classe scolaire.
|
||||
*/
|
||||
final readonly class ArchiveClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $classId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ArchiveClass;
|
||||
|
||||
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery;
|
||||
use App\Administration\Domain\Exception\ClasseNonSupprimableException;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Messenger\Stamp\HandledStamp;
|
||||
|
||||
/**
|
||||
* Handler pour archiver (soft delete) une classe scolaire.
|
||||
*
|
||||
* Vérifie qu'aucun élève n'est affecté à la classe avant de permettre l'archivage.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ArchiveClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassRepository $classRepository,
|
||||
private MessageBusInterface $queryBus,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ArchiveClassCommand $command): SchoolClass
|
||||
{
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$class = $this->classRepository->get($classId);
|
||||
|
||||
// Vérifier s'il y a des élèves affectés
|
||||
$envelope = $this->queryBus->dispatch(new HasStudentsInClassQuery($command->classId));
|
||||
/** @var HandledStamp|null $handledStamp */
|
||||
$handledStamp = $envelope->last(HandledStamp::class);
|
||||
|
||||
if ($handledStamp !== null) {
|
||||
/** @var int $studentCount */
|
||||
$studentCount = $handledStamp->getResult();
|
||||
|
||||
if ($studentCount > 0) {
|
||||
throw ClasseNonSupprimableException::carElevesAffectes($classId, $studentCount);
|
||||
}
|
||||
}
|
||||
|
||||
$class->archiver($this->clock->now());
|
||||
|
||||
$this->classRepository->save($class);
|
||||
|
||||
return $class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateClass;
|
||||
|
||||
/**
|
||||
* Command pour créer une nouvelle classe scolaire.
|
||||
*/
|
||||
final readonly class CreateClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
public string $academicYearId,
|
||||
public string $name,
|
||||
public ?string $level,
|
||||
public ?int $capacity,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateClass;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour créer une nouvelle classe scolaire.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassRepository $classRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CreateClassCommand $command): SchoolClass
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
$name = new ClassName($command->name);
|
||||
|
||||
// Vérifier l'unicité du nom dans le tenant et l'année scolaire
|
||||
$existingClass = $this->classRepository->findByName($name, $tenantId, $academicYearId);
|
||||
if ($existingClass !== null) {
|
||||
throw ClasseDejaExistanteException::avecNom($name);
|
||||
}
|
||||
|
||||
$class = SchoolClass::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: SchoolId::fromString($command->schoolId),
|
||||
academicYearId: $academicYearId,
|
||||
name: $name,
|
||||
level: $command->level !== null ? SchoolLevel::from($command->level) : null,
|
||||
capacity: $command->capacity,
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$this->classRepository->save($class);
|
||||
|
||||
return $class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateClass;
|
||||
|
||||
/**
|
||||
* Résultat de la création d'une classe.
|
||||
*/
|
||||
final readonly class CreateClassResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $classId,
|
||||
public string $name,
|
||||
public ?string $level,
|
||||
public ?int $capacity,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateClass;
|
||||
|
||||
/**
|
||||
* Command pour modifier une classe scolaire existante.
|
||||
*/
|
||||
final readonly class UpdateClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $classId,
|
||||
public ?string $name = null,
|
||||
public ?string $level = null,
|
||||
public ?int $capacity = null,
|
||||
public ?string $description = null,
|
||||
public bool $clearLevel = false,
|
||||
public bool $clearCapacity = false,
|
||||
public bool $clearDescription = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateClass;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour modifier une classe scolaire existante.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassRepository $classRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UpdateClassCommand $command): SchoolClass
|
||||
{
|
||||
$class = $this->classRepository->get(ClassId::fromString($command->classId));
|
||||
$now = $this->clock->now();
|
||||
|
||||
if ($command->name !== null) {
|
||||
$newName = new ClassName($command->name);
|
||||
|
||||
// Vérifier l'unicité du nouveau nom (sauf si c'est le même)
|
||||
if (!$class->name->equals($newName)) {
|
||||
$existingClass = $this->classRepository->findByName(
|
||||
$newName,
|
||||
$class->tenantId,
|
||||
$class->academicYearId,
|
||||
);
|
||||
|
||||
if ($existingClass !== null && !$existingClass->id->equals($class->id)) {
|
||||
throw ClasseDejaExistanteException::avecNom($newName);
|
||||
}
|
||||
}
|
||||
|
||||
$class->renommer($newName, $now);
|
||||
}
|
||||
|
||||
if ($command->level !== null) {
|
||||
$class->changerNiveau(SchoolLevel::from($command->level), $now);
|
||||
} elseif ($command->clearLevel) {
|
||||
$class->changerNiveau(null, $now);
|
||||
}
|
||||
|
||||
if ($command->capacity !== null) {
|
||||
$class->changerCapacite($command->capacity, $now);
|
||||
} elseif ($command->clearCapacity) {
|
||||
$class->changerCapacite(null, $now);
|
||||
}
|
||||
|
||||
if ($command->description !== null) {
|
||||
$class->decrire($command->description, $now);
|
||||
} elseif ($command->clearDescription) {
|
||||
$class->decrire(null, $now);
|
||||
}
|
||||
|
||||
$this->classRepository->save($class);
|
||||
|
||||
return $class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* DTO pour représenter une classe dans les réponses de query.
|
||||
*/
|
||||
final readonly class ClassDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public ?string $level,
|
||||
public ?int $capacity,
|
||||
public string $status,
|
||||
public ?string $description,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public DateTimeImmutable $updatedAt,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDomain(SchoolClass $class): self
|
||||
{
|
||||
return new self(
|
||||
id: (string) $class->id,
|
||||
name: (string) $class->name,
|
||||
level: $class->level?->value,
|
||||
capacity: $class->capacity,
|
||||
status: $class->status->value,
|
||||
description: $class->description,
|
||||
createdAt: $class->createdAt,
|
||||
updatedAt: $class->updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour récupérer les classes actives d'un tenant.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetClassesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassRepository $classRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ClassDto[]
|
||||
*/
|
||||
public function __invoke(GetClassesQuery $query): array
|
||||
{
|
||||
$classes = $this->classRepository->findActiveByTenantAndYear(
|
||||
TenantId::fromString($query->tenantId),
|
||||
AcademicYearId::fromString($query->academicYearId),
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn ($class) => ClassDto::fromDomain($class),
|
||||
$classes,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
/**
|
||||
* Query pour récupérer les classes actives d'un tenant pour une année scolaire.
|
||||
*/
|
||||
final readonly class GetClassesQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour vérifier si des élèves sont affectés à une classe.
|
||||
*
|
||||
* Note: L'implémentation complète sera ajoutée quand le module Élèves sera disponible.
|
||||
* Pour l'instant, retourne toujours 0 (aucun élève).
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class HasStudentsInClassHandler
|
||||
{
|
||||
public function __invoke(HasStudentsInClassQuery $query): int
|
||||
{
|
||||
// TODO: Implémenter la vérification réelle quand le module Élèves sera disponible
|
||||
// Pour l'instant, retourne 0 (permet l'archivage)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
/**
|
||||
* Query pour vérifier si des élèves sont affectés à une classe.
|
||||
*
|
||||
* Retourne le nombre d'élèves affectés (0 si aucun).
|
||||
*/
|
||||
final readonly class HasStudentsInClassQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $classId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
37
backend/src/Administration/Domain/Event/ClasseArchivee.php
Normal file
37
backend/src/Administration/Domain/Event/ClasseArchivee.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de l'archivage d'une classe (soft delete).
|
||||
*/
|
||||
final readonly class ClasseArchivee implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ClassId $classId,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->classId->value;
|
||||
}
|
||||
}
|
||||
41
backend/src/Administration/Domain/Event/ClasseCreee.php
Normal file
41
backend/src/Administration/Domain/Event/ClasseCreee.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de la création d'une classe.
|
||||
*/
|
||||
final readonly class ClasseCreee implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ClassId $classId,
|
||||
public TenantId $tenantId,
|
||||
public ClassName $name,
|
||||
public ?SchoolLevel $level,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->classId->value;
|
||||
}
|
||||
}
|
||||
40
backend/src/Administration/Domain/Event/ClasseModifiee.php
Normal file
40
backend/src/Administration/Domain/Event/ClasseModifiee.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de la modification du nom d'une classe.
|
||||
*/
|
||||
final readonly class ClasseModifiee implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ClassId $classId,
|
||||
public TenantId $tenantId,
|
||||
public ClassName $ancienNom,
|
||||
public ClassName $nouveauNom,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->classId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ClassNameInvalideException extends RuntimeException
|
||||
{
|
||||
public static function pourLongueur(string $value, int $min, int $max): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le nom de classe "%s" doit contenir entre %d et %d caractères.',
|
||||
$value,
|
||||
$min,
|
||||
$max,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ClasseDejaExistanteException extends RuntimeException
|
||||
{
|
||||
public static function avecNom(ClassName $name): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Une classe avec le nom "%s" existe déjà pour cette année scolaire.',
|
||||
$name,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ClasseNonSupprimableException extends RuntimeException
|
||||
{
|
||||
public static function carElevesAffectes(ClassId $classId, int $nombreEleves): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La classe "%s" ne peut pas être supprimée car %d élève(s) y sont encore affectés. Veuillez d\'abord réaffecter les élèves.',
|
||||
$classId,
|
||||
$nombreEleves,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ClasseNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function withId(ClassId $classId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La classe avec l\'ID "%s" n\'a pas été trouvée.',
|
||||
$classId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolClass;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
/**
|
||||
* Identifiant unique d'une année scolaire.
|
||||
*/
|
||||
final readonly class AcademicYearId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolClass;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ClassId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolClass;
|
||||
|
||||
use App\Administration\Domain\Exception\ClassNameInvalideException;
|
||||
|
||||
use function assert;
|
||||
use function mb_strlen;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Value Object représentant le nom d'une classe.
|
||||
*
|
||||
* Contraintes :
|
||||
* - Entre 2 et 50 caractères
|
||||
* - Non vide après trim
|
||||
*
|
||||
* Note: Les property hooks PHP 8.5 ne sont pas compatibles avec readonly.
|
||||
* La validation reste dans le constructeur pour préserver l'immutabilité du Value Object.
|
||||
*/
|
||||
final readonly class ClassName
|
||||
{
|
||||
private const int MIN_LENGTH = 2;
|
||||
private const int MAX_LENGTH = 50;
|
||||
|
||||
/** @var non-empty-string */
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
$length = mb_strlen($trimmed);
|
||||
|
||||
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
|
||||
throw ClassNameInvalideException::pourLongueur($value, self::MIN_LENGTH, self::MAX_LENGTH);
|
||||
}
|
||||
|
||||
// After validation, $trimmed is guaranteed to be non-empty (MIN_LENGTH >= 2)
|
||||
assert($trimmed !== '');
|
||||
$this->value = $trimmed;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return non-empty-string
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolClass;
|
||||
|
||||
/**
|
||||
* Statut du cycle de vie d'une classe.
|
||||
*/
|
||||
enum ClassStatus: string
|
||||
{
|
||||
case ACTIVE = 'active';
|
||||
case ARCHIVED = 'archived';
|
||||
|
||||
/**
|
||||
* Détermine si la classe peut recevoir des élèves.
|
||||
*/
|
||||
public function peutRecevoirEleves(): bool
|
||||
{
|
||||
return $this === self::ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si la classe est visible dans les listes actives.
|
||||
*/
|
||||
public function estVisible(): bool
|
||||
{
|
||||
return $this === self::ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le libellé pour affichage.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ACTIVE => 'Active',
|
||||
self::ARCHIVED => 'Archivée',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolClass;
|
||||
|
||||
use App\Administration\Domain\Event\ClasseArchivee;
|
||||
use App\Administration\Domain\Event\ClasseCreee;
|
||||
use App\Administration\Domain\Event\ClasseModifiee;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant une classe scolaire.
|
||||
*
|
||||
* Une classe appartient à un établissement (tenant), une école et une année scolaire.
|
||||
* Elle peut recevoir des élèves et est organisée par niveau scolaire.
|
||||
*
|
||||
* @see FR73: Organiser les élèves par groupes pédagogiques
|
||||
*/
|
||||
final class SchoolClass extends AggregateRoot
|
||||
{
|
||||
public private(set) ?string $description = null;
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
public private(set) ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) ClassId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) SchoolId $schoolId,
|
||||
public private(set) AcademicYearId $academicYearId,
|
||||
public private(set) ClassName $name,
|
||||
public private(set) ?SchoolLevel $level,
|
||||
public private(set) ?int $capacity,
|
||||
public private(set) ClassStatus $status,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une nouvelle classe scolaire.
|
||||
*/
|
||||
public static function creer(
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
AcademicYearId $academicYearId,
|
||||
ClassName $name,
|
||||
?SchoolLevel $level,
|
||||
?int $capacity,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
$class = new self(
|
||||
id: ClassId::generate(),
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
academicYearId: $academicYearId,
|
||||
name: $name,
|
||||
level: $level,
|
||||
capacity: $capacity,
|
||||
status: ClassStatus::ACTIVE,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$class->recordEvent(new ClasseCreee(
|
||||
classId: $class->id,
|
||||
tenantId: $class->tenantId,
|
||||
name: $class->name,
|
||||
level: $class->level,
|
||||
occurredOn: $createdAt,
|
||||
));
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renomme la classe.
|
||||
*/
|
||||
public function renommer(ClassName $nouveauNom, DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->name->equals($nouveauNom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ancienNom = $this->name;
|
||||
$this->name = $nouveauNom;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new ClasseModifiee(
|
||||
classId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
ancienNom: $ancienNom,
|
||||
nouveauNom: $nouveauNom,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie le niveau scolaire de la classe.
|
||||
*/
|
||||
public function changerNiveau(?SchoolLevel $niveau, DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->level === $niveau) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->level = $niveau;
|
||||
$this->updatedAt = $at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie la capacité maximale de la classe.
|
||||
*/
|
||||
public function changerCapacite(?int $capacity, DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->capacity === $capacity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->capacity = $capacity;
|
||||
$this->updatedAt = $at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute ou modifie la description de la classe.
|
||||
*/
|
||||
public function decrire(?string $description, DateTimeImmutable $at): void
|
||||
{
|
||||
$this->description = $description;
|
||||
$this->updatedAt = $at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive la classe (soft delete).
|
||||
*
|
||||
* Note: La vérification des élèves affectés doit être faite par l'Application Layer
|
||||
* via une Query avant d'appeler cette méthode.
|
||||
*/
|
||||
public function archiver(DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->status === ClassStatus::ARCHIVED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->status = ClassStatus::ARCHIVED;
|
||||
$this->deletedAt = $at;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new ClasseArchivee(
|
||||
classId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si la classe est active.
|
||||
*/
|
||||
public function estActive(): bool
|
||||
{
|
||||
return $this->status === ClassStatus::ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si la classe peut recevoir des élèves.
|
||||
*/
|
||||
public function peutRecevoirEleves(): bool
|
||||
{
|
||||
return $this->status->peutRecevoirEleves();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue une SchoolClass depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ClassId $id,
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
AcademicYearId $academicYearId,
|
||||
ClassName $name,
|
||||
?SchoolLevel $level,
|
||||
?int $capacity,
|
||||
ClassStatus $status,
|
||||
?string $description,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
?DateTimeImmutable $deletedAt,
|
||||
): self {
|
||||
$class = new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
academicYearId: $academicYearId,
|
||||
name: $name,
|
||||
level: $level,
|
||||
capacity: $capacity,
|
||||
status: $status,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$class->description = $description;
|
||||
$class->updatedAt = $updatedAt;
|
||||
$class->deletedAt = $deletedAt;
|
||||
|
||||
return $class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolClass;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
/**
|
||||
* Identifiant unique d'un établissement scolaire.
|
||||
*/
|
||||
final readonly class SchoolId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolClass;
|
||||
|
||||
/**
|
||||
* Niveau scolaire selon le référentiel Éducation Nationale.
|
||||
*
|
||||
* Primaire : CP, CE1, CE2, CM1, CM2
|
||||
* Collège : 6ème, 5ème, 4ème, 3ème
|
||||
* Lycée : 2nde, 1ère, Terminale
|
||||
*/
|
||||
enum SchoolLevel: string
|
||||
{
|
||||
// Primaire
|
||||
case CP = 'CP';
|
||||
case CE1 = 'CE1';
|
||||
case CE2 = 'CE2';
|
||||
case CM1 = 'CM1';
|
||||
case CM2 = 'CM2';
|
||||
|
||||
// Collège
|
||||
case SIXIEME = '6ème';
|
||||
case CINQUIEME = '5ème';
|
||||
case QUATRIEME = '4ème';
|
||||
case TROISIEME = '3ème';
|
||||
|
||||
// Lycée
|
||||
case SECONDE = '2nde';
|
||||
case PREMIERE = '1ère';
|
||||
case TERMINALE = 'Terminale';
|
||||
|
||||
/**
|
||||
* Retourne le libellé du niveau pour affichage.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si le niveau appartient au cycle primaire.
|
||||
*/
|
||||
public function estPrimaire(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::CP, self::CE1, self::CE2, self::CM1, self::CM2 => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si le niveau appartient au cycle collège.
|
||||
*/
|
||||
public function estCollege(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SIXIEME, self::CINQUIEME, self::QUATRIEME, self::TROISIEME => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si le niveau appartient au cycle lycée.
|
||||
*/
|
||||
public function estLycee(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SECONDE, self::PREMIERE, self::TERMINALE => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le cycle d'enseignement.
|
||||
*/
|
||||
public function cycle(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CP, self::CE1, self::CE2, self::CM1, self::CM2 => 'Primaire',
|
||||
self::SIXIEME, self::CINQUIEME, self::QUATRIEME, self::TROISIEME => 'Collège',
|
||||
self::SECONDE, self::PREMIERE, self::TERMINALE => 'Lycée',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolClass;
|
||||
|
||||
/**
|
||||
* Liste des niveaux scolaires valides selon le référentiel Éducation Nationale.
|
||||
*
|
||||
* Utilisé pour :
|
||||
* - L'enum SchoolLevel
|
||||
* - La validation API Platform
|
||||
* - Le frontend (formulaires)
|
||||
*/
|
||||
final class SchoolLevels
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const array ALL = [
|
||||
'CP',
|
||||
'CE1',
|
||||
'CE2',
|
||||
'CM1',
|
||||
'CM2',
|
||||
'6ème',
|
||||
'5ème',
|
||||
'4ème',
|
||||
'3ème',
|
||||
'2nde',
|
||||
'1ère',
|
||||
'Terminale',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface ClassRepository
|
||||
{
|
||||
public function save(SchoolClass $class): void;
|
||||
|
||||
/**
|
||||
* @throws \App\Administration\Domain\Exception\ClasseNotFoundException
|
||||
*/
|
||||
public function get(ClassId $id): SchoolClass;
|
||||
|
||||
public function findById(ClassId $id): ?SchoolClass;
|
||||
|
||||
/**
|
||||
* Recherche une classe par nom dans un tenant et une année scolaire.
|
||||
*/
|
||||
public function findByName(
|
||||
ClassName $name,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): ?SchoolClass;
|
||||
|
||||
/**
|
||||
* Retourne toutes les classes actives d'un tenant pour une année scolaire.
|
||||
*
|
||||
* @return SchoolClass[]
|
||||
*/
|
||||
public function findActiveByTenantAndYear(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Supprime une classe du repository.
|
||||
*/
|
||||
public function delete(ClassId $id): void;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\CreateClass\CreateClassCommand;
|
||||
use App\Administration\Application\Command\CreateClass\CreateClassHandler;
|
||||
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||
use App\Administration\Domain\Exception\ClassNameInvalideException;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour créer une classe.
|
||||
*
|
||||
* @implements ProcessorInterface<ClassResource, ClassResource>
|
||||
*/
|
||||
final readonly class CreateClassProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CreateClassHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ClassResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClassResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(ClassVoter::CREATE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une classe.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
// TODO: Récupérer school_id et academic_year_id depuis le contexte utilisateur
|
||||
// quand les modules Schools et AcademicYears seront implémentés.
|
||||
// Pour l'instant, on utilise des UUIDs déterministes basés sur le tenant.
|
||||
$schoolId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString();
|
||||
$academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString();
|
||||
|
||||
try {
|
||||
$command = new CreateClassCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
academicYearId: $academicYearId,
|
||||
name: $data->name ?? '',
|
||||
level: $data->level,
|
||||
capacity: $data->capacity,
|
||||
);
|
||||
|
||||
$class = ($this->handler)($command);
|
||||
|
||||
// Dispatch domain events from the created aggregate
|
||||
foreach ($class->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Return the created resource
|
||||
$resource = new ClassResource();
|
||||
$resource->id = (string) $class->id;
|
||||
$resource->name = (string) $class->name;
|
||||
$resource->level = $class->level?->value;
|
||||
$resource->capacity = $class->capacity;
|
||||
$resource->status = $class->status->value;
|
||||
|
||||
return $resource;
|
||||
} catch (ClassNameInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
} catch (ClasseDejaExistanteException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\ArchiveClass\ArchiveClassCommand;
|
||||
use App\Administration\Application\Command\ArchiveClass\ArchiveClassHandler;
|
||||
use App\Administration\Domain\Exception\ClasseNonSupprimableException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour supprimer (archiver) une classe.
|
||||
*
|
||||
* @implements ProcessorInterface<ClassResource, null>
|
||||
*/
|
||||
final readonly class DeleteClassProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ArchiveClassHandler $handler,
|
||||
private ClassRepository $classRepository,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ClassResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||
{
|
||||
$classId = $uriVariables['id'] ?? null;
|
||||
|
||||
if (!is_string($classId)) {
|
||||
throw new BadRequestHttpException('ID de classe manquant.');
|
||||
}
|
||||
|
||||
// Vérifier les permissions avant toute action
|
||||
$class = $this->classRepository->findById(ClassId::fromString($classId));
|
||||
if ($class === null) {
|
||||
throw new NotFoundHttpException('Classe introuvable.');
|
||||
}
|
||||
|
||||
if (!$this->authorizationChecker->isGranted(ClassVoter::DELETE, $class)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette classe.');
|
||||
}
|
||||
|
||||
try {
|
||||
$command = new ArchiveClassCommand(classId: $classId);
|
||||
|
||||
$archivedClass = ($this->handler)($command);
|
||||
|
||||
// Dispatch domain events from the archived aggregate
|
||||
foreach ($archivedClass->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (ClasseNotFoundException) {
|
||||
throw new NotFoundHttpException('Classe introuvable.');
|
||||
} catch (ClasseNonSupprimableException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UpdateClass\UpdateClassCommand;
|
||||
use App\Administration\Application\Command\UpdateClass\UpdateClassHandler;
|
||||
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\ClassNameInvalideException;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour modifier une classe.
|
||||
*
|
||||
* @implements ProcessorInterface<ClassResource, ClassResource>
|
||||
*/
|
||||
final readonly class UpdateClassProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateClassHandler $handler,
|
||||
private ClassRepository $classRepository,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ClassResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClassResource
|
||||
{
|
||||
$classId = $uriVariables['id'] ?? null;
|
||||
|
||||
if (!is_string($classId)) {
|
||||
throw new BadRequestHttpException('ID de classe manquant.');
|
||||
}
|
||||
|
||||
// Vérifier les permissions avant toute action
|
||||
$class = $this->classRepository->findById(ClassId::fromString($classId));
|
||||
if ($class === null) {
|
||||
throw new NotFoundHttpException('Classe introuvable.');
|
||||
}
|
||||
|
||||
if (!$this->authorizationChecker->isGranted(ClassVoter::EDIT, $class)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier cette classe.');
|
||||
}
|
||||
|
||||
try {
|
||||
$command = new UpdateClassCommand(
|
||||
classId: $classId,
|
||||
name: $data->name,
|
||||
level: $data->level,
|
||||
capacity: $data->capacity,
|
||||
description: $data->description,
|
||||
clearLevel: $data->clearLevel ?? false,
|
||||
clearCapacity: $data->clearCapacity ?? false,
|
||||
clearDescription: $data->clearDescription ?? false,
|
||||
);
|
||||
|
||||
$updatedClass = ($this->handler)($command);
|
||||
|
||||
// Dispatch domain events from the mutated aggregate
|
||||
foreach ($updatedClass->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Return updated resource
|
||||
$resource = new ClassResource();
|
||||
$resource->id = (string) $updatedClass->id;
|
||||
$resource->name = (string) $updatedClass->name;
|
||||
$resource->level = $updatedClass->level?->value;
|
||||
$resource->capacity = $updatedClass->capacity;
|
||||
$resource->description = $updatedClass->description;
|
||||
$resource->status = $updatedClass->status->value;
|
||||
$resource->createdAt = $updatedClass->createdAt;
|
||||
$resource->updatedAt = $updatedClass->updatedAt;
|
||||
|
||||
return $resource;
|
||||
} catch (ClasseNotFoundException) {
|
||||
throw new NotFoundHttpException('Classe introuvable.');
|
||||
} catch (ClassNameInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
} catch (ClasseDejaExistanteException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
|
||||
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer la liste des classes.
|
||||
*
|
||||
* @implements ProviderInterface<ClassResource>
|
||||
*/
|
||||
final readonly class ClassCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetClassesHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ClassResource[]
|
||||
*/
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
// Vérifier les permissions de lecture (sans sujet spécifique)
|
||||
if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les classes.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
// TODO: Récupérer academic_year_id depuis le contexte utilisateur
|
||||
// quand le module AcademicYears sera implémenté.
|
||||
$academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
);
|
||||
|
||||
$classDtos = ($this->handler)($query);
|
||||
|
||||
return array_map(
|
||||
static function ($dto) {
|
||||
$resource = new ClassResource();
|
||||
$resource->id = $dto->id;
|
||||
$resource->name = $dto->name;
|
||||
$resource->level = $dto->level;
|
||||
$resource->capacity = $dto->capacity;
|
||||
$resource->description = $dto->description;
|
||||
$resource->status = $dto->status;
|
||||
$resource->createdAt = $dto->createdAt;
|
||||
$resource->updatedAt = $dto->updatedAt;
|
||||
|
||||
return $resource;
|
||||
},
|
||||
$classDtos,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use InvalidArgumentException;
|
||||
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer une classe par son ID.
|
||||
*
|
||||
* @implements ProviderInterface<ClassResource>
|
||||
*/
|
||||
final readonly class ClassItemProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ClassRepository $classRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?ClassResource
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
|
||||
if (!is_string($id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
try {
|
||||
$classId = ClassId::fromString($id);
|
||||
} catch (InvalidArgumentException) {
|
||||
throw new NotFoundHttpException('Classe introuvable.');
|
||||
}
|
||||
|
||||
$class = $this->classRepository->findById($classId);
|
||||
|
||||
if ($class === null) {
|
||||
throw new NotFoundHttpException('Classe introuvable.');
|
||||
}
|
||||
|
||||
// Vérifier que la classe appartient au tenant courant (comparaison par valeur string)
|
||||
if ((string) $class->tenantId !== (string) $this->tenantContext->getCurrentTenantId()) {
|
||||
throw new NotFoundHttpException('Classe introuvable.');
|
||||
}
|
||||
|
||||
// Vérifier les permissions de lecture
|
||||
if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW, $class)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette classe.');
|
||||
}
|
||||
|
||||
$resource = new ClassResource();
|
||||
$resource->id = (string) $class->id;
|
||||
$resource->name = (string) $class->name;
|
||||
$resource->level = $class->level?->value;
|
||||
$resource->capacity = $class->capacity;
|
||||
$resource->description = $class->description;
|
||||
$resource->status = $class->status->value;
|
||||
$resource->createdAt = $class->createdAt;
|
||||
$resource->updatedAt = $class->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevels;
|
||||
use App\Administration\Infrastructure\Api\Processor\CreateClassProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\DeleteClassProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateClassProcessor;
|
||||
use App\Administration\Infrastructure\Api\Provider\ClassCollectionProvider;
|
||||
use App\Administration\Infrastructure\Api\Provider\ClassItemProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion des classes scolaires.
|
||||
*
|
||||
* @see Story 2.1 - Création et Gestion des Classes
|
||||
* @see FR73 - Organiser les élèves par groupes pédagogiques
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'Class',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/classes',
|
||||
provider: ClassCollectionProvider::class,
|
||||
name: 'get_classes',
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/classes/{id}',
|
||||
provider: ClassItemProvider::class,
|
||||
name: 'get_class',
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/classes',
|
||||
processor: CreateClassProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'create']],
|
||||
name: 'create_class',
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/classes/{id}',
|
||||
provider: ClassItemProvider::class,
|
||||
processor: UpdateClassProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'update']],
|
||||
name: 'update_class',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/classes/{id}',
|
||||
provider: ClassItemProvider::class,
|
||||
processor: DeleteClassProcessor::class,
|
||||
name: 'delete_class',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class ClassResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Le nom de la classe est requis.', groups: ['create'])]
|
||||
#[Assert\Length(
|
||||
min: 2,
|
||||
max: 50,
|
||||
minMessage: 'Le nom de la classe doit contenir au moins {{ limit }} caractères.',
|
||||
maxMessage: 'Le nom de la classe ne peut pas dépasser {{ limit }} caractères.',
|
||||
)]
|
||||
public ?string $name = null;
|
||||
|
||||
#[Assert\Choice(
|
||||
choices: SchoolLevels::ALL,
|
||||
message: 'Le niveau scolaire doit être un niveau valide.',
|
||||
)]
|
||||
public ?string $level = null;
|
||||
|
||||
#[Assert\PositiveOrZero(message: 'La capacité doit être un nombre positif.')]
|
||||
public ?int $capacity = null;
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
public ?string $status = null;
|
||||
|
||||
public ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
/**
|
||||
* Permet de supprimer explicitement le niveau lors d'un PATCH.
|
||||
* Si true, le niveau sera mis à null même si level n'est pas fourni.
|
||||
*/
|
||||
#[ApiProperty(readable: false)]
|
||||
public ?bool $clearLevel = null;
|
||||
|
||||
/**
|
||||
* Permet de supprimer explicitement la capacité lors d'un PATCH.
|
||||
* Si true, la capacité sera mise à null même si capacity n'est pas fourni.
|
||||
*/
|
||||
#[ApiProperty(readable: false)]
|
||||
public ?bool $clearCapacity = null;
|
||||
|
||||
/**
|
||||
* Permet de supprimer explicitement la description lors d'un PATCH.
|
||||
* Si true, la description sera mise à null même si description n'est pas fourni.
|
||||
*/
|
||||
#[ApiProperty(readable: false)]
|
||||
public ?bool $clearDescription = null;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineClassRepository implements ClassRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(SchoolClass $class): void
|
||||
{
|
||||
$data = [
|
||||
'id' => (string) $class->id,
|
||||
'tenant_id' => (string) $class->tenantId,
|
||||
'school_id' => (string) $class->schoolId,
|
||||
'academic_year_id' => (string) $class->academicYearId,
|
||||
'name' => (string) $class->name,
|
||||
'level' => $class->level?->value,
|
||||
'capacity' => $class->capacity,
|
||||
'status' => $class->status->value,
|
||||
'description' => $class->description,
|
||||
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
|
||||
$exists = $this->findById($class->id) !== null;
|
||||
|
||||
if ($exists) {
|
||||
$this->connection->update('school_classes', $data, ['id' => (string) $class->id]);
|
||||
} else {
|
||||
$this->connection->insert('school_classes', $data);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ClassId $id): SchoolClass
|
||||
{
|
||||
$class = $this->findById($id);
|
||||
|
||||
if ($class === null) {
|
||||
throw ClasseNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ClassId $id): ?SchoolClass
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM school_classes WHERE id = :id',
|
||||
['id' => (string) $id],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByName(
|
||||
ClassName $name,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): ?SchoolClass {
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM school_classes
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
AND name = :name
|
||||
AND deleted_at IS NULL',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
'name' => (string) $name,
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByTenantAndYear(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM school_classes
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
AND status = :status
|
||||
ORDER BY name ASC',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
'status' => ClassStatus::ACTIVE->value,
|
||||
],
|
||||
);
|
||||
|
||||
return array_map(fn ($row) => $this->hydrate($row), $rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ClassId $id): void
|
||||
{
|
||||
$this->connection->delete('school_classes', ['id' => (string) $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): SchoolClass
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $schoolId */
|
||||
$schoolId = $row['school_id'];
|
||||
/** @var string $academicYearId */
|
||||
$academicYearId = $row['academic_year_id'];
|
||||
/** @var string $name */
|
||||
$name = $row['name'];
|
||||
/** @var string|null $level */
|
||||
$level = $row['level'];
|
||||
/** @var int|string|null $capacity */
|
||||
$capacity = $row['capacity'];
|
||||
/** @var string $status */
|
||||
$status = $row['status'];
|
||||
/** @var string|null $description */
|
||||
$description = $row['description'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
/** @var string|null $deletedAt */
|
||||
$deletedAt = $row['deleted_at'];
|
||||
|
||||
return SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
schoolId: SchoolId::fromString($schoolId),
|
||||
academicYearId: AcademicYearId::fromString($academicYearId),
|
||||
name: new ClassName($name),
|
||||
level: $level !== null ? SchoolLevel::from($level) : null,
|
||||
capacity: $capacity !== null ? (int) $capacity : null,
|
||||
status: ClassStatus::from($status),
|
||||
description: $description,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
deletedAt: $deletedAt !== null ? new DateTimeImmutable($deletedAt) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemoryClassRepository implements ClassRepository
|
||||
{
|
||||
/** @var array<string, SchoolClass> Indexed by ID */
|
||||
private array $byId = [];
|
||||
|
||||
/** @var array<string, SchoolClass> Indexed by tenant:year:name */
|
||||
private array $byTenantYearName = [];
|
||||
|
||||
#[Override]
|
||||
public function save(SchoolClass $class): void
|
||||
{
|
||||
// If class already exists, remove the old name key (handles renames)
|
||||
$existingClass = $this->byId[(string) $class->id] ?? null;
|
||||
if ($existingClass !== null) {
|
||||
$oldKey = $this->nameKey($existingClass->name, $existingClass->tenantId, $existingClass->academicYearId);
|
||||
unset($this->byTenantYearName[$oldKey]);
|
||||
}
|
||||
|
||||
$this->byId[(string) $class->id] = $class;
|
||||
$this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)] = $class;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ClassId $id): SchoolClass
|
||||
{
|
||||
$class = $this->findById($id);
|
||||
|
||||
if ($class === null) {
|
||||
throw ClasseNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ClassId $id): ?SchoolClass
|
||||
{
|
||||
return $this->byId[(string) $id] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByName(
|
||||
ClassName $name,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): ?SchoolClass {
|
||||
return $this->byTenantYearName[$this->nameKey($name, $tenantId, $academicYearId)] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByTenantAndYear(
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
): array {
|
||||
$result = [];
|
||||
|
||||
foreach ($this->byId as $class) {
|
||||
if ($class->tenantId->equals($tenantId)
|
||||
&& $class->academicYearId->equals($academicYearId)
|
||||
&& $class->status === ClassStatus::ACTIVE
|
||||
) {
|
||||
$result[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(ClassId $id): void
|
||||
{
|
||||
$class = $this->byId[(string) $id] ?? null;
|
||||
|
||||
if ($class !== null) {
|
||||
unset($this->byId[(string) $id]);
|
||||
unset($this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)]);
|
||||
}
|
||||
}
|
||||
|
||||
private function nameKey(ClassName $name, TenantId $tenantId, AcademicYearId $academicYearId): string
|
||||
{
|
||||
return $tenantId . ':' . $academicYearId . ':' . mb_strtolower((string) $name, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Voter pour les autorisations sur les classes scolaires.
|
||||
*
|
||||
* Règles d'accès :
|
||||
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
|
||||
* - ENSEIGNANT : lecture seule (via affectations)
|
||||
* - ELEVE et PARENT : lecture de leur classe uniquement
|
||||
*
|
||||
* @extends Voter<string, SchoolClass|ClassResource>
|
||||
*/
|
||||
final class ClassVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'CLASS_VIEW';
|
||||
public const string CREATE = 'CLASS_CREATE';
|
||||
public const string EDIT = 'CLASS_EDIT';
|
||||
public const string DELETE = 'CLASS_DELETE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CREATE,
|
||||
self::EDIT,
|
||||
self::DELETE,
|
||||
];
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// CREATE and VIEW (for collections) don't require a subject
|
||||
if ($attribute === self::CREATE || ($attribute === self::VIEW && $subject === null)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $subject instanceof SchoolClass || $subject instanceof ClassResource;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof UserInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Récupérer le rôle depuis les rôles Symfony
|
||||
$roles = $user->getRoles();
|
||||
|
||||
return match ($attribute) {
|
||||
self::VIEW => $this->canView($roles),
|
||||
self::CREATE => $this->canCreate($roles),
|
||||
self::EDIT => $this->canEdit($roles),
|
||||
self::DELETE => $this->canDelete($roles),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canView(array $roles): bool
|
||||
{
|
||||
// Personnel de l'établissement uniquement
|
||||
// ELEVE et PARENT sont exclus car ils ne doivent voir que leur propre classe
|
||||
// via un endpoint dédié (non implémenté - nécessite le module Affectations)
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
Role::SECRETARIAT->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canCreate(array $roles): bool
|
||||
{
|
||||
// Seuls ADMIN et SUPER_ADMIN peuvent créer des classes
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canEdit(array $roles): bool
|
||||
{
|
||||
// Seuls ADMIN et SUPER_ADMIN peuvent modifier des classes
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canDelete(array $roles): bool
|
||||
{
|
||||
// Seuls ADMIN et SUPER_ADMIN peuvent supprimer des classes
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userRoles
|
||||
* @param string[] $allowedRoles
|
||||
*/
|
||||
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
|
||||
{
|
||||
foreach ($userRoles as $role) {
|
||||
if (in_array($role, $allowedRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user