feat: Gestion des classes scolaires
Permet aux administrateurs de créer, modifier et supprimer des classes pour organiser les élèves par niveau. L'archivage soft-delete préserve l'historique tout en masquant les classes obsolètes. Inclut la validation des noms (2-50 caractères), les niveaux scolaires du CP à la Terminale, et les contrôles d'accès par rôle.
This commit is contained in:
@@ -26,6 +26,7 @@ services:
|
|||||||
# Bind named message buses
|
# Bind named message buses
|
||||||
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
|
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
|
||||||
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
|
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
|
||||||
|
Symfony\Component\Messenger\MessageBusInterface $queryBus: '@query.bus'
|
||||||
|
|
||||||
# makes classes in src/ available to be used as services
|
# makes classes in src/ available to be used as services
|
||||||
# this creates a service per class whose id is the fully-qualified class name
|
# this creates a service per class whose id is the fully-qualified class name
|
||||||
@@ -125,6 +126,10 @@ services:
|
|||||||
App\Administration\Domain\Repository\SessionRepository:
|
App\Administration\Domain\Repository\SessionRepository:
|
||||||
alias: App\Administration\Infrastructure\Persistence\Redis\RedisSessionRepository
|
alias: App\Administration\Infrastructure\Persistence\Redis\RedisSessionRepository
|
||||||
|
|
||||||
|
# Class Repository (Story 2.1 - Gestion des classes)
|
||||||
|
App\Administration\Domain\Repository\ClassRepository:
|
||||||
|
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineClassRepository
|
||||||
|
|
||||||
# GeoLocation Service (null implementation - no geolocation)
|
# GeoLocation Service (null implementation - no geolocation)
|
||||||
App\Administration\Application\Port\GeoLocationService:
|
App\Administration\Application\Port\GeoLocationService:
|
||||||
alias: App\Administration\Infrastructure\Service\NullGeoLocationService
|
alias: App\Administration\Infrastructure\Service\NullGeoLocationService
|
||||||
|
|||||||
59
backend/migrations/Version20260204100000.php
Normal file
59
backend/migrations/Version20260204100000.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration pour la table school_classes.
|
||||||
|
*
|
||||||
|
* @see Story 2.1 - Création et Gestion des Classes
|
||||||
|
* @see FR73: Organiser les élèves par groupes pédagogiques
|
||||||
|
*/
|
||||||
|
final class Version20260204100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create school_classes table (Story 2.1)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE school_classes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
school_id UUID NOT NULL,
|
||||||
|
academic_year_id UUID NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
level VARCHAR(50),
|
||||||
|
capacity INT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
UNIQUE(tenant_id, academic_year_id, name)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Index pour les requêtes fréquentes
|
||||||
|
$this->addSql('CREATE INDEX idx_school_classes_tenant_id ON school_classes(tenant_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_school_classes_academic_year ON school_classes(academic_year_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_school_classes_status ON school_classes(status)');
|
||||||
|
$this->addSql('CREATE INDEX idx_school_classes_school ON school_classes(school_id)');
|
||||||
|
|
||||||
|
// Index composite pour les requêtes de liste par tenant et année
|
||||||
|
$this->addSql('CREATE INDEX idx_school_classes_tenant_year_status ON school_classes(tenant_id, academic_year_id, status)');
|
||||||
|
|
||||||
|
$this->addSql("COMMENT ON TABLE school_classes IS 'Classes scolaires organisées par année académique et établissement (FR73)'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS school_classes');
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/migrations/Version20260205100000.php
Normal file
48
backend/migrations/Version20260205100000.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration pour corriger la contrainte d'unicité sur school_classes.
|
||||||
|
*
|
||||||
|
* La contrainte UNIQUE(tenant_id, academic_year_id, name) incluait les classes
|
||||||
|
* archivées (soft delete), ce qui empêchait de recréer une classe avec le même
|
||||||
|
* nom après archivage.
|
||||||
|
*
|
||||||
|
* Cette migration remplace la contrainte par un index unique partiel qui exclut
|
||||||
|
* les lignes archivées (deleted_at IS NOT NULL).
|
||||||
|
*/
|
||||||
|
final class Version20260205100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Replace school_classes unique constraint with partial unique index for soft delete support';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Supprimer la contrainte unique existante
|
||||||
|
$this->addSql('ALTER TABLE school_classes DROP CONSTRAINT IF EXISTS school_classes_tenant_id_academic_year_id_name_key');
|
||||||
|
|
||||||
|
// Créer un index unique partiel qui exclut les classes archivées
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE UNIQUE INDEX idx_school_classes_unique_name_active
|
||||||
|
ON school_classes (tenant_id, academic_year_id, name)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Supprimer l'index partiel
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS idx_school_classes_unique_name_active');
|
||||||
|
|
||||||
|
// Restaurer la contrainte unique originale
|
||||||
|
$this->addSql('ALTER TABLE school_classes ADD CONSTRAINT school_classes_tenant_id_academic_year_id_name_key UNIQUE (tenant_id, academic_year_id, name)');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\ArchiveClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command pour archiver (soft delete) une classe scolaire.
|
||||||
|
*/
|
||||||
|
final readonly class ArchiveClassCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $classId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\ArchiveClass;
|
||||||
|
|
||||||
|
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery;
|
||||||
|
use App\Administration\Domain\Exception\ClasseNonSupprimableException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Messenger\Stamp\HandledStamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour archiver (soft delete) une classe scolaire.
|
||||||
|
*
|
||||||
|
* Vérifie qu'aucun élève n'est affecté à la classe avant de permettre l'archivage.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class ArchiveClassHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
private MessageBusInterface $queryBus,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(ArchiveClassCommand $command): SchoolClass
|
||||||
|
{
|
||||||
|
$classId = ClassId::fromString($command->classId);
|
||||||
|
$class = $this->classRepository->get($classId);
|
||||||
|
|
||||||
|
// Vérifier s'il y a des élèves affectés
|
||||||
|
$envelope = $this->queryBus->dispatch(new HasStudentsInClassQuery($command->classId));
|
||||||
|
/** @var HandledStamp|null $handledStamp */
|
||||||
|
$handledStamp = $envelope->last(HandledStamp::class);
|
||||||
|
|
||||||
|
if ($handledStamp !== null) {
|
||||||
|
/** @var int $studentCount */
|
||||||
|
$studentCount = $handledStamp->getResult();
|
||||||
|
|
||||||
|
if ($studentCount > 0) {
|
||||||
|
throw ClasseNonSupprimableException::carElevesAffectes($classId, $studentCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$class->archiver($this->clock->now());
|
||||||
|
|
||||||
|
$this->classRepository->save($class);
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\CreateClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command pour créer une nouvelle classe scolaire.
|
||||||
|
*/
|
||||||
|
final readonly class CreateClassCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $tenantId,
|
||||||
|
public string $schoolId,
|
||||||
|
public string $academicYearId,
|
||||||
|
public string $name,
|
||||||
|
public ?string $level,
|
||||||
|
public ?int $capacity,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\CreateClass;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour créer une nouvelle classe scolaire.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class CreateClassHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(CreateClassCommand $command): SchoolClass
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString($command->tenantId);
|
||||||
|
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||||
|
$name = new ClassName($command->name);
|
||||||
|
|
||||||
|
// Vérifier l'unicité du nom dans le tenant et l'année scolaire
|
||||||
|
$existingClass = $this->classRepository->findByName($name, $tenantId, $academicYearId);
|
||||||
|
if ($existingClass !== null) {
|
||||||
|
throw ClasseDejaExistanteException::avecNom($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = SchoolClass::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: SchoolId::fromString($command->schoolId),
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
name: $name,
|
||||||
|
level: $command->level !== null ? SchoolLevel::from($command->level) : null,
|
||||||
|
capacity: $command->capacity,
|
||||||
|
createdAt: $this->clock->now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->classRepository->save($class);
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\CreateClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat de la création d'une classe.
|
||||||
|
*/
|
||||||
|
final readonly class CreateClassResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $classId,
|
||||||
|
public string $name,
|
||||||
|
public ?string $level,
|
||||||
|
public ?int $capacity,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\UpdateClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command pour modifier une classe scolaire existante.
|
||||||
|
*/
|
||||||
|
final readonly class UpdateClassCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $classId,
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?string $level = null,
|
||||||
|
public ?int $capacity = null,
|
||||||
|
public ?string $description = null,
|
||||||
|
public bool $clearLevel = false,
|
||||||
|
public bool $clearCapacity = false,
|
||||||
|
public bool $clearDescription = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Command\UpdateClass;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour modifier une classe scolaire existante.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'command.bus')]
|
||||||
|
final readonly class UpdateClassHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(UpdateClassCommand $command): SchoolClass
|
||||||
|
{
|
||||||
|
$class = $this->classRepository->get(ClassId::fromString($command->classId));
|
||||||
|
$now = $this->clock->now();
|
||||||
|
|
||||||
|
if ($command->name !== null) {
|
||||||
|
$newName = new ClassName($command->name);
|
||||||
|
|
||||||
|
// Vérifier l'unicité du nouveau nom (sauf si c'est le même)
|
||||||
|
if (!$class->name->equals($newName)) {
|
||||||
|
$existingClass = $this->classRepository->findByName(
|
||||||
|
$newName,
|
||||||
|
$class->tenantId,
|
||||||
|
$class->academicYearId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existingClass !== null && !$existingClass->id->equals($class->id)) {
|
||||||
|
throw ClasseDejaExistanteException::avecNom($newName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$class->renommer($newName, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($command->level !== null) {
|
||||||
|
$class->changerNiveau(SchoolLevel::from($command->level), $now);
|
||||||
|
} elseif ($command->clearLevel) {
|
||||||
|
$class->changerNiveau(null, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($command->capacity !== null) {
|
||||||
|
$class->changerCapacite($command->capacity, $now);
|
||||||
|
} elseif ($command->clearCapacity) {
|
||||||
|
$class->changerCapacite(null, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($command->description !== null) {
|
||||||
|
$class->decrire($command->description, $now);
|
||||||
|
} elseif ($command->clearDescription) {
|
||||||
|
$class->decrire(null, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->classRepository->save($class);
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Query\GetClasses;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour représenter une classe dans les réponses de query.
|
||||||
|
*/
|
||||||
|
final readonly class ClassDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $name,
|
||||||
|
public ?string $level,
|
||||||
|
public ?int $capacity,
|
||||||
|
public string $status,
|
||||||
|
public ?string $description,
|
||||||
|
public DateTimeImmutable $createdAt,
|
||||||
|
public DateTimeImmutable $updatedAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromDomain(SchoolClass $class): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: (string) $class->id,
|
||||||
|
name: (string) $class->name,
|
||||||
|
level: $class->level?->value,
|
||||||
|
capacity: $class->capacity,
|
||||||
|
status: $class->status->value,
|
||||||
|
description: $class->description,
|
||||||
|
createdAt: $class->createdAt,
|
||||||
|
updatedAt: $class->updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Query\GetClasses;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler pour récupérer les classes actives d'un tenant.
|
||||||
|
*/
|
||||||
|
#[AsMessageHandler(bus: 'query.bus')]
|
||||||
|
final readonly class GetClassesHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ClassDto[]
|
||||||
|
*/
|
||||||
|
public function __invoke(GetClassesQuery $query): array
|
||||||
|
{
|
||||||
|
$classes = $this->classRepository->findActiveByTenantAndYear(
|
||||||
|
TenantId::fromString($query->tenantId),
|
||||||
|
AcademicYearId::fromString($query->academicYearId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn ($class) => ClassDto::fromDomain($class),
|
||||||
|
$classes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Query\GetClasses;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query pour récupérer les classes actives d'un tenant pour une année scolaire.
|
||||||
|
*/
|
||||||
|
final readonly class GetClassesQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $tenantId,
|
||||||
|
public string $academicYearId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||||
|
|
||||||
|
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 __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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query pour vérifier si des élèves sont affectés à une classe.
|
||||||
|
*
|
||||||
|
* Retourne le nombre d'élèves affectés (0 si aucun).
|
||||||
|
*/
|
||||||
|
final readonly class HasStudentsInClassQuery
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $classId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/Administration/Domain/Event/ClasseArchivee.php
Normal file
37
backend/src/Administration/Domain/Event/ClasseArchivee.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lors de l'archivage d'une classe (soft delete).
|
||||||
|
*/
|
||||||
|
final readonly class ClasseArchivee implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ClassId $classId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->classId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/Administration/Domain/Event/ClasseCreee.php
Normal file
41
backend/src/Administration/Domain/Event/ClasseCreee.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lors de la création d'une classe.
|
||||||
|
*/
|
||||||
|
final readonly class ClasseCreee implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ClassId $classId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public ClassName $name,
|
||||||
|
public ?SchoolLevel $level,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->classId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/src/Administration/Domain/Event/ClasseModifiee.php
Normal file
40
backend/src/Administration/Domain/Event/ClasseModifiee.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Event;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Événement émis lors de la modification du nom d'une classe.
|
||||||
|
*/
|
||||||
|
final readonly class ClasseModifiee implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ClassId $classId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public ClassName $ancienNom,
|
||||||
|
public ClassName $nouveauNom,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->classId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ClassNameInvalideException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function pourLongueur(string $value, int $min, int $max): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Le nom de classe "%s" doit contenir entre %d et %d caractères.',
|
||||||
|
$value,
|
||||||
|
$min,
|
||||||
|
$max,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ClasseDejaExistanteException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function avecNom(ClassName $name): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'Une classe avec le nom "%s" existe déjà pour cette année scolaire.',
|
||||||
|
$name,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ClasseNonSupprimableException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function carElevesAffectes(ClassId $classId, int $nombreEleves): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'La classe "%s" ne peut pas être supprimée car %d élève(s) y sont encore affectés. Veuillez d\'abord réaffecter les élèves.',
|
||||||
|
$classId,
|
||||||
|
$nombreEleves,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class ClasseNotFoundException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function withId(ClassId $classId): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'La classe avec l\'ID "%s" n\'a pas été trouvée.',
|
||||||
|
$classId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifiant unique d'une année scolaire.
|
||||||
|
*/
|
||||||
|
final readonly class AcademicYearId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
final readonly class ClassId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ClassNameInvalideException;
|
||||||
|
|
||||||
|
use function assert;
|
||||||
|
use function mb_strlen;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object représentant le nom d'une classe.
|
||||||
|
*
|
||||||
|
* Contraintes :
|
||||||
|
* - Entre 2 et 50 caractères
|
||||||
|
* - Non vide après trim
|
||||||
|
*
|
||||||
|
* Note: Les property hooks PHP 8.5 ne sont pas compatibles avec readonly.
|
||||||
|
* La validation reste dans le constructeur pour préserver l'immutabilité du Value Object.
|
||||||
|
*/
|
||||||
|
final readonly class ClassName
|
||||||
|
{
|
||||||
|
private const int MIN_LENGTH = 2;
|
||||||
|
private const int MAX_LENGTH = 50;
|
||||||
|
|
||||||
|
/** @var non-empty-string */
|
||||||
|
public string $value;
|
||||||
|
|
||||||
|
public function __construct(string $value)
|
||||||
|
{
|
||||||
|
$trimmed = trim($value);
|
||||||
|
$length = mb_strlen($trimmed);
|
||||||
|
|
||||||
|
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
|
||||||
|
throw ClassNameInvalideException::pourLongueur($value, self::MIN_LENGTH, self::MAX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After validation, $trimmed is guaranteed to be non-empty (MIN_LENGTH >= 2)
|
||||||
|
assert($trimmed !== '');
|
||||||
|
$this->value = $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === $other->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return non-empty-string
|
||||||
|
*/
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statut du cycle de vie d'une classe.
|
||||||
|
*/
|
||||||
|
enum ClassStatus: string
|
||||||
|
{
|
||||||
|
case ACTIVE = 'active';
|
||||||
|
case ARCHIVED = 'archived';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine si la classe peut recevoir des élèves.
|
||||||
|
*/
|
||||||
|
public function peutRecevoirEleves(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine si la classe est visible dans les listes actives.
|
||||||
|
*/
|
||||||
|
public function estVisible(): bool
|
||||||
|
{
|
||||||
|
return $this === self::ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le libellé pour affichage.
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ACTIVE => 'Active',
|
||||||
|
self::ARCHIVED => 'Archivée',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\ClasseArchivee;
|
||||||
|
use App\Administration\Domain\Event\ClasseCreee;
|
||||||
|
use App\Administration\Domain\Event\ClasseModifiee;
|
||||||
|
use App\Shared\Domain\AggregateRoot;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate Root représentant une classe scolaire.
|
||||||
|
*
|
||||||
|
* Une classe appartient à un établissement (tenant), une école et une année scolaire.
|
||||||
|
* Elle peut recevoir des élèves et est organisée par niveau scolaire.
|
||||||
|
*
|
||||||
|
* @see FR73: Organiser les élèves par groupes pédagogiques
|
||||||
|
*/
|
||||||
|
final class SchoolClass extends AggregateRoot
|
||||||
|
{
|
||||||
|
public private(set) ?string $description = null;
|
||||||
|
public private(set) DateTimeImmutable $updatedAt;
|
||||||
|
public private(set) ?DateTimeImmutable $deletedAt = null;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public private(set) ClassId $id,
|
||||||
|
public private(set) TenantId $tenantId,
|
||||||
|
public private(set) SchoolId $schoolId,
|
||||||
|
public private(set) AcademicYearId $academicYearId,
|
||||||
|
public private(set) ClassName $name,
|
||||||
|
public private(set) ?SchoolLevel $level,
|
||||||
|
public private(set) ?int $capacity,
|
||||||
|
public private(set) ClassStatus $status,
|
||||||
|
public private(set) DateTimeImmutable $createdAt,
|
||||||
|
) {
|
||||||
|
$this->updatedAt = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une nouvelle classe scolaire.
|
||||||
|
*/
|
||||||
|
public static function creer(
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
ClassName $name,
|
||||||
|
?SchoolLevel $level,
|
||||||
|
?int $capacity,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
): self {
|
||||||
|
$class = new self(
|
||||||
|
id: ClassId::generate(),
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
name: $name,
|
||||||
|
level: $level,
|
||||||
|
capacity: $capacity,
|
||||||
|
status: ClassStatus::ACTIVE,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$class->recordEvent(new ClasseCreee(
|
||||||
|
classId: $class->id,
|
||||||
|
tenantId: $class->tenantId,
|
||||||
|
name: $class->name,
|
||||||
|
level: $class->level,
|
||||||
|
occurredOn: $createdAt,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renomme la classe.
|
||||||
|
*/
|
||||||
|
public function renommer(ClassName $nouveauNom, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->name->equals($nouveauNom)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ancienNom = $this->name;
|
||||||
|
$this->name = $nouveauNom;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
|
||||||
|
$this->recordEvent(new ClasseModifiee(
|
||||||
|
classId: $this->id,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
ancienNom: $ancienNom,
|
||||||
|
nouveauNom: $nouveauNom,
|
||||||
|
occurredOn: $at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifie le niveau scolaire de la classe.
|
||||||
|
*/
|
||||||
|
public function changerNiveau(?SchoolLevel $niveau, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->level === $niveau) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->level = $niveau;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifie la capacité maximale de la classe.
|
||||||
|
*/
|
||||||
|
public function changerCapacite(?int $capacity, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->capacity === $capacity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->capacity = $capacity;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute ou modifie la description de la classe.
|
||||||
|
*/
|
||||||
|
public function decrire(?string $description, DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive la classe (soft delete).
|
||||||
|
*
|
||||||
|
* Note: La vérification des élèves affectés doit être faite par l'Application Layer
|
||||||
|
* via une Query avant d'appeler cette méthode.
|
||||||
|
*/
|
||||||
|
public function archiver(DateTimeImmutable $at): void
|
||||||
|
{
|
||||||
|
if ($this->status === ClassStatus::ARCHIVED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = ClassStatus::ARCHIVED;
|
||||||
|
$this->deletedAt = $at;
|
||||||
|
$this->updatedAt = $at;
|
||||||
|
|
||||||
|
$this->recordEvent(new ClasseArchivee(
|
||||||
|
classId: $this->id,
|
||||||
|
tenantId: $this->tenantId,
|
||||||
|
occurredOn: $at,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la classe est active.
|
||||||
|
*/
|
||||||
|
public function estActive(): bool
|
||||||
|
{
|
||||||
|
return $this->status === ClassStatus::ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si la classe peut recevoir des élèves.
|
||||||
|
*/
|
||||||
|
public function peutRecevoirEleves(): bool
|
||||||
|
{
|
||||||
|
return $this->status->peutRecevoirEleves();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstitue une SchoolClass depuis le stockage.
|
||||||
|
*
|
||||||
|
* @internal Pour usage Infrastructure uniquement
|
||||||
|
*/
|
||||||
|
public static function reconstitute(
|
||||||
|
ClassId $id,
|
||||||
|
TenantId $tenantId,
|
||||||
|
SchoolId $schoolId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
ClassName $name,
|
||||||
|
?SchoolLevel $level,
|
||||||
|
?int $capacity,
|
||||||
|
ClassStatus $status,
|
||||||
|
?string $description,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
DateTimeImmutable $updatedAt,
|
||||||
|
?DateTimeImmutable $deletedAt,
|
||||||
|
): self {
|
||||||
|
$class = new self(
|
||||||
|
id: $id,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
name: $name,
|
||||||
|
level: $level,
|
||||||
|
capacity: $capacity,
|
||||||
|
status: $status,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$class->description = $description;
|
||||||
|
$class->updatedAt = $updatedAt;
|
||||||
|
$class->deletedAt = $deletedAt;
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifiant unique d'un établissement scolaire.
|
||||||
|
*/
|
||||||
|
final readonly class SchoolId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Niveau scolaire selon le référentiel Éducation Nationale.
|
||||||
|
*
|
||||||
|
* Primaire : CP, CE1, CE2, CM1, CM2
|
||||||
|
* Collège : 6ème, 5ème, 4ème, 3ème
|
||||||
|
* Lycée : 2nde, 1ère, Terminale
|
||||||
|
*/
|
||||||
|
enum SchoolLevel: string
|
||||||
|
{
|
||||||
|
// Primaire
|
||||||
|
case CP = 'CP';
|
||||||
|
case CE1 = 'CE1';
|
||||||
|
case CE2 = 'CE2';
|
||||||
|
case CM1 = 'CM1';
|
||||||
|
case CM2 = 'CM2';
|
||||||
|
|
||||||
|
// Collège
|
||||||
|
case SIXIEME = '6ème';
|
||||||
|
case CINQUIEME = '5ème';
|
||||||
|
case QUATRIEME = '4ème';
|
||||||
|
case TROISIEME = '3ème';
|
||||||
|
|
||||||
|
// Lycée
|
||||||
|
case SECONDE = '2nde';
|
||||||
|
case PREMIERE = '1ère';
|
||||||
|
case TERMINALE = 'Terminale';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le libellé du niveau pour affichage.
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine si le niveau appartient au cycle primaire.
|
||||||
|
*/
|
||||||
|
public function estPrimaire(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CP, self::CE1, self::CE2, self::CM1, self::CM2 => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine si le niveau appartient au cycle collège.
|
||||||
|
*/
|
||||||
|
public function estCollege(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::SIXIEME, self::CINQUIEME, self::QUATRIEME, self::TROISIEME => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine si le niveau appartient au cycle lycée.
|
||||||
|
*/
|
||||||
|
public function estLycee(): bool
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::SECONDE, self::PREMIERE, self::TERMINALE => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le cycle d'enseignement.
|
||||||
|
*/
|
||||||
|
public function cycle(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CP, self::CE1, self::CE2, self::CM1, self::CM2 => 'Primaire',
|
||||||
|
self::SIXIEME, self::CINQUIEME, self::QUATRIEME, self::TROISIEME => 'Collège',
|
||||||
|
self::SECONDE, self::PREMIERE, self::TERMINALE => 'Lycée',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des niveaux scolaires valides selon le référentiel Éducation Nationale.
|
||||||
|
*
|
||||||
|
* Utilisé pour :
|
||||||
|
* - L'enum SchoolLevel
|
||||||
|
* - La validation API Platform
|
||||||
|
* - Le frontend (formulaires)
|
||||||
|
*/
|
||||||
|
final class SchoolLevels
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const array ALL = [
|
||||||
|
'CP',
|
||||||
|
'CE1',
|
||||||
|
'CE2',
|
||||||
|
'CM1',
|
||||||
|
'CM2',
|
||||||
|
'6ème',
|
||||||
|
'5ème',
|
||||||
|
'4ème',
|
||||||
|
'3ème',
|
||||||
|
'2nde',
|
||||||
|
'1ère',
|
||||||
|
'Terminale',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
interface ClassRepository
|
||||||
|
{
|
||||||
|
public function save(SchoolClass $class): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \App\Administration\Domain\Exception\ClasseNotFoundException
|
||||||
|
*/
|
||||||
|
public function get(ClassId $id): SchoolClass;
|
||||||
|
|
||||||
|
public function findById(ClassId $id): ?SchoolClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche une classe par nom dans un tenant et une année scolaire.
|
||||||
|
*/
|
||||||
|
public function findByName(
|
||||||
|
ClassName $name,
|
||||||
|
TenantId $tenantId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
): ?SchoolClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne toutes les classes actives d'un tenant pour une année scolaire.
|
||||||
|
*
|
||||||
|
* @return SchoolClass[]
|
||||||
|
*/
|
||||||
|
public function findActiveByTenantAndYear(
|
||||||
|
TenantId $tenantId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime une classe du repository.
|
||||||
|
*/
|
||||||
|
public function delete(ClassId $id): void;
|
||||||
|
}
|
||||||
@@ -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\CreateClass\CreateClassCommand;
|
||||||
|
use App\Administration\Application\Command\CreateClass\CreateClassHandler;
|
||||||
|
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\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
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 classe.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<ClassResource, ClassResource>
|
||||||
|
*/
|
||||||
|
final readonly class CreateClassProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CreateClassHandler $handler,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ClassResource $data
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClassResource
|
||||||
|
{
|
||||||
|
if (!$this->authorizationChecker->isGranted(ClassVoter::CREATE)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer une classe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->tenantContext->hasTenant()) {
|
||||||
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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.
|
||||||
|
$schoolId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "school-{$tenantId}")->toString();
|
||||||
|
$academicYearId = Uuid::uuid5(Uuid::NAMESPACE_DNS, "academic-year-2024-2025-{$tenantId}")->toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = new CreateClassCommand(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
name: $data->name ?? '',
|
||||||
|
level: $data->level,
|
||||||
|
capacity: $data->capacity,
|
||||||
|
);
|
||||||
|
|
||||||
|
$class = ($this->handler)($command);
|
||||||
|
|
||||||
|
// Dispatch domain events from the created aggregate
|
||||||
|
foreach ($class->pullDomainEvents() as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the created resource
|
||||||
|
$resource = new ClassResource();
|
||||||
|
$resource->id = (string) $class->id;
|
||||||
|
$resource->name = (string) $class->name;
|
||||||
|
$resource->level = $class->level?->value;
|
||||||
|
$resource->capacity = $class->capacity;
|
||||||
|
$resource->status = $class->status->value;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
} catch (ClassNameInvalideException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
} catch (ClasseDejaExistanteException $e) {
|
||||||
|
throw new ConflictHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Administration\Application\Command\ArchiveClass\ArchiveClassCommand;
|
||||||
|
use App\Administration\Application\Command\ArchiveClass\ArchiveClassHandler;
|
||||||
|
use App\Administration\Domain\Exception\ClasseNonSupprimableException;
|
||||||
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||||
|
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor API Platform pour supprimer (archiver) une classe.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<ClassResource, null>
|
||||||
|
*/
|
||||||
|
final readonly class DeleteClassProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ArchiveClassHandler $handler,
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ClassResource $data
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||||
|
{
|
||||||
|
$classId = $uriVariables['id'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($classId)) {
|
||||||
|
throw new BadRequestHttpException('ID de classe manquant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les permissions avant toute action
|
||||||
|
$class = $this->classRepository->findById(ClassId::fromString($classId));
|
||||||
|
if ($class === null) {
|
||||||
|
throw new NotFoundHttpException('Classe introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->authorizationChecker->isGranted(ClassVoter::DELETE, $class)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à supprimer cette classe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = new ArchiveClassCommand(classId: $classId);
|
||||||
|
|
||||||
|
$archivedClass = ($this->handler)($command);
|
||||||
|
|
||||||
|
// Dispatch domain events from the archived aggregate
|
||||||
|
foreach ($archivedClass->pullDomainEvents() as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (ClasseNotFoundException) {
|
||||||
|
throw new NotFoundHttpException('Classe introuvable.');
|
||||||
|
} catch (ClasseNonSupprimableException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Administration\Application\Command\UpdateClass\UpdateClassCommand;
|
||||||
|
use App\Administration\Application\Command\UpdateClass\UpdateClassHandler;
|
||||||
|
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||||
|
use App\Administration\Domain\Exception\ClassNameInvalideException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||||
|
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
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\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor API Platform pour modifier une classe.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<ClassResource, ClassResource>
|
||||||
|
*/
|
||||||
|
final readonly class UpdateClassProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UpdateClassHandler $handler,
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
private MessageBusInterface $eventBus,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ClassResource $data
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClassResource
|
||||||
|
{
|
||||||
|
$classId = $uriVariables['id'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($classId)) {
|
||||||
|
throw new BadRequestHttpException('ID de classe manquant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les permissions avant toute action
|
||||||
|
$class = $this->classRepository->findById(ClassId::fromString($classId));
|
||||||
|
if ($class === null) {
|
||||||
|
throw new NotFoundHttpException('Classe introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->authorizationChecker->isGranted(ClassVoter::EDIT, $class)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier cette classe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: $classId,
|
||||||
|
name: $data->name,
|
||||||
|
level: $data->level,
|
||||||
|
capacity: $data->capacity,
|
||||||
|
description: $data->description,
|
||||||
|
clearLevel: $data->clearLevel ?? false,
|
||||||
|
clearCapacity: $data->clearCapacity ?? false,
|
||||||
|
clearDescription: $data->clearDescription ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$updatedClass = ($this->handler)($command);
|
||||||
|
|
||||||
|
// Dispatch domain events from the mutated aggregate
|
||||||
|
foreach ($updatedClass->pullDomainEvents() as $event) {
|
||||||
|
$this->eventBus->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated resource
|
||||||
|
$resource = new ClassResource();
|
||||||
|
$resource->id = (string) $updatedClass->id;
|
||||||
|
$resource->name = (string) $updatedClass->name;
|
||||||
|
$resource->level = $updatedClass->level?->value;
|
||||||
|
$resource->capacity = $updatedClass->capacity;
|
||||||
|
$resource->description = $updatedClass->description;
|
||||||
|
$resource->status = $updatedClass->status->value;
|
||||||
|
$resource->createdAt = $updatedClass->createdAt;
|
||||||
|
$resource->updatedAt = $updatedClass->updatedAt;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
} catch (ClasseNotFoundException) {
|
||||||
|
throw new NotFoundHttpException('Classe introuvable.');
|
||||||
|
} catch (ClassNameInvalideException $e) {
|
||||||
|
throw new BadRequestHttpException($e->getMessage());
|
||||||
|
} catch (ClasseDejaExistanteException $e) {
|
||||||
|
throw new ConflictHttpException($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
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\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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State Provider pour récupérer la liste des classes.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<ClassResource>
|
||||||
|
*/
|
||||||
|
final readonly class ClassCollectionProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GetClassesHandler $handler,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ClassResource[]
|
||||||
|
*/
|
||||||
|
#[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(ClassVoter::VIEW)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les classes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->tenantContext->hasTenant()) {
|
||||||
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$query = new GetClassesQuery(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
);
|
||||||
|
|
||||||
|
$classDtos = ($this->handler)($query);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static function ($dto) {
|
||||||
|
$resource = new ClassResource();
|
||||||
|
$resource->id = $dto->id;
|
||||||
|
$resource->name = $dto->name;
|
||||||
|
$resource->level = $dto->level;
|
||||||
|
$resource->capacity = $dto->capacity;
|
||||||
|
$resource->description = $dto->description;
|
||||||
|
$resource->status = $dto->status;
|
||||||
|
$resource->createdAt = $dto->createdAt;
|
||||||
|
$resource->updatedAt = $dto->updatedAt;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
},
|
||||||
|
$classDtos,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||||
|
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
use Override;
|
||||||
|
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 classe par son ID.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<ClassResource>
|
||||||
|
*/
|
||||||
|
final readonly class ClassItemProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ClassRepository $classRepository,
|
||||||
|
private TenantContext $tenantContext,
|
||||||
|
private AuthorizationCheckerInterface $authorizationChecker,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?ClassResource
|
||||||
|
{
|
||||||
|
$id = $uriVariables['id'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->tenantContext->hasTenant()) {
|
||||||
|
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$classId = ClassId::fromString($id);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
throw new NotFoundHttpException('Classe introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = $this->classRepository->findById($classId);
|
||||||
|
|
||||||
|
if ($class === null) {
|
||||||
|
throw new NotFoundHttpException('Classe introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que la classe appartient au tenant courant (comparaison par valeur string)
|
||||||
|
if ((string) $class->tenantId !== (string) $this->tenantContext->getCurrentTenantId()) {
|
||||||
|
throw new NotFoundHttpException('Classe introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les permissions de lecture
|
||||||
|
if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW, $class)) {
|
||||||
|
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir cette classe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$resource = new ClassResource();
|
||||||
|
$resource->id = (string) $class->id;
|
||||||
|
$resource->name = (string) $class->name;
|
||||||
|
$resource->level = $class->level?->value;
|
||||||
|
$resource->capacity = $class->capacity;
|
||||||
|
$resource->description = $class->description;
|
||||||
|
$resource->status = $class->status->value;
|
||||||
|
$resource->createdAt = $class->createdAt;
|
||||||
|
$resource->updatedAt = $class->updatedAt;
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?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\Domain\Model\SchoolClass\SchoolLevels;
|
||||||
|
use App\Administration\Infrastructure\Api\Processor\CreateClassProcessor;
|
||||||
|
use App\Administration\Infrastructure\Api\Processor\DeleteClassProcessor;
|
||||||
|
use App\Administration\Infrastructure\Api\Processor\UpdateClassProcessor;
|
||||||
|
use App\Administration\Infrastructure\Api\Provider\ClassCollectionProvider;
|
||||||
|
use App\Administration\Infrastructure\Api\Provider\ClassItemProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Resource pour la gestion des classes scolaires.
|
||||||
|
*
|
||||||
|
* @see Story 2.1 - Création et Gestion des Classes
|
||||||
|
* @see FR73 - Organiser les élèves par groupes pédagogiques
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'Class',
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/classes',
|
||||||
|
provider: ClassCollectionProvider::class,
|
||||||
|
name: 'get_classes',
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/classes/{id}',
|
||||||
|
provider: ClassItemProvider::class,
|
||||||
|
name: 'get_class',
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/classes',
|
||||||
|
processor: CreateClassProcessor::class,
|
||||||
|
validationContext: ['groups' => ['Default', 'create']],
|
||||||
|
name: 'create_class',
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/classes/{id}',
|
||||||
|
provider: ClassItemProvider::class,
|
||||||
|
processor: UpdateClassProcessor::class,
|
||||||
|
validationContext: ['groups' => ['Default', 'update']],
|
||||||
|
name: 'update_class',
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
uriTemplate: '/classes/{id}',
|
||||||
|
provider: ClassItemProvider::class,
|
||||||
|
processor: DeleteClassProcessor::class,
|
||||||
|
name: 'delete_class',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class ClassResource
|
||||||
|
{
|
||||||
|
#[ApiProperty(identifier: true)]
|
||||||
|
public ?string $id = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Le nom de la classe est requis.', groups: ['create'])]
|
||||||
|
#[Assert\Length(
|
||||||
|
min: 2,
|
||||||
|
max: 50,
|
||||||
|
minMessage: 'Le nom de la classe doit contenir au moins {{ limit }} caractères.',
|
||||||
|
maxMessage: 'Le nom de la classe ne peut pas dépasser {{ limit }} caractères.',
|
||||||
|
)]
|
||||||
|
public ?string $name = null;
|
||||||
|
|
||||||
|
#[Assert\Choice(
|
||||||
|
choices: SchoolLevels::ALL,
|
||||||
|
message: 'Le niveau scolaire doit être un niveau valide.',
|
||||||
|
)]
|
||||||
|
public ?string $level = null;
|
||||||
|
|
||||||
|
#[Assert\PositiveOrZero(message: 'La capacité doit être un nombre positif.')]
|
||||||
|
public ?int $capacity = null;
|
||||||
|
|
||||||
|
public ?string $description = null;
|
||||||
|
|
||||||
|
public ?string $status = null;
|
||||||
|
|
||||||
|
public ?DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
public ?DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permet de supprimer explicitement le niveau lors d'un PATCH.
|
||||||
|
* Si true, le niveau sera mis à null même si level n'est pas fourni.
|
||||||
|
*/
|
||||||
|
#[ApiProperty(readable: false)]
|
||||||
|
public ?bool $clearLevel = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permet de supprimer explicitement la capacité lors d'un PATCH.
|
||||||
|
* Si true, la capacité sera mise à null même si capacity n'est pas fourni.
|
||||||
|
*/
|
||||||
|
#[ApiProperty(readable: false)]
|
||||||
|
public ?bool $clearCapacity = 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final readonly class DoctrineClassRepository implements ClassRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(SchoolClass $class): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'id' => (string) $class->id,
|
||||||
|
'tenant_id' => (string) $class->tenantId,
|
||||||
|
'school_id' => (string) $class->schoolId,
|
||||||
|
'academic_year_id' => (string) $class->academicYearId,
|
||||||
|
'name' => (string) $class->name,
|
||||||
|
'level' => $class->level?->value,
|
||||||
|
'capacity' => $class->capacity,
|
||||||
|
'status' => $class->status->value,
|
||||||
|
'description' => $class->description,
|
||||||
|
'created_at' => $class->createdAt->format(DateTimeImmutable::ATOM),
|
||||||
|
'updated_at' => $class->updatedAt->format(DateTimeImmutable::ATOM),
|
||||||
|
'deleted_at' => $class->deletedAt?->format(DateTimeImmutable::ATOM),
|
||||||
|
];
|
||||||
|
|
||||||
|
$exists = $this->findById($class->id) !== null;
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->connection->update('school_classes', $data, ['id' => (string) $class->id]);
|
||||||
|
} else {
|
||||||
|
$this->connection->insert('school_classes', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function get(ClassId $id): SchoolClass
|
||||||
|
{
|
||||||
|
$class = $this->findById($id);
|
||||||
|
|
||||||
|
if ($class === null) {
|
||||||
|
throw ClasseNotFoundException::withId($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findById(ClassId $id): ?SchoolClass
|
||||||
|
{
|
||||||
|
$row = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM school_classes WHERE id = :id',
|
||||||
|
['id' => (string) $id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hydrate($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByName(
|
||||||
|
ClassName $name,
|
||||||
|
TenantId $tenantId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
): ?SchoolClass {
|
||||||
|
$row = $this->connection->fetchAssociative(
|
||||||
|
'SELECT * FROM school_classes
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND academic_year_id = :academic_year_id
|
||||||
|
AND name = :name
|
||||||
|
AND deleted_at IS NULL',
|
||||||
|
[
|
||||||
|
'tenant_id' => (string) $tenantId,
|
||||||
|
'academic_year_id' => (string) $academicYearId,
|
||||||
|
'name' => (string) $name,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hydrate($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findActiveByTenantAndYear(
|
||||||
|
TenantId $tenantId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
): array {
|
||||||
|
$rows = $this->connection->fetchAllAssociative(
|
||||||
|
'SELECT * FROM school_classes
|
||||||
|
WHERE tenant_id = :tenant_id
|
||||||
|
AND academic_year_id = :academic_year_id
|
||||||
|
AND status = :status
|
||||||
|
ORDER BY name ASC',
|
||||||
|
[
|
||||||
|
'tenant_id' => (string) $tenantId,
|
||||||
|
'academic_year_id' => (string) $academicYearId,
|
||||||
|
'status' => ClassStatus::ACTIVE->value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(fn ($row) => $this->hydrate($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function delete(ClassId $id): void
|
||||||
|
{
|
||||||
|
$this->connection->delete('school_classes', ['id' => (string) $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
*/
|
||||||
|
private function hydrate(array $row): SchoolClass
|
||||||
|
{
|
||||||
|
/** @var string $id */
|
||||||
|
$id = $row['id'];
|
||||||
|
/** @var string $tenantId */
|
||||||
|
$tenantId = $row['tenant_id'];
|
||||||
|
/** @var string $schoolId */
|
||||||
|
$schoolId = $row['school_id'];
|
||||||
|
/** @var string $academicYearId */
|
||||||
|
$academicYearId = $row['academic_year_id'];
|
||||||
|
/** @var string $name */
|
||||||
|
$name = $row['name'];
|
||||||
|
/** @var string|null $level */
|
||||||
|
$level = $row['level'];
|
||||||
|
/** @var int|string|null $capacity */
|
||||||
|
$capacity = $row['capacity'];
|
||||||
|
/** @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 SchoolClass::reconstitute(
|
||||||
|
id: ClassId::fromString($id),
|
||||||
|
tenantId: TenantId::fromString($tenantId),
|
||||||
|
schoolId: SchoolId::fromString($schoolId),
|
||||||
|
academicYearId: AcademicYearId::fromString($academicYearId),
|
||||||
|
name: new ClassName($name),
|
||||||
|
level: $level !== null ? SchoolLevel::from($level) : null,
|
||||||
|
capacity: $capacity !== null ? (int) $capacity : null,
|
||||||
|
status: ClassStatus::from($status),
|
||||||
|
description: $description,
|
||||||
|
createdAt: new DateTimeImmutable($createdAt),
|
||||||
|
updatedAt: new DateTimeImmutable($updatedAt),
|
||||||
|
deletedAt: $deletedAt !== null ? new DateTimeImmutable($deletedAt) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Repository\ClassRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final class InMemoryClassRepository implements ClassRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, SchoolClass> Indexed by ID */
|
||||||
|
private array $byId = [];
|
||||||
|
|
||||||
|
/** @var array<string, SchoolClass> Indexed by tenant:year:name */
|
||||||
|
private array $byTenantYearName = [];
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(SchoolClass $class): void
|
||||||
|
{
|
||||||
|
// If class already exists, remove the old name key (handles renames)
|
||||||
|
$existingClass = $this->byId[(string) $class->id] ?? null;
|
||||||
|
if ($existingClass !== null) {
|
||||||
|
$oldKey = $this->nameKey($existingClass->name, $existingClass->tenantId, $existingClass->academicYearId);
|
||||||
|
unset($this->byTenantYearName[$oldKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->byId[(string) $class->id] = $class;
|
||||||
|
$this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)] = $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function get(ClassId $id): SchoolClass
|
||||||
|
{
|
||||||
|
$class = $this->findById($id);
|
||||||
|
|
||||||
|
if ($class === null) {
|
||||||
|
throw ClasseNotFoundException::withId($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findById(ClassId $id): ?SchoolClass
|
||||||
|
{
|
||||||
|
return $this->byId[(string) $id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findByName(
|
||||||
|
ClassName $name,
|
||||||
|
TenantId $tenantId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
): ?SchoolClass {
|
||||||
|
return $this->byTenantYearName[$this->nameKey($name, $tenantId, $academicYearId)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findActiveByTenantAndYear(
|
||||||
|
TenantId $tenantId,
|
||||||
|
AcademicYearId $academicYearId,
|
||||||
|
): array {
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($this->byId as $class) {
|
||||||
|
if ($class->tenantId->equals($tenantId)
|
||||||
|
&& $class->academicYearId->equals($academicYearId)
|
||||||
|
&& $class->status === ClassStatus::ACTIVE
|
||||||
|
) {
|
||||||
|
$result[] = $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function delete(ClassId $id): void
|
||||||
|
{
|
||||||
|
$class = $this->byId[(string) $id] ?? null;
|
||||||
|
|
||||||
|
if ($class !== null) {
|
||||||
|
unset($this->byId[(string) $id]);
|
||||||
|
unset($this->byTenantYearName[$this->nameKey($class->name, $class->tenantId, $class->academicYearId)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nameKey(ClassName $name, TenantId $tenantId, AcademicYearId $academicYearId): string
|
||||||
|
{
|
||||||
|
return $tenantId . ':' . $academicYearId . ':' . mb_strtolower((string) $name, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Administration\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||||
|
|
||||||
|
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 classes scolaires.
|
||||||
|
*
|
||||||
|
* Règles d'accès :
|
||||||
|
* - ADMIN et SUPER_ADMIN : accès complet (CRUD)
|
||||||
|
* - ENSEIGNANT : lecture seule (via affectations)
|
||||||
|
* - ELEVE et PARENT : lecture de leur classe uniquement
|
||||||
|
*
|
||||||
|
* @extends Voter<string, SchoolClass|ClassResource>
|
||||||
|
*/
|
||||||
|
final class ClassVoter extends Voter
|
||||||
|
{
|
||||||
|
public const string VIEW = 'CLASS_VIEW';
|
||||||
|
public const string CREATE = 'CLASS_CREATE';
|
||||||
|
public const string EDIT = 'CLASS_EDIT';
|
||||||
|
public const string DELETE = 'CLASS_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 and VIEW (for collections) don't require a subject
|
||||||
|
if ($attribute === self::CREATE || ($attribute === self::VIEW && $subject === null)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subject instanceof SchoolClass || $subject instanceof ClassResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
// ELEVE et PARENT sont exclus car ils ne doivent voir que leur propre classe
|
||||||
|
// via un endpoint dédié (non implémenté - nécessite le module Affectations)
|
||||||
|
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 classes
|
||||||
|
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 classes
|
||||||
|
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 classes
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command\ArchiveClass;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\ArchiveClass\ArchiveClassCommand;
|
||||||
|
use App\Administration\Application\Command\ArchiveClass\ArchiveClassHandler;
|
||||||
|
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery;
|
||||||
|
use App\Administration\Domain\Exception\ClasseNonSupprimableException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Messenger\Envelope;
|
||||||
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
use Symfony\Component\Messenger\Stamp\HandledStamp;
|
||||||
|
|
||||||
|
final class ArchiveClassHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||||
|
|
||||||
|
private InMemoryClassRepository $classRepository;
|
||||||
|
private Clock $clock;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->classRepository = new InMemoryClassRepository();
|
||||||
|
$this->clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itArchivesEmptyClass(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$queryBus = $this->createQueryBusReturning(0);
|
||||||
|
$handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock);
|
||||||
|
|
||||||
|
$command = new ArchiveClassCommand(classId: (string) $class->id);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$archivedClass = $this->classRepository->get($class->id);
|
||||||
|
self::assertSame(ClassStatus::ARCHIVED, $archivedClass->status);
|
||||||
|
self::assertNotNull($archivedClass->deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsExceptionWhenStudentsAreAffected(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$queryBus = $this->createQueryBusReturning(5);
|
||||||
|
$handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock);
|
||||||
|
|
||||||
|
$command = new ArchiveClassCommand(classId: (string) $class->id);
|
||||||
|
|
||||||
|
$this->expectException(ClasseNonSupprimableException::class);
|
||||||
|
$this->expectExceptionMessage('5 élève(s)');
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDoesNotModifyStatusWhenAlreadyArchived(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$archiveTime = new DateTimeImmutable('2026-01-20 10:00:00');
|
||||||
|
$class->archiver($archiveTime);
|
||||||
|
$this->classRepository->save($class);
|
||||||
|
|
||||||
|
$queryBus = $this->createQueryBusReturning(0);
|
||||||
|
$handler = new ArchiveClassHandler($this->classRepository, $queryBus, $this->clock);
|
||||||
|
|
||||||
|
$command = new ArchiveClassCommand(classId: (string) $class->id);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$archivedClass = $this->classRepository->get($class->id);
|
||||||
|
// deletedAt should not have changed
|
||||||
|
self::assertEquals($archiveTime, $archivedClass->deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAndSaveClass(): SchoolClass
|
||||||
|
{
|
||||||
|
$class = SchoolClass::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
name: new ClassName('6ème A'),
|
||||||
|
level: SchoolLevel::SIXIEME,
|
||||||
|
capacity: 30,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->classRepository->save($class);
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createQueryBusReturning(int $studentCount): MessageBusInterface
|
||||||
|
{
|
||||||
|
return new class($studentCount) implements MessageBusInterface {
|
||||||
|
public function __construct(private readonly int $studentCount)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(object $message, array $stamps = []): Envelope
|
||||||
|
{
|
||||||
|
if (!$message instanceof HasStudentsInClassQuery) {
|
||||||
|
throw new RuntimeException('Unexpected message type');
|
||||||
|
}
|
||||||
|
|
||||||
|
$envelope = new Envelope($message);
|
||||||
|
|
||||||
|
return $envelope->with(new HandledStamp($this->studentCount, 'handler'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command\CreateClass;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\CreateClass\CreateClassCommand;
|
||||||
|
use App\Administration\Application\Command\CreateClass\CreateClassHandler;
|
||||||
|
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class CreateClassHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||||
|
|
||||||
|
private InMemoryClassRepository $classRepository;
|
||||||
|
private Clock $clock;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->classRepository = new InMemoryClassRepository();
|
||||||
|
$this->clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-01-31 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesClassSuccessfully(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateClassHandler($this->classRepository, $this->clock);
|
||||||
|
$command = new CreateClassCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||||
|
name: '6ème A',
|
||||||
|
level: SchoolLevel::SIXIEME->value,
|
||||||
|
capacity: 30,
|
||||||
|
);
|
||||||
|
|
||||||
|
$class = $handler($command);
|
||||||
|
|
||||||
|
self::assertNotEmpty((string) $class->id);
|
||||||
|
self::assertSame('6ème A', (string) $class->name);
|
||||||
|
self::assertSame(SchoolLevel::SIXIEME, $class->level);
|
||||||
|
self::assertSame(30, $class->capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCreatesClassWithNullLevelAndCapacity(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateClassHandler($this->classRepository, $this->clock);
|
||||||
|
$command = new CreateClassCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||||
|
name: 'Classe spéciale',
|
||||||
|
level: null,
|
||||||
|
capacity: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$class = $handler($command);
|
||||||
|
|
||||||
|
self::assertNotEmpty((string) $class->id);
|
||||||
|
self::assertSame('Classe spéciale', (string) $class->name);
|
||||||
|
self::assertNull($class->level);
|
||||||
|
self::assertNull($class->capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPersistsClassInRepository(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateClassHandler($this->classRepository, $this->clock);
|
||||||
|
$command = new CreateClassCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||||
|
name: '6ème A',
|
||||||
|
level: SchoolLevel::SIXIEME->value,
|
||||||
|
capacity: 30,
|
||||||
|
);
|
||||||
|
|
||||||
|
$createdClass = $handler($command);
|
||||||
|
|
||||||
|
$class = $this->classRepository->get(
|
||||||
|
ClassId::fromString((string) $createdClass->id),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('6ème A', (string) $class->name);
|
||||||
|
self::assertSame(ClassStatus::ACTIVE, $class->status);
|
||||||
|
self::assertSame(SchoolLevel::SIXIEME, $class->level);
|
||||||
|
self::assertSame(30, $class->capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsExceptionWhenClassNameAlreadyExists(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateClassHandler($this->classRepository, $this->clock);
|
||||||
|
$command = new CreateClassCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||||
|
name: '6ème A',
|
||||||
|
level: SchoolLevel::SIXIEME->value,
|
||||||
|
capacity: 30,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First creation should succeed
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
// Second creation with same name should throw
|
||||||
|
$this->expectException(ClasseDejaExistanteException::class);
|
||||||
|
$this->expectExceptionMessage('Une classe avec le nom "6ème A" existe déjà pour cette année scolaire.');
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsSameNameInDifferentTenant(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
// Create in tenant 1
|
||||||
|
$command1 = new CreateClassCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||||
|
name: '6ème A',
|
||||||
|
level: SchoolLevel::SIXIEME->value,
|
||||||
|
capacity: 30,
|
||||||
|
);
|
||||||
|
$class1 = $handler($command1);
|
||||||
|
|
||||||
|
// Create same name in tenant 2 should succeed
|
||||||
|
$command2 = new CreateClassCommand(
|
||||||
|
tenantId: '550e8400-e29b-41d4-a716-446655440099',
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||||
|
name: '6ème A',
|
||||||
|
level: SchoolLevel::SIXIEME->value,
|
||||||
|
capacity: 30,
|
||||||
|
);
|
||||||
|
$class2 = $handler($command2);
|
||||||
|
|
||||||
|
self::assertFalse($class1->id->equals($class2->id));
|
||||||
|
self::assertSame('6ème A', (string) $class1->name);
|
||||||
|
self::assertSame('6ème A', (string) $class2->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsSameNameInDifferentAcademicYear(): void
|
||||||
|
{
|
||||||
|
$handler = new CreateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
// Create in year 1
|
||||||
|
$command1 = new CreateClassCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||||
|
name: '6ème A',
|
||||||
|
level: SchoolLevel::SIXIEME->value,
|
||||||
|
capacity: 30,
|
||||||
|
);
|
||||||
|
$class1 = $handler($command1);
|
||||||
|
|
||||||
|
// Create same name in year 2 should succeed
|
||||||
|
$command2 = new CreateClassCommand(
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
schoolId: self::SCHOOL_ID,
|
||||||
|
academicYearId: '550e8400-e29b-41d4-a716-446655440099',
|
||||||
|
name: '6ème A',
|
||||||
|
level: SchoolLevel::SIXIEME->value,
|
||||||
|
capacity: 30,
|
||||||
|
);
|
||||||
|
$class2 = $handler($command2);
|
||||||
|
|
||||||
|
self::assertFalse($class1->id->equals($class2->id));
|
||||||
|
self::assertSame('6ème A', (string) $class1->name);
|
||||||
|
self::assertSame('6ème A', (string) $class2->name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Application\Command\UpdateClass;
|
||||||
|
|
||||||
|
use App\Administration\Application\Command\UpdateClass\UpdateClassCommand;
|
||||||
|
use App\Administration\Application\Command\UpdateClass\UpdateClassHandler;
|
||||||
|
use App\Administration\Domain\Exception\ClasseDejaExistanteException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class UpdateClassHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||||
|
|
||||||
|
private InMemoryClassRepository $classRepository;
|
||||||
|
private Clock $clock;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->classRepository = new InMemoryClassRepository();
|
||||||
|
$this->clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesClassName(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: (string) $class->id,
|
||||||
|
name: '6ème B',
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$updatedClass = $this->classRepository->get($class->id);
|
||||||
|
self::assertSame('6ème B', (string) $updatedClass->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesClassLevel(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: (string) $class->id,
|
||||||
|
level: SchoolLevel::CINQUIEME->value,
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$updatedClass = $this->classRepository->get($class->id);
|
||||||
|
self::assertSame(SchoolLevel::CINQUIEME, $updatedClass->level);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itClearsClassLevel(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: (string) $class->id,
|
||||||
|
clearLevel: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$updatedClass = $this->classRepository->get($class->id);
|
||||||
|
self::assertNull($updatedClass->level);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesClassCapacity(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: (string) $class->id,
|
||||||
|
capacity: 35,
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$updatedClass = $this->classRepository->get($class->id);
|
||||||
|
self::assertSame(35, $updatedClass->capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesClassDescription(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: (string) $class->id,
|
||||||
|
description: 'Classe option musique',
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$updatedClass = $this->classRepository->get($class->id);
|
||||||
|
self::assertSame('Classe option musique', $updatedClass->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUpdatesMultipleFields(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: (string) $class->id,
|
||||||
|
name: '5ème C',
|
||||||
|
level: SchoolLevel::CINQUIEME->value,
|
||||||
|
capacity: 28,
|
||||||
|
description: 'Section européenne',
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$updatedClass = $this->classRepository->get($class->id);
|
||||||
|
self::assertSame('5ème C', (string) $updatedClass->name);
|
||||||
|
self::assertSame(SchoolLevel::CINQUIEME, $updatedClass->level);
|
||||||
|
self::assertSame(28, $updatedClass->capacity);
|
||||||
|
self::assertSame('Section européenne', $updatedClass->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itThrowsExceptionWhenRenamingToExistingName(): void
|
||||||
|
{
|
||||||
|
// Create first class
|
||||||
|
$class1 = $this->createAndSaveClass();
|
||||||
|
|
||||||
|
// Create second class with different name
|
||||||
|
$class2 = SchoolClass::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
name: new ClassName('6ème B'),
|
||||||
|
level: SchoolLevel::SIXIEME,
|
||||||
|
capacity: 30,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->classRepository->save($class2);
|
||||||
|
|
||||||
|
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
// Try to rename class2 to class1's name
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: (string) $class2->id,
|
||||||
|
name: '6ème A',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(ClasseDejaExistanteException::class);
|
||||||
|
$this->expectExceptionMessage('Une classe avec le nom "6ème A" existe déjà pour cette année scolaire.');
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAllowsRenamingToSameName(): void
|
||||||
|
{
|
||||||
|
$class = $this->createAndSaveClass();
|
||||||
|
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||||
|
|
||||||
|
// Renaming to the same name should work
|
||||||
|
$command = new UpdateClassCommand(
|
||||||
|
classId: (string) $class->id,
|
||||||
|
name: '6ème A',
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler($command);
|
||||||
|
|
||||||
|
$updatedClass = $this->classRepository->get($class->id);
|
||||||
|
self::assertSame('6ème A', (string) $updatedClass->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAndSaveClass(): SchoolClass
|
||||||
|
{
|
||||||
|
$class = SchoolClass::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
name: new ClassName('6ème A'),
|
||||||
|
level: SchoolLevel::SIXIEME,
|
||||||
|
capacity: 30,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->classRepository->save($class);
|
||||||
|
|
||||||
|
return $class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ClassNameInvalideException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ClassNameTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function constructWithValidName(): void
|
||||||
|
{
|
||||||
|
$name = new ClassName('6ème A');
|
||||||
|
|
||||||
|
self::assertSame('6ème A', $name->value);
|
||||||
|
self::assertSame('6ème A', (string) $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function constructTrimsWhitespace(): void
|
||||||
|
{
|
||||||
|
$name = new ClassName(' 6ème A ');
|
||||||
|
|
||||||
|
self::assertSame('6ème A', $name->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('validNamesProvider')]
|
||||||
|
public function constructAcceptsValidNames(string $value): void
|
||||||
|
{
|
||||||
|
$name = new ClassName($value);
|
||||||
|
|
||||||
|
self::assertNotEmpty($name->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function validNamesProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'minimum length' => ['AB'],
|
||||||
|
'typical class name' => ['6ème A'],
|
||||||
|
'longer name' => ['Classe préparatoire aux grandes écoles'],
|
||||||
|
'with numbers' => ['CM1-2'],
|
||||||
|
'maximum length' => [str_repeat('A', 50)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('invalidNamesProvider')]
|
||||||
|
public function constructRejectsInvalidNames(string $value): void
|
||||||
|
{
|
||||||
|
$this->expectException(ClassNameInvalideException::class);
|
||||||
|
|
||||||
|
new ClassName($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function invalidNamesProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'empty string' => [''],
|
||||||
|
'single character' => ['A'],
|
||||||
|
'only whitespace' => [' '],
|
||||||
|
'one char after trim' => [' A '],
|
||||||
|
'too long' => [str_repeat('A', 51)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsTrueForSameValue(): void
|
||||||
|
{
|
||||||
|
$name1 = new ClassName('6ème A');
|
||||||
|
$name2 = new ClassName('6ème A');
|
||||||
|
|
||||||
|
self::assertTrue($name1->equals($name2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsReturnsFalseForDifferentValue(): void
|
||||||
|
{
|
||||||
|
$name1 = new ClassName('6ème A');
|
||||||
|
$name2 = new ClassName('6ème B');
|
||||||
|
|
||||||
|
self::assertFalse($name1->equals($name2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function equalsIsCaseSensitive(): void
|
||||||
|
{
|
||||||
|
$name1 = new ClassName('Classe A');
|
||||||
|
$name2 = new ClassName('classe A');
|
||||||
|
|
||||||
|
self::assertFalse($name1->equals($name2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Event\ClasseArchivee;
|
||||||
|
use App\Administration\Domain\Event\ClasseCreee;
|
||||||
|
use App\Administration\Domain\Event\ClasseModifiee;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SchoolClassTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerCreatesClassWithActiveStatus(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
|
||||||
|
self::assertSame(ClassStatus::ACTIVE, $class->status);
|
||||||
|
self::assertTrue($class->estActive());
|
||||||
|
self::assertTrue($class->peutRecevoirEleves());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerRecordsClasseCreeeEvent(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
|
||||||
|
$events = $class->pullDomainEvents();
|
||||||
|
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(ClasseCreee::class, $events[0]);
|
||||||
|
self::assertSame($class->id, $events[0]->classId);
|
||||||
|
self::assertSame($class->tenantId, $events[0]->tenantId);
|
||||||
|
self::assertSame($class->name, $events[0]->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerSetsAllProperties(): void
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
|
||||||
|
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
|
||||||
|
$name = new ClassName('6ème A');
|
||||||
|
$level = SchoolLevel::SIXIEME;
|
||||||
|
$capacity = 30;
|
||||||
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||||
|
|
||||||
|
$class = SchoolClass::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
name: $name,
|
||||||
|
level: $level,
|
||||||
|
capacity: $capacity,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($class->tenantId->equals($tenantId));
|
||||||
|
self::assertTrue($class->schoolId->equals($schoolId));
|
||||||
|
self::assertTrue($class->academicYearId->equals($academicYearId));
|
||||||
|
self::assertTrue($class->name->equals($name));
|
||||||
|
self::assertSame($level, $class->level);
|
||||||
|
self::assertSame($capacity, $class->capacity);
|
||||||
|
self::assertEquals($createdAt, $class->createdAt);
|
||||||
|
self::assertEquals($createdAt, $class->updatedAt);
|
||||||
|
self::assertNull($class->deletedAt);
|
||||||
|
self::assertNull($class->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerWithNullLevelAndCapacity(): void
|
||||||
|
{
|
||||||
|
$class = SchoolClass::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
name: new ClassName('Classe spéciale'),
|
||||||
|
level: null,
|
||||||
|
capacity: null,
|
||||||
|
createdAt: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($class->level);
|
||||||
|
self::assertNull($class->capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function renommerChangesNameAndRecordsEvent(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$class->pullDomainEvents();
|
||||||
|
$ancienNom = $class->name;
|
||||||
|
$nouveauNom = new ClassName('6ème B');
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$class->renommer($nouveauNom, $at);
|
||||||
|
|
||||||
|
self::assertTrue($class->name->equals($nouveauNom));
|
||||||
|
self::assertEquals($at, $class->updatedAt);
|
||||||
|
|
||||||
|
$events = $class->pullDomainEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(ClasseModifiee::class, $events[0]);
|
||||||
|
self::assertTrue($events[0]->ancienNom->equals($ancienNom));
|
||||||
|
self::assertTrue($events[0]->nouveauNom->equals($nouveauNom));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function renommerWithSameNameDoesNothing(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$class->pullDomainEvents();
|
||||||
|
$originalUpdatedAt = $class->updatedAt;
|
||||||
|
|
||||||
|
$class->renommer(new ClassName('6ème A'), new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||||
|
|
||||||
|
self::assertEquals($originalUpdatedAt, $class->updatedAt);
|
||||||
|
self::assertEmpty($class->pullDomainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerNiveauUpdatesLevel(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$class->changerNiveau(SchoolLevel::CINQUIEME, $at);
|
||||||
|
|
||||||
|
self::assertSame(SchoolLevel::CINQUIEME, $class->level);
|
||||||
|
self::assertEquals($at, $class->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerNiveauWithSameLevelDoesNothing(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$originalUpdatedAt = $class->updatedAt;
|
||||||
|
|
||||||
|
$class->changerNiveau(SchoolLevel::SIXIEME, new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||||
|
|
||||||
|
self::assertEquals($originalUpdatedAt, $class->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerCapaciteUpdatesCapacity(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$class->changerCapacite(35, $at);
|
||||||
|
|
||||||
|
self::assertSame(35, $class->capacity);
|
||||||
|
self::assertEquals($at, $class->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function changerCapaciteWithSameValueDoesNothing(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$originalUpdatedAt = $class->updatedAt;
|
||||||
|
|
||||||
|
$class->changerCapacite(30, new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||||
|
|
||||||
|
self::assertEquals($originalUpdatedAt, $class->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function decrireUpdatesDescription(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$class->decrire('Classe option musique', $at);
|
||||||
|
|
||||||
|
self::assertSame('Classe option musique', $class->description);
|
||||||
|
self::assertEquals($at, $class->updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function archiverChangesStatusAndRecordsEvent(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$class->pullDomainEvents();
|
||||||
|
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$class->archiver($at);
|
||||||
|
|
||||||
|
self::assertSame(ClassStatus::ARCHIVED, $class->status);
|
||||||
|
self::assertFalse($class->estActive());
|
||||||
|
self::assertFalse($class->peutRecevoirEleves());
|
||||||
|
self::assertEquals($at, $class->deletedAt);
|
||||||
|
self::assertEquals($at, $class->updatedAt);
|
||||||
|
|
||||||
|
$events = $class->pullDomainEvents();
|
||||||
|
self::assertCount(1, $events);
|
||||||
|
self::assertInstanceOf(ClasseArchivee::class, $events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function archiverAlreadyArchivedClassDoesNothing(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass();
|
||||||
|
$class->archiver(new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||||
|
$class->pullDomainEvents();
|
||||||
|
$originalDeletedAt = $class->deletedAt;
|
||||||
|
|
||||||
|
$class->archiver(new DateTimeImmutable('2026-02-02 10:00:00'));
|
||||||
|
|
||||||
|
self::assertEquals($originalDeletedAt, $class->deletedAt);
|
||||||
|
self::assertEmpty($class->pullDomainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function reconstituteRestoresAllProperties(): void
|
||||||
|
{
|
||||||
|
$id = \App\Administration\Domain\Model\SchoolClass\ClassId::generate();
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
|
||||||
|
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
|
||||||
|
$name = new ClassName('6ème A');
|
||||||
|
$level = SchoolLevel::SIXIEME;
|
||||||
|
$capacity = 30;
|
||||||
|
$status = ClassStatus::ARCHIVED;
|
||||||
|
$description = 'Test description';
|
||||||
|
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||||
|
$updatedAt = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
$deletedAt = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||||
|
|
||||||
|
$class = SchoolClass::reconstitute(
|
||||||
|
id: $id,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
schoolId: $schoolId,
|
||||||
|
academicYearId: $academicYearId,
|
||||||
|
name: $name,
|
||||||
|
level: $level,
|
||||||
|
capacity: $capacity,
|
||||||
|
status: $status,
|
||||||
|
description: $description,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
updatedAt: $updatedAt,
|
||||||
|
deletedAt: $deletedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($class->id->equals($id));
|
||||||
|
self::assertTrue($class->tenantId->equals($tenantId));
|
||||||
|
self::assertTrue($class->schoolId->equals($schoolId));
|
||||||
|
self::assertTrue($class->academicYearId->equals($academicYearId));
|
||||||
|
self::assertTrue($class->name->equals($name));
|
||||||
|
self::assertSame($level, $class->level);
|
||||||
|
self::assertSame($capacity, $class->capacity);
|
||||||
|
self::assertSame($status, $class->status);
|
||||||
|
self::assertSame($description, $class->description);
|
||||||
|
self::assertEquals($createdAt, $class->createdAt);
|
||||||
|
self::assertEquals($updatedAt, $class->updatedAt);
|
||||||
|
self::assertEquals($deletedAt, $class->deletedAt);
|
||||||
|
self::assertEmpty($class->pullDomainEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createClass(): SchoolClass
|
||||||
|
{
|
||||||
|
return SchoolClass::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
name: new ClassName('6ème A'),
|
||||||
|
level: SchoolLevel::SIXIEME,
|
||||||
|
capacity: 30,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Domain\Model\SchoolClass;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SchoolLevelTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('primaryLevelsProvider')]
|
||||||
|
public function estPrimaireReturnsTrueForPrimaryLevels(SchoolLevel $level): void
|
||||||
|
{
|
||||||
|
self::assertTrue($level->estPrimaire());
|
||||||
|
self::assertFalse($level->estCollege());
|
||||||
|
self::assertFalse($level->estLycee());
|
||||||
|
self::assertSame('Primaire', $level->cycle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{SchoolLevel}>
|
||||||
|
*/
|
||||||
|
public static function primaryLevelsProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'CP' => [SchoolLevel::CP],
|
||||||
|
'CE1' => [SchoolLevel::CE1],
|
||||||
|
'CE2' => [SchoolLevel::CE2],
|
||||||
|
'CM1' => [SchoolLevel::CM1],
|
||||||
|
'CM2' => [SchoolLevel::CM2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('collegeLevelsProvider')]
|
||||||
|
public function estCollegeReturnsTrueForCollegeLevels(SchoolLevel $level): void
|
||||||
|
{
|
||||||
|
self::assertFalse($level->estPrimaire());
|
||||||
|
self::assertTrue($level->estCollege());
|
||||||
|
self::assertFalse($level->estLycee());
|
||||||
|
self::assertSame('Collège', $level->cycle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{SchoolLevel}>
|
||||||
|
*/
|
||||||
|
public static function collegeLevelsProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'6ème' => [SchoolLevel::SIXIEME],
|
||||||
|
'5ème' => [SchoolLevel::CINQUIEME],
|
||||||
|
'4ème' => [SchoolLevel::QUATRIEME],
|
||||||
|
'3ème' => [SchoolLevel::TROISIEME],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('lyceeLevelsProvider')]
|
||||||
|
public function estLyceeReturnsTrueForLyceeLevels(SchoolLevel $level): void
|
||||||
|
{
|
||||||
|
self::assertFalse($level->estPrimaire());
|
||||||
|
self::assertFalse($level->estCollege());
|
||||||
|
self::assertTrue($level->estLycee());
|
||||||
|
self::assertSame('Lycée', $level->cycle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{SchoolLevel}>
|
||||||
|
*/
|
||||||
|
public static function lyceeLevelsProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'2nde' => [SchoolLevel::SECONDE],
|
||||||
|
'1ère' => [SchoolLevel::PREMIERE],
|
||||||
|
'Terminale' => [SchoolLevel::TERMINALE],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function labelReturnsValue(): void
|
||||||
|
{
|
||||||
|
self::assertSame('6ème', SchoolLevel::SIXIEME->label());
|
||||||
|
self::assertSame('CM2', SchoolLevel::CM2->label());
|
||||||
|
self::assertSame('Terminale', SchoolLevel::TERMINALE->label());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function valueMatchesExpectedString(): void
|
||||||
|
{
|
||||||
|
self::assertSame('CP', SchoolLevel::CP->value);
|
||||||
|
self::assertSame('6ème', SchoolLevel::SIXIEME->value);
|
||||||
|
self::assertSame('2nde', SchoolLevel::SECONDE->value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||||
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class InMemoryClassRepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||||
|
|
||||||
|
private InMemoryClassRepository $repository;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->repository = new InMemoryClassRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function saveAndGet(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass('6ème A');
|
||||||
|
|
||||||
|
$this->repository->save($class);
|
||||||
|
|
||||||
|
$retrieved = $this->repository->get($class->id);
|
||||||
|
self::assertTrue($class->id->equals($retrieved->id));
|
||||||
|
self::assertTrue($class->name->equals($retrieved->name));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getThrowsExceptionForUnknownId(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ClasseNotFoundException::class);
|
||||||
|
|
||||||
|
$this->repository->get(ClassId::generate());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByIdReturnsNullForUnknownId(): void
|
||||||
|
{
|
||||||
|
$result = $this->repository->findById(ClassId::generate());
|
||||||
|
|
||||||
|
self::assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByName(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass('6ème A');
|
||||||
|
$this->repository->save($class);
|
||||||
|
|
||||||
|
$found = $this->repository->findByName(
|
||||||
|
new ClassName('6ème A'),
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotNull($found);
|
||||||
|
self::assertTrue($class->id->equals($found->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByNameReturnsNullForUnknownName(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass('6ème A');
|
||||||
|
$this->repository->save($class);
|
||||||
|
|
||||||
|
$found = $this->repository->findByName(
|
||||||
|
new ClassName('6ème B'),
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNull($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByNameIsCaseInsensitive(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass('6ème A');
|
||||||
|
$this->repository->save($class);
|
||||||
|
|
||||||
|
$found = $this->repository->findByName(
|
||||||
|
new ClassName('6ÈME A'),
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertNotNull($found);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findActiveByTenantAndYear(): void
|
||||||
|
{
|
||||||
|
$class1 = $this->createClass('6ème A');
|
||||||
|
$class2 = $this->createClass('6ème B');
|
||||||
|
$class3 = $this->createClass('6ème C');
|
||||||
|
$class3->archiver(new DateTimeImmutable());
|
||||||
|
|
||||||
|
$this->repository->save($class1);
|
||||||
|
$this->repository->save($class2);
|
||||||
|
$this->repository->save($class3);
|
||||||
|
|
||||||
|
$activeClasses = $this->repository->findActiveByTenantAndYear(
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(2, $activeClasses);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findActiveByTenantAndYearReturnsEmptyArrayForDifferentTenant(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass('6ème A');
|
||||||
|
$this->repository->save($class);
|
||||||
|
|
||||||
|
$activeClasses = $this->repository->findActiveByTenantAndYear(
|
||||||
|
TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
|
||||||
|
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertEmpty($activeClasses);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
$class = $this->createClass('6ème A');
|
||||||
|
$this->repository->save($class);
|
||||||
|
|
||||||
|
$this->repository->delete($class->id);
|
||||||
|
|
||||||
|
self::assertNull($this->repository->findById($class->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function deleteNonExistentClassDoesNotThrow(): void
|
||||||
|
{
|
||||||
|
// Should not throw
|
||||||
|
$this->repository->delete(ClassId::generate());
|
||||||
|
|
||||||
|
$this->expectNotToPerformAssertions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createClass(string $name): SchoolClass
|
||||||
|
{
|
||||||
|
return SchoolClass::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||||
|
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||||
|
name: new ClassName($name),
|
||||||
|
level: SchoolLevel::SIXIEME,
|
||||||
|
capacity: 30,
|
||||||
|
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
465
frontend/e2e/classes.spec.ts
Normal file
465
frontend/e2e/classes.spec.ts
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
// Test credentials
|
||||||
|
const ADMIN_EMAIL = 'e2e-classes-admin@example.com';
|
||||||
|
const ADMIN_PASSWORD = 'ClassesTest123';
|
||||||
|
|
||||||
|
// Force serial execution to ensure Empty State runs first
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Classes Management (Story 2.1)', () => {
|
||||||
|
// Create admin user and clean up classes before running tests
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create admin user
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
console.log('Classes E2E test admin user created');
|
||||||
|
|
||||||
|
// Clean up all classes for this tenant to ensure Empty State test works
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
console.log('Classes cleaned up for E2E tests');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to login as admin
|
||||||
|
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||||
|
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /se connecter/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to open "Nouvelle classe" dialog with proper wait
|
||||||
|
async function openNewClassDialog(page: import('@playwright/test').Page) {
|
||||||
|
const button = page.getByRole('button', { name: /nouvelle classe/i });
|
||||||
|
await button.waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
// Wait for any pending network requests to finish before clicking
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Click the button
|
||||||
|
await button.click();
|
||||||
|
|
||||||
|
// Wait for dialog to appear - retry click if needed (webkit timing issue)
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
try {
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||||
|
} catch {
|
||||||
|
// Retry once - webkit sometimes needs a second click
|
||||||
|
await button.click();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EMPTY STATE - Must run FIRST before any class is created
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Empty State', () => {
|
||||||
|
test('shows empty state message when no classes exist', async ({ page }) => {
|
||||||
|
// Clean up classes right before this specific test to avoid race conditions with parallel browsers
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Should show empty state
|
||||||
|
await expect(page.locator('.empty-state')).toBeVisible();
|
||||||
|
await expect(page.getByText(/aucune classe/i)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /créer une classe/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// List Display
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('List Display', () => {
|
||||||
|
test('displays all created classes in the list', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
// Create multiple classes
|
||||||
|
const classNames = [
|
||||||
|
`Liste-6emeA-${Date.now()}`,
|
||||||
|
`Liste-6emeB-${Date.now()}`,
|
||||||
|
`Liste-5emeA-${Date.now()}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of classNames) {
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
await page.locator('#class-name').fill(name);
|
||||||
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ALL classes appear in the list
|
||||||
|
for (const name of classNames) {
|
||||||
|
await expect(page.getByText(name)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the number of class cards matches (at least the ones we created)
|
||||||
|
const classCards = page.locator('.class-card');
|
||||||
|
const count = await classCards.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(classNames.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displays class details correctly (level, capacity)', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
// Create a class with all details
|
||||||
|
const className = `Details-${Date.now()}`;
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
await page.locator('#class-name').fill(className);
|
||||||
|
await page.locator('#class-level').selectOption('CM2');
|
||||||
|
await page.locator('#class-capacity').fill('25');
|
||||||
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Find the class card
|
||||||
|
const classCard = page.locator('.class-card', { hasText: className });
|
||||||
|
await expect(classCard).toBeVisible();
|
||||||
|
|
||||||
|
// Verify details are displayed
|
||||||
|
await expect(classCard.getByText('CM2')).toBeVisible();
|
||||||
|
await expect(classCard.getByText('25 places')).toBeVisible();
|
||||||
|
await expect(classCard.getByText('Active')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC1: Class Creation
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC1: Class Creation', () => {
|
||||||
|
test('can create a new class with all fields', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
|
||||||
|
// Navigate to classes page
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Click "Nouvelle classe" button
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
await expect(page.getByRole('heading', { name: /nouvelle classe/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
const uniqueName = `Test-E2E-${Date.now()}`;
|
||||||
|
await page.locator('#class-name').fill(uniqueName);
|
||||||
|
await page.locator('#class-level').selectOption('6ème');
|
||||||
|
await page.locator('#class-capacity').fill('30');
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
|
|
||||||
|
// Modal should close and class should appear in list
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can create a class with only required fields (name)', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
|
||||||
|
// Fill only the name (required)
|
||||||
|
const uniqueName = `Minimal-${Date.now()}`;
|
||||||
|
await page.locator('#class-name').fill(uniqueName);
|
||||||
|
|
||||||
|
// Submit button should be enabled
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer la classe/i });
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Class should be created
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit button is disabled when name is empty', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
|
||||||
|
// Don't fill the name
|
||||||
|
const submitButton = page.getByRole('button', { name: /créer la classe/i });
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Fill level and capacity but not name
|
||||||
|
await page.locator('#class-level').selectOption('CE1');
|
||||||
|
await page.locator('#class-capacity').fill('25');
|
||||||
|
await expect(submitButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Fill name - button should enable
|
||||||
|
await page.locator('#class-name').fill('Test');
|
||||||
|
await expect(submitButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can cancel class creation', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await page.locator('#class-name').fill('Should-Not-Be-Created');
|
||||||
|
|
||||||
|
// Click cancel
|
||||||
|
await page.getByRole('button', { name: /annuler/i }).click();
|
||||||
|
|
||||||
|
// Modal should close
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Class should not appear in list
|
||||||
|
await expect(page.getByText('Should-Not-Be-Created')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC2: Class Modification
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC2: Class Modification', () => {
|
||||||
|
test('can modify an existing class', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
// First create a class to modify
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
const originalName = `ToModify-${Date.now()}`;
|
||||||
|
await page.locator('#class-name').fill(originalName);
|
||||||
|
await page.locator('#class-level').selectOption('CM1');
|
||||||
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Find the class card and click modify
|
||||||
|
const classCard = page.locator('.class-card', { hasText: originalName });
|
||||||
|
await classCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|
||||||
|
// Should navigate to edit page
|
||||||
|
await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/);
|
||||||
|
await expect(page.getByRole('heading', { name: /modifier la classe/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Modify the name
|
||||||
|
const newName = `Modified-${Date.now()}`;
|
||||||
|
await page.locator('#class-name').fill(newName);
|
||||||
|
await page.locator('#class-level').selectOption('CM2');
|
||||||
|
await page.locator('#class-capacity').fill('28');
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||||
|
|
||||||
|
// Should show success message
|
||||||
|
await expect(page.getByText(/modifiée avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Go back to list and verify
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
await expect(page.getByText(newName)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can cancel modification', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
// Create a class
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
const originalName = `NoChange-${Date.now()}`;
|
||||||
|
await page.locator('#class-name').fill(originalName);
|
||||||
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click modify
|
||||||
|
const classCard = page.locator('.class-card', { hasText: originalName });
|
||||||
|
await classCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|
||||||
|
// Modify but cancel
|
||||||
|
await page.locator('#class-name').fill('Should-Not-Change');
|
||||||
|
await page.getByRole('button', { name: /annuler/i }).click();
|
||||||
|
|
||||||
|
// Should go back to list
|
||||||
|
await expect(page).toHaveURL(/\/admin\/classes$/);
|
||||||
|
|
||||||
|
// Original name should still be there
|
||||||
|
await expect(page.getByText(originalName)).toBeVisible();
|
||||||
|
await expect(page.getByText('Should-Not-Change')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC3: Deletion blocked if students assigned
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC3: Deletion blocked if students assigned', () => {
|
||||||
|
// SKIP REASON: The Students module is not yet implemented.
|
||||||
|
// HasStudentsInClassHandler currently returns 0 (stub), so all classes
|
||||||
|
// appear empty and can be deleted. This test will be enabled once the
|
||||||
|
// Students module allows assigning students to classes.
|
||||||
|
//
|
||||||
|
// When enabled, this test should:
|
||||||
|
// 1. Create a class
|
||||||
|
// 2. Assign at least one student to it
|
||||||
|
// 3. Attempt to delete the class
|
||||||
|
// 4. Verify the error message "Vous devez d'abord réaffecter les élèves"
|
||||||
|
// 5. Verify the class still exists
|
||||||
|
test.skip('shows warning when trying to delete class with students', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
// Implementation pending Students module
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AC4: Empty class deletion (soft delete)
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('AC4: Empty class deletion (soft delete)', () => {
|
||||||
|
test('can delete an empty class', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
// Create a class to delete
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
const className = `ToDelete-${Date.now()}`;
|
||||||
|
await page.locator('#class-name').fill(className);
|
||||||
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(className)).toBeVisible();
|
||||||
|
|
||||||
|
// Find and click delete button
|
||||||
|
const classCard = page.locator('.class-card', { hasText: className });
|
||||||
|
await classCard.getByRole('button', { name: /supprimer/i }).click();
|
||||||
|
|
||||||
|
// Confirmation modal should appear
|
||||||
|
const deleteModal = page.getByRole('alertdialog');
|
||||||
|
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(deleteModal.getByText(className)).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm deletion
|
||||||
|
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
|
||||||
|
|
||||||
|
// Modal should close and class should no longer appear in list
|
||||||
|
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(className)).not.toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can cancel deletion', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
// Create a class
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
const className = `NoDelete-${Date.now()}`;
|
||||||
|
await page.locator('#class-name').fill(className);
|
||||||
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Find and click delete
|
||||||
|
const classCard = page.locator('.class-card', { hasText: className });
|
||||||
|
await classCard.getByRole('button', { name: /supprimer/i }).click();
|
||||||
|
|
||||||
|
// Confirmation modal should appear
|
||||||
|
const deleteModal = page.getByRole('alertdialog');
|
||||||
|
await expect(deleteModal).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Cancel deletion
|
||||||
|
await deleteModal.getByRole('button', { name: /annuler/i }).click();
|
||||||
|
|
||||||
|
// Modal should close and class should still be there
|
||||||
|
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(className)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Navigation
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Navigation', () => {
|
||||||
|
test('can access classes page directly', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
|
||||||
|
// Navigate directly to classes page
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/admin\/classes/);
|
||||||
|
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('breadcrumb navigation works on edit page', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
// Create a class
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
const className = `Breadcrumb-${Date.now()}`;
|
||||||
|
await page.locator('#class-name').fill(className);
|
||||||
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Go to edit page
|
||||||
|
const classCard = page.locator('.class-card', { hasText: className });
|
||||||
|
await classCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|
||||||
|
// Click breadcrumb to go back
|
||||||
|
await page.getByRole('link', { name: 'Classes' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/admin\/classes$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Validation
|
||||||
|
// ============================================================================
|
||||||
|
test.describe('Validation', () => {
|
||||||
|
test('shows validation for class name length', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
await openNewClassDialog(page);
|
||||||
|
|
||||||
|
// Try a name that's too short (1 char)
|
||||||
|
await page.locator('#class-name').fill('A');
|
||||||
|
|
||||||
|
// The HTML5 minlength validation should prevent submission
|
||||||
|
// or show an error
|
||||||
|
const nameInput = page.locator('#class-name');
|
||||||
|
const isInvalid = await nameInput.evaluate(
|
||||||
|
(el: HTMLInputElement) => !el.validity.valid
|
||||||
|
);
|
||||||
|
expect(isInvalid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -306,10 +306,10 @@ test.describe('Login Flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Tenant Isolation', () => {
|
test.describe('Tenant Isolation', () => {
|
||||||
// Extract port from PLAYWRIGHT_BASE_URL or use default
|
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
|
||||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
const PORT = urlMatch ? urlMatch[1] : '5174';
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
const BETA_URL = `http://ecole-beta.classeo.local:${PORT}`;
|
const BETA_URL = `http://ecole-beta.classeo.local:${PORT}`;
|
||||||
const ALPHA_EMAIL = 'tenant-test-alpha@example.com';
|
const ALPHA_EMAIL = 'tenant-test-alpha@example.com';
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const config: PlaywrightTestConfig = {
|
|||||||
},
|
},
|
||||||
testDir: 'e2e',
|
testDir: 'e2e',
|
||||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
||||||
|
// Run browsers sequentially in CI to avoid race conditions with shared database
|
||||||
|
// Classes tests use mode: 'serial' which only works within a single worker
|
||||||
|
fullyParallel: !process.env.CI,
|
||||||
|
// Use 1 worker in CI to ensure no parallel execution across different browser projects
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|||||||
@@ -31,11 +31,11 @@
|
|||||||
<span class="action-label">Gérer les utilisateurs</span>
|
<span class="action-label">Gérer les utilisateurs</span>
|
||||||
<span class="action-hint">Bientôt disponible</span>
|
<span class="action-hint">Bientôt disponible</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-card disabled" aria-disabled="true">
|
<a class="action-card" href="/admin/classes">
|
||||||
<span class="action-icon">🏫</span>
|
<span class="action-icon">🏫</span>
|
||||||
<span class="action-label">Configurer les classes</span>
|
<span class="action-label">Configurer les classes</span>
|
||||||
<span class="action-hint">Bientôt disponible</span>
|
<span class="action-hint">Créer et gérer</span>
|
||||||
</div>
|
</a>
|
||||||
<div class="action-card disabled" aria-disabled="true">
|
<div class="action-card disabled" aria-disabled="true">
|
||||||
<span class="action-icon">📅</span>
|
<span class="action-icon">📅</span>
|
||||||
<span class="action-label">Calendrier scolaire</span>
|
<span class="action-label">Calendrier scolaire</span>
|
||||||
|
|||||||
33
frontend/src/lib/constants/schoolLevels.ts
Normal file
33
frontend/src/lib/constants/schoolLevels.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Liste des niveaux scolaires valides selon le référentiel Éducation Nationale.
|
||||||
|
*
|
||||||
|
* Utilisé pour :
|
||||||
|
* - La validation dans les formulaires
|
||||||
|
* - Les selects/dropdowns
|
||||||
|
*
|
||||||
|
* @see backend/src/Administration/Domain/Model/SchoolClass/SchoolLevels.php
|
||||||
|
*/
|
||||||
|
export const SCHOOL_LEVELS = [
|
||||||
|
'CP',
|
||||||
|
'CE1',
|
||||||
|
'CE2',
|
||||||
|
'CM1',
|
||||||
|
'CM2',
|
||||||
|
'6ème',
|
||||||
|
'5ème',
|
||||||
|
'4ème',
|
||||||
|
'3ème',
|
||||||
|
'2nde',
|
||||||
|
'1ère',
|
||||||
|
'Terminale'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SchoolLevel = (typeof SCHOOL_LEVELS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options pour les selects de niveaux scolaires.
|
||||||
|
*/
|
||||||
|
export const SCHOOL_LEVEL_OPTIONS = SCHOOL_LEVELS.map((level) => ({
|
||||||
|
value: level,
|
||||||
|
label: level
|
||||||
|
}));
|
||||||
684
frontend/src/routes/admin/classes/+page.svelte
Normal file
684
frontend/src/routes/admin/classes/+page.svelte
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
import { SCHOOL_LEVEL_OPTIONS } from '$lib/constants/schoolLevels';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface SchoolClass {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
level: string | null;
|
||||||
|
capacity: number | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
let classes = $state<SchoolClass[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let showCreateModal = $state(false);
|
||||||
|
let showDeleteModal = $state(false);
|
||||||
|
let classToDelete = $state<SchoolClass | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let newClassName = $state('');
|
||||||
|
let newClassLevel = $state<string | null>(null);
|
||||||
|
let newClassCapacity = $state<number | null>(null);
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let isDeleting = $state(false);
|
||||||
|
|
||||||
|
// Load classes on mount
|
||||||
|
$effect(() => {
|
||||||
|
loadClasses();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadClasses() {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/classes`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erreur lors du chargement des classes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// API Platform peut retourner hydra:member, member, ou un tableau direct
|
||||||
|
classes = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
// Use demo data for now
|
||||||
|
classes = [];
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateClass() {
|
||||||
|
if (!newClassName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSubmitting = true;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/classes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newClassName.trim(),
|
||||||
|
level: newClassLevel,
|
||||||
|
capacity: newClassCapacity
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Erreur lors de la création');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload classes and close modal
|
||||||
|
await loadClasses();
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors de la création';
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteModal(schoolClass: SchoolClass) {
|
||||||
|
classToDelete = schoolClass;
|
||||||
|
showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
showDeleteModal = false;
|
||||||
|
classToDelete = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmDelete() {
|
||||||
|
if (!classToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isDeleting = true;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/classes/${classToDelete.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Erreur lors de la suppression');
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDeleteModal();
|
||||||
|
await loadClasses();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors de la suppression';
|
||||||
|
} finally {
|
||||||
|
isDeleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
showCreateModal = true;
|
||||||
|
newClassName = '';
|
||||||
|
newClassLevel = null;
|
||||||
|
newClassCapacity = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showCreateModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToEdit(classId: string) {
|
||||||
|
goto(`/admin/classes/${classId}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Gestion des classes - Classeo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="classes-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>Gestion des classes</h1>
|
||||||
|
<p class="subtitle">Créez et gérez les classes de votre établissement</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick={openCreateModal}>
|
||||||
|
<span class="btn-icon">+</span>
|
||||||
|
Nouvelle classe
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span class="alert-icon">⚠️</span>
|
||||||
|
{error}
|
||||||
|
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Chargement des classes...</p>
|
||||||
|
</div>
|
||||||
|
{:else if classes.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<span class="empty-icon">🏫</span>
|
||||||
|
<h2>Aucune classe</h2>
|
||||||
|
<p>Commencez par créer votre première classe</p>
|
||||||
|
<button class="btn-primary" onclick={openCreateModal}>Créer une classe</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="classes-grid">
|
||||||
|
{#each classes as schoolClass (schoolClass.id)}
|
||||||
|
<div class="class-card">
|
||||||
|
<div class="class-info">
|
||||||
|
<h3 class="class-name">{schoolClass.name}</h3>
|
||||||
|
{#if schoolClass.level}
|
||||||
|
<span class="class-level">{schoolClass.level}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="class-meta">
|
||||||
|
{#if schoolClass.capacity}
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-icon">👥</span>
|
||||||
|
{schoolClass.capacity} places
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="meta-item status-{schoolClass.status}">
|
||||||
|
{schoolClass.status === 'active' ? 'Active' : 'Archivée'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="class-actions">
|
||||||
|
<button class="btn-secondary btn-sm" onclick={() => navigateToEdit(schoolClass.id)}>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-danger btn-sm"
|
||||||
|
onclick={() => openDeleteModal(schoolClass)}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
{#if showCreateModal}
|
||||||
|
<div class="modal-overlay" onclick={closeModal} role="presentation">
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
>
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2 id="modal-title">Nouvelle classe</h2>
|
||||||
|
<button class="modal-close" onclick={closeModal} aria-label="Fermer">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="modal-body" onsubmit={(e) => { e.preventDefault(); handleCreateClass(); }}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="class-name">Nom de la classe *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="class-name"
|
||||||
|
bind:value={newClassName}
|
||||||
|
placeholder="ex: 6ème A"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="class-level">Niveau scolaire</label>
|
||||||
|
<select id="class-level" bind:value={newClassLevel}>
|
||||||
|
<option value={null}>-- Sélectionner --</option>
|
||||||
|
{#each SCHOOL_LEVEL_OPTIONS as level}
|
||||||
|
<option value={level.value}>{level.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="class-capacity">Capacité maximale</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="class-capacity"
|
||||||
|
bind:value={newClassCapacity}
|
||||||
|
placeholder="ex: 30"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick={closeModal} disabled={isSubmitting}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-primary" disabled={isSubmitting || !newClassName.trim()}>
|
||||||
|
{#if isSubmitting}
|
||||||
|
Création...
|
||||||
|
{:else}
|
||||||
|
Créer la classe
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
{#if showDeleteModal && classToDelete}
|
||||||
|
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
|
||||||
|
<div
|
||||||
|
class="modal modal-confirm"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-modal-title"
|
||||||
|
aria-describedby="delete-modal-description"
|
||||||
|
>
|
||||||
|
<header class="modal-header modal-header-danger">
|
||||||
|
<h2 id="delete-modal-title">Supprimer la classe</h2>
|
||||||
|
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="delete-modal-description">
|
||||||
|
Êtes-vous sûr de vouloir supprimer la classe <strong>{classToDelete.name}</strong> ?
|
||||||
|
</p>
|
||||||
|
<p class="delete-warning">Cette action est irréversible.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick={closeDeleteModal} disabled={isDeleting}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-danger" onclick={handleConfirmDelete} disabled={isDeleting}>
|
||||||
|
{#if isDeleting}
|
||||||
|
Suppression...
|
||||||
|
{:else}
|
||||||
|
Supprimer
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.classes-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 2px dashed #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classes-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-level {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #3b82f6;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-icon {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-archived {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete confirmation modal */
|
||||||
|
.modal-confirm {
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-confirm .modal-actions {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-bottom-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-danger h2 {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-warning {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
487
frontend/src/routes/admin/classes/[id]/+page.svelte
Normal file
487
frontend/src/routes/admin/classes/[id]/+page.svelte
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
import { SCHOOL_LEVEL_OPTIONS } from '$lib/constants/schoolLevels';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface SchoolClass {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
level: string | null;
|
||||||
|
capacity: number | null;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
let schoolClass = $state<SchoolClass | null>(null);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let successMessage = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Form state (bound to schoolClass)
|
||||||
|
let formName = $state('');
|
||||||
|
let formLevel = $state<string | null>(null);
|
||||||
|
let formCapacity = $state<number | null>(null);
|
||||||
|
let formDescription = $state('');
|
||||||
|
|
||||||
|
// Track original values to detect intentional clearing
|
||||||
|
let originalLevel = $state<string | null>(null);
|
||||||
|
let originalCapacity = $state<number | null>(null);
|
||||||
|
let originalDescription = $state<string | null>(null);
|
||||||
|
|
||||||
|
const classId = $derived(page.params.id);
|
||||||
|
|
||||||
|
// Load class on mount
|
||||||
|
$effect(() => {
|
||||||
|
if (classId) {
|
||||||
|
loadClass(classId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadClass(id: string) {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/classes/${id}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('Classe introuvable');
|
||||||
|
}
|
||||||
|
throw new Error('Erreur lors du chargement de la classe');
|
||||||
|
}
|
||||||
|
|
||||||
|
schoolClass = await response.json();
|
||||||
|
|
||||||
|
// Initialize form with loaded data
|
||||||
|
if (schoolClass) {
|
||||||
|
formName = schoolClass.name;
|
||||||
|
formLevel = schoolClass.level;
|
||||||
|
formCapacity = schoolClass.capacity;
|
||||||
|
formDescription = schoolClass.description ?? '';
|
||||||
|
|
||||||
|
// Track original values for clear detection
|
||||||
|
originalLevel = schoolClass.level;
|
||||||
|
originalCapacity = schoolClass.capacity;
|
||||||
|
originalDescription = schoolClass.description;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!formName.trim() || !schoolClass) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSaving = true;
|
||||||
|
error = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
|
||||||
|
// Detect if user intentionally cleared optional fields
|
||||||
|
const clearLevel = originalLevel !== null && formLevel === null;
|
||||||
|
const clearCapacity = originalCapacity !== null && formCapacity === null;
|
||||||
|
const trimmedDescription = formDescription.trim() || null;
|
||||||
|
const clearDescription = originalDescription !== null && trimmedDescription === null;
|
||||||
|
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/classes/${schoolClass.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/merge-patch+json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formName.trim(),
|
||||||
|
level: formLevel,
|
||||||
|
capacity: formCapacity,
|
||||||
|
description: trimmedDescription,
|
||||||
|
clearLevel,
|
||||||
|
clearCapacity,
|
||||||
|
clearDescription
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Erreur lors de la modification');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedClass: SchoolClass = await response.json();
|
||||||
|
schoolClass = updatedClass;
|
||||||
|
successMessage = 'Classe modifiée avec succès';
|
||||||
|
|
||||||
|
// Update original values after successful save
|
||||||
|
originalLevel = updatedClass.level;
|
||||||
|
originalCapacity = updatedClass.capacity;
|
||||||
|
originalDescription = updatedClass.description;
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
window.setTimeout(() => {
|
||||||
|
successMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors de la modification';
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
goto('/admin/classes');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{schoolClass?.name ?? 'Modifier la classe'} - Classeo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="edit-page">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/admin/classes">Classes</a>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<span class="current">{schoolClass?.name ?? 'Chargement...'}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Chargement de la classe...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error && !schoolClass}
|
||||||
|
<div class="error-state">
|
||||||
|
<span class="error-icon">⚠️</span>
|
||||||
|
<h2>Erreur</h2>
|
||||||
|
<p>{error}</p>
|
||||||
|
<button class="btn-primary" onclick={goBack}>Retour à la liste</button>
|
||||||
|
</div>
|
||||||
|
{:else if schoolClass}
|
||||||
|
<div class="edit-form-container">
|
||||||
|
<header class="form-header">
|
||||||
|
<h1>Modifier la classe</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Créée le {new Date(schoolClass.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span class="alert-icon">⚠️</span>
|
||||||
|
{error}
|
||||||
|
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if successMessage}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<span class="alert-icon">✓</span>
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="class-name">Nom de la classe *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="class-name"
|
||||||
|
bind:value={formName}
|
||||||
|
placeholder="ex: 6ème A"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="class-level">Niveau scolaire</label>
|
||||||
|
<select id="class-level" bind:value={formLevel}>
|
||||||
|
<option value={null}>-- Aucun --</option>
|
||||||
|
{#each SCHOOL_LEVEL_OPTIONS as level}
|
||||||
|
<option value={level.value}>{level.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="class-capacity">Capacité maximale</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="class-capacity"
|
||||||
|
bind:value={formCapacity}
|
||||||
|
placeholder="ex: 30"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="class-description">Description (optionnelle)</label>
|
||||||
|
<textarea
|
||||||
|
id="class-description"
|
||||||
|
bind:value={formDescription}
|
||||||
|
placeholder="ex: Classe à option musique"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-secondary" onclick={goBack} disabled={isSaving}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-primary" disabled={isSaving || !formName.trim()}>
|
||||||
|
{#if isSaving}
|
||||||
|
Enregistrement...
|
||||||
|
{:else}
|
||||||
|
Enregistrer les modifications
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.edit-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .separator {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .current {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user