feat: Gestion des matières scolaires
Les établissements ont besoin de définir leur référentiel de matières pour pouvoir ensuite les associer aux enseignants et aux classes. Cette fonctionnalité permet aux administrateurs de créer, modifier et archiver les matières avec leurs propriétés (nom, code court, couleur). L'architecture suit le pattern DDD avec des Value Objects utilisant les property hooks PHP 8.5 pour garantir l'immutabilité et la validation. L'isolation multi-tenant est assurée par vérification dans les handlers.
This commit is contained in:
@@ -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\CreateSubject\CreateSubjectCommand;
|
||||
use App\Administration\Application\Command\CreateSubject\CreateSubjectHandler;
|
||||
use App\Administration\Domain\Exception\SubjectCodeInvalideException;
|
||||
use App\Administration\Domain\Exception\SubjectColorInvalideException;
|
||||
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||
use App\Administration\Domain\Exception\SubjectNameInvalideException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
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\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour créer une matière.
|
||||
*
|
||||
* @implements ProcessorInterface<SubjectResource, SubjectResource>
|
||||
*/
|
||||
final readonly class CreateSubjectProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CreateSubjectHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubjectResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SubjectResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SubjectVoter::CREATE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une matière.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
try {
|
||||
$command = new CreateSubjectCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: $data->name ?? '',
|
||||
code: $data->code ?? '',
|
||||
color: $data->color,
|
||||
);
|
||||
|
||||
$subject = ($this->handler)($command);
|
||||
|
||||
// Dispatch domain events from the created aggregate
|
||||
foreach ($subject->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Return the created resource
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
} catch (SubjectDejaExistanteException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectCommand;
|
||||
use App\Administration\Application\Command\ArchiveSubject\ArchiveSubjectHandler;
|
||||
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour supprimer (archiver) une matière.
|
||||
*
|
||||
* Note: Cette implémentation fait un soft delete (archivage).
|
||||
* La vérification des notes associées (T6) sera ajoutée ultérieurement
|
||||
* quand le module Notes sera implémenté.
|
||||
*
|
||||
* @implements ProcessorInterface<SubjectResource, null>
|
||||
*/
|
||||
final readonly class DeleteSubjectProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ArchiveSubjectHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubjectResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SubjectVoter::DELETE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette matière.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string|null $subjectId */
|
||||
$subjectId = $uriVariables['id'] ?? null;
|
||||
if ($subjectId === null) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
try {
|
||||
// TODO: Vérifier si des notes sont associées (T6)
|
||||
// et retourner un warning si c'est le cas (via query param ?confirm=true)
|
||||
|
||||
$command = new ArchiveSubjectCommand(
|
||||
subjectId: $subjectId,
|
||||
tenantId: $tenantId,
|
||||
);
|
||||
|
||||
$subject = ($this->handler)($command);
|
||||
|
||||
// Dispatch domain events from the archived aggregate
|
||||
foreach ($subject->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (SubjectNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UpdateSubject\UpdateSubjectCommand;
|
||||
use App\Administration\Application\Command\UpdateSubject\UpdateSubjectHandler;
|
||||
use App\Administration\Domain\Exception\SubjectCodeInvalideException;
|
||||
use App\Administration\Domain\Exception\SubjectColorInvalideException;
|
||||
use App\Administration\Domain\Exception\SubjectDejaExistanteException;
|
||||
use App\Administration\Domain\Exception\SubjectNameInvalideException;
|
||||
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour modifier une matière.
|
||||
*
|
||||
* @implements ProcessorInterface<SubjectResource, SubjectResource>
|
||||
*/
|
||||
final readonly class UpdateSubjectProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateSubjectHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SubjectResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SubjectResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SubjectVoter::EDIT)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier cette matière.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string|null $subjectId */
|
||||
$subjectId = $uriVariables['id'] ?? null;
|
||||
if ($subjectId === null) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
try {
|
||||
$command = new UpdateSubjectCommand(
|
||||
subjectId: $subjectId,
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: $data->name,
|
||||
code: $data->code,
|
||||
color: $data->color,
|
||||
description: $data->description,
|
||||
clearColor: $data->clearColor ?? false,
|
||||
clearDescription: $data->clearDescription ?? false,
|
||||
);
|
||||
|
||||
$subject = ($this->handler)($command);
|
||||
|
||||
// Dispatch domain events from the updated aggregate
|
||||
foreach ($subject->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Return the updated resource
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
} catch (SubjectNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
} catch (SubjectNameInvalideException|SubjectCodeInvalideException|SubjectColorInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
} catch (SubjectDejaExistanteException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
|
||||
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
|
||||
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Override;
|
||||
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 matières.
|
||||
*
|
||||
* @implements ProviderInterface<SubjectResource>
|
||||
*/
|
||||
final readonly class SubjectCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetSubjectsHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SubjectResource[]
|
||||
*/
|
||||
#[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(SubjectVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les matières.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
);
|
||||
|
||||
$subjectDtos = ($this->handler)($query);
|
||||
|
||||
return array_map(
|
||||
static function ($dto) {
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = $dto->id;
|
||||
$resource->name = $dto->name;
|
||||
$resource->code = $dto->code;
|
||||
$resource->color = $dto->color;
|
||||
$resource->description = $dto->description;
|
||||
$resource->status = $dto->status;
|
||||
$resource->createdAt = $dto->createdAt;
|
||||
$resource->updatedAt = $dto->updatedAt;
|
||||
$resource->teacherCount = $dto->teacherCount;
|
||||
$resource->classCount = $dto->classCount;
|
||||
|
||||
return $resource;
|
||||
},
|
||||
$subjectDtos,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||
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\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer une matière par son ID.
|
||||
*
|
||||
* @implements ProviderInterface<SubjectResource>
|
||||
*/
|
||||
final readonly class SubjectItemProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private SubjectRepository $subjectRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SubjectResource
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string|null $subjectId */
|
||||
$subjectId = $uriVariables['id'] ?? null;
|
||||
if ($subjectId === null) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
}
|
||||
|
||||
try {
|
||||
$subject = $this->subjectRepository->get(SubjectId::fromString($subjectId));
|
||||
} catch (SubjectNotFoundException|InvalidUuidStringException) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
}
|
||||
|
||||
// Vérifier que la matière appartient au tenant actuel
|
||||
if ((string) $subject->tenantId !== (string) $this->tenantContext->getCurrentTenantId()) {
|
||||
throw new NotFoundHttpException('Matière non trouvée.');
|
||||
}
|
||||
|
||||
// Vérifier les permissions de lecture
|
||||
if (!$this->authorizationChecker->isGranted(SubjectVoter::VIEW, $subject)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette matière.');
|
||||
}
|
||||
|
||||
$resource = new SubjectResource();
|
||||
$resource->id = (string) $subject->id;
|
||||
$resource->name = (string) $subject->name;
|
||||
$resource->code = (string) $subject->code;
|
||||
$resource->color = $subject->color !== null ? (string) $subject->color : null;
|
||||
$resource->description = $subject->description;
|
||||
$resource->status = $subject->status->value;
|
||||
$resource->createdAt = $subject->createdAt;
|
||||
$resource->updatedAt = $subject->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?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\Infrastructure\Api\Processor\CreateSubjectProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\DeleteSubjectProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateSubjectProcessor;
|
||||
use App\Administration\Infrastructure\Api\Provider\SubjectCollectionProvider;
|
||||
use App\Administration\Infrastructure\Api\Provider\SubjectItemProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion des matières.
|
||||
*
|
||||
* @see Story 2.2 - Création et Gestion des Matières
|
||||
* @see FR74 - Structurer l'offre pédagogique
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'Subject',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/subjects',
|
||||
provider: SubjectCollectionProvider::class,
|
||||
name: 'get_subjects',
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/subjects/{id}',
|
||||
provider: SubjectItemProvider::class,
|
||||
name: 'get_subject',
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/subjects',
|
||||
processor: CreateSubjectProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'create']],
|
||||
name: 'create_subject',
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/subjects/{id}',
|
||||
provider: SubjectItemProvider::class,
|
||||
processor: UpdateSubjectProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'update']],
|
||||
name: 'update_subject',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/subjects/{id}',
|
||||
provider: SubjectItemProvider::class,
|
||||
processor: DeleteSubjectProcessor::class,
|
||||
name: 'delete_subject',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class SubjectResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Le nom de la matière est requis.', groups: ['create'])]
|
||||
#[Assert\Length(
|
||||
min: 2,
|
||||
max: 100,
|
||||
minMessage: 'Le nom de la matière doit contenir au moins {{ limit }} caractères.',
|
||||
maxMessage: 'Le nom de la matière ne peut pas dépasser {{ limit }} caractères.',
|
||||
)]
|
||||
public ?string $name = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Le code de la matière est requis.', groups: ['create'])]
|
||||
#[Assert\Regex(
|
||||
pattern: '/^[A-Za-z0-9]{2,10}$/',
|
||||
message: 'Le code doit contenir entre 2 et 10 caractères alphanumériques.',
|
||||
)]
|
||||
public ?string $code = null;
|
||||
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit être au format hexadécimal #RRGGBB.',
|
||||
)]
|
||||
public ?string $color = null;
|
||||
|
||||
public ?string $description = null;
|
||||
|
||||
public ?string $status = null;
|
||||
|
||||
public ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
/**
|
||||
* Statistiques : nombre d'enseignants associés à cette matière.
|
||||
* Disponible uniquement dans GetCollection.
|
||||
*/
|
||||
#[ApiProperty(readable: true, writable: false)]
|
||||
public ?int $teacherCount = null;
|
||||
|
||||
/**
|
||||
* Statistiques : nombre de classes associées à cette matière.
|
||||
* Disponible uniquement dans GetCollection.
|
||||
*/
|
||||
#[ApiProperty(readable: true, writable: false)]
|
||||
public ?int $classCount = null;
|
||||
|
||||
/**
|
||||
* Permet de supprimer explicitement la couleur lors d'un PATCH.
|
||||
* Si true, la couleur sera mise à null même si color n'est pas fourni.
|
||||
*/
|
||||
#[ApiProperty(readable: false)]
|
||||
public ?bool $clearColor = 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;
|
||||
|
||||
/**
|
||||
* Indique si la matière a des notes associées (pour avertissement avant suppression).
|
||||
*/
|
||||
#[ApiProperty(readable: true, writable: false)]
|
||||
public ?bool $hasGrades = null;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||
use App\Administration\Domain\Model\Subject\SubjectStatus;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineSubjectRepository implements SubjectRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(Subject $subject): void
|
||||
{
|
||||
$data = [
|
||||
'id' => (string) $subject->id,
|
||||
'tenant_id' => (string) $subject->tenantId,
|
||||
'school_id' => (string) $subject->schoolId,
|
||||
'name' => (string) $subject->name,
|
||||
'code' => (string) $subject->code,
|
||||
'color' => $subject->color !== null ? (string) $subject->color : null,
|
||||
'status' => $subject->status->value,
|
||||
'description' => $subject->description,
|
||||
'created_at' => $subject->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $subject->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
'deleted_at' => $subject->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
|
||||
$exists = $this->findById($subject->id) !== null;
|
||||
|
||||
if ($exists) {
|
||||
$this->connection->update('subjects', $data, ['id' => (string) $subject->id]);
|
||||
} else {
|
||||
$this->connection->insert('subjects', $data);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(SubjectId $id): Subject
|
||||
{
|
||||
$subject = $this->findById($id);
|
||||
|
||||
if ($subject === null) {
|
||||
throw SubjectNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(SubjectId $id): ?Subject
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM subjects WHERE id = :id',
|
||||
['id' => (string) $id],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByCode(
|
||||
SubjectCode $code,
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
): ?Subject {
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM subjects
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND school_id = :school_id
|
||||
AND code = :code
|
||||
AND deleted_at IS NULL',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'school_id' => (string) $schoolId,
|
||||
'code' => (string) $code,
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByTenantAndSchool(
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
): array {
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM subjects
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND school_id = :school_id
|
||||
AND status = :status
|
||||
ORDER BY name ASC',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'school_id' => (string) $schoolId,
|
||||
'status' => SubjectStatus::ACTIVE->value,
|
||||
],
|
||||
);
|
||||
|
||||
return array_map(fn ($row) => $this->hydrate($row), $rows);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(SubjectId $id): void
|
||||
{
|
||||
$this->connection->delete('subjects', ['id' => (string) $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): Subject
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $schoolId */
|
||||
$schoolId = $row['school_id'];
|
||||
/** @var non-empty-string $name */
|
||||
$name = $row['name'];
|
||||
/** @var non-empty-string $code */
|
||||
$code = $row['code'];
|
||||
/** @var non-empty-string|null $color */
|
||||
$color = $row['color'];
|
||||
/** @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 Subject::reconstitute(
|
||||
id: SubjectId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
schoolId: SchoolId::fromString($schoolId),
|
||||
name: new SubjectName($name),
|
||||
code: new SubjectCode($code),
|
||||
color: $color !== null ? new SubjectColor($color) : null,
|
||||
status: SubjectStatus::from($status),
|
||||
description: $description,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
deletedAt: $deletedAt !== null ? new DateTimeImmutable($deletedAt) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\SubjectNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectStatus;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function mb_strtolower;
|
||||
|
||||
use Override;
|
||||
|
||||
final class InMemorySubjectRepository implements SubjectRepository
|
||||
{
|
||||
/** @var array<string, Subject> Indexed by ID */
|
||||
private array $byId = [];
|
||||
|
||||
/** @var array<string, Subject> Indexed by tenant:school:code */
|
||||
private array $byTenantSchoolCode = [];
|
||||
|
||||
/** @var array<string, string> Maps subject ID to its current code key */
|
||||
private array $codeKeyById = [];
|
||||
|
||||
#[Override]
|
||||
public function save(Subject $subject): void
|
||||
{
|
||||
$subjectIdStr = (string) $subject->id;
|
||||
|
||||
// If subject already exists, remove the old code key (handles code changes)
|
||||
if (isset($this->codeKeyById[$subjectIdStr])) {
|
||||
$oldKey = $this->codeKeyById[$subjectIdStr];
|
||||
unset($this->byTenantSchoolCode[$oldKey]);
|
||||
}
|
||||
|
||||
$newKey = $this->codeKey($subject->code, $subject->tenantId, $subject->schoolId);
|
||||
|
||||
$this->byId[$subjectIdStr] = $subject;
|
||||
$this->byTenantSchoolCode[$newKey] = $subject;
|
||||
$this->codeKeyById[$subjectIdStr] = $newKey;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(SubjectId $id): Subject
|
||||
{
|
||||
$subject = $this->findById($id);
|
||||
|
||||
if ($subject === null) {
|
||||
throw SubjectNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(SubjectId $id): ?Subject
|
||||
{
|
||||
return $this->byId[(string) $id] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByCode(
|
||||
SubjectCode $code,
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
): ?Subject {
|
||||
$subject = $this->byTenantSchoolCode[$this->codeKey($code, $tenantId, $schoolId)] ?? null;
|
||||
|
||||
// Filtrer les matières archivées (comme Doctrine avec deleted_at IS NULL)
|
||||
if ($subject !== null && $subject->deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findActiveByTenantAndSchool(
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
): array {
|
||||
$result = [];
|
||||
|
||||
foreach ($this->byId as $subject) {
|
||||
if ($subject->tenantId->equals($tenantId)
|
||||
&& $subject->schoolId->equals($schoolId)
|
||||
&& $subject->status === SubjectStatus::ACTIVE
|
||||
) {
|
||||
$result[] = $subject;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(SubjectId $id): void
|
||||
{
|
||||
$subjectIdStr = (string) $id;
|
||||
|
||||
if (isset($this->byId[$subjectIdStr])) {
|
||||
if (isset($this->codeKeyById[$subjectIdStr])) {
|
||||
unset($this->byTenantSchoolCode[$this->codeKeyById[$subjectIdStr]]);
|
||||
unset($this->codeKeyById[$subjectIdStr]);
|
||||
}
|
||||
unset($this->byId[$subjectIdStr]);
|
||||
}
|
||||
}
|
||||
|
||||
private function codeKey(SubjectCode $code, TenantId $tenantId, SchoolId $schoolId): string
|
||||
{
|
||||
return $tenantId . ':' . $schoolId . ':' . mb_strtolower((string) $code, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\School;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* Service temporaire pour résoudre le schoolId à partir du tenant.
|
||||
*
|
||||
* TODO: Ce service sera remplacé quand le module Schools sera implémenté.
|
||||
* Actuellement, il génère un UUID déterministe basé sur le tenantId,
|
||||
* ce qui suppose qu'un tenant = une école.
|
||||
*
|
||||
* Quand le module multi-écoles sera implémenté :
|
||||
* 1. Ce service lira le schoolId depuis le contexte utilisateur
|
||||
* 2. Les données existantes devront être migrées vers les vraies écoles
|
||||
*/
|
||||
final readonly class SchoolIdResolver
|
||||
{
|
||||
/**
|
||||
* Résout le schoolId pour le tenant courant.
|
||||
*
|
||||
* @param string $tenantId L'identifiant du tenant
|
||||
*
|
||||
* @return string L'identifiant de l'école (UUID)
|
||||
*/
|
||||
public function resolveForTenant(string $tenantId): string
|
||||
{
|
||||
// Génère un UUID déterministe basé sur le tenantId
|
||||
// Cela garantit que le même tenantId donne toujours le même schoolId
|
||||
return Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||
|
||||
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 matières.
|
||||
*
|
||||
* Règles d'accès :
|
||||
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
|
||||
* - ENSEIGNANT : lecture seule (via affectations)
|
||||
* - VIE_SCOLAIRE, SECRETARIAT : lecture seule
|
||||
* - ELEVE et PARENT : pas d'accès direct
|
||||
*
|
||||
* @extends Voter<string, Subject|SubjectResource>
|
||||
*/
|
||||
final class SubjectVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'SUBJECT_VIEW';
|
||||
public const string CREATE = 'SUBJECT_CREATE';
|
||||
public const string EDIT = 'SUBJECT_EDIT';
|
||||
public const string DELETE = 'SUBJECT_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, EDIT, DELETE, and VIEW (for collections) don't require a subject
|
||||
// since authorization is role-based, not object-based
|
||||
if ($subject === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $subject instanceof Subject || $subject instanceof SubjectResource;
|
||||
}
|
||||
|
||||
#[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
|
||||
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 matières
|
||||
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 matières
|
||||
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 matières
|
||||
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