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
|
$PHPIZE_DEPS
|
||||||
|
|
||||||
# Install PHP extensions (opcache is pre-installed in FrankenPHP)
|
# 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
|
# Install AMQP extension for RabbitMQ
|
||||||
RUN pecl install amqp && docker-php-ext-enable amqp
|
RUN pecl install amqp && docker-php-ext-enable amqp
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ services:
|
|||||||
App\Administration\Domain\Repository\PeriodConfigurationRepository:
|
App\Administration\Domain\Repository\PeriodConfigurationRepository:
|
||||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrinePeriodConfigurationRepository
|
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)
|
# GradeExistenceChecker (stub until Notes module exists)
|
||||||
App\Administration\Application\Port\GradeExistenceChecker:
|
App\Administration\Application\Port\GradeExistenceChecker:
|
||||||
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
|
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;
|
namespace App\Administration\Application\Port;
|
||||||
|
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,4 +21,16 @@ interface GradeExistenceChecker
|
|||||||
AcademicYearId $academicYearId,
|
AcademicYearId $academicYearId,
|
||||||
int $periodSequence,
|
int $periodSequence,
|
||||||
): bool;
|
): 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\Security\PeriodVoter;
|
||||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
use DateMalformedStringException;
|
||||||
use Override;
|
use Override;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
@@ -108,6 +109,8 @@ final readonly class UpdatePeriodProcessor implements ProcessorInterface
|
|||||||
throw new ConflictHttpException($e->getMessage());
|
throw new ConflictHttpException($e->getMessage());
|
||||||
} catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) {
|
} catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) {
|
||||||
throw new BadRequestHttpException($e->getMessage());
|
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');
|
$baseUrlOption = $input->getOption('base-url');
|
||||||
$baseUrl = rtrim($baseUrlOption, '/');
|
$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
|
// Convert short role name to full Symfony role format
|
||||||
$roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput;
|
$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\Application\Port\GradeExistenceChecker;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||||
use App\Shared\Domain\Tenant\TenantId;
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
use Override;
|
use Override;
|
||||||
|
|
||||||
@@ -24,4 +25,13 @@ final class NoOpGradeExistenceChecker implements GradeExistenceChecker
|
|||||||
): bool {
|
): bool {
|
||||||
return false;
|
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\DefaultPeriods;
|
||||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
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\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
|
||||||
use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker;
|
use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
@@ -54,7 +55,7 @@ final class UpdatePeriodHandlerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function itUpdatesPeriodDates(): void
|
public function itRejectsOverlappingPeriodDates(): void
|
||||||
{
|
{
|
||||||
$this->seedTrimesterConfig();
|
$this->seedTrimesterConfig();
|
||||||
|
|
||||||
@@ -124,13 +125,7 @@ final class UpdatePeriodHandlerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->seedTrimesterConfig();
|
$this->seedTrimesterConfig();
|
||||||
|
|
||||||
$gradeChecker = new class implements GradeExistenceChecker {
|
$gradeChecker = $this->createGradeCheckerWithGrades();
|
||||||
#[Override]
|
|
||||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
|
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
|
||||||
|
|
||||||
@@ -150,13 +145,7 @@ final class UpdatePeriodHandlerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->seedTrimesterConfig();
|
$this->seedTrimesterConfig();
|
||||||
|
|
||||||
$gradeChecker = new class implements GradeExistenceChecker {
|
$gradeChecker = $this->createGradeCheckerWithGrades();
|
||||||
#[Override]
|
|
||||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
|
$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'));
|
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
|
private function seedTrimesterConfig(): void
|
||||||
{
|
{
|
||||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
|
$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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,23 +25,15 @@ test.describe('Classes Management (Story 2.1)', () => {
|
|||||||
const projectRoot = join(__dirname, '../..');
|
const projectRoot = join(__dirname, '../..');
|
||||||
const composeFile = join(projectRoot, 'compose.yaml');
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
try {
|
|
||||||
// Create admin user
|
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
console.log('Classes E2E test admin user created');
|
|
||||||
|
|
||||||
// Clean up all classes for this tenant to ensure Empty State test works
|
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
console.log('Classes cleaned up for E2E tests');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Setup error:', error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to login as admin
|
// Helper to login as admin
|
||||||
|
|||||||
284
frontend/e2e/pedagogy.spec.ts
Normal file
284
frontend/e2e/pedagogy.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = 'e2e-pedagogy-admin@example.com';
|
||||||
|
const ADMIN_PASSWORD = 'PedagogyTest123';
|
||||||
|
|
||||||
|
test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const projectRoot = join(__dirname, '../..');
|
||||||
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset grading mode to default (numeric_20) to ensure clean state
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php -r "
|
||||||
|
require 'vendor/autoload.php';
|
||||||
|
\\$kernel = new App\\Kernel('dev', true);
|
||||||
|
\\$kernel->boot();
|
||||||
|
\\$conn = \\$kernel->getContainer()->get('doctrine')->getConnection();
|
||||||
|
\\$conn->executeStatement('DELETE FROM school_grading_configurations');
|
||||||
|
" 2>&1`,
|
||||||
|
{ encoding: 'utf-8' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Table might not exist yet, that's OK — default mode applies
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||||
|
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /se connecter/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Navigation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('pedagogy link is visible in admin navigation', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
const pedagogyLink = page.getByRole('link', { name: /pédagogie/i });
|
||||||
|
await expect(pedagogyLink).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pedagogy link navigates to pedagogy page', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/classes`);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: /pédagogie/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pedagogy card is visible on admin dashboard', async ({ page, browserName }) => {
|
||||||
|
// Svelte 5 delegated onclick is not triggered by Playwright click on webkit
|
||||||
|
test.skip(browserName === 'webkit', 'Demo role switcher click not supported on webkit');
|
||||||
|
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
|
||||||
|
// Switch to admin view in demo dashboard
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard`);
|
||||||
|
const adminButton = page.getByRole('button', { name: /admin/i });
|
||||||
|
await adminButton.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
const pedagogyCard = page.getByRole('link', { name: /pédagogie/i });
|
||||||
|
await expect(pedagogyCard).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC1: Display grading mode options
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('shows page title and subtitle', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /mode de notation/i })).toBeVisible();
|
||||||
|
await expect(page.getByText(/système d'évaluation/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows current mode banner', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
// Wait for loading to finish
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Should show "Mode actuel" banner
|
||||||
|
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displays all five grading modes', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// All 5 mode cards should be visible (scope to .mode-card to avoid ambiguous matches)
|
||||||
|
const modeCards = page.locator('.mode-card');
|
||||||
|
await expect(modeCards).toHaveCount(5, { timeout: 10000 });
|
||||||
|
await expect(modeCards.filter({ hasText: /notes \/20/i })).toBeVisible();
|
||||||
|
await expect(modeCards.filter({ hasText: /notes \/10/i })).toBeVisible();
|
||||||
|
await expect(modeCards.filter({ hasText: /lettres/i })).toBeVisible();
|
||||||
|
await expect(modeCards.filter({ hasText: /compétences/i })).toBeVisible();
|
||||||
|
await expect(modeCards.filter({ hasText: /sans notes/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default mode is Notes sur 20', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// The "Notes /20" mode card should be selected (has mode-selected class)
|
||||||
|
const selectedCard = page.locator('.mode-card.mode-selected');
|
||||||
|
await expect(selectedCard).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(selectedCard).toContainText(/notes \/20/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC1: Year selector
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('can switch between year tabs', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
const tabs = page.getByRole('tab');
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Click next year tab
|
||||||
|
await tabs.nth(2).click();
|
||||||
|
await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Click previous year tab
|
||||||
|
await tabs.nth(0).click();
|
||||||
|
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC2-4: Mode selection and preview
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('selecting a different mode shows save button', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Save button should not be visible initially
|
||||||
|
await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Click a different mode
|
||||||
|
const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i });
|
||||||
|
await competenciesCard.click();
|
||||||
|
|
||||||
|
// Save and cancel buttons should appear
|
||||||
|
await expect(page.getByRole('button', { name: /enregistrer/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /annuler/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel button reverts mode selection', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Select a different mode
|
||||||
|
const lettersCard = page.locator('.mode-card').filter({ hasText: /lettres/i });
|
||||||
|
await lettersCard.click();
|
||||||
|
|
||||||
|
// Click cancel
|
||||||
|
await page.getByRole('button', { name: /annuler/i }).click();
|
||||||
|
|
||||||
|
// Save button should disappear
|
||||||
|
await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting competencies mode shows competency-specific description', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i });
|
||||||
|
await competenciesCard.click();
|
||||||
|
|
||||||
|
// Check description inside the card (scoped to avoid matching bulletin impact text)
|
||||||
|
await expect(competenciesCard.locator('.mode-description')).toContainText(
|
||||||
|
/acquis.*en cours.*non acquis/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting sans notes mode shows no-grades description', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const noGradesCard = page.locator('.mode-card').filter({ hasText: /sans notes/i });
|
||||||
|
await noGradesCard.click();
|
||||||
|
|
||||||
|
// Check description inside the card (scoped to avoid matching bulletin impact text)
|
||||||
|
await expect(noGradesCard.locator('.mode-description')).toContainText(
|
||||||
|
/appréciations textuelles/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows bulletin impact preview when mode is selected', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Impact section should be visible
|
||||||
|
await expect(page.getByText(/impact sur les bulletins/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC2: Can change to a different mode
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('can save a new grading mode', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Select "Notes sur 10"
|
||||||
|
const numeric10Card = page.locator('.mode-card').filter({ hasText: /notes \/10/i });
|
||||||
|
await numeric10Card.click();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||||
|
|
||||||
|
// Success message should appear
|
||||||
|
await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// The selected mode should now be "Notes /10"
|
||||||
|
const selectedCard = page.locator('.mode-card.mode-selected');
|
||||||
|
await expect(selectedCard).toContainText(/notes \/10/i);
|
||||||
|
|
||||||
|
// Reload the page to verify server-side persistence
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.locator('.mode-card.mode-selected')).toContainText(/notes \/10/i, {
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore default mode for other tests
|
||||||
|
const numeric20Card = page.locator('.mode-card').filter({ hasText: /notes \/20/i });
|
||||||
|
await numeric20Card.click();
|
||||||
|
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||||
|
await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,23 +23,15 @@ test.describe('Periods Management (Story 2.3)', () => {
|
|||||||
const projectRoot = join(__dirname, '../..');
|
const projectRoot = join(__dirname, '../..');
|
||||||
const composeFile = join(projectRoot, 'compose.yaml');
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
try {
|
|
||||||
// Create admin user
|
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
console.log('Periods E2E test admin user created');
|
|
||||||
|
|
||||||
// Clean up all periods for this tenant
|
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
console.log('Periods cleaned up for E2E tests');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Setup error:', error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||||
|
|||||||
@@ -25,23 +25,15 @@ test.describe('Subjects Management (Story 2.2)', () => {
|
|||||||
const projectRoot = join(__dirname, '../..');
|
const projectRoot = join(__dirname, '../..');
|
||||||
const composeFile = join(projectRoot, 'compose.yaml');
|
const composeFile = join(projectRoot, 'compose.yaml');
|
||||||
|
|
||||||
try {
|
|
||||||
// Create admin user
|
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
console.log('Subjects E2E test admin user created');
|
|
||||||
|
|
||||||
// Clean up all subjects for this tenant to ensure Empty State test works
|
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
console.log('Subjects cleaned up for E2E tests');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Setup error:', error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to login as admin
|
// Helper to login as admin
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
import type { SerenityScore } from '$types';
|
import type { SerenityScore } from '$types';
|
||||||
import { getSerenityEmoji, getSerenityLabel } from '$lib/features/dashboard/serenity-score';
|
import { getSerenityEmoji, getSerenityLabel } from '$lib/features/dashboard/serenity-score';
|
||||||
|
|
||||||
|
// TODO: Adapter la formule et les poids affichés selon le mode de notation
|
||||||
|
// (no_grades: 0/50/50, competencies: renommer Notes→Compétences + note mapping).
|
||||||
|
// À traiter quand le Score Sérénité sera connecté aux vraies données.
|
||||||
|
// Voir backend SerenityScoreWeights::forMode() pour la logique de pondération.
|
||||||
|
|
||||||
let {
|
let {
|
||||||
score,
|
score,
|
||||||
isEnabled = false,
|
isEnabled = false,
|
||||||
@@ -16,10 +21,9 @@
|
|||||||
onToggleOptIn?: ((enabled: boolean) => void) | undefined;
|
onToggleOptIn?: ((enabled: boolean) => void) | undefined;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let localEnabled = $state(isEnabled);
|
let localEnabled = $state(false);
|
||||||
|
|
||||||
// Sync local state with parent prop changes
|
$effect.pre(() => {
|
||||||
$effect(() => {
|
|
||||||
localEnabled = isEnabled;
|
localEnabled = isEnabled;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,8 +47,8 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
|
||||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
|
||||||
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
<h2 id="modal-title">Comment fonctionne le Score Sérénité ?</h2>
|
<h2 id="modal-title">Comment fonctionne le Score Sérénité ?</h2>
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
<span class="action-label">Périodes scolaires</span>
|
<span class="action-label">Périodes scolaires</span>
|
||||||
<span class="action-hint">Trimestres et semestres</span>
|
<span class="action-hint">Trimestres et semestres</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="action-card" href="/admin/pedagogy">
|
||||||
|
<span class="action-icon">🎓</span>
|
||||||
|
<span class="action-label">Pédagogie</span>
|
||||||
|
<span class="action-hint">Mode de notation</span>
|
||||||
|
</a>
|
||||||
<div class="action-card disabled" aria-disabled="true">
|
<div class="action-card disabled" aria-disabled="true">
|
||||||
<span class="action-icon">📤</span>
|
<span class="action-icon">📤</span>
|
||||||
<span class="action-label">Importer des données</span>
|
<span class="action-label">Importer des données</span>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function createDefaultReporter(options: {
|
|||||||
return (metric: VitalMetric) => {
|
return (metric: VitalMetric) => {
|
||||||
// Log in development
|
// Log in development
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[WebVitals] ${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`);
|
console.log(`[WebVitals] ${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
|
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
|
||||||
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
|
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
|
||||||
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
|
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
|
||||||
|
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="admin-layout">
|
<div class="admin-layout">
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
|
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
|
||||||
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
|
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
|
||||||
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
|
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
|
||||||
|
<a href="/admin/pedagogy" class="nav-link" class:active={isPedagogyActive}>Pédagogie</a>
|
||||||
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
||||||
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||||
{#if isLoggingOut}
|
{#if isLoggingOut}
|
||||||
|
|||||||
@@ -63,6 +63,19 @@
|
|||||||
// Derived
|
// Derived
|
||||||
let hasConfig = $derived(config !== null && config.periods.length > 0);
|
let hasConfig = $derived(config !== null && config.periods.length > 0);
|
||||||
|
|
||||||
|
// Close modals on Escape key
|
||||||
|
$effect(() => {
|
||||||
|
if (!showConfigureModal && !showEditModal) return;
|
||||||
|
const onKeydown = (e: globalThis.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
showConfigureModal = false;
|
||||||
|
if (showEditModal) closeEditModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
// Reload when year changes
|
// Reload when year changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void selectedYear; // Track dependency to re-run on change
|
void selectedYear; // Track dependency to re-run on change
|
||||||
@@ -186,13 +199,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
return new Date(dateString).toLocaleDateString('fr-FR', {
|
const [y, m, d] = dateString.split('-');
|
||||||
|
return new Date(Number(y), Number(m) - 1, Number(d)).toLocaleDateString('fr-FR', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lastDayOfFeb(year: number): number {
|
||||||
|
return new Date(year, 2, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
function typeLabel(type: string): string {
|
function typeLabel(type: string): string {
|
||||||
return type === 'trimester' ? 'Trimestres' : 'Semestres';
|
return type === 'trimester' ? 'Trimestres' : 'Semestres';
|
||||||
}
|
}
|
||||||
@@ -314,13 +332,16 @@
|
|||||||
|
|
||||||
<!-- Configure Modal -->
|
<!-- Configure Modal -->
|
||||||
{#if showConfigureModal}
|
{#if showConfigureModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="modal-overlay" onclick={() => (showConfigureModal = false)} role="presentation">
|
<div class="modal-overlay" onclick={() => (showConfigureModal = false)} role="presentation">
|
||||||
<div
|
<div
|
||||||
class="modal"
|
class="modal"
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="configure-title"
|
aria-labelledby="configure-title"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') showConfigureModal = false; }}
|
||||||
>
|
>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
<h2 id="configure-title">Configurer les périodes</h2>
|
<h2 id="configure-title">Configurer les périodes</h2>
|
||||||
@@ -356,7 +377,7 @@
|
|||||||
<strong>T1 :</strong> 1er sept. {startYear} - 30 nov. {startYear}
|
<strong>T1 :</strong> 1er sept. {startYear} - 30 nov. {startYear}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>T2 :</strong> 1er déc. {startYear} - 28 fév. {startYear + 1}
|
<strong>T2 :</strong> 1er déc. {startYear} - {lastDayOfFeb(startYear + 1)} fév. {startYear + 1}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>T3 :</strong> 1er mars {startYear + 1} - 30 juin {startYear + 1}
|
<strong>T3 :</strong> 1er mars {startYear + 1} - 30 juin {startYear + 1}
|
||||||
@@ -395,13 +416,16 @@
|
|||||||
|
|
||||||
<!-- Edit Period Modal -->
|
<!-- Edit Period Modal -->
|
||||||
{#if showEditModal && editingPeriod}
|
{#if showEditModal && editingPeriod}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
|
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
|
||||||
<div
|
<div
|
||||||
class="modal"
|
class="modal"
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="edit-title"
|
aria-labelledby="edit-title"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') closeEditModal(); }}
|
||||||
>
|
>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
<h2 id="edit-title">Modifier {editingPeriod.label}</h2>
|
<h2 id="edit-title">Modifier {editingPeriod.label}</h2>
|
||||||
|
|||||||
@@ -217,13 +217,16 @@
|
|||||||
|
|
||||||
<!-- Create Modal -->
|
<!-- Create Modal -->
|
||||||
{#if showCreateModal}
|
{#if showCreateModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="modal-overlay" onclick={closeModal} role="presentation">
|
<div class="modal-overlay" onclick={closeModal} role="presentation">
|
||||||
<div
|
<div
|
||||||
class="modal"
|
class="modal"
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-title"
|
aria-labelledby="modal-title"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }}
|
||||||
>
|
>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
<h2 id="modal-title">Nouvelle classe</h2>
|
<h2 id="modal-title">Nouvelle classe</h2>
|
||||||
@@ -285,14 +288,17 @@
|
|||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
{#if showDeleteModal && classToDelete}
|
{#if showDeleteModal && classToDelete}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
|
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
|
||||||
<div
|
<div
|
||||||
class="modal modal-confirm"
|
class="modal modal-confirm"
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="delete-modal-title"
|
aria-labelledby="delete-modal-title"
|
||||||
aria-describedby="delete-modal-description"
|
aria-describedby="delete-modal-description"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
|
||||||
>
|
>
|
||||||
<header class="modal-header modal-header-danger">
|
<header class="modal-header modal-header-danger">
|
||||||
<h2 id="delete-modal-title">Supprimer la classe</h2>
|
<h2 id="delete-modal-title">Supprimer la classe</h2>
|
||||||
|
|||||||
658
frontend/src/routes/admin/pedagogy/+page.svelte
Normal file
658
frontend/src/routes/admin/pedagogy/+page.svelte
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getApiBaseUrl } from '$lib/api/config';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface AvailableMode {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GradingModeConfig {
|
||||||
|
academicYearId: string;
|
||||||
|
mode: string;
|
||||||
|
label: string;
|
||||||
|
scaleMax: number | null;
|
||||||
|
isNumeric: boolean;
|
||||||
|
calculatesAverage: boolean;
|
||||||
|
hasExistingGrades: boolean;
|
||||||
|
availableModes: AvailableMode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
let config = $state<GradingModeConfig | null>(null);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let successMessage = $state<string | null>(null);
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let selectedMode = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Academic year selector
|
||||||
|
type YearKey = 'previous' | 'current' | 'next';
|
||||||
|
const yearOptions: { key: YearKey; offset: number }[] = [
|
||||||
|
{ key: 'previous', offset: -1 },
|
||||||
|
{ key: 'current', offset: 0 },
|
||||||
|
{ key: 'next', offset: 1 }
|
||||||
|
];
|
||||||
|
let selectedYear = $state<YearKey>('current');
|
||||||
|
let academicYearId = $derived(selectedYear);
|
||||||
|
|
||||||
|
function baseStartYear(): number {
|
||||||
|
const now = new Date();
|
||||||
|
return now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schoolYearLabel(offset: number): string {
|
||||||
|
const sy = baseStartYear() + offset;
|
||||||
|
return `${sy}-${sy + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived
|
||||||
|
let isLocked = $derived(config?.hasExistingGrades === true);
|
||||||
|
let hasChanges = $derived(selectedMode !== null && config !== null && selectedMode !== config.mode);
|
||||||
|
|
||||||
|
// Version counter to discard stale responses on quick year switches
|
||||||
|
let loadVersion = 0;
|
||||||
|
|
||||||
|
// Reload when year changes
|
||||||
|
$effect(() => {
|
||||||
|
void selectedYear;
|
||||||
|
loadConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const myVersion = ++loadVersion;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
successMessage = null;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${apiUrl}/academic-years/${academicYearId}/grading-mode`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (myVersion !== loadVersion) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erreur lors du chargement de la configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GradingModeConfig = await response.json();
|
||||||
|
config = data;
|
||||||
|
selectedMode = data.mode;
|
||||||
|
} catch (e) {
|
||||||
|
if (myVersion !== loadVersion) return;
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
config = null;
|
||||||
|
} finally {
|
||||||
|
if (myVersion === loadVersion) {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!selectedMode || !hasChanges) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSubmitting = true;
|
||||||
|
error = null;
|
||||||
|
successMessage = null;
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${apiUrl}/academic-years/${academicYearId}/grading-mode`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: selectedMode })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
error =
|
||||||
|
'Impossible de changer le mode de notation : des notes existent pour cette année scolaire. Veuillez attendre la prochaine année.';
|
||||||
|
selectedMode = config?.mode ?? null;
|
||||||
|
await loadConfig();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(
|
||||||
|
errorData['hydra:description'] || errorData.message || 'Erreur lors de la configuration'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GradingModeConfig = await response.json();
|
||||||
|
config = data;
|
||||||
|
selectedMode = data.mode;
|
||||||
|
successMessage = 'Mode de notation mis à jour avec succès.';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeDescription(mode: string): string {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
numeric_20: 'Notes sur 20 points. Les moyennes sont calculées classiquement.',
|
||||||
|
numeric_10: 'Notes sur 10 points. Les moyennes sont calculées classiquement.',
|
||||||
|
letters: 'Lettres A, B, C, D, E. Les moyennes sont calculées avec un barème.',
|
||||||
|
competencies:
|
||||||
|
'Acquis, En cours, Non acquis. Pas de moyenne numérique. Le Score Sérénité utilise un mapping adapté.',
|
||||||
|
no_grades:
|
||||||
|
'Appréciations textuelles uniquement. Le Score Sérénité est basé sur les absences et devoirs.'
|
||||||
|
};
|
||||||
|
return descriptions[mode] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeBulletinImpact(mode: string): string {
|
||||||
|
const impacts: Record<string, string> = {
|
||||||
|
numeric_20: 'Bulletins avec moyennes sur 20, classements et appréciations.',
|
||||||
|
numeric_10: 'Bulletins avec moyennes sur 10, classements et appréciations.',
|
||||||
|
letters:
|
||||||
|
'Bulletins avec lettres (A-E) et moyennes converties selon le barème.',
|
||||||
|
competencies:
|
||||||
|
'Bulletins avec niveaux de compétences : Acquis, En cours, Non acquis. Pas de moyenne.',
|
||||||
|
no_grades: 'Bulletins avec appréciations textuelles uniquement. Aucune note ni compétence.'
|
||||||
|
};
|
||||||
|
return impacts[mode] ?? '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Pédagogie - Classeo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="pedagogy-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1>Mode de notation</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Choisissez le système d'évaluation de votre établissement
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="year-selector" role="tablist" aria-label="Année scolaire">
|
||||||
|
{#each yearOptions as { key, offset } (key)}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class="year-tab"
|
||||||
|
class:year-tab-active={selectedYear === key}
|
||||||
|
aria-selected={selectedYear === key}
|
||||||
|
onclick={() => (selectedYear = key as YearKey)}
|
||||||
|
>
|
||||||
|
{schoolYearLabel(offset)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error" role="alert">
|
||||||
|
<span class="alert-icon">!</span>
|
||||||
|
{error}
|
||||||
|
<button class="alert-close" onclick={() => (error = null)}>x</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if successMessage}
|
||||||
|
<div class="alert alert-success" role="alert">
|
||||||
|
{successMessage}
|
||||||
|
<button class="alert-close" onclick={() => (successMessage = null)}>x</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Chargement de la configuration...</p>
|
||||||
|
</div>
|
||||||
|
{:else if config}
|
||||||
|
{#if isLocked}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>Mode verrouillé :</strong> des notes existent pour cette année scolaire.
|
||||||
|
Le changement de mode de notation sera possible lors de la configuration de la prochaine année.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Current mode summary -->
|
||||||
|
<div class="current-mode-banner">
|
||||||
|
<div class="banner-content">
|
||||||
|
<span class="banner-label">Mode actuel</span>
|
||||||
|
<span class="banner-mode">{config.label}</span>
|
||||||
|
</div>
|
||||||
|
{#if config.scaleMax}
|
||||||
|
<div class="banner-detail">
|
||||||
|
<span class="detail-number">{config.scaleMax}</span>
|
||||||
|
<span class="detail-label">points</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode selector -->
|
||||||
|
<div class="modes-section">
|
||||||
|
<h2 class="section-title">Modes disponibles</h2>
|
||||||
|
<div class="modes-grid">
|
||||||
|
{#each config.availableModes as mode (mode.value)}
|
||||||
|
<button
|
||||||
|
class="mode-card"
|
||||||
|
class:mode-selected={selectedMode === mode.value}
|
||||||
|
class:mode-locked={isLocked && mode.value !== config.mode}
|
||||||
|
disabled={isLocked && mode.value !== config.mode}
|
||||||
|
onclick={() => (selectedMode = mode.value)}
|
||||||
|
aria-pressed={selectedMode === mode.value}
|
||||||
|
>
|
||||||
|
<div class="mode-header">
|
||||||
|
<span class="mode-radio" class:mode-radio-checked={selectedMode === mode.value}
|
||||||
|
></span>
|
||||||
|
<span class="mode-label">{mode.label}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mode-description">{modeDescription(mode.value)}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulletin impact preview -->
|
||||||
|
{#if selectedMode}
|
||||||
|
<div class="preview-section">
|
||||||
|
<h2 class="section-title">Impact sur les bulletins</h2>
|
||||||
|
<div class="preview-card">
|
||||||
|
<p class="preview-text">{modeBulletinImpact(selectedMode)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Save button -->
|
||||||
|
{#if hasChanges}
|
||||||
|
<div class="save-bar">
|
||||||
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
onclick={() => (selectedMode = config?.mode ?? null)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" onclick={handleSave} disabled={isSubmitting}>
|
||||||
|
{#if isSubmitting}
|
||||||
|
Enregistrement...
|
||||||
|
{:else}
|
||||||
|
Enregistrer le mode
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pedagogy-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
display: flex;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-tab {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-tab-active {
|
||||||
|
background: white;
|
||||||
|
color: #1f2937;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-tab:hover:not(.year-tab-active) {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current mode banner */
|
||||||
|
.current-mode-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-mode {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-detail {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-number {
|
||||||
|
display: block;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modes section */
|
||||||
|
.modes-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modes-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-card:hover:not(:disabled) {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-selected {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
background: #f5f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-locked {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-radio {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-radio-checked {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-radio-checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview section */
|
||||||
|
.preview-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Save bar */
|
||||||
|
.save-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #92400e;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 2px dashed #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #8b5cf6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modes-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-mode-banner {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-detail {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -273,13 +273,16 @@
|
|||||||
|
|
||||||
<!-- Create Modal -->
|
<!-- Create Modal -->
|
||||||
{#if showCreateModal}
|
{#if showCreateModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="modal-overlay" onclick={closeModal} role="presentation">
|
<div class="modal-overlay" onclick={closeModal} role="presentation">
|
||||||
<div
|
<div
|
||||||
class="modal"
|
class="modal"
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-title"
|
aria-labelledby="modal-title"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }}
|
||||||
>
|
>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
<h2 id="modal-title">Nouvelle matière</h2>
|
<h2 id="modal-title">Nouvelle matière</h2>
|
||||||
@@ -376,14 +379,17 @@
|
|||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
{#if showDeleteModal && subjectToDelete}
|
{#if showDeleteModal && subjectToDelete}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
|
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
|
||||||
<div
|
<div
|
||||||
class="modal modal-confirm"
|
class="modal modal-confirm"
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="delete-modal-title"
|
aria-labelledby="delete-modal-title"
|
||||||
aria-describedby="delete-modal-description"
|
aria-describedby="delete-modal-description"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
|
||||||
>
|
>
|
||||||
<header class="modal-header modal-header-danger">
|
<header class="modal-header modal-header-danger">
|
||||||
<h2 id="delete-modal-title">Supprimer la matière</h2>
|
<h2 id="delete-modal-title">Supprimer la matière</h2>
|
||||||
@@ -738,8 +744,7 @@
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input[type='text'],
|
.form-group input[type='text'] {
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
|
|||||||
Reference in New Issue
Block a user