feat: Affectation des enseignants aux classes et matières

Permet aux administrateurs d'associer un enseignant à une classe pour une
matière donnée au sein d'une année scolaire. Cette brique est nécessaire
pour construire les emplois du temps et les carnets de notes par la suite.

Le modèle impose l'unicité du triplet enseignant × classe × matière par
année scolaire, avec réactivation automatique d'une affectation retirée
plutôt que duplication. L'isolation multi-tenant est garantie au niveau
du repository (findById/get filtrent par tenant_id).
This commit is contained in:
2026-02-13 20:22:39 +01:00
parent 73a473ec93
commit 88e7f319db
61 changed files with 6484 additions and 52 deletions

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignTeacher;
final readonly class AssignTeacherCommand
{
public function __construct(
public string $tenantId,
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
) {
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignTeacher;
use App\Administration\Domain\Exception\AffectationDejaExistanteException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class AssignTeacherHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private Clock $clock,
) {
}
public function __invoke(AssignTeacherCommand $command): TeacherAssignment
{
$tenantId = TenantId::fromString($command->tenantId);
$teacherId = UserId::fromString($command->teacherId);
$classId = ClassId::fromString($command->classId);
$subjectId = SubjectId::fromString($command->subjectId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence des entités référencées (throws NotFoundException)
$this->userRepository->get($teacherId);
$this->classRepository->get($classId);
$this->subjectRepository->get($subjectId);
// Vérifier l'unicité du triplet enseignant × classe × matière
$existing = $this->assignmentRepository->findByTeacherClassSubject(
$teacherId,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
if ($existing !== null) {
throw AffectationDejaExistanteException::pourTriple($teacherId, $classId, $subjectId);
}
// Vérifier si une affectation retirée existe pour ce même triplet.
// Si oui, la réactiver au lieu d'en créer une nouvelle (évite la violation
// de la contrainte UNIQUE qui couvre tous les statuts).
$removed = $this->assignmentRepository->findRemovedByTeacherClassSubject(
$teacherId,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
if ($removed !== null) {
$removed->reactiver($this->clock->now());
$this->assignmentRepository->save($removed);
return $removed;
}
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
createdAt: $this->clock->now(),
);
$this->assignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RemoveAssignment;
final readonly class RemoveAssignmentCommand
{
public function __construct(
public string $assignmentId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RemoveAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class RemoveAssignmentHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private Clock $clock,
) {
}
public function __invoke(RemoveAssignmentCommand $command): TeacherAssignment
{
$assignmentId = TeacherAssignmentId::fromString($command->assignmentId);
$tenantId = TenantId::fromString($command->tenantId);
$assignment = $this->assignmentRepository->get($assignmentId, $tenantId);
$assignment->retirer($this->clock->now());
$this->assignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
/**
* Port pour vérifier si un enseignant est affecté à une classe/matière.
*
* Utilisé par les Voters (GradeVoter, HomeworkVoter) pour autoriser
* la saisie de notes ou devoirs uniquement aux enseignants affectés.
*
* @see AC2: Peut saisir notes pour cette matière dans cette classe
* @see AC4: Bloquer saisie si affectation retirée
*/
interface TeacherAssignmentChecker
{
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): bool;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignment;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAssignmentHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
public function __invoke(GetAssignmentQuery $query): ?TeacherAssignmentDto
{
$assignment = $this->assignmentRepository->findByTeacherClassSubject(
UserId::fromString($query->teacherId),
ClassId::fromString($query->classId),
SubjectId::fromString($query->subjectId),
AcademicYearId::fromString($query->academicYearId),
TenantId::fromString($query->tenantId),
);
if ($assignment === null) {
return null;
}
return TeacherAssignmentDto::fromDomain($assignment);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignment;
final readonly class GetAssignmentQuery
{
public function __construct(
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignmentsForTeacher;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAssignmentsForTeacherHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
/**
* @return TeacherAssignmentDto[]
*/
public function __invoke(GetAssignmentsForTeacherQuery $query): array
{
$teacherId = UserId::fromString($query->teacherId);
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findActiveByTeacher($teacherId, $tenantId);
return array_map(TeacherAssignmentDto::fromDomain(...), $assignments);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignmentsForTeacher;
final readonly class GetAssignmentsForTeacherQuery
{
public function __construct(
public string $teacherId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAssignmentsForTeacher;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use DateTimeImmutable;
final readonly class TeacherAssignmentDto
{
public function __construct(
public string $id,
public string $teacherId,
public string $classId,
public string $subjectId,
public string $academicYearId,
public string $status,
public DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
public DateTimeImmutable $createdAt,
) {
}
public static function fromDomain(TeacherAssignment $assignment): self
{
return new self(
id: (string) $assignment->id,
teacherId: (string) $assignment->teacherId,
classId: (string) $assignment->classId,
subjectId: (string) $assignment->subjectId,
academicYearId: (string) $assignment->academicYearId,
status: $assignment->status->value,
startDate: $assignment->startDate,
endDate: $assignment->endDate,
createdAt: $assignment->createdAt,
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetTeachersForClass;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetTeachersForClassHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
/**
* @return TeacherAssignmentDto[]
*/
public function __invoke(GetTeachersForClassQuery $query): array
{
$classId = ClassId::fromString($query->classId);
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findActiveByClass($classId, $tenantId);
return array_map(TeacherAssignmentDto::fromDomain(...), $assignments);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetTeachersForClass;
final readonly class GetTeachersForClassQuery
{
public function __construct(
public string $classId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class AffectationRetiree implements DomainEvent
{
public function __construct(
public TeacherAssignmentId $assignmentId,
public UserId $teacherId,
public ClassId $classId,
public SubjectId $subjectId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->assignmentId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class EnseignantAffecte implements DomainEvent
{
public function __construct(
public TeacherAssignmentId $assignmentId,
public UserId $teacherId,
public ClassId $classId,
public SubjectId $subjectId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->assignmentId->value;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class AffectationDejaExistanteException extends DomainException
{
public static function pourTriple(UserId $teacherId, ClassId $classId, SubjectId $subjectId): self
{
return new self(sprintf(
'L\'enseignant "%s" est déjà affecté à la classe "%s" pour la matière "%s".',
$teacherId,
$classId,
$subjectId,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use DomainException;
use function sprintf;
final class AffectationNotFoundException extends DomainException
{
public static function withId(TeacherAssignmentId $id): self
{
return new self(sprintf(
'L\'affectation avec l\'ID "%s" n\'a pas été trouvée.',
$id,
));
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\TeacherAssignment;
enum AssignmentStatus: string
{
case ACTIVE = 'active';
case REMOVED = 'removed';
public function estActive(): bool
{
return $this === self::ACTIVE;
}
public function label(): string
{
return match ($this) {
self::ACTIVE => 'Active',
self::REMOVED => 'Retirée',
};
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\TeacherAssignment;
use App\Administration\Domain\Event\AffectationRetiree;
use App\Administration\Domain\Event\EnseignantAffecte;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root représentant l'affectation d'un enseignant à une classe et une matière.
*
* Relation ternaire : Teacher × Class × Subject, scopée par année scolaire.
* Supporte le multi-matière (un enseignant, plusieurs matières par classe)
* et le co-enseignement (plusieurs enseignants, même matière dans une classe).
*
* @see FR78: Affecter enseignant à classe et matière
*/
final class TeacherAssignment extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) TeacherAssignmentId $id,
public private(set) TenantId $tenantId,
public private(set) UserId $teacherId,
public private(set) ClassId $classId,
public private(set) SubjectId $subjectId,
public private(set) AcademicYearId $academicYearId,
public private(set) DateTimeImmutable $startDate,
public private(set) ?DateTimeImmutable $endDate,
public private(set) AssignmentStatus $status,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function creer(
TenantId $tenantId,
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
DateTimeImmutable $createdAt,
): self {
$assignment = new self(
id: TeacherAssignmentId::generate(),
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
startDate: $createdAt,
endDate: null,
status: AssignmentStatus::ACTIVE,
createdAt: $createdAt,
);
$assignment->recordEvent(new EnseignantAffecte(
assignmentId: $assignment->id,
teacherId: $assignment->teacherId,
classId: $assignment->classId,
subjectId: $assignment->subjectId,
occurredOn: $createdAt,
));
return $assignment;
}
/**
* Retire l'affectation. Les notes existantes sont conservées (historique),
* mais l'enseignant ne peut plus en ajouter.
*/
public function retirer(DateTimeImmutable $at): void
{
if ($this->status === AssignmentStatus::REMOVED) {
return;
}
$this->status = AssignmentStatus::REMOVED;
$this->endDate = $at;
$this->updatedAt = $at;
$this->recordEvent(new AffectationRetiree(
assignmentId: $this->id,
teacherId: $this->teacherId,
classId: $this->classId,
subjectId: $this->subjectId,
occurredOn: $at,
));
}
/**
* Réactive une affectation précédemment retirée.
*
* Permet de ré-affecter un enseignant au même triplet sans violer
* la contrainte d'unicité en base.
*/
public function reactiver(DateTimeImmutable $at): void
{
if ($this->status === AssignmentStatus::ACTIVE) {
return;
}
$this->status = AssignmentStatus::ACTIVE;
$this->endDate = null;
$this->startDate = $at;
$this->updatedAt = $at;
$this->recordEvent(new EnseignantAffecte(
assignmentId: $this->id,
teacherId: $this->teacherId,
classId: $this->classId,
subjectId: $this->subjectId,
occurredOn: $at,
));
}
public function estActive(): bool
{
return $this->status->estActive();
}
/**
* Reconstitue une TeacherAssignment depuis le stockage.
*
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
TeacherAssignmentId $id,
TenantId $tenantId,
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
AssignmentStatus $status,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$assignment = new self(
id: $id,
tenantId: $tenantId,
teacherId: $teacherId,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
startDate: $startDate,
endDate: $endDate,
status: $status,
createdAt: $createdAt,
);
$assignment->updatedAt = $updatedAt;
return $assignment;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\TeacherAssignment;
use App\Shared\Domain\EntityId;
final readonly class TeacherAssignmentId extends EntityId
{
}

View File

@@ -0,0 +1,70 @@
<?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\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
interface TeacherAssignmentRepository
{
public function save(TeacherAssignment $assignment): void;
/**
* @throws \App\Administration\Domain\Exception\AffectationNotFoundException
*/
public function get(TeacherAssignmentId $id, TenantId $tenantId): TeacherAssignment;
public function findById(TeacherAssignmentId $id, TenantId $tenantId): ?TeacherAssignment;
/**
* Vérifie si une affectation active existe déjà pour ce triplet enseignant × classe × matière.
*/
public function findByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment;
/**
* Recherche une affectation retirée pour ce triplet enseignant × classe × matière.
*
* Utilisé pour réactiver une affectation au lieu d'en créer une nouvelle,
* évitant ainsi une violation de la contrainte d'unicité.
*/
public function findRemovedByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment;
/**
* Retourne toutes les affectations actives d'un enseignant.
*
* @return TeacherAssignment[]
*/
public function findActiveByTeacher(
UserId $teacherId,
TenantId $tenantId,
): array;
/**
* Retourne tous les enseignants affectés à une classe.
*
* @return TeacherAssignment[]
*/
public function findActiveByClass(
ClassId $classId,
TenantId $tenantId,
): array;
}

View File

@@ -12,6 +12,7 @@ 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\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Uuid;
@@ -34,6 +35,7 @@ final readonly class CreateClassProcessor implements ProcessorInterface
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
@@ -53,11 +55,11 @@ final readonly class CreateClassProcessor implements ProcessorInterface
$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.
// TODO: Récupérer school_id depuis le contexte utilisateur
// quand le module Schools sera implémenté.
$schoolId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString();
$academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString();
$academicYearId = $this->academicYearResolver->resolve('current')
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
try {
$command = new CreateClassCommand(

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\AssignTeacher\AssignTeacherCommand;
use App\Administration\Application\Command\AssignTeacher\AssignTeacherHandler;
use App\Administration\Domain\Exception\AffectationDejaExistanteException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\SubjectNotFoundException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
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\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<TeacherAssignmentResource, TeacherAssignmentResource>
*/
final readonly class CreateTeacherAssignmentProcessor implements ProcessorInterface
{
public function __construct(
private AssignTeacherHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* @param TeacherAssignmentResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TeacherAssignmentResource
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::CREATE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une affectation.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$rawAcademicYearId = $this->academicYearResolver->resolve($data->academicYearId ?? 'current');
if ($rawAcademicYearId === null) {
throw new BadRequestHttpException('Identifiant d\'année scolaire invalide.');
}
try {
$command = new AssignTeacherCommand(
tenantId: $tenantId,
teacherId: $data->teacherId ?? '',
classId: $data->classId ?? '',
subjectId: $data->subjectId ?? '',
academicYearId: $rawAcademicYearId,
);
$assignment = ($this->handler)($command);
foreach ($assignment->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return TeacherAssignmentResource::fromDomain($assignment);
} catch (AffectationDejaExistanteException $e) {
throw new ConflictHttpException($e->getMessage());
} catch (UserNotFoundException|ClasseNotFoundException|SubjectNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\RemoveAssignment\RemoveAssignmentCommand;
use App\Administration\Application\Command\RemoveAssignment\RemoveAssignmentHandler;
use App\Administration\Domain\Exception\AffectationNotFoundException;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<TeacherAssignmentResource, null>
*/
final readonly class RemoveTeacherAssignmentProcessor implements ProcessorInterface
{
public function __construct(
private RemoveAssignmentHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @param TeacherAssignmentResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::DELETE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à retirer une affectation.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string|null $assignmentId */
$assignmentId = $uriVariables['id'] ?? null;
if ($assignmentId === null) {
throw new NotFoundHttpException('Affectation non trouvée.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$command = new RemoveAssignmentCommand(
assignmentId: $assignmentId,
tenantId: $tenantId,
);
$assignment = ($this->handler)($command);
foreach ($assignment->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return null;
} catch (AffectationNotFoundException|InvalidUuidStringException) {
throw new NotFoundHttpException('Affectation non trouvée.');
}
}
}

View File

@@ -10,9 +10,9 @@ 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\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
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;
@@ -28,6 +28,7 @@ final readonly class ClassCollectionProvider implements ProviderInterface
private GetClassesHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
@@ -48,9 +49,10 @@ final readonly class ClassCollectionProvider implements ProviderInterface
$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();
$academicYearId = $this->academicYearResolver->resolve('current') ?? '';
if ($academicYearId === '') {
return [];
}
$query = new GetClassesQuery(
tenantId: $tenantId,

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use InvalidArgumentException;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentItemProvider implements ProviderInterface
{
public function __construct(
private TeacherAssignmentRepository $repository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?TeacherAssignmentResource
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::DELETE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette affectation.');
}
/** @var string $id */
$id = $uriVariables['id'] ?? '';
try {
$assignment = $this->repository->findById(
TeacherAssignmentId::fromString($id),
$this->tenantContext->getCurrentTenantId(),
);
} catch (InvalidArgumentException) {
return null;
}
if ($assignment === null) {
return null;
}
return TeacherAssignmentResource::fromDomain($assignment);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetTeachersForClass\GetTeachersForClassHandler;
use App\Administration\Application\Query\GetTeachersForClass\GetTeachersForClassQuery;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentsByClassProvider implements ProviderInterface
{
public function __construct(
private GetTeachersForClassHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @return TeacherAssignmentResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les enseignants de cette classe.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $classId */
$classId = $uriVariables['classId'] ?? '';
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$query = new GetTeachersForClassQuery(
classId: $classId,
tenantId: $tenantId,
);
$dtos = ($this->handler)($query);
return array_map(TeacherAssignmentResource::fromDto(...), $dtos);
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('Identifiant classe invalide.', $e);
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetAssignmentsForTeacher\GetAssignmentsForTeacherHandler;
use App\Administration\Application\Query\GetAssignmentsForTeacher\GetAssignmentsForTeacherQuery;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentsByTeacherProvider implements ProviderInterface
{
public function __construct(
private GetAssignmentsForTeacherHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @return TeacherAssignmentResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $teacherId */
$teacherId = $uriVariables['teacherId'] ?? '';
// Passer une ressource avec le teacherId pour que le voter puisse
// vérifier que l'enseignant ne consulte que ses propres affectations.
$subjectForVoter = new TeacherAssignmentResource();
$subjectForVoter->teacherId = $teacherId;
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW, $subjectForVoter)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les affectations.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
try {
$query = new GetAssignmentsForTeacherQuery(
teacherId: $teacherId,
tenantId: $tenantId,
);
$dtos = ($this->handler)($query);
return array_map(TeacherAssignmentResource::fromDto(...), $dtos);
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('Identifiant enseignant invalide.', $e);
}
}
}

View File

@@ -0,0 +1,124 @@
<?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\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Post;
use App\Administration\Application\Query\GetAssignmentsForTeacher\TeacherAssignmentDto;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Infrastructure\Api\Processor\CreateTeacherAssignmentProcessor;
use App\Administration\Infrastructure\Api\Processor\RemoveTeacherAssignmentProcessor;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentItemProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByClassProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByTeacherProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la gestion des affectations enseignants.
*
* @see Story 2.8 - Affectation Enseignants aux Classes et Matières
* @see FR78 - Affecter enseignant à classe et matière
*/
#[ApiResource(
shortName: 'TeacherAssignment',
operations: [
new GetCollection(
uriTemplate: '/teachers/{teacherId}/assignments',
uriVariables: [
'teacherId' => new Link(
fromClass: self::class,
identifiers: ['teacherId'],
),
],
provider: TeacherAssignmentsByTeacherProvider::class,
name: 'get_teacher_assignments',
),
new GetCollection(
uriTemplate: '/classes/{classId}/teachers',
uriVariables: [
'classId' => new Link(
fromClass: self::class,
identifiers: ['classId'],
),
],
provider: TeacherAssignmentsByClassProvider::class,
name: 'get_class_teachers',
),
new Post(
uriTemplate: '/teacher-assignments',
processor: CreateTeacherAssignmentProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'create_teacher_assignment',
),
new Delete(
uriTemplate: '/teacher-assignments/{id}',
provider: TeacherAssignmentItemProvider::class,
processor: RemoveTeacherAssignmentProcessor::class,
name: 'remove_teacher_assignment',
),
],
)]
final class TeacherAssignmentResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
#[Assert\NotBlank(message: 'L\'identifiant de l\'enseignant est requis.', groups: ['create'])]
public ?string $teacherId = null;
#[Assert\NotBlank(message: 'L\'identifiant de la classe est requis.', groups: ['create'])]
public ?string $classId = null;
#[Assert\NotBlank(message: 'L\'identifiant de la matière est requis.', groups: ['create'])]
public ?string $subjectId = null;
#[Assert\NotBlank(message: 'L\'identifiant de l\'année scolaire est requis.', groups: ['create'])]
public ?string $academicYearId = null;
public ?string $status = null;
public ?DateTimeImmutable $startDate = null;
public ?DateTimeImmutable $endDate = null;
public ?DateTimeImmutable $createdAt = null;
public static function fromDomain(TeacherAssignment $assignment): self
{
$resource = new self();
$resource->id = (string) $assignment->id;
$resource->teacherId = (string) $assignment->teacherId;
$resource->classId = (string) $assignment->classId;
$resource->subjectId = (string) $assignment->subjectId;
$resource->academicYearId = (string) $assignment->academicYearId;
$resource->status = $assignment->status->value;
$resource->startDate = $assignment->startDate;
$resource->endDate = $assignment->endDate;
$resource->createdAt = $assignment->createdAt;
return $resource;
}
public static function fromDto(TeacherAssignmentDto $dto): self
{
$resource = new self();
$resource->id = $dto->id;
$resource->teacherId = $dto->teacherId;
$resource->classId = $dto->classId;
$resource->subjectId = $dto->subjectId;
$resource->academicYearId = $dto->academicYearId;
$resource->status = $dto->status;
$resource->startDate = $dto->startDate;
$resource->endDate = $dto->endDate;
$resource->createdAt = $dto->createdAt;
return $resource;
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\AffectationDejaExistanteException;
use App\Administration\Domain\Exception\AffectationNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\AssignmentStatus;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Override;
final readonly class DoctrineTeacherAssignmentRepository implements TeacherAssignmentRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(TeacherAssignment $assignment): void
{
try {
$this->connection->executeStatement(
'INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, start_date, end_date, status, created_at, updated_at)
VALUES (:id, :tenant_id, :teacher_id, :school_class_id, :subject_id, :academic_year_id, :start_date, :end_date, :status, :created_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $assignment->id,
'tenant_id' => (string) $assignment->tenantId,
'teacher_id' => (string) $assignment->teacherId,
'school_class_id' => (string) $assignment->classId,
'subject_id' => (string) $assignment->subjectId,
'academic_year_id' => (string) $assignment->academicYearId,
'start_date' => $assignment->startDate->format(DateTimeImmutable::ATOM),
'end_date' => $assignment->endDate?->format(DateTimeImmutable::ATOM),
'status' => $assignment->status->value,
'created_at' => $assignment->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $assignment->updatedAt->format(DateTimeImmutable::ATOM),
],
);
} catch (UniqueConstraintViolationException) {
throw AffectationDejaExistanteException::pourTriple(
$assignment->teacherId,
$assignment->classId,
$assignment->subjectId,
);
}
}
#[Override]
public function get(TeacherAssignmentId $id, TenantId $tenantId): TeacherAssignment
{
$assignment = $this->findById($id, $tenantId);
if ($assignment === null) {
throw AffectationNotFoundException::withId($id);
}
return $assignment;
}
#[Override]
public function findById(TeacherAssignmentId $id, TenantId $tenantId): ?TeacherAssignment
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM teacher_assignments WHERE id = :id AND tenant_id = :tenant_id',
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment {
$row = $this->connection->fetchAssociative(
'SELECT * FROM teacher_assignments
WHERE tenant_id = :tenant_id
AND teacher_id = :teacher_id
AND school_class_id = :school_class_id
AND subject_id = :subject_id
AND academic_year_id = :academic_year_id
AND status = :status',
[
'tenant_id' => (string) $tenantId,
'teacher_id' => (string) $teacherId,
'school_class_id' => (string) $classId,
'subject_id' => (string) $subjectId,
'academic_year_id' => (string) $academicYearId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findRemovedByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment {
$row = $this->connection->fetchAssociative(
'SELECT * FROM teacher_assignments
WHERE tenant_id = :tenant_id
AND teacher_id = :teacher_id
AND school_class_id = :school_class_id
AND subject_id = :subject_id
AND academic_year_id = :academic_year_id
AND status = :status',
[
'tenant_id' => (string) $tenantId,
'teacher_id' => (string) $teacherId,
'school_class_id' => (string) $classId,
'subject_id' => (string) $subjectId,
'academic_year_id' => (string) $academicYearId,
'status' => AssignmentStatus::REMOVED->value,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findActiveByTeacher(
UserId $teacherId,
TenantId $tenantId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_assignments
WHERE teacher_id = :teacher_id
AND tenant_id = :tenant_id
AND status = :status
ORDER BY created_at ASC',
[
'teacher_id' => (string) $teacherId,
'tenant_id' => (string) $tenantId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findActiveByClass(
ClassId $classId,
TenantId $tenantId,
): array {
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_assignments
WHERE school_class_id = :school_class_id
AND tenant_id = :tenant_id
AND status = :status
ORDER BY created_at ASC',
[
'school_class_id' => (string) $classId,
'tenant_id' => (string) $tenantId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): TeacherAssignment
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $teacherId */
$teacherId = $row['teacher_id'];
/** @var string $classId */
$classId = $row['school_class_id'];
/** @var string $subjectId */
$subjectId = $row['subject_id'];
/** @var string $academicYearId */
$academicYearId = $row['academic_year_id'];
/** @var string $startDate */
$startDate = $row['start_date'];
/** @var string|null $endDate */
$endDate = $row['end_date'];
/** @var string $status */
$status = $row['status'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return TeacherAssignment::reconstitute(
id: TeacherAssignmentId::fromString($id),
tenantId: TenantId::fromString($tenantId),
teacherId: UserId::fromString($teacherId),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString($subjectId),
academicYearId: AcademicYearId::fromString($academicYearId),
startDate: new DateTimeImmutable($startDate),
endDate: $endDate !== null ? new DateTimeImmutable($endDate) : null,
status: AssignmentStatus::from($status),
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\AffectationNotFoundException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\AssignmentStatus;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryTeacherAssignmentRepository implements TeacherAssignmentRepository
{
/** @var array<string, TeacherAssignment> */
private array $byId = [];
#[Override]
public function save(TeacherAssignment $assignment): void
{
$this->byId[(string) $assignment->id] = $assignment;
}
#[Override]
public function get(TeacherAssignmentId $id, TenantId $tenantId): TeacherAssignment
{
$assignment = $this->findById($id, $tenantId);
if ($assignment === null) {
throw AffectationNotFoundException::withId($id);
}
return $assignment;
}
#[Override]
public function findById(TeacherAssignmentId $id, TenantId $tenantId): ?TeacherAssignment
{
$assignment = $this->byId[(string) $id] ?? null;
if ($assignment !== null && !$assignment->tenantId->equals($tenantId)) {
return null;
}
return $assignment;
}
#[Override]
public function findByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment {
foreach ($this->byId as $assignment) {
if ($assignment->tenantId->equals($tenantId)
&& $assignment->teacherId->equals($teacherId)
&& $assignment->classId->equals($classId)
&& $assignment->subjectId->equals($subjectId)
&& $assignment->academicYearId->equals($academicYearId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
return $assignment;
}
}
return null;
}
#[Override]
public function findRemovedByTeacherClassSubject(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): ?TeacherAssignment {
foreach ($this->byId as $assignment) {
if ($assignment->tenantId->equals($tenantId)
&& $assignment->teacherId->equals($teacherId)
&& $assignment->classId->equals($classId)
&& $assignment->subjectId->equals($subjectId)
&& $assignment->academicYearId->equals($academicYearId)
&& $assignment->status === AssignmentStatus::REMOVED
) {
return $assignment;
}
}
return null;
}
#[Override]
public function findActiveByTeacher(
UserId $teacherId,
TenantId $tenantId,
): array {
$result = [];
foreach ($this->byId as $assignment) {
if ($assignment->teacherId->equals($teacherId)
&& $assignment->tenantId->equals($tenantId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
$result[] = $assignment;
}
}
return $result;
}
#[Override]
public function findActiveByClass(
ClassId $classId,
TenantId $tenantId,
): array {
$result = [];
foreach ($this->byId as $assignment) {
if ($assignment->classId->equals($classId)
&& $assignment->tenantId->equals($tenantId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
$result[] = $assignment;
}
}
return $result;
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
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;
/**
* Voter pour les autorisations sur les affectations enseignants.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
* - ENSEIGNANT : lecture seule (ses propres affectations)
* - VIE_SCOLAIRE, SECRETARIAT : lecture seule
* - ELEVE et PARENT : pas d'accès direct
*
* @extends Voter<string, TeacherAssignment|TeacherAssignmentResource>
*/
final class TeacherAssignmentVoter extends Voter
{
public const string VIEW = 'TEACHER_ASSIGNMENT_VIEW';
public const string CREATE = 'TEACHER_ASSIGNMENT_CREATE';
public const string DELETE = 'TEACHER_ASSIGNMENT_DELETE';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CREATE,
self::DELETE,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
if (!in_array($attribute, self::SUPPORTED_ATTRIBUTES, true)) {
return false;
}
if ($subject === null) {
return true;
}
return $subject instanceof TeacherAssignment || $subject instanceof TeacherAssignmentResource;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
$roles = $user->getRoles();
return match ($attribute) {
self::VIEW => $this->canView($roles, $user, $subject),
self::CREATE => $this->canCreate($roles),
self::DELETE => $this->canDelete($roles),
default => false,
};
}
/**
* @param string[] $roles
*/
private function canView(array $roles, SecurityUser $user, mixed $subject): bool
{
// Admins et personnel administratif : accès complet en lecture
if ($this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::VIE_SCOLAIRE->value,
Role::SECRETARIAT->value,
])) {
return true;
}
// Enseignant : lecture seule de ses propres affectations
if ($this->hasAnyRole($roles, [Role::PROF->value])) {
return $this->isOwnResource($user, $subject);
}
return false;
}
/**
* Vérifie que la ressource appartient à l'enseignant connecté.
*/
private function isOwnResource(SecurityUser $user, mixed $subject): bool
{
if ($subject instanceof TeacherAssignment) {
return (string) $subject->teacherId === $user->userId();
}
if ($subject instanceof TeacherAssignmentResource) {
return $subject->teacherId === $user->userId();
}
// Pas de sujet (collection sans filtre) : refuser par défaut
return false;
}
/**
* @param string[] $roles
*/
private function canCreate(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $roles
*/
private function canDelete(array $roles): bool
{
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;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\TeacherAssignmentChecker;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final readonly class RepositoryTeacherAssignmentChecker implements TeacherAssignmentChecker
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
) {
}
#[Override]
public function estAffecte(
UserId $teacherId,
ClassId $classId,
SubjectId $subjectId,
AcademicYearId $academicYearId,
TenantId $tenantId,
): bool {
$assignment = $this->assignmentRepository->findByTeacherClassSubject(
$teacherId,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
return $assignment !== null;
}
}