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:
@@ -21,7 +21,7 @@ RUN apk add --no-cache \
|
||||
$PHPIZE_DEPS
|
||||
|
||||
# Install PHP extensions (opcache is pre-installed in FrankenPHP)
|
||||
RUN docker-php-ext-install intl pdo_pgsql zip sockets
|
||||
RUN docker-php-ext-install intl pcntl pdo_pgsql zip sockets
|
||||
|
||||
# Install AMQP extension for RabbitMQ
|
||||
RUN pecl install amqp && docker-php-ext-enable amqp
|
||||
|
||||
@@ -138,6 +138,10 @@ services:
|
||||
App\Administration\Domain\Repository\PeriodConfigurationRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrinePeriodConfigurationRepository
|
||||
|
||||
# Grading Configuration Repository (Story 2.4 - Mode de notation)
|
||||
App\Administration\Domain\Repository\GradingConfigurationRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository
|
||||
|
||||
# GradeExistenceChecker (stub until Notes module exists)
|
||||
App\Administration\Application\Port\GradeExistenceChecker:
|
||||
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
|
||||
|
||||
47
backend/migrations/Version20260206100000.php
Normal file
47
backend/migrations/Version20260206100000.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Migration pour créer la table school_grading_configurations.
|
||||
*
|
||||
* @see FR24 - Supporter différentes pédagogies
|
||||
*/
|
||||
final class Version20260206100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create school_grading_configurations table for grading mode management';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE school_grading_configurations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
school_id UUID NOT NULL,
|
||||
academic_year_id UUID NOT NULL,
|
||||
grading_mode VARCHAR(20) NOT NULL DEFAULT 'numeric_20' CHECK (grading_mode IN ('numeric_20', 'numeric_10', 'letters', 'competencies', 'no_grades')),
|
||||
configured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_grading_config_tenant_id ON school_grading_configurations(tenant_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX idx_grading_config_unique
|
||||
ON school_grading_configurations (tenant_id, school_id, academic_year_id)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS school_grading_configurations');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
20
backend/src/Scolarite/Domain/Model/GradingMode.php
Normal file
20
backend/src/Scolarite/Domain/Model/GradingMode.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Model;
|
||||
|
||||
/**
|
||||
* Mode de notation vu depuis le contexte Scolarité.
|
||||
*
|
||||
* Représentation locale indépendante de Administration,
|
||||
* utilisée par SerenityScoreWeights pour adapter les pondérations.
|
||||
*/
|
||||
enum GradingMode: string
|
||||
{
|
||||
case NUMERIC_20 = 'numeric_20';
|
||||
case NUMERIC_10 = 'numeric_10';
|
||||
case LETTERS = 'letters';
|
||||
case COMPETENCIES = 'competencies';
|
||||
case NO_GRADES = 'no_grades';
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Service;
|
||||
|
||||
use App\Scolarite\Domain\Model\GradingMode;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Value Object contenant les pondérations du Score Sérénité adaptées au mode de notation.
|
||||
*
|
||||
* Standard : Notes×0.4 + Absences×0.3 + Devoirs×0.3
|
||||
* Sans notes : Absences×0.5 + Devoirs×0.5 (composante notes exclue)
|
||||
* Compétences : même pondération, mais les notes sont converties via le mapping Acquis→100, En cours→50, Non acquis→0
|
||||
*/
|
||||
final readonly class SerenityScoreWeights
|
||||
{
|
||||
private const array COMPETENCY_MAPPING = [
|
||||
'acquired' => 100,
|
||||
'in_progress' => 50,
|
||||
'not_acquired' => 0,
|
||||
];
|
||||
|
||||
private function __construct(
|
||||
public float $notesWeight,
|
||||
public float $absencesWeight,
|
||||
public float $devoirsWeight,
|
||||
private bool $usesCompetencyMapping,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function forMode(GradingMode $mode): self
|
||||
{
|
||||
return match ($mode) {
|
||||
GradingMode::NO_GRADES => new self(
|
||||
notesWeight: 0.0,
|
||||
absencesWeight: 0.5,
|
||||
devoirsWeight: 0.5,
|
||||
usesCompetencyMapping: false,
|
||||
),
|
||||
GradingMode::COMPETENCIES => new self(
|
||||
notesWeight: 0.4,
|
||||
absencesWeight: 0.3,
|
||||
devoirsWeight: 0.3,
|
||||
usesCompetencyMapping: true,
|
||||
),
|
||||
default => new self(
|
||||
notesWeight: 0.4,
|
||||
absencesWeight: 0.3,
|
||||
devoirsWeight: 0.3,
|
||||
usesCompetencyMapping: false,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un niveau de compétence en score numérique (0-100).
|
||||
*
|
||||
* @return int|null Score numérique, ou null si le mode n'utilise pas le mapping compétences
|
||||
*/
|
||||
public function competencyToScore(string $competencyLevel): ?int
|
||||
{
|
||||
if (!$this->usesCompetencyMapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::COMPETENCY_MAPPING[$competencyLevel]
|
||||
?? throw new InvalidArgumentException("Niveau de compétence inconnu : '{$competencyLevel}'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* Tests for grading mode API endpoints.
|
||||
*
|
||||
* @see Story 2.4 - Configuration Mode de Notation
|
||||
*/
|
||||
final class GradingModeEndpointsTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
// =========================================================================
|
||||
// Security - Without tenant
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getGradingModeReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/api/academic-years/current/grading-mode', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function configureGradingModeReturns404WithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('PUT', '/api/academic-years/current/grading-mode', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => ['mode' => 'numeric_20'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Security - Without authentication (with tenant)
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getGradingModeReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function configureGradingModeReturns401WithoutAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => ['mode' => 'numeric_20'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Validation - Invalid mode
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function configureGradingModeRejectsInvalidModeWithoutTenant(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('PUT', '/api/academic-years/current/grading-mode', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => ['mode' => 'invalid_mode'],
|
||||
]);
|
||||
|
||||
// Without tenant, returns 404 before validation kicks in
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Special identifiers - 'current', 'next', 'previous'
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getGradingModeAcceptsCurrentIdentifier(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
// 401 (no auth) not 404 (invalid id) — proves 'current' is accepted
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getGradingModeAcceptsNextIdentifier(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/next/grading-mode', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getGradingModeAcceptsPreviousIdentifier(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/previous/grading-mode', [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\ConfigureGradingMode;
|
||||
|
||||
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\GradingConfiguration\GradingMode;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryGradingConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ConfigureGradingModeHandlerTest 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 InMemoryGradingConfigurationRepository $repository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryGradingConfigurationRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesNewConfigurationWhenNoneExists(): void
|
||||
{
|
||||
$handler = $this->createHandler(hasGrades: false);
|
||||
|
||||
$result = $handler(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'numeric_20',
|
||||
));
|
||||
|
||||
self::assertSame(GradingMode::NUMERIC_20, $result->gradingConfiguration->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPersistsConfigurationInRepository(): void
|
||||
{
|
||||
$handler = $this->createHandler(hasGrades: false);
|
||||
|
||||
$handler(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'competencies',
|
||||
));
|
||||
|
||||
$found = $this->repository->findBySchoolAndYear(
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
SchoolId::fromString(self::SCHOOL_ID),
|
||||
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
);
|
||||
|
||||
self::assertNotNull($found);
|
||||
self::assertSame(GradingMode::COMPETENCIES, $found->gradingConfiguration->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChangesExistingModeWhenNoGradesExist(): void
|
||||
{
|
||||
$handler = $this->createHandler(hasGrades: false);
|
||||
|
||||
$handler(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'numeric_20',
|
||||
));
|
||||
|
||||
$result = $handler(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'letters',
|
||||
));
|
||||
|
||||
self::assertSame(GradingMode::LETTERS, $result->gradingConfiguration->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itBlocksChangeWhenGradesExist(): void
|
||||
{
|
||||
$handlerNoGrades = $this->createHandler(hasGrades: false);
|
||||
$handlerNoGrades(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'numeric_20',
|
||||
));
|
||||
|
||||
$handlerWithGrades = $this->createHandler(hasGrades: true);
|
||||
|
||||
$this->expectException(CannotChangeGradingModeWithExistingGradesException::class);
|
||||
|
||||
$handlerWithGrades(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'competencies',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsSameModeEvenWithGradesExisting(): void
|
||||
{
|
||||
$handlerNoGrades = $this->createHandler(hasGrades: false);
|
||||
$handlerNoGrades(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'numeric_20',
|
||||
));
|
||||
|
||||
$handlerWithGrades = $this->createHandler(hasGrades: true);
|
||||
$result = $handlerWithGrades(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'numeric_20',
|
||||
));
|
||||
|
||||
self::assertSame(GradingMode::NUMERIC_20, $result->gradingConfiguration->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itBlocksInitialNonDefaultModeWhenGradesExist(): void
|
||||
{
|
||||
$handler = $this->createHandler(hasGrades: true);
|
||||
|
||||
$this->expectException(CannotChangeGradingModeWithExistingGradesException::class);
|
||||
|
||||
$handler(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'competencies',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsolatesConfigurationByTenant(): void
|
||||
{
|
||||
$handler = $this->createHandler(hasGrades: false);
|
||||
|
||||
$handler(new ConfigureGradingModeCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'numeric_20',
|
||||
));
|
||||
|
||||
$otherTenantId = '550e8400-e29b-41d4-a716-446655440099';
|
||||
$handler(new ConfigureGradingModeCommand(
|
||||
tenantId: $otherTenantId,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
gradingMode: 'competencies',
|
||||
));
|
||||
|
||||
$config1 = $this->repository->findBySchoolAndYear(
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
SchoolId::fromString(self::SCHOOL_ID),
|
||||
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
);
|
||||
|
||||
$config2 = $this->repository->findBySchoolAndYear(
|
||||
TenantId::fromString($otherTenantId),
|
||||
SchoolId::fromString(self::SCHOOL_ID),
|
||||
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
);
|
||||
|
||||
self::assertNotNull($config1);
|
||||
self::assertNotNull($config2);
|
||||
self::assertSame(GradingMode::NUMERIC_20, $config1->gradingConfiguration->mode);
|
||||
self::assertSame(GradingMode::COMPETENCIES, $config2->gradingConfiguration->mode);
|
||||
}
|
||||
|
||||
private function createHandler(bool $hasGrades): ConfigureGradingModeHandler
|
||||
{
|
||||
$gradeChecker = new class($hasGrades) implements GradeExistenceChecker {
|
||||
public function __construct(private bool $hasGrades)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return $this->hasGrades;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool
|
||||
{
|
||||
return $this->hasGrades;
|
||||
}
|
||||
};
|
||||
|
||||
return new ConfigureGradingModeHandler(
|
||||
$this->repository,
|
||||
$gradeChecker,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Administration\Domain\Exception\PeriodsOverlapException;
|
||||
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
|
||||
use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker;
|
||||
use App\Shared\Domain\Clock;
|
||||
@@ -54,7 +55,7 @@ final class UpdatePeriodHandlerTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUpdatesPeriodDates(): void
|
||||
public function itRejectsOverlappingPeriodDates(): void
|
||||
{
|
||||
$this->seedTrimesterConfig();
|
||||
|
||||
@@ -124,13 +125,7 @@ final class UpdatePeriodHandlerTest extends TestCase
|
||||
{
|
||||
$this->seedTrimesterConfig();
|
||||
|
||||
$gradeChecker = new class implements GradeExistenceChecker {
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
$gradeChecker = $this->createGradeCheckerWithGrades();
|
||||
|
||||
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
|
||||
|
||||
@@ -150,13 +145,7 @@ final class UpdatePeriodHandlerTest extends TestCase
|
||||
{
|
||||
$this->seedTrimesterConfig();
|
||||
|
||||
$gradeChecker = new class implements GradeExistenceChecker {
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
$gradeChecker = $this->createGradeCheckerWithGrades();
|
||||
|
||||
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
|
||||
|
||||
@@ -172,6 +161,23 @@ final class UpdatePeriodHandlerTest extends TestCase
|
||||
self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
private function createGradeCheckerWithGrades(): GradeExistenceChecker
|
||||
{
|
||||
return new class implements GradeExistenceChecker {
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function seedTrimesterConfig(): void
|
||||
{
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Query\HasGradesForYear;
|
||||
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Application\Query\HasGradesForYear\HasGradesForYearHandler;
|
||||
use App\Administration\Application\Query\HasGradesForYear\HasGradesForYearQuery;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HasGradesForYearHandlerTest 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 itReturnsFalseWhenNoGradesExist(): void
|
||||
{
|
||||
$handler = new HasGradesForYearHandler($this->createChecker(hasGrades: false));
|
||||
|
||||
$result = $handler(new HasGradesForYearQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
));
|
||||
|
||||
self::assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsTrueWhenGradesExist(): void
|
||||
{
|
||||
$handler = new HasGradesForYearHandler($this->createChecker(hasGrades: true));
|
||||
|
||||
$result = $handler(new HasGradesForYearQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
));
|
||||
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
private function createChecker(bool $hasGrades): GradeExistenceChecker
|
||||
{
|
||||
return new class($hasGrades) implements GradeExistenceChecker {
|
||||
public function __construct(private bool $hasGrades)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return $this->hasGrades;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool
|
||||
{
|
||||
return $this->hasGrades;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\GradingConfiguration;
|
||||
|
||||
use App\Administration\Domain\Model\GradingConfiguration\GradingConfiguration;
|
||||
use App\Administration\Domain\Model\GradingConfiguration\GradingMode;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GradingConfigurationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCreatesNumeric20Configuration(): void
|
||||
{
|
||||
$config = new GradingConfiguration(GradingMode::NUMERIC_20);
|
||||
|
||||
self::assertSame(GradingMode::NUMERIC_20, $config->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesCompetenciesConfiguration(): void
|
||||
{
|
||||
$config = new GradingConfiguration(GradingMode::COMPETENCIES);
|
||||
|
||||
self::assertSame(GradingMode::COMPETENCIES, $config->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesNoGradesConfiguration(): void
|
||||
{
|
||||
$config = new GradingConfiguration(GradingMode::NO_GRADES);
|
||||
|
||||
self::assertSame(GradingMode::NO_GRADES, $config->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalConfigurationsAreEqual(): void
|
||||
{
|
||||
$config1 = new GradingConfiguration(GradingMode::NUMERIC_20);
|
||||
$config2 = new GradingConfiguration(GradingMode::NUMERIC_20);
|
||||
|
||||
self::assertTrue($config1->equals($config2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function differentConfigurationsAreNotEqual(): void
|
||||
{
|
||||
$config1 = new GradingConfiguration(GradingMode::NUMERIC_20);
|
||||
$config2 = new GradingConfiguration(GradingMode::COMPETENCIES);
|
||||
|
||||
self::assertFalse($config1->equals($config2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDelegatesToModeForScaleMax(): void
|
||||
{
|
||||
$config = new GradingConfiguration(GradingMode::NUMERIC_20);
|
||||
self::assertSame(20, $config->scaleMax());
|
||||
|
||||
$config = new GradingConfiguration(GradingMode::COMPETENCIES);
|
||||
self::assertNull($config->scaleMax());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDelegatesToModeForEstNumerique(): void
|
||||
{
|
||||
self::assertTrue((new GradingConfiguration(GradingMode::NUMERIC_20))->estNumerique());
|
||||
self::assertFalse((new GradingConfiguration(GradingMode::NO_GRADES))->estNumerique());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDelegatesToModeForCalculeMoyenne(): void
|
||||
{
|
||||
self::assertTrue((new GradingConfiguration(GradingMode::NUMERIC_20))->calculeMoyenne());
|
||||
self::assertFalse((new GradingConfiguration(GradingMode::COMPETENCIES))->calculeMoyenne());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\GradingConfiguration;
|
||||
|
||||
use App\Administration\Domain\Model\GradingConfiguration\GradingMode;
|
||||
use App\Scolarite\Domain\Model\GradingMode as ScolariteGradingMode;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GradingModeTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itHasCorrectValues(): void
|
||||
{
|
||||
self::assertSame('numeric_20', GradingMode::NUMERIC_20->value);
|
||||
self::assertSame('numeric_10', GradingMode::NUMERIC_10->value);
|
||||
self::assertSame('letters', GradingMode::LETTERS->value);
|
||||
self::assertSame('competencies', GradingMode::COMPETENCIES->value);
|
||||
self::assertSame('no_grades', GradingMode::NO_GRADES->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function numeric20HasCorrectScale(): void
|
||||
{
|
||||
self::assertSame(20, GradingMode::NUMERIC_20->scaleMax());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function numeric10HasCorrectScale(): void
|
||||
{
|
||||
self::assertSame(10, GradingMode::NUMERIC_10->scaleMax());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nonNumericModesHaveNullScale(): void
|
||||
{
|
||||
self::assertNull(GradingMode::LETTERS->scaleMax());
|
||||
self::assertNull(GradingMode::COMPETENCIES->scaleMax());
|
||||
self::assertNull(GradingMode::NO_GRADES->scaleMax());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function numericModesUseNumericGrading(): void
|
||||
{
|
||||
self::assertTrue(GradingMode::NUMERIC_20->estNumerique());
|
||||
self::assertTrue(GradingMode::NUMERIC_10->estNumerique());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nonNumericModesDoNotUseNumericGrading(): void
|
||||
{
|
||||
self::assertFalse(GradingMode::LETTERS->estNumerique());
|
||||
self::assertFalse(GradingMode::COMPETENCIES->estNumerique());
|
||||
self::assertFalse(GradingMode::NO_GRADES->estNumerique());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modesRequiringAverageCalculation(): void
|
||||
{
|
||||
self::assertTrue(GradingMode::NUMERIC_20->calculeMoyenne());
|
||||
self::assertTrue(GradingMode::NUMERIC_10->calculeMoyenne());
|
||||
self::assertTrue(GradingMode::LETTERS->calculeMoyenne());
|
||||
self::assertFalse(GradingMode::COMPETENCIES->calculeMoyenne());
|
||||
self::assertFalse(GradingMode::NO_GRADES->calculeMoyenne());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function labelsAreInFrench(): void
|
||||
{
|
||||
self::assertSame('Notes /20', GradingMode::NUMERIC_20->label());
|
||||
self::assertSame('Notes /10', GradingMode::NUMERIC_10->label());
|
||||
self::assertSame('Lettres (A-E)', GradingMode::LETTERS->label());
|
||||
self::assertSame('Compétences', GradingMode::COMPETENCIES->label());
|
||||
self::assertSame('Sans notes', GradingMode::NO_GRADES->label());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function administrationAndScolariteEnumsAreInSync(): void
|
||||
{
|
||||
$adminValues = array_map(
|
||||
static fn (GradingMode $m) => $m->value,
|
||||
GradingMode::cases(),
|
||||
);
|
||||
$scolariteValues = array_map(
|
||||
static fn (ScolariteGradingMode $m) => $m->value,
|
||||
ScolariteGradingMode::cases(),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
$adminValues,
|
||||
$scolariteValues,
|
||||
'Les enums GradingMode des contextes Administration et Scolarité doivent rester synchronisées.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\GradingConfiguration;
|
||||
|
||||
use App\Administration\Domain\Exception\CannotChangeGradingModeWithExistingGradesException;
|
||||
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\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SchoolGradingConfigurationTest extends TestCase
|
||||
{
|
||||
private TenantId $tenantId;
|
||||
private SchoolId $schoolId;
|
||||
private AcademicYearId $academicYearId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tenantId = TenantId::generate();
|
||||
$this->schoolId = SchoolId::generate();
|
||||
$this->academicYearId = AcademicYearId::generate();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itConfiguresGradingMode(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: $this->schoolId,
|
||||
academicYearId: $this->academicYearId,
|
||||
mode: GradingMode::NUMERIC_20,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
|
||||
self::assertSame(GradingMode::NUMERIC_20, $config->gradingConfiguration->mode);
|
||||
self::assertSame($this->tenantId, $config->tenantId);
|
||||
self::assertSame($this->schoolId, $config->schoolId);
|
||||
self::assertSame($this->academicYearId, $config->academicYearId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itEmitsEventOnCreation(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: $this->schoolId,
|
||||
academicYearId: $this->academicYearId,
|
||||
mode: GradingMode::COMPETENCIES,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
|
||||
$events = $config->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChangesGradingModeWhenNoGradesExist(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: $this->schoolId,
|
||||
academicYearId: $this->academicYearId,
|
||||
mode: GradingMode::NUMERIC_20,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
$config->pullDomainEvents();
|
||||
|
||||
$config->changerMode(
|
||||
nouveauMode: GradingMode::COMPETENCIES,
|
||||
hasExistingGrades: false,
|
||||
at: new DateTimeImmutable('2026-02-02'),
|
||||
);
|
||||
|
||||
self::assertSame(GradingMode::COMPETENCIES, $config->gradingConfiguration->mode);
|
||||
$events = $config->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itBlocksChangeWhenGradesExist(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: $this->schoolId,
|
||||
academicYearId: $this->academicYearId,
|
||||
mode: GradingMode::NUMERIC_20,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
|
||||
$this->expectException(CannotChangeGradingModeWithExistingGradesException::class);
|
||||
|
||||
$config->changerMode(
|
||||
nouveauMode: GradingMode::COMPETENCIES,
|
||||
hasExistingGrades: true,
|
||||
at: new DateTimeImmutable('2026-02-02'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotEmitEventWhenModeIsUnchanged(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: $this->schoolId,
|
||||
academicYearId: $this->academicYearId,
|
||||
mode: GradingMode::NUMERIC_20,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
$config->pullDomainEvents();
|
||||
|
||||
$config->changerMode(
|
||||
nouveauMode: GradingMode::NUMERIC_20,
|
||||
hasExistingGrades: false,
|
||||
at: new DateTimeImmutable('2026-02-02'),
|
||||
);
|
||||
|
||||
self::assertEmpty($config->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReconstitutesFromStorage(): void
|
||||
{
|
||||
$id = SchoolGradingConfiguration::generateId();
|
||||
|
||||
$config = SchoolGradingConfiguration::reconstitute(
|
||||
id: $id,
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: $this->schoolId,
|
||||
academicYearId: $this->academicYearId,
|
||||
gradingConfiguration: new GradingConfiguration(GradingMode::LETTERS),
|
||||
configuredAt: new DateTimeImmutable('2026-02-01'),
|
||||
updatedAt: new DateTimeImmutable('2026-02-02'),
|
||||
);
|
||||
|
||||
self::assertSame(GradingMode::LETTERS, $config->gradingConfiguration->mode);
|
||||
self::assertEmpty($config->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itBlocksInitialNonDefaultModeWhenGradesExist(): void
|
||||
{
|
||||
$this->expectException(CannotChangeGradingModeWithExistingGradesException::class);
|
||||
|
||||
SchoolGradingConfiguration::configurer(
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: $this->schoolId,
|
||||
academicYearId: $this->academicYearId,
|
||||
mode: GradingMode::COMPETENCIES,
|
||||
hasExistingGrades: true,
|
||||
configuredAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsInitialDefaultModeWhenGradesExist(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: $this->tenantId,
|
||||
schoolId: $this->schoolId,
|
||||
academicYearId: $this->academicYearId,
|
||||
mode: GradingMode::NUMERIC_20,
|
||||
hasExistingGrades: true,
|
||||
configuredAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
|
||||
self::assertSame(GradingMode::NUMERIC_20, $config->gradingConfiguration->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function defaultModeIsNumeric20(): void
|
||||
{
|
||||
self::assertSame(GradingMode::NUMERIC_20, SchoolGradingConfiguration::DEFAULT_MODE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Administration\Application\Command\ConfigureGradingMode\ConfigureGradingModeHandler;
|
||||
use App\Administration\Application\Port\GradeExistenceChecker;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Infrastructure\Api\Processor\ConfigureGradingModeProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\GradingModeResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryGradingConfigurationRepository;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
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\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AccessDecision;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class ConfigureGradingModeProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryGradingConfigurationRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryGradingConfigurationRepository();
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->clock = new class implements Clock {
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2025-10-15 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsUnauthorizedAccess(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: false);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'numeric_20';
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsRequestWithoutTenant(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'numeric_20';
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsInvalidAcademicYearId(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'numeric_20';
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'invalid']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itConfiguresGradingMode(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'competencies';
|
||||
|
||||
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertInstanceOf(GradingModeResource::class, $result);
|
||||
self::assertSame('competencies', $result->mode);
|
||||
self::assertSame('Compétences', $result->label);
|
||||
self::assertFalse($result->hasExistingGrades);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSetsAvailableModesOnResult(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'numeric_20';
|
||||
|
||||
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertNotNull($result->availableModes);
|
||||
self::assertCount(5, $result->availableModes);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsConflictWhenGradesExistAndModeChanges(): void
|
||||
{
|
||||
// First configure with numeric_20 (no grades)
|
||||
$processor = $this->createProcessor(granted: true, hasGrades: false);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'numeric_20';
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
|
||||
// Now try to change mode with grades existing
|
||||
$processorWithGrades = $this->createProcessor(granted: true, hasGrades: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'competencies';
|
||||
|
||||
$this->expectException(ConflictHttpException::class);
|
||||
$processorWithGrades->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChangesExistingModeWhenNoGradesExist(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'numeric_20';
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'letters';
|
||||
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertSame('letters', $result->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesNextAcademicYear(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'no_grades';
|
||||
|
||||
$result = $processor->process($data, new Put(), ['academicYearId' => 'next']);
|
||||
|
||||
self::assertSame('no_grades', $result->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsInvalidGradingModeWithBadRequest(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new GradingModeResource();
|
||||
$data->mode = 'invalid_mode';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
private function setTenant(): void
|
||||
{
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
subdomain: 'test',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
}
|
||||
|
||||
private function createProcessor(bool $granted, bool $hasGrades = false): ConfigureGradingModeProcessor
|
||||
{
|
||||
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
|
||||
public function __construct(private readonly bool $granted)
|
||||
{
|
||||
}
|
||||
|
||||
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
|
||||
{
|
||||
return $this->granted;
|
||||
}
|
||||
};
|
||||
|
||||
$eventBus = new class implements MessageBusInterface {
|
||||
public function dispatch(object $message, array $stamps = []): Envelope
|
||||
{
|
||||
return new Envelope($message);
|
||||
}
|
||||
};
|
||||
|
||||
$gradeChecker = new class($hasGrades) implements GradeExistenceChecker {
|
||||
public function __construct(private readonly bool $hasGrades)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return $this->hasGrades;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool
|
||||
{
|
||||
return $this->hasGrades;
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new ConfigureGradingModeHandler($this->repository, $gradeChecker, $this->clock);
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$schoolIdResolver = new SchoolIdResolver();
|
||||
|
||||
return new ConfigureGradingModeProcessor(
|
||||
$handler,
|
||||
$gradeChecker,
|
||||
$this->tenantContext,
|
||||
$eventBus,
|
||||
$authChecker,
|
||||
$resolver,
|
||||
$schoolIdResolver,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Get;
|
||||
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\Infrastructure\Api\Provider\GradingModeProvider;
|
||||
use App\Administration\Infrastructure\Api\Resource\GradingModeResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryGradingConfigurationRepository;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AccessDecision;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class GradingModeProviderTest extends TestCase
|
||||
{
|
||||
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryGradingConfigurationRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryGradingConfigurationRepository();
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->clock = new class implements Clock {
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2025-10-15 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsUnauthorizedAccess(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: false);
|
||||
$this->setTenant();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsRequestWithoutTenant(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsInvalidAcademicYearId(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
$provider->provide(new Get(), ['academicYearId' => 'invalid']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsDefaultModeWhenNoConfigurationExists(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertInstanceOf(GradingModeResource::class, $result);
|
||||
self::assertSame('numeric_20', $result->mode);
|
||||
self::assertSame('Notes /20', $result->label);
|
||||
self::assertFalse($result->hasExistingGrades);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsExistingConfigurationWhenPresent(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$academicYearId = $resolver->resolve('current');
|
||||
self::assertNotNull($academicYearId);
|
||||
|
||||
$schoolIdResolver = new SchoolIdResolver();
|
||||
$schoolId = $schoolIdResolver->resolveForTenant(self::TENANT_UUID);
|
||||
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: TenantId::fromString(self::TENANT_UUID),
|
||||
schoolId: SchoolId::fromString($schoolId),
|
||||
academicYearId: AcademicYearId::fromString($academicYearId),
|
||||
mode: GradingMode::COMPETENCIES,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable(),
|
||||
);
|
||||
$this->repository->save($config);
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertInstanceOf(GradingModeResource::class, $result);
|
||||
self::assertSame('competencies', $result->mode);
|
||||
self::assertSame('Compétences', $result->label);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSetsAvailableModesOnResult(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertNotNull($result->availableModes);
|
||||
self::assertCount(5, $result->availableModes);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSetsHasExistingGradesFlag(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true, hasGrades: true);
|
||||
$this->setTenant();
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertTrue($result->hasExistingGrades);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesNextAcademicYear(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'next']);
|
||||
|
||||
self::assertInstanceOf(GradingModeResource::class, $result);
|
||||
self::assertSame('numeric_20', $result->mode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesPreviousAcademicYear(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'previous']);
|
||||
|
||||
self::assertInstanceOf(GradingModeResource::class, $result);
|
||||
self::assertSame('numeric_20', $result->mode);
|
||||
}
|
||||
|
||||
private function setTenant(): void
|
||||
{
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
subdomain: 'test',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
}
|
||||
|
||||
private function createProvider(bool $granted, bool $hasGrades = false): GradingModeProvider
|
||||
{
|
||||
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
|
||||
public function __construct(private readonly bool $granted)
|
||||
{
|
||||
}
|
||||
|
||||
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
|
||||
{
|
||||
return $this->granted;
|
||||
}
|
||||
};
|
||||
|
||||
$gradeChecker = new class($hasGrades) implements GradeExistenceChecker {
|
||||
public function __construct(private readonly bool $hasGrades)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return $this->hasGrades;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool
|
||||
{
|
||||
return $this->hasGrades;
|
||||
}
|
||||
};
|
||||
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$schoolIdResolver = new SchoolIdResolver();
|
||||
|
||||
return new GradingModeProvider(
|
||||
$this->repository,
|
||||
$gradeChecker,
|
||||
$this->tenantContext,
|
||||
$authChecker,
|
||||
$resolver,
|
||||
$schoolIdResolver,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
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\Infrastructure\Api\Resource\GradingModeResource;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GradingModeResourceTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function fromDomainMapsAllFields(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: TenantId::generate(),
|
||||
schoolId: SchoolId::generate(),
|
||||
academicYearId: AcademicYearId::generate(),
|
||||
mode: GradingMode::NUMERIC_20,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$resource = GradingModeResource::fromDomain($config);
|
||||
|
||||
self::assertSame('numeric_20', $resource->mode);
|
||||
self::assertSame('Notes /20', $resource->label);
|
||||
self::assertSame(20, $resource->scaleMax);
|
||||
self::assertTrue($resource->isNumeric);
|
||||
self::assertTrue($resource->calculatesAverage);
|
||||
self::assertSame((string) $config->academicYearId, $resource->academicYearId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromDomainMapsCompetenciesMode(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: TenantId::generate(),
|
||||
schoolId: SchoolId::generate(),
|
||||
academicYearId: AcademicYearId::generate(),
|
||||
mode: GradingMode::COMPETENCIES,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$resource = GradingModeResource::fromDomain($config);
|
||||
|
||||
self::assertSame('competencies', $resource->mode);
|
||||
self::assertSame('Compétences', $resource->label);
|
||||
self::assertNull($resource->scaleMax);
|
||||
self::assertFalse($resource->isNumeric);
|
||||
self::assertFalse($resource->calculatesAverage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromDomainMapsNoGradesMode(): void
|
||||
{
|
||||
$config = SchoolGradingConfiguration::configurer(
|
||||
tenantId: TenantId::generate(),
|
||||
schoolId: SchoolId::generate(),
|
||||
academicYearId: AcademicYearId::generate(),
|
||||
mode: GradingMode::NO_GRADES,
|
||||
hasExistingGrades: false,
|
||||
configuredAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$resource = GradingModeResource::fromDomain($config);
|
||||
|
||||
self::assertSame('no_grades', $resource->mode);
|
||||
self::assertSame('Sans notes', $resource->label);
|
||||
self::assertNull($resource->scaleMax);
|
||||
self::assertFalse($resource->isNumeric);
|
||||
self::assertFalse($resource->calculatesAverage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function defaultForYearUsesNumeric20(): void
|
||||
{
|
||||
$yearId = 'some-year-id';
|
||||
$resource = GradingModeResource::defaultForYear($yearId);
|
||||
|
||||
self::assertSame($yearId, $resource->academicYearId);
|
||||
self::assertSame('numeric_20', $resource->mode);
|
||||
self::assertSame('Notes /20', $resource->label);
|
||||
self::assertSame(20, $resource->scaleMax);
|
||||
self::assertTrue($resource->isNumeric);
|
||||
self::assertTrue($resource->calculatesAverage);
|
||||
self::assertFalse($resource->hasExistingGrades);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allAvailableModesReturnsAllFiveModes(): void
|
||||
{
|
||||
$modes = GradingModeResource::allAvailableModes();
|
||||
|
||||
self::assertCount(5, $modes);
|
||||
|
||||
$values = array_column($modes, 'value');
|
||||
self::assertContains('numeric_20', $values);
|
||||
self::assertContains('numeric_10', $values);
|
||||
self::assertContains('letters', $values);
|
||||
self::assertContains('competencies', $values);
|
||||
self::assertContains('no_grades', $values);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allAvailableModesContainsLabels(): void
|
||||
{
|
||||
$modes = GradingModeResource::allAvailableModes();
|
||||
|
||||
foreach ($modes as $mode) {
|
||||
self::assertArrayHasKey('value', $mode);
|
||||
self::assertArrayHasKey('label', $mode);
|
||||
self::assertNotEmpty($mode['label']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Infrastructure\Security\GradingModeVoter;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class GradingModeVoterTest extends TestCase
|
||||
{
|
||||
private GradingModeVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new GradingModeVoter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForUnrelatedAttributes(): void
|
||||
{
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$user->method('getRoles')->willReturn(['ROLE_ADMIN']);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
|
||||
|
||||
self::assertSame(GradingModeVoter::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToUnauthenticatedUsers(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
$result = $this->voter->vote($token, null, [GradingModeVoter::VIEW]);
|
||||
|
||||
self::assertSame(GradingModeVoter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToSuperAdmin(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_SUPER_ADMIN', GradingModeVoter::VIEW);
|
||||
self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToAdmin(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_ADMIN', GradingModeVoter::VIEW);
|
||||
self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToProf(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_PROF', GradingModeVoter::VIEW);
|
||||
self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToVieScolaire(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_VIE_SCOLAIRE', GradingModeVoter::VIEW);
|
||||
self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToSecretariat(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_SECRETARIAT', GradingModeVoter::VIEW);
|
||||
self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesViewToParent(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_PARENT', GradingModeVoter::VIEW);
|
||||
self::assertSame(GradingModeVoter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesViewToEleve(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_ELEVE', GradingModeVoter::VIEW);
|
||||
self::assertSame(GradingModeVoter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsConfigureToSuperAdmin(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_SUPER_ADMIN', GradingModeVoter::CONFIGURE);
|
||||
self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsConfigureToAdmin(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_ADMIN', GradingModeVoter::CONFIGURE);
|
||||
self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesConfigureToProf(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_PROF', GradingModeVoter::CONFIGURE);
|
||||
self::assertSame(GradingModeVoter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesConfigureToVieScolaire(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_VIE_SCOLAIRE', GradingModeVoter::CONFIGURE);
|
||||
self::assertSame(GradingModeVoter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesConfigureToSecretariat(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_SECRETARIAT', GradingModeVoter::CONFIGURE);
|
||||
self::assertSame(GradingModeVoter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
private function voteWithRole(string $role, string $attribute): int
|
||||
{
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$user->method('getRoles')->willReturn([$role]);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
return $this->voter->vote($token, null, [$attribute]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Service;
|
||||
|
||||
use App\Scolarite\Domain\Model\GradingMode;
|
||||
use App\Scolarite\Domain\Service\SerenityScoreWeights;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SerenityScoreWeightsTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function numericModeUsesStandardWeights(): void
|
||||
{
|
||||
$weights = SerenityScoreWeights::forMode(GradingMode::NUMERIC_20);
|
||||
|
||||
self::assertSame(0.4, $weights->notesWeight);
|
||||
self::assertSame(0.3, $weights->absencesWeight);
|
||||
self::assertSame(0.3, $weights->devoirsWeight);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function competencyModeUsesAdaptedWeights(): void
|
||||
{
|
||||
$weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES);
|
||||
|
||||
self::assertSame(0.4, $weights->notesWeight);
|
||||
self::assertSame(0.3, $weights->absencesWeight);
|
||||
self::assertSame(0.3, $weights->devoirsWeight);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function noGradesModeExcludesNotesComponent(): void
|
||||
{
|
||||
$weights = SerenityScoreWeights::forMode(GradingMode::NO_GRADES);
|
||||
|
||||
self::assertSame(0.0, $weights->notesWeight);
|
||||
self::assertSame(0.5, $weights->absencesWeight);
|
||||
self::assertSame(0.5, $weights->devoirsWeight);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function lettersModeUsesStandardWeights(): void
|
||||
{
|
||||
$weights = SerenityScoreWeights::forMode(GradingMode::LETTERS);
|
||||
|
||||
self::assertSame(0.4, $weights->notesWeight);
|
||||
self::assertSame(0.3, $weights->absencesWeight);
|
||||
self::assertSame(0.3, $weights->devoirsWeight);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function weightsAlwaysSumToOne(): void
|
||||
{
|
||||
foreach (GradingMode::cases() as $mode) {
|
||||
$weights = SerenityScoreWeights::forMode($mode);
|
||||
$sum = $weights->notesWeight + $weights->absencesWeight + $weights->devoirsWeight;
|
||||
self::assertEqualsWithDelta(1.0, $sum, 0.001, "Weights for {$mode->value} must sum to 1.0");
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function competencyMappingConvertsToPercentage(): void
|
||||
{
|
||||
$weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES);
|
||||
|
||||
self::assertSame(100, $weights->competencyToScore('acquired'));
|
||||
self::assertSame(50, $weights->competencyToScore('in_progress'));
|
||||
self::assertSame(0, $weights->competencyToScore('not_acquired'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nonCompetencyModesReturnNullForMapping(): void
|
||||
{
|
||||
$weights = SerenityScoreWeights::forMode(GradingMode::NUMERIC_20);
|
||||
|
||||
self::assertNull($weights->competencyToScore('acquired'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function competencyMappingThrowsOnUnknownLevel(): void
|
||||
{
|
||||
$weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("Niveau de compétence inconnu : 'unknown_level'");
|
||||
|
||||
$weights->competencyToScore('unknown_level');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user