feat: Configuration du mode de notation par établissement

Les établissements scolaires utilisent des systèmes d'évaluation variés
(notes /20, /10, lettres, compétences, sans notes). Jusqu'ici l'application
imposait implicitement le mode notes /20, ce qui ne correspondait pas
à la réalité pédagogique de nombreuses écoles.

Cette configuration permet à chaque établissement de choisir son mode
de notation par année scolaire, avec verrouillage automatique dès que
des notes ont été saisies pour éviter les incohérences. Le Score Sérénité
adapte ses pondérations selon le mode choisi (les compétences sont
converties via un mapping, le mode sans notes exclut la composante notes).
This commit is contained in:
2026-02-07 01:06:55 +01:00
parent f19d0ae3ef
commit ff18850a43
51 changed files with 3963 additions and 79 deletions

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ConfigureGradingMode;
final readonly class ConfigureGradingModeCommand
{
public function __construct(
public string $tenantId,
public string $schoolId,
public string $academicYearId,
public string $gradingMode,
) {
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ConfigureGradingMode;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Model\GradingConfiguration\GradingMode;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfiguration;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\GradingConfigurationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour configurer le mode de notation d'un établissement.
*
* Crée une nouvelle configuration si aucune n'existe pour l'année scolaire,
* ou modifie la configuration existante si aucune note n'a été saisie.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ConfigureGradingModeHandler
{
public function __construct(
private GradingConfigurationRepository $repository,
private GradeExistenceChecker $gradeExistenceChecker,
private Clock $clock,
) {
}
public function __invoke(ConfigureGradingModeCommand $command): SchoolGradingConfiguration
{
$tenantId = TenantId::fromString($command->tenantId);
$schoolId = SchoolId::fromString($command->schoolId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$mode = GradingMode::from($command->gradingMode);
$now = $this->clock->now();
$existing = $this->repository->findBySchoolAndYear($tenantId, $schoolId, $academicYearId);
$hasGrades = $this->gradeExistenceChecker->hasGradesForYear($tenantId, $schoolId, $academicYearId);
if ($existing === null) {
$configuration = SchoolGradingConfiguration::configurer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
mode: $mode,
hasExistingGrades: $hasGrades,
configuredAt: $now,
);
} else {
$existing->changerMode(
nouveauMode: $mode,
hasExistingGrades: $hasGrades,
at: $now,
);
$configuration = $existing;
}
$this->repository->save($configuration);
return $configuration;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\Tenant\TenantId;
/**
@@ -20,4 +21,16 @@ interface GradeExistenceChecker
AcademicYearId $academicYearId,
int $periodSequence,
): bool;
/**
* Vérifie si des notes existent pour une année scolaire entière.
*
* Utilisé pour bloquer le changement de mode de notation quand
* des évaluations ont déjà été saisies.
*/
public function hasGradesForYear(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
): bool;
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\HasGradesForYear;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour vérifier la présence de notes dans une année scolaire.
*
* Délègue au port GradeExistenceChecker qui sera implémenté par le module Notes
* quand il existera. Pour l'instant, retourne toujours false (pas de notes).
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class HasGradesForYearHandler
{
public function __construct(
private GradeExistenceChecker $gradeExistenceChecker,
) {
}
public function __invoke(HasGradesForYearQuery $query): bool
{
return $this->gradeExistenceChecker->hasGradesForYear(
TenantId::fromString($query->tenantId),
SchoolId::fromString($query->schoolId),
AcademicYearId::fromString($query->academicYearId),
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\HasGradesForYear;
/**
* Query pour vérifier si des notes existent pour une année scolaire entière.
*
* Utilisée pour bloquer le changement de mode de notation quand des
* évaluations ont déjà été saisies durant l'année en cours.
*/
final readonly class HasGradesForYearQuery
{
public function __construct(
public string $tenantId,
public string $schoolId,
public string $academicYearId,
) {
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\GradingConfiguration\GradingMode;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
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 configuration ou du changement du mode de notation.
*/
final readonly class ModeNotationConfigure implements DomainEvent
{
public function __construct(
public SchoolGradingConfigurationId $configurationId,
public TenantId $tenantId,
public SchoolId $schoolId,
public AcademicYearId $academicYearId,
public GradingMode $mode,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->configurationId->value;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
final class CannotChangeGradingModeWithExistingGradesException extends RuntimeException
{
public function __construct()
{
parent::__construct(
'Le mode de notation ne peut pas être modifié car des notes existent déjà cette année.',
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use RuntimeException;
use function sprintf;
final class GradingConfigurationNotFoundException extends RuntimeException
{
public static function withId(SchoolGradingConfigurationId $id): self
{
return new self(sprintf(
'Configuration de notation "%s" introuvable.',
$id,
));
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\GradingConfiguration;
/**
* Value Object encapsulant la configuration de notation d'un établissement.
*
* Délègue le comportement au GradingMode tout en fournissant un point
* d'extension si des options supplémentaires sont ajoutées ultérieurement
* (ex: autoriser les demi-points, afficher la moyenne de classe).
*/
final readonly class GradingConfiguration
{
public function __construct(
public GradingMode $mode,
) {
}
public function scaleMax(): ?int
{
return $this->mode->scaleMax();
}
public function estNumerique(): bool
{
return $this->mode->estNumerique();
}
public function calculeMoyenne(): bool
{
return $this->mode->calculeMoyenne();
}
public function equals(self $other): bool
{
return $this->mode === $other->mode;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\GradingConfiguration;
/**
* Mode de notation de l'établissement.
*
* Chaque établissement choisit un mode unique qui détermine comment
* les enseignants saisissent les évaluations et comment les moyennes
* sont calculées (ou non).
*
* @see FR24: Supporter différentes pédagogies
*/
enum GradingMode: string
{
case NUMERIC_20 = 'numeric_20';
case NUMERIC_10 = 'numeric_10';
case LETTERS = 'letters';
case COMPETENCIES = 'competencies';
case NO_GRADES = 'no_grades';
/**
* Échelle maximale pour les modes numériques.
*/
public function scaleMax(): ?int
{
return match ($this) {
self::NUMERIC_20 => 20,
self::NUMERIC_10 => 10,
self::LETTERS, self::COMPETENCIES, self::NO_GRADES => null,
};
}
/**
* Détermine si le mode utilise une notation numérique.
*/
public function estNumerique(): bool
{
return match ($this) {
self::NUMERIC_20, self::NUMERIC_10 => true,
self::LETTERS, self::COMPETENCIES, self::NO_GRADES => false,
};
}
/**
* Détermine si le mode nécessite un calcul de moyenne.
*
* Les compétences et le mode sans notes ne calculent pas de moyenne.
*/
public function calculeMoyenne(): bool
{
return match ($this) {
self::NUMERIC_20, self::NUMERIC_10, self::LETTERS => true,
self::COMPETENCIES, self::NO_GRADES => false,
};
}
/**
* Libellé utilisateur en français.
*/
public function label(): string
{
return match ($this) {
self::NUMERIC_20 => 'Notes /20',
self::NUMERIC_10 => 'Notes /10',
self::LETTERS => 'Lettres (A-E)',
self::COMPETENCIES => 'Compétences',
self::NO_GRADES => 'Sans notes',
};
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\GradingConfiguration;
use App\Administration\Domain\Event\ModeNotationConfigure;
use App\Administration\Domain\Exception\CannotChangeGradingModeWithExistingGradesException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
/**
* Aggregate Root associant un mode de notation à un établissement pour une année scolaire.
*
* Le mode de notation détermine comment les enseignants évaluent les élèves :
* numériquement, par lettres, par compétences ou sans notes du tout.
*
* Invariant : le mode ne peut pas être changé si des notes existent déjà.
*
* @see FR24: Supporter différentes pédagogies
*/
final class SchoolGradingConfiguration extends AggregateRoot
{
public const GradingMode DEFAULT_MODE = GradingMode::NUMERIC_20;
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) SchoolGradingConfigurationId $id,
public private(set) TenantId $tenantId,
public private(set) SchoolId $schoolId,
public private(set) AcademicYearId $academicYearId,
public private(set) GradingConfiguration $gradingConfiguration,
public private(set) DateTimeImmutable $configuredAt,
) {
$this->updatedAt = $configuredAt;
}
public static function generateId(): SchoolGradingConfigurationId
{
return SchoolGradingConfigurationId::generate();
}
/**
* Configure le mode de notation pour un établissement et une année scolaire.
*
* @param bool $hasExistingGrades Résultat de la vérification par l'Application Layer
*/
public static function configurer(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
GradingMode $mode,
bool $hasExistingGrades,
DateTimeImmutable $configuredAt,
): self {
// Le mode par défaut (NUMERIC_20) est autorisé même avec des notes existantes :
// c'est le mode implicite avant toute configuration, donc le créer explicitement
// est une opération idempotente. En revanche, changerMode() bloque tout changement
// dès qu'il y a des notes, quel que soit le mode cible.
if ($hasExistingGrades && $mode !== self::DEFAULT_MODE) {
throw new CannotChangeGradingModeWithExistingGradesException();
}
$config = new self(
id: SchoolGradingConfigurationId::generate(),
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
gradingConfiguration: new GradingConfiguration($mode),
configuredAt: $configuredAt,
);
$config->recordEvent(new ModeNotationConfigure(
configurationId: $config->id,
tenantId: $config->tenantId,
schoolId: $config->schoolId,
academicYearId: $config->academicYearId,
mode: $mode,
occurredOn: $configuredAt,
));
return $config;
}
/**
* Change le mode de notation.
*
* @param bool $hasExistingGrades Résultat de la vérification par l'Application Layer
*/
public function changerMode(
GradingMode $nouveauMode,
bool $hasExistingGrades,
DateTimeImmutable $at,
): void {
if ($this->gradingConfiguration->mode === $nouveauMode) {
return;
}
if ($hasExistingGrades) {
throw new CannotChangeGradingModeWithExistingGradesException();
}
$this->gradingConfiguration = new GradingConfiguration($nouveauMode);
$this->updatedAt = $at;
$this->recordEvent(new ModeNotationConfigure(
configurationId: $this->id,
tenantId: $this->tenantId,
schoolId: $this->schoolId,
academicYearId: $this->academicYearId,
mode: $nouveauMode,
occurredOn: $at,
));
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
SchoolGradingConfigurationId $id,
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
GradingConfiguration $gradingConfiguration,
DateTimeImmutable $configuredAt,
DateTimeImmutable $updatedAt,
): self {
$config = new self(
id: $id,
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
gradingConfiguration: $gradingConfiguration,
configuredAt: $configuredAt,
);
$config->updatedAt = $updatedAt;
return $config;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\GradingConfiguration;
use App\Shared\Domain\EntityId;
final readonly class SchoolGradingConfigurationId extends EntityId
{
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfiguration;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\Tenant\TenantId;
interface GradingConfigurationRepository
{
public function save(SchoolGradingConfiguration $configuration): void;
public function findBySchoolAndYear(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
): ?SchoolGradingConfiguration;
public function get(SchoolGradingConfigurationId $id, TenantId $tenantId): SchoolGradingConfiguration;
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ConfigureGradingMode\ConfigureGradingModeCommand;
use App\Administration\Application\Command\ConfigureGradingMode\ConfigureGradingModeHandler;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Exception\CannotChangeGradingModeWithExistingGradesException;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Infrastructure\Api\Resource\GradingModeResource;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Administration\Infrastructure\Security\GradingModeVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use ValueError;
/**
* Processor API Platform pour configurer le mode de notation.
*
* @implements ProcessorInterface<GradingModeResource, GradingModeResource>
*/
final readonly class ConfigureGradingModeProcessor implements ProcessorInterface
{
public function __construct(
private ConfigureGradingModeHandler $handler,
private GradeExistenceChecker $gradeExistenceChecker,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
private SchoolIdResolver $schoolIdResolver,
) {
}
/**
* @param GradingModeResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GradingModeResource
{
if (!$this->authorizationChecker->isGranted(GradingModeVoter::CONFIGURE)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le mode de notation.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($academicYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
try {
$command = new ConfigureGradingModeCommand(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
gradingMode: $data->mode ?? '',
);
$configuration = ($this->handler)($command);
foreach ($configuration->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
$resource = GradingModeResource::fromDomain($configuration);
$resource->hasExistingGrades = $this->gradeExistenceChecker->hasGradesForYear(
TenantId::fromString($tenantId),
SchoolId::fromString($schoolId),
AcademicYearId::fromString($academicYearId),
);
$resource->availableModes = GradingModeResource::allAvailableModes();
return $resource;
} catch (CannotChangeGradingModeWithExistingGradesException $e) {
throw new ConflictHttpException($e->getMessage());
} catch (ValueError $e) {
throw new BadRequestHttpException('Mode de notation invalide : ' . $e->getMessage());
}
}
}

View File

@@ -19,6 +19,7 @@ use App\Administration\Infrastructure\Api\Resource\PeriodResource;
use App\Administration\Infrastructure\Security\PeriodVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use DateMalformedStringException;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -108,6 +109,8 @@ final readonly class UpdatePeriodProcessor implements ProcessorInterface
throw new ConflictHttpException($e->getMessage());
} catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (DateMalformedStringException $e) {
throw new BadRequestHttpException('Format de date invalide : ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\GradingConfigurationRepository;
use App\Administration\Infrastructure\Api\Resource\GradingModeResource;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Administration\Infrastructure\Security\GradingModeVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
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 la configuration du mode de notation.
*
* @implements ProviderInterface<GradingModeResource>
*/
final readonly class GradingModeProvider implements ProviderInterface
{
public function __construct(
private GradingConfigurationRepository $repository,
private GradeExistenceChecker $gradeExistenceChecker,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private CurrentAcademicYearResolver $academicYearResolver,
private SchoolIdResolver $schoolIdResolver,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GradingModeResource
{
if (!$this->authorizationChecker->isGranted(GradingModeVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir la configuration de notation.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantIdStr = (string) $this->tenantContext->getCurrentTenantId();
$tenantId = TenantId::fromString($tenantIdStr);
/** @var string $rawAcademicYearId */
$rawAcademicYearId = $uriVariables['academicYearId'];
$resolvedYearId = $this->academicYearResolver->resolve($rawAcademicYearId);
if ($resolvedYearId === null) {
throw new NotFoundHttpException('Année scolaire non trouvée.');
}
$academicYearId = AcademicYearId::fromString($resolvedYearId);
$schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant($tenantIdStr));
$config = $this->repository->findBySchoolAndYear(
$tenantId,
$schoolId,
$academicYearId,
);
if ($config === null) {
$resource = GradingModeResource::defaultForYear($resolvedYearId);
} else {
$resource = GradingModeResource::fromDomain($config);
}
$resource->hasExistingGrades = $this->gradeExistenceChecker->hasGradesForYear(
$tenantId,
$schoolId,
$academicYearId,
);
$resource->availableModes = GradingModeResource::allAvailableModes();
return $resource;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\Administration\Domain\Model\GradingConfiguration\GradingMode;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfiguration;
use App\Administration\Infrastructure\Api\Processor\ConfigureGradingModeProcessor;
use App\Administration\Infrastructure\Api\Provider\GradingModeProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la configuration du mode de notation.
*
* @see FR24 - Supporter différentes pédagogies
*/
#[ApiResource(
shortName: 'GradingMode',
operations: [
new Get(
uriTemplate: '/academic-years/{academicYearId}/grading-mode',
provider: GradingModeProvider::class,
name: 'get_grading_mode',
),
new Put(
uriTemplate: '/academic-years/{academicYearId}/grading-mode',
read: false,
processor: ConfigureGradingModeProcessor::class,
name: 'configure_grading_mode',
),
],
)]
final class GradingModeResource
{
#[ApiProperty(identifier: true)]
public ?string $academicYearId = null;
#[Assert\NotBlank(message: 'Le mode de notation est requis.')]
#[Assert\Choice(
choices: ['numeric_20', 'numeric_10', 'letters', 'competencies', 'no_grades'],
message: 'Mode de notation invalide. Valeurs acceptées : numeric_20, numeric_10, letters, competencies, no_grades.',
)]
public ?string $mode = null;
#[ApiProperty(readable: true, writable: false)]
public ?string $label = null;
#[ApiProperty(readable: true, writable: false)]
public ?int $scaleMax = null;
#[ApiProperty(readable: true, writable: false)]
public ?bool $isNumeric = null;
#[ApiProperty(readable: true, writable: false)]
public ?bool $calculatesAverage = null;
#[ApiProperty(readable: true, writable: false)]
public ?bool $hasExistingGrades = null;
/** @var array<array{value: string, label: string}>|null */
#[ApiProperty(readable: true, writable: false)]
public ?array $availableModes = null;
public static function fromDomain(SchoolGradingConfiguration $config): self
{
$resource = new self();
$resource->academicYearId = (string) $config->academicYearId;
$resource->mode = $config->gradingConfiguration->mode->value;
$resource->label = $config->gradingConfiguration->mode->label();
$resource->scaleMax = $config->gradingConfiguration->scaleMax();
$resource->isNumeric = $config->gradingConfiguration->estNumerique();
$resource->calculatesAverage = $config->gradingConfiguration->calculeMoyenne();
return $resource;
}
public static function defaultForYear(string $academicYearId): self
{
$resource = new self();
$resource->academicYearId = $academicYearId;
$resource->mode = SchoolGradingConfiguration::DEFAULT_MODE->value;
$resource->label = SchoolGradingConfiguration::DEFAULT_MODE->label();
$resource->scaleMax = SchoolGradingConfiguration::DEFAULT_MODE->scaleMax();
$resource->isNumeric = SchoolGradingConfiguration::DEFAULT_MODE->estNumerique();
$resource->calculatesAverage = SchoolGradingConfiguration::DEFAULT_MODE->calculeMoyenne();
$resource->hasExistingGrades = false;
return $resource;
}
/**
* @return array<array{value: string, label: string}>
*/
public static function allAvailableModes(): array
{
return array_map(
static fn (GradingMode $mode) => [
'value' => $mode->value,
'label' => $mode->label(),
],
GradingMode::cases(),
);
}
}

View File

@@ -108,6 +108,11 @@ final class CreateTestActivationTokenCommand extends Command
$baseUrlOption = $input->getOption('base-url');
$baseUrl = rtrim($baseUrlOption, '/');
// In interactive mode, replace localhost with tenant subdomain
if ($input->isInteractive() && $usingDefaults) {
$baseUrl = (string) preg_replace('#//localhost([:/])#', "//{$tenantSubdomain}.classeo.local\$1", $baseUrl);
}
// Convert short role name to full Symfony role format
$roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput;

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\GradingConfigurationNotFoundException;
use App\Administration\Domain\Model\GradingConfiguration\GradingConfiguration;
use App\Administration\Domain\Model\GradingConfiguration\GradingMode;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfiguration;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\GradingConfigurationRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineGradingConfigurationRepository implements GradingConfigurationRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(SchoolGradingConfiguration $configuration): void
{
$this->connection->executeStatement(
'INSERT INTO school_grading_configurations (id, tenant_id, school_id, academic_year_id, grading_mode, configured_at, updated_at)
VALUES (:id, :tenant_id, :school_id, :academic_year_id, :grading_mode, :configured_at, :updated_at)
ON CONFLICT (tenant_id, school_id, academic_year_id) DO UPDATE SET
grading_mode = EXCLUDED.grading_mode,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $configuration->id,
'tenant_id' => (string) $configuration->tenantId,
'school_id' => (string) $configuration->schoolId,
'academic_year_id' => (string) $configuration->academicYearId,
'grading_mode' => $configuration->gradingConfiguration->mode->value,
'configured_at' => $configuration->configuredAt->format(DateTimeImmutable::ATOM),
'updated_at' => $configuration->updatedAt->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function findBySchoolAndYear(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
): ?SchoolGradingConfiguration {
$row = $this->connection->fetchAssociative(
'SELECT * FROM school_grading_configurations
WHERE tenant_id = :tenant_id
AND school_id = :school_id
AND academic_year_id = :academic_year_id',
[
'tenant_id' => (string) $tenantId,
'school_id' => (string) $schoolId,
'academic_year_id' => (string) $academicYearId,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function get(SchoolGradingConfigurationId $id, TenantId $tenantId): SchoolGradingConfiguration
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM school_grading_configurations WHERE id = :id AND tenant_id = :tenant_id',
[
'id' => (string) $id,
'tenant_id' => (string) $tenantId,
],
);
if ($row === false) {
throw GradingConfigurationNotFoundException::withId($id);
}
return $this->hydrate($row);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): SchoolGradingConfiguration
{
/** @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 $gradingMode */
$gradingMode = $row['grading_mode'];
/** @var string $configuredAt */
$configuredAt = $row['configured_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return SchoolGradingConfiguration::reconstitute(
id: SchoolGradingConfigurationId::fromString($id),
tenantId: TenantId::fromString($tenantId),
schoolId: SchoolId::fromString($schoolId),
academicYearId: AcademicYearId::fromString($academicYearId),
gradingConfiguration: new GradingConfiguration(GradingMode::from($gradingMode)),
configuredAt: new DateTimeImmutable($configuredAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\GradingConfigurationNotFoundException;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfiguration;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\GradingConfigurationRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryGradingConfigurationRepository implements GradingConfigurationRepository
{
/** @var array<string, SchoolGradingConfiguration> Indexed by ID */
private array $byId = [];
/** @var array<string, SchoolGradingConfiguration> Indexed by tenant:school:year */
private array $byTenantSchoolYear = [];
#[Override]
public function save(SchoolGradingConfiguration $configuration): void
{
$idStr = (string) $configuration->id;
$key = $this->compositeKey($configuration->tenantId, $configuration->schoolId, $configuration->academicYearId);
$this->byId[$idStr] = $configuration;
$this->byTenantSchoolYear[$key] = $configuration;
}
#[Override]
public function findBySchoolAndYear(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
): ?SchoolGradingConfiguration {
return $this->byTenantSchoolYear[$this->compositeKey($tenantId, $schoolId, $academicYearId)] ?? null;
}
#[Override]
public function get(SchoolGradingConfigurationId $id, TenantId $tenantId): SchoolGradingConfiguration
{
$config = $this->byId[(string) $id] ?? throw GradingConfigurationNotFoundException::withId($id);
if (!$config->tenantId->equals($tenantId)) {
throw GradingConfigurationNotFoundException::withId($id);
}
return $config;
}
private function compositeKey(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): string
{
return $tenantId . ':' . $schoolId . ':' . $academicYearId;
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Api\Resource\GradingModeResource;
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 la configuration du mode de notation.
*
* Règles d'accès :
* - ADMIN et SUPER_ADMIN : accès complet (lecture + configuration)
* - PROF, VIE_SCOLAIRE, SECRETARIAT : lecture seule
*
* @extends Voter<string, GradingModeResource|null>
*/
final class GradingModeVoter extends Voter
{
public const string VIEW = 'GRADING_MODE_VIEW';
public const string CONFIGURE = 'GRADING_MODE_CONFIGURE';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
self::CONFIGURE,
];
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true);
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
$roles = $user->getRoles();
return match ($attribute) {
self::VIEW => $this->canView($roles),
self::CONFIGURE => $this->canConfigure($roles),
default => false,
};
}
/**
* @param string[] $roles
*/
private function canView(array $roles): bool
{
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 canConfigure(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
]);
}
/**
* @param string[] $userRoles
* @param string[] $allowedRoles
*/
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
{
foreach ($userRoles as $role) {
if (in_array($role, $allowedRoles, true)) {
return true;
}
}
return false;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\Tenant\TenantId;
use Override;
@@ -24,4 +25,13 @@ final class NoOpGradeExistenceChecker implements GradeExistenceChecker
): bool {
return false;
}
#[Override]
public function hasGradesForYear(
TenantId $tenantId,
SchoolId $schoolId,
AcademicYearId $academicYearId,
): bool {
return false;
}
}