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

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