feat: Permettre la création manuelle d'élèves et leur affectation aux classes

Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un
élève en cours d'année sans passer par un import CSV. Cette fonctionnalité
pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui
sera réutilisé par l'import CSV en masse (Story 3.1).

L'email est désormais optionnel pour les élèves : si fourni, une invitation
est envoyée (User::inviter) ; sinon l'élève est créé avec le statut
INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur
et l'affectation à la classe sont atomiques (transaction DBAL).

Côté frontend, la page /admin/students offre liste paginée, recherche,
filtrage par classe, création via modale (avec détection de doublons
côté serveur), et changement de classe avec optimistic update.
This commit is contained in:
2026-02-23 19:12:21 +01:00
parent e5203097ef
commit 560b941821
49 changed files with 5184 additions and 65 deletions

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\StudentResource;
use App\Administration\Infrastructure\Security\StudentVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
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\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<StudentResource, StudentResource>
*/
final readonly class ChangeStudentClassProcessor implements ProcessorInterface
{
public function __construct(
private ChangeStudentClassHandler $handler,
private ClassRepository $classRepository,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
) {
}
/**
* @param StudentResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): StudentResource
{
if (!$this->authorizationChecker->isGranted(StudentVoter::MANAGE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à changer la classe d\'un élève.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var string $studentId */
$studentId = $uriVariables['id'] ?? '';
$academicYearId = $this->academicYearResolver->resolve('current')
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
try {
$command = new ChangeStudentClassCommand(
tenantId: $tenantId,
studentId: $studentId,
newClassId: $data->classId ?? '',
academicYearId: $academicYearId,
);
$assignment = ($this->handler)($command);
// Dispatch domain events
foreach ($assignment->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
// Update the resource with new class info
$newClass = $this->classRepository->get($assignment->classId);
$data->classId = (string) $assignment->classId;
$data->className = (string) $newClass->name;
$data->classLevel = $newClass->level?->value;
return $data;
} catch (AffectationEleveNonTrouveeException) {
throw new NotFoundHttpException('Élève non trouvé.');
} catch (ClasseNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}