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:
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\AssignStudentToClass;
|
||||
|
||||
final readonly class AssignStudentToClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $studentId,
|
||||
public string $classId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\AssignStudentToClass;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EleveDejaAffecteException;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
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 AssignStudentToClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(AssignStudentToClassCommand $command): ClassAssignment
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$studentId = UserId::fromString($command->studentId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence des entités référencées et leur tenant
|
||||
$student = $this->userRepository->get($studentId);
|
||||
|
||||
if (!$student->tenantId->equals($tenantId)) {
|
||||
throw UserNotFoundException::withId($studentId);
|
||||
}
|
||||
|
||||
$class = $this->classRepository->get($classId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
// Vérifier qu'il n'y a pas déjà une affectation pour cette année scolaire
|
||||
$existing = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
|
||||
|
||||
if ($existing !== null) {
|
||||
throw EleveDejaAffecteException::pourAnneeScolaire($studentId);
|
||||
}
|
||||
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: $tenantId,
|
||||
studentId: $studentId,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
final readonly class ChangeStudentClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $studentId,
|
||||
public string $newClassId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ChangeStudentClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ChangeStudentClassCommand $command): ClassAssignment
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$studentId = UserId::fromString($command->studentId);
|
||||
$newClassId = ClassId::fromString($command->newClassId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence de la nouvelle classe, son tenant et son statut
|
||||
$class = $this->classRepository->get($newClassId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($newClassId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($newClassId);
|
||||
}
|
||||
|
||||
// Trouver l'affectation existante
|
||||
$assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
|
||||
|
||||
if ($assignment === null) {
|
||||
throw AffectationEleveNonTrouveeException::pourEleve($studentId);
|
||||
}
|
||||
|
||||
$assignment->changerClasse($newClassId, $this->clock->now());
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateStudent;
|
||||
|
||||
final readonly class CreateStudentCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolName,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public string $classId,
|
||||
public string $academicYearId,
|
||||
public ?string $email = null,
|
||||
public ?string $dateNaissance = null,
|
||||
public ?string $studentNumber = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateStudent;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Throwable;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateStudentHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Connection $connection,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CreateStudentCommand $command): User
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence de la classe, son tenant et son statut
|
||||
$class = $this->classRepository->get($classId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Vérifier l'unicité de l'email si fourni
|
||||
if ($command->email !== null) {
|
||||
$email = new Email($command->email);
|
||||
$existingUser = $this->userRepository->findByEmail($email, $tenantId);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
// Créer l'utilisateur
|
||||
$user = $command->email !== null
|
||||
? User::inviter(
|
||||
email: new Email($command->email),
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $command->firstName,
|
||||
lastName: $command->lastName,
|
||||
invitedAt: $now,
|
||||
dateNaissance: $command->dateNaissance !== null
|
||||
? new DateTimeImmutable($command->dateNaissance)
|
||||
: null,
|
||||
studentNumber: $command->studentNumber,
|
||||
)
|
||||
: User::inscrire(
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $command->firstName,
|
||||
lastName: $command->lastName,
|
||||
inscritAt: $now,
|
||||
dateNaissance: $command->dateNaissance !== null
|
||||
? new DateTimeImmutable($command->dateNaissance)
|
||||
: null,
|
||||
studentNumber: $command->studentNumber,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
// Affecter à la classe
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: $tenantId,
|
||||
studentId: $user->id,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $now,
|
||||
);
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetStudentsWithClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<StudentWithClassDto>
|
||||
*/
|
||||
public function __invoke(GetStudentsWithClassQuery $query): PaginatedResult
|
||||
{
|
||||
$params = [
|
||||
'tenant_id' => $query->tenantId,
|
||||
'academic_year_id' => $query->academicYearId,
|
||||
'role' => json_encode([Role::ELEVE->value]),
|
||||
];
|
||||
|
||||
$whereClause = 'u.tenant_id = :tenant_id AND u.roles::jsonb @> :role::jsonb';
|
||||
|
||||
if ($query->classId !== null) {
|
||||
$whereClause .= ' AND ca.school_class_id = :class_id';
|
||||
$params['class_id'] = $query->classId;
|
||||
}
|
||||
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$whereClause .= ' AND (LOWER(u.last_name) LIKE :search OR LOWER(u.first_name) LIKE :search)';
|
||||
$params['search'] = '%' . mb_strtolower($query->search) . '%';
|
||||
}
|
||||
|
||||
// Count total
|
||||
$countSql = <<<SQL
|
||||
SELECT COUNT(*)
|
||||
FROM users u
|
||||
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
|
||||
WHERE {$whereClause}
|
||||
SQL;
|
||||
|
||||
/** @var int|string|false $totalRaw */
|
||||
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||
$total = (int) $totalRaw;
|
||||
|
||||
// Fetch paginated results
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
|
||||
$selectSql = <<<SQL
|
||||
SELECT
|
||||
u.id,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
u.statut,
|
||||
u.student_number,
|
||||
u.date_naissance,
|
||||
ca.school_class_id AS class_id,
|
||||
sc.name AS class_name,
|
||||
sc.level AS class_level
|
||||
FROM users u
|
||||
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
|
||||
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
|
||||
WHERE {$whereClause}
|
||||
ORDER BY u.last_name ASC, u.first_name ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL;
|
||||
|
||||
$params['limit'] = $query->limit;
|
||||
$params['offset'] = $offset;
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||
|
||||
$items = array_map(static function (array $row): StudentWithClassDto {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $firstName */
|
||||
$firstName = $row['first_name'];
|
||||
/** @var string $lastName */
|
||||
$lastName = $row['last_name'];
|
||||
/** @var string|null $email */
|
||||
$email = $row['email'];
|
||||
/** @var string $statut */
|
||||
$statut = $row['statut'];
|
||||
/** @var string|null $studentNumber */
|
||||
$studentNumber = $row['student_number'];
|
||||
/** @var string|null $dateNaissance */
|
||||
$dateNaissance = $row['date_naissance'];
|
||||
/** @var string|null $classId */
|
||||
$classId = $row['class_id'];
|
||||
/** @var string|null $className */
|
||||
$className = $row['class_name'];
|
||||
/** @var string|null $classLevel */
|
||||
$classLevel = $row['class_level'];
|
||||
|
||||
return new StudentWithClassDto(
|
||||
id: $id,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
email: $email,
|
||||
statut: $statut,
|
||||
studentNumber: $studentNumber,
|
||||
dateNaissance: $dateNaissance,
|
||||
classId: $classId,
|
||||
className: $className,
|
||||
classLevel: $classLevel,
|
||||
);
|
||||
}, $rows);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: $items,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
final readonly class GetStudentsWithClassQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public ?string $classId = null,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
final readonly class StudentWithClassDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public ?string $email,
|
||||
public string $statut,
|
||||
public ?string $studentNumber,
|
||||
public ?string $dateNaissance,
|
||||
public ?string $classId,
|
||||
public ?string $className,
|
||||
public ?string $classLevel,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour vérifier si des élèves sont affectés à une classe.
|
||||
*
|
||||
* Note: L'implémentation complète sera ajoutée quand le module Élèves sera disponible.
|
||||
* Pour l'instant, retourne toujours 0 (aucun élève).
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class HasStudentsInClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(HasStudentsInClassQuery $query): int
|
||||
{
|
||||
// TODO: Implémenter la vérification réelle quand le module Élèves sera disponible
|
||||
// Pour l'instant, retourne 0 (permet l'archivage)
|
||||
return 0;
|
||||
return $this->classAssignmentRepository->countByClass(
|
||||
ClassId::fromString($query->classId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user