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,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;
}
}