feat: Permettre à l'enseignant de saisir les notes dans une grille inline
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

L'enseignant avait besoin d'un moyen rapide de saisir les notes après
une évaluation. La grille inline permet de compléter 30 élèves en moins
de 3 minutes grâce à la navigation clavier (Tab/Enter/Shift+Tab),
la validation temps réel, l'auto-save debounced (500ms) et les
raccourcis /abs et /disp pour marquer absents/dispensés.

Les notes restent en brouillon jusqu'à publication explicite (avec
confirmation modale). Une fois publiées, les élèves les voient
immédiatement ; les parents après un délai de 24h (VisibiliteNotesPolicy).
Le mode offline stocke les notes en IndexedDB et synchronise
automatiquement au retour de la connexion.

Chaque modification est auditée dans grade_events via un event
subscriber qui écoute NoteSaisie/NoteModifiee sur le bus d'événements.
This commit is contained in:
2026-03-29 09:55:45 +02:00
parent 98be1951bf
commit b70d5ec2ad
45 changed files with 3902 additions and 11 deletions

View File

@@ -262,6 +262,9 @@ services:
App\Scolarite\Domain\Repository\EvaluationRepository: App\Scolarite\Domain\Repository\EvaluationRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationRepository alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationRepository
App\Scolarite\Domain\Repository\GradeRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineGradeRepository
App\Scolarite\Application\Port\EvaluationGradesChecker: App\Scolarite\Application\Port\EvaluationGradesChecker:
alias: App\Scolarite\Infrastructure\Service\NoGradesEvaluationGradesChecker alias: App\Scolarite\Infrastructure\Service\NoGradesEvaluationGradesChecker

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260327211549 extends AbstractMigration
{
public function getDescription(): string
{
return 'Création des tables grades et grade_events pour la saisie de notes et l\'audit';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE grades (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
evaluation_id UUID NOT NULL REFERENCES evaluations(id),
student_id UUID NOT NULL REFERENCES users(id),
value DECIMAL(5,2),
status VARCHAR(20) NOT NULL DEFAULT 'graded',
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (evaluation_id, student_id)
)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_grades_tenant ON grades(tenant_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_grades_evaluation ON grades(evaluation_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_grades_student ON grades(student_id)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE grade_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
grade_id UUID NOT NULL REFERENCES grades(id),
event_type VARCHAR(50) NOT NULL,
old_value DECIMAL(5,2),
new_value DECIMAL(5,2),
old_status VARCHAR(20),
new_status VARCHAR(20),
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_grade_events_grade ON grade_events(grade_id)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS grade_events');
$this->addSql('DROP TABLE IF EXISTS grades');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260327212231 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajout de grades_published_at sur evaluations pour gérer la publication des notes';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE evaluations ADD COLUMN grades_published_at TIMESTAMPTZ DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE evaluations DROP COLUMN grades_published_at');
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\PublishGrades;
final readonly class PublishGradesCommand
{
public function __construct(
public string $tenantId,
public string $evaluationId,
public string $teacherId,
) {
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\PublishGrades;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class PublishGradesHandler
{
public function __construct(
private EvaluationRepository $evaluationRepository,
private GradeRepository $gradeRepository,
private Clock $clock,
) {
}
public function __invoke(PublishGradesCommand $command): Evaluation
{
$tenantId = TenantId::fromString($command->tenantId);
$evaluationId = EvaluationId::fromString($command->evaluationId);
$teacherId = UserId::fromString($command->teacherId);
$now = $this->clock->now();
$evaluation = $this->evaluationRepository->get($evaluationId, $tenantId);
if ((string) $evaluation->teacherId !== (string) $teacherId) {
throw NonProprietaireDeLEvaluationException::withId($evaluationId);
}
if (!$this->gradeRepository->hasGradesForEvaluation($evaluationId, $tenantId)) {
throw AucuneNoteSaisieException::pourEvaluation($evaluationId);
}
$evaluation->publierNotes($now);
$this->evaluationRepository->save($evaluation);
return $evaluation;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\SaveGrades;
final readonly class SaveGradesCommand
{
/**
* @param array<array{studentId: string, value: ?float, status: string}> $grades
*/
public function __construct(
public string $tenantId,
public string $evaluationId,
public string $teacherId,
public array $grades,
) {
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\SaveGrades;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\Grade\GradeValue;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class SaveGradesHandler
{
public function __construct(
private EvaluationRepository $evaluationRepository,
private GradeRepository $gradeRepository,
private Clock $clock,
) {
}
/** @return array<Grade> */
public function __invoke(SaveGradesCommand $command): array
{
$tenantId = TenantId::fromString($command->tenantId);
$evaluationId = EvaluationId::fromString($command->evaluationId);
$teacherId = UserId::fromString($command->teacherId);
$now = $this->clock->now();
$evaluation = $this->evaluationRepository->get($evaluationId, $tenantId);
if ((string) $evaluation->teacherId !== (string) $teacherId) {
throw NonProprietaireDeLEvaluationException::withId($evaluationId);
}
$existingGrades = $this->gradeRepository->findByEvaluation($evaluationId, $tenantId);
$existingByStudent = [];
foreach ($existingGrades as $grade) {
$existingByStudent[(string) $grade->studentId] = $grade;
}
$savedGrades = [];
foreach ($command->grades as $gradeInput) {
$studentId = UserId::fromString($gradeInput['studentId']);
$status = GradeStatus::from($gradeInput['status']);
$value = $gradeInput['value'] !== null ? new GradeValue($gradeInput['value']) : null;
$existing = $existingByStudent[(string) $studentId] ?? null;
if ($existing !== null) {
$existing->modifier(
value: $value,
status: $status,
gradeScale: $evaluation->gradeScale,
modifiedBy: $teacherId,
now: $now,
);
$this->gradeRepository->save($existing);
$savedGrades[] = $existing;
} else {
$grade = Grade::saisir(
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: $status,
gradeScale: $evaluation->gradeScale,
createdBy: $teacherId,
now: $now,
);
$this->gradeRepository->save($grade);
$savedGrades[] = $grade;
}
}
return $savedGrades;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class NoteModifiee implements DomainEvent
{
public function __construct(
public GradeId $gradeId,
public string $evaluationId,
public ?float $oldValue,
public ?float $newValue,
public string $oldStatus,
public string $newStatus,
public string $modifiedBy,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->gradeId->value;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class NoteSaisie implements DomainEvent
{
public function __construct(
public GradeId $gradeId,
public string $evaluationId,
public string $studentId,
public ?float $value,
public string $status,
public string $createdBy,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->gradeId->value;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class NotesPubliees implements DomainEvent
{
public function __construct(
public EvaluationId $evaluationId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->evaluationId->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use DomainException;
use function sprintf;
final class AucuneNoteSaisieException extends DomainException
{
public static function pourEvaluation(EvaluationId $id): self
{
return new self(sprintf(
'Impossible de publier l\'évaluation "%s" : aucune note n\'a été saisie.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Grade\GradeId;
use DomainException;
use function sprintf;
final class GradeNotFoundException extends DomainException
{
public static function withId(GradeId $id): self
{
return new self(sprintf(
'La note avec l\'ID "%s" n\'a pas été trouvée.',
$id,
));
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
final class NoteRequiseException extends DomainException
{
public static function pourStatutNote(): self
{
return new self(
'Une valeur de note est requise pour le statut "graded".',
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use DomainException;
use function sprintf;
final class NotesDejaPublieesException extends DomainException
{
public static function pourEvaluation(EvaluationId $id): self
{
return new self(sprintf(
'Les notes de l\'évaluation "%s" sont déjà publiées.',
$id,
));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
use function sprintf;
final class ValeurNoteInvalideException extends DomainException
{
public static function valeurNegative(float $value): self
{
return new self(sprintf(
'La valeur de la note ne peut pas être négative, %s donné.',
$value,
));
}
public static function depasseBareme(float $value, int $maxValue): self
{
return new self(sprintf(
'La valeur de la note (%s) dépasse le barème maximum (%d).',
$value,
$maxValue,
));
}
}

View File

@@ -10,8 +10,10 @@ use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\EvaluationCreee; use App\Scolarite\Domain\Event\EvaluationCreee;
use App\Scolarite\Domain\Event\EvaluationModifiee; use App\Scolarite\Domain\Event\EvaluationModifiee;
use App\Scolarite\Domain\Event\EvaluationSupprimee; use App\Scolarite\Domain\Event\EvaluationSupprimee;
use App\Scolarite\Domain\Event\NotesPubliees;
use App\Scolarite\Domain\Exception\BaremeNonModifiableException; use App\Scolarite\Domain\Exception\BaremeNonModifiableException;
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException; use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
use App\Shared\Domain\AggregateRoot; use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
@@ -19,6 +21,7 @@ use DateTimeImmutable;
final class Evaluation extends AggregateRoot final class Evaluation extends AggregateRoot
{ {
public private(set) DateTimeImmutable $updatedAt; public private(set) DateTimeImmutable $updatedAt;
public private(set) ?DateTimeImmutable $gradesPublishedAt = null;
private function __construct( private function __construct(
public private(set) EvaluationId $id, public private(set) EvaluationId $id,
@@ -113,6 +116,30 @@ final class Evaluation extends AggregateRoot
)); ));
} }
public function publierNotes(DateTimeImmutable $now): void
{
if ($this->status === EvaluationStatus::DELETED) {
throw EvaluationDejaSupprimeeException::withId($this->id);
}
if ($this->gradesPublishedAt !== null) {
throw NotesDejaPublieesException::pourEvaluation($this->id);
}
$this->gradesPublishedAt = $now;
$this->updatedAt = $now;
$this->recordEvent(new NotesPubliees(
evaluationId: $this->id,
occurredOn: $now,
));
}
public function notesPubliees(): bool
{
return $this->gradesPublishedAt !== null;
}
public function supprimer(DateTimeImmutable $now): void public function supprimer(DateTimeImmutable $now): void
{ {
if ($this->status === EvaluationStatus::DELETED) { if ($this->status === EvaluationStatus::DELETED) {
@@ -145,6 +172,7 @@ final class Evaluation extends AggregateRoot
EvaluationStatus $status, EvaluationStatus $status,
DateTimeImmutable $createdAt, DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt, DateTimeImmutable $updatedAt,
?DateTimeImmutable $gradesPublishedAt = null,
): self { ): self {
$evaluation = new self( $evaluation = new self(
id: $id, id: $id,
@@ -162,6 +190,7 @@ final class Evaluation extends AggregateRoot
); );
$evaluation->updatedAt = $updatedAt; $evaluation->updatedAt = $updatedAt;
$evaluation->gradesPublishedAt = $gradesPublishedAt;
return $evaluation; return $evaluation;
} }

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\NoteModifiee;
use App\Scolarite\Domain\Event\NoteSaisie;
use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final class Grade extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) GradeId $id,
public private(set) TenantId $tenantId,
public private(set) EvaluationId $evaluationId,
public private(set) UserId $studentId,
public private(set) ?GradeValue $value,
public private(set) GradeStatus $status,
public private(set) UserId $createdBy,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function saisir(
TenantId $tenantId,
EvaluationId $evaluationId,
UserId $studentId,
?GradeValue $value,
GradeStatus $status,
GradeScale $gradeScale,
UserId $createdBy,
DateTimeImmutable $now,
): self {
self::validerCoherence($value, $status, $gradeScale);
$grade = new self(
id: GradeId::generate(),
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: $status,
createdBy: $createdBy,
createdAt: $now,
);
$grade->recordEvent(new NoteSaisie(
gradeId: $grade->id,
evaluationId: (string) $evaluationId,
studentId: (string) $studentId,
value: $value?->value,
status: $status->value,
createdBy: (string) $createdBy,
occurredOn: $now,
));
return $grade;
}
public function modifier(
?GradeValue $value,
GradeStatus $status,
GradeScale $gradeScale,
UserId $modifiedBy,
DateTimeImmutable $now,
): void {
self::validerCoherence($value, $status, $gradeScale);
$oldValue = $this->value?->value;
$oldStatus = $this->status->value;
$this->value = $value;
$this->status = $status;
$this->updatedAt = $now;
$this->recordEvent(new NoteModifiee(
gradeId: $this->id,
evaluationId: (string) $this->evaluationId,
oldValue: $oldValue,
newValue: $value?->value,
oldStatus: $oldStatus,
newStatus: $status->value,
modifiedBy: (string) $modifiedBy,
occurredOn: $now,
));
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
GradeId $id,
TenantId $tenantId,
EvaluationId $evaluationId,
UserId $studentId,
?GradeValue $value,
GradeStatus $status,
UserId $createdBy,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$grade = new self(
id: $id,
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: $status,
createdBy: $createdBy,
createdAt: $createdAt,
);
$grade->updatedAt = $updatedAt;
return $grade;
}
private static function validerCoherence(
?GradeValue $value,
GradeStatus $status,
GradeScale $gradeScale,
): void {
if ($status === GradeStatus::GRADED && $value === null) {
throw NoteRequiseException::pourStatutNote();
}
if ($value !== null && $value->value > $gradeScale->maxValue) {
throw ValeurNoteInvalideException::depasseBareme($value->value, $gradeScale->maxValue);
}
}
}

View File

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

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
enum GradeStatus: string
{
case GRADED = 'graded';
case ABSENT = 'absent';
case DISPENSED = 'dispensed';
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
use function abs;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
final readonly class GradeValue
{
public function __construct(
public float $value,
) {
if ($value < 0) {
throw ValeurNoteInvalideException::valeurNegative($value);
}
}
public function equals(self $other): bool
{
return abs($this->value - $other->value) < 0.001;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Policy;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Shared\Domain\Clock;
final readonly class VisibiliteNotesPolicy
{
private const int DELAI_PARENTS_HEURES = 24;
public function __construct(
private Clock $clock,
) {
}
public function visiblePourEleve(Evaluation $evaluation): bool
{
return $evaluation->notesPubliees();
}
public function visiblePourParent(Evaluation $evaluation): bool
{
if (!$evaluation->notesPubliees()) {
return false;
}
$delai = $evaluation->gradesPublishedAt?->modify('+' . self::DELAI_PARENTS_HEURES . ' hours');
return $delai !== null && $delai <= $this->clock->now();
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Exception\GradeNotFoundException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Shared\Domain\Tenant\TenantId;
interface GradeRepository
{
public function save(Grade $grade): void;
/** @throws GradeNotFoundException */
public function get(GradeId $id, TenantId $tenantId): Grade;
public function findById(GradeId $id, TenantId $tenantId): ?Grade;
/** @return array<Grade> */
public function findByEvaluation(EvaluationId $evaluationId, TenantId $tenantId): array;
public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool;
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\PublishGrades\PublishGradesCommand;
use App\Scolarite\Application\Command\PublishGrades\PublishGradesHandler;
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
use App\Scolarite\Infrastructure\Api\Resource\GradeResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @implements ProcessorInterface<GradeResource, GradeResource>
*/
final readonly class PublishGradesProcessor implements ProcessorInterface
{
public function __construct(
private PublishGradesHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private Security $security,
) {
}
/**
* @param GradeResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GradeResource
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
/** @var string $evaluationId */
$evaluationId = $uriVariables['evaluationId'] ?? '';
try {
$command = new PublishGradesCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
evaluationId: $evaluationId,
teacherId: $user->userId(),
);
$evaluation = ($this->handler)($command);
foreach ($evaluation->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
$resource = new GradeResource();
$resource->id = (string) $evaluation->id;
$resource->evaluationId = (string) $evaluation->id;
$resource->published = true;
$resource->gradesPublishedAt = $evaluation->gradesPublishedAt;
return $resource;
} catch (NonProprietaireDeLEvaluationException $e) {
throw new AccessDeniedHttpException($e->getMessage());
} catch (NotesDejaPublieesException|AucuneNoteSaisieException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\SaveGrades\SaveGradesCommand;
use App\Scolarite\Application\Command\SaveGrades\SaveGradesHandler;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
use App\Scolarite\Infrastructure\Api\Resource\GradeResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @implements ProcessorInterface<GradeResource, array<GradeResource>>
*/
final readonly class SaveGradesProcessor implements ProcessorInterface
{
public function __construct(
private SaveGradesHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private Security $security,
) {
}
/**
* @param GradeResource $data
*
* @return array<GradeResource>
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
/** @var string $evaluationId */
$evaluationId = $uriVariables['evaluationId'] ?? '';
try {
$command = new SaveGradesCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
evaluationId: $evaluationId,
teacherId: $user->userId(),
grades: $data->grades ?? [],
);
$savedGrades = ($this->handler)($command);
foreach ($savedGrades as $grade) {
foreach ($grade->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
return array_map(
static fn ($grade) => GradeResource::fromDomain($grade),
$savedGrades,
);
} catch (NonProprietaireDeLEvaluationException $e) {
throw new AccessDeniedHttpException($e->getMessage());
} catch (ValeurNoteInvalideException|NoteRequiseException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Infrastructure\Api\Resource\GradeResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use Doctrine\DBAL\Connection;
use function is_string;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* @implements ProviderInterface<GradeResource>
*/
final readonly class GradeCollectionProvider implements ProviderInterface
{
public function __construct(
private Connection $connection,
private EvaluationRepository $evaluationRepository,
private TenantContext $tenantContext,
private Security $security,
) {
}
/** @return array<GradeResource> */
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
/** @var string $evaluationIdStr */
$evaluationIdStr = $uriVariables['evaluationId'] ?? '';
if (!is_string($evaluationIdStr) || $evaluationIdStr === '') {
throw new NotFoundHttpException('Évaluation non trouvée.');
}
$tenantId = $this->tenantContext->getCurrentTenantId();
$evaluationId = EvaluationId::fromString($evaluationIdStr);
$evaluation = $this->evaluationRepository->findById($evaluationId, $tenantId);
if ($evaluation === null) {
throw new NotFoundHttpException('Évaluation non trouvée.');
}
if ((string) $evaluation->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Accès refusé.');
}
$classId = (string) $evaluation->classId;
// Return all students in the class, with LEFT JOIN to grades
$rows = $this->connection->fetchAllAssociative(
'SELECT u.id AS student_id, u.first_name, u.last_name,
g.id AS grade_id, g.evaluation_id, g.value, g.status AS grade_status
FROM class_assignments ca
JOIN users u ON u.id = ca.user_id
LEFT JOIN grades g ON g.student_id = u.id AND g.evaluation_id = :evaluation_id AND g.tenant_id = :tenant_id
WHERE ca.school_class_id = :class_id
AND ca.tenant_id = :tenant_id
ORDER BY u.last_name ASC, u.first_name ASC',
[
'evaluation_id' => $evaluationIdStr,
'tenant_id' => (string) $tenantId,
'class_id' => $classId,
],
);
return array_map(static function (array $row) use ($evaluationIdStr): GradeResource {
$resource = new GradeResource();
/** @var string $studentId */
$studentId = $row['student_id'];
/** @var string|null $gradeId */
$gradeId = $row['grade_id'] ?? null;
$resource->id = $gradeId ?? $studentId;
$resource->evaluationId = $evaluationIdStr;
$resource->studentId = $studentId;
/** @var string|null $firstName */
$firstName = $row['first_name'] ?? null;
/** @var string|null $lastName */
$lastName = $row['last_name'] ?? null;
$resource->studentName = $firstName !== null && $lastName !== null
? $lastName . ' ' . $firstName
: null;
/** @var string|float|null $valueRaw */
$valueRaw = $row['value'] ?? null;
$resource->value = $valueRaw !== null ? (float) $valueRaw : null;
/** @var string|null $gradeStatus */
$gradeStatus = $row['grade_status'] ?? null;
$resource->status = $gradeStatus;
return $resource;
}, $rows);
}
}

View File

@@ -94,6 +94,8 @@ final class EvaluationResource
public ?DateTimeImmutable $updatedAt = null; public ?DateTimeImmutable $updatedAt = null;
public ?DateTimeImmutable $gradesPublishedAt = null;
public static function fromDomain( public static function fromDomain(
Evaluation $evaluation, Evaluation $evaluation,
?string $className = null, ?string $className = null,
@@ -114,6 +116,7 @@ final class EvaluationResource
$resource->subjectName = $subjectName; $resource->subjectName = $subjectName;
$resource->createdAt = $evaluation->createdAt; $resource->createdAt = $evaluation->createdAt;
$resource->updatedAt = $evaluation->updatedAt; $resource->updatedAt = $evaluation->updatedAt;
$resource->gradesPublishedAt = $evaluation->gradesPublishedAt;
return $resource; return $resource;
} }

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Infrastructure\Api\Processor\PublishGradesProcessor;
use App\Scolarite\Infrastructure\Api\Processor\SaveGradesProcessor;
use App\Scolarite\Infrastructure\Api\Provider\GradeCollectionProvider;
use DateTimeImmutable;
#[ApiResource(
shortName: 'Grade',
operations: [
new GetCollection(
uriTemplate: '/evaluations/{evaluationId}/grades',
uriVariables: ['evaluationId'],
provider: GradeCollectionProvider::class,
itemUriTemplate: '/grades/{id}',
name: 'get_evaluation_grades',
),
new Put(
uriTemplate: '/evaluations/{evaluationId}/grades',
uriVariables: ['evaluationId'],
read: false,
processor: SaveGradesProcessor::class,
name: 'save_evaluation_grades',
),
new Post(
uriTemplate: '/evaluations/{evaluationId}/publish',
uriVariables: ['evaluationId'],
read: false,
processor: PublishGradesProcessor::class,
name: 'publish_evaluation_grades',
),
],
)]
final class GradeResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
public ?string $evaluationId = null;
public ?string $studentId = null;
public ?string $studentName = null;
public ?float $value = null;
public ?string $status = null;
public ?DateTimeImmutable $createdAt = null;
public ?DateTimeImmutable $updatedAt = null;
/** @var array<array{studentId: string, value: ?float, status: string}>|null */
public ?array $grades = null;
public ?bool $published = null;
public ?DateTimeImmutable $gradesPublishedAt = null;
public static function fromDomain(Grade $grade, ?string $studentName = null): self
{
$resource = new self();
$resource->id = (string) $grade->id;
$resource->evaluationId = (string) $grade->evaluationId;
$resource->studentId = (string) $grade->studentId;
$resource->studentName = $studentName;
$resource->value = $grade->value?->value;
$resource->status = $grade->status->value;
$resource->createdAt = $grade->createdAt;
$resource->updatedAt = $grade->updatedAt;
return $resource;
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\EventHandler;
use App\Scolarite\Domain\Event\NoteModifiee;
use App\Scolarite\Domain\Event\NoteSaisie;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'event.bus')]
final readonly class GradeEventSubscriber
{
public function __construct(
private Connection $connection,
) {
}
public function __invoke(NoteSaisie|NoteModifiee $event): void
{
if ($event instanceof NoteSaisie) {
$this->handleNoteSaisie($event);
} else {
$this->handleNoteModifiee($event);
}
}
private function handleNoteSaisie(NoteSaisie $event): void
{
$this->connection->executeStatement(
'INSERT INTO grade_events (id, grade_id, event_type, old_value, new_value, old_status, new_status, created_by, created_at)
VALUES (gen_random_uuid(), :grade_id, :event_type, NULL, :new_value, NULL, :new_status, :created_by, :created_at)',
[
'grade_id' => (string) $event->gradeId,
'event_type' => 'note_saisie',
'new_value' => $event->value,
'new_status' => $event->status,
'created_by' => $event->createdBy,
'created_at' => $event->occurredOn()->format(DateTimeImmutable::ATOM),
],
);
}
private function handleNoteModifiee(NoteModifiee $event): void
{
$this->connection->executeStatement(
'INSERT INTO grade_events (id, grade_id, event_type, old_value, new_value, old_status, new_status, created_by, created_at)
VALUES (gen_random_uuid(), :grade_id, :event_type, :old_value, :new_value, :old_status, :new_status, :created_by, :created_at)',
[
'grade_id' => (string) $event->gradeId,
'event_type' => 'note_modifiee',
'old_value' => $event->oldValue,
'new_value' => $event->newValue,
'old_status' => $event->oldStatus,
'new_status' => $event->newStatus,
'created_by' => $event->modifiedBy,
'created_at' => $event->occurredOn()->format(DateTimeImmutable::ATOM),
],
);
}
}

View File

@@ -33,8 +33,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
public function save(Evaluation $evaluation): void public function save(Evaluation $evaluation): void
{ {
$this->connection->executeStatement( $this->connection->executeStatement(
'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, description, evaluation_date, grade_scale, coefficient, status, created_at, updated_at) 'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, description, evaluation_date, grade_scale, coefficient, status, created_at, updated_at, grades_published_at)
VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :evaluation_date, :grade_scale, :coefficient, :status, :created_at, :updated_at) VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :evaluation_date, :grade_scale, :coefficient, :status, :created_at, :updated_at, :grades_published_at)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
description = EXCLUDED.description, description = EXCLUDED.description,
@@ -42,7 +42,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
grade_scale = EXCLUDED.grade_scale, grade_scale = EXCLUDED.grade_scale,
coefficient = EXCLUDED.coefficient, coefficient = EXCLUDED.coefficient,
status = EXCLUDED.status, status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at', updated_at = EXCLUDED.updated_at,
grades_published_at = EXCLUDED.grades_published_at',
[ [
'id' => (string) $evaluation->id, 'id' => (string) $evaluation->id,
'tenant_id' => (string) $evaluation->tenantId, 'tenant_id' => (string) $evaluation->tenantId,
@@ -57,6 +58,7 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
'status' => $evaluation->status->value, 'status' => $evaluation->status->value,
'created_at' => $evaluation->createdAt->format(DateTimeImmutable::ATOM), 'created_at' => $evaluation->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $evaluation->updatedAt->format(DateTimeImmutable::ATOM), 'updated_at' => $evaluation->updatedAt->format(DateTimeImmutable::ATOM),
'grades_published_at' => $evaluation->gradesPublishedAt?->format(DateTimeImmutable::ATOM),
], ],
); );
} }
@@ -187,6 +189,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
$createdAt = $row['created_at']; $createdAt = $row['created_at'];
/** @var string $updatedAt */ /** @var string $updatedAt */
$updatedAt = $row['updated_at']; $updatedAt = $row['updated_at'];
/** @var string|null $gradesPublishedAt */
$gradesPublishedAt = $row['grades_published_at'] ?? null;
return Evaluation::reconstitute( return Evaluation::reconstitute(
id: EvaluationId::fromString($id), id: EvaluationId::fromString($id),
@@ -202,6 +206,7 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
status: EvaluationStatus::from($status), status: EvaluationStatus::from($status),
createdAt: new DateTimeImmutable($createdAt), createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt), updatedAt: new DateTimeImmutable($updatedAt),
gradesPublishedAt: $gradesPublishedAt !== null ? new DateTimeImmutable($gradesPublishedAt) : null,
); );
} }
} }

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\GradeNotFoundException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\Grade\GradeValue;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineGradeRepository implements GradeRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(Grade $grade): void
{
$this->connection->executeStatement(
'INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at)
VALUES (:id, :tenant_id, :evaluation_id, :student_id, :value, :status, :created_by, :created_at, :updated_at)
ON CONFLICT (evaluation_id, student_id) DO UPDATE SET
value = EXCLUDED.value,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $grade->id,
'tenant_id' => (string) $grade->tenantId,
'evaluation_id' => (string) $grade->evaluationId,
'student_id' => (string) $grade->studentId,
'value' => $grade->value?->value,
'status' => $grade->status->value,
'created_by' => (string) $grade->createdBy,
'created_at' => $grade->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $grade->updatedAt->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function get(GradeId $id, TenantId $tenantId): Grade
{
$grade = $this->findById($id, $tenantId);
if ($grade === null) {
throw GradeNotFoundException::withId($id);
}
return $grade;
}
#[Override]
public function findById(GradeId $id, TenantId $tenantId): ?Grade
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM grades WHERE id = :id AND tenant_id = :tenant_id',
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByEvaluation(EvaluationId $evaluationId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM grades
WHERE evaluation_id = :evaluation_id
AND tenant_id = :tenant_id
ORDER BY created_at ASC',
[
'evaluation_id' => (string) $evaluationId,
'tenant_id' => (string) $tenantId,
],
);
return array_map($this->hydrate(...), $rows);
}
#[Override]
public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool
{
$count = $this->connection->fetchOne(
'SELECT COUNT(*) FROM grades WHERE evaluation_id = :evaluation_id AND tenant_id = :tenant_id',
[
'evaluation_id' => (string) $evaluationId,
'tenant_id' => (string) $tenantId,
],
);
/** @var int|string|false $countValue */
$countValue = $count;
return (int) $countValue > 0;
}
/** @param array<string, mixed> $row */
private function hydrate(array $row): Grade
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $evaluationId */
$evaluationId = $row['evaluation_id'];
/** @var string $studentId */
$studentId = $row['student_id'];
/** @var string|float|null $valueRaw */
$valueRaw = $row['value'];
/** @var string $status */
$status = $row['status'];
/** @var string $createdBy */
$createdBy = $row['created_by'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return Grade::reconstitute(
id: GradeId::fromString($id),
tenantId: TenantId::fromString($tenantId),
evaluationId: EvaluationId::fromString($evaluationId),
studentId: UserId::fromString($studentId),
value: $valueRaw !== null ? new GradeValue((float) $valueRaw) : null,
status: GradeStatus::from($status),
createdBy: UserId::fromString($createdBy),
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
use App\Scolarite\Domain\Exception\GradeNotFoundException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_values;
use Override;
final class InMemoryGradeRepository implements GradeRepository
{
/** @var array<string, Grade> */
private array $byId = [];
#[Override]
public function save(Grade $grade): void
{
$this->byId[(string) $grade->id] = $grade;
}
#[Override]
public function get(GradeId $id, TenantId $tenantId): Grade
{
$grade = $this->findById($id, $tenantId);
if ($grade === null) {
throw GradeNotFoundException::withId($id);
}
return $grade;
}
#[Override]
public function findById(GradeId $id, TenantId $tenantId): ?Grade
{
$grade = $this->byId[(string) $id] ?? null;
if ($grade === null || !$grade->tenantId->equals($tenantId)) {
return null;
}
return $grade;
}
#[Override]
public function findByEvaluation(EvaluationId $evaluationId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (Grade $g): bool => $g->evaluationId->equals($evaluationId)
&& $g->tenantId->equals($tenantId),
));
}
#[Override]
public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool
{
foreach ($this->byId as $grade) {
if ($grade->evaluationId->equals($evaluationId) && $grade->tenantId->equals($tenantId)) {
return true;
}
}
return false;
}
}

View File

@@ -6,14 +6,20 @@ namespace App\Scolarite\Infrastructure\Service;
use App\Scolarite\Application\Port\EvaluationGradesChecker; use App\Scolarite\Application\Port\EvaluationGradesChecker;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId; use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\GradeRepository;
use App\Shared\Domain\Tenant\TenantId; use App\Shared\Domain\Tenant\TenantId;
use Override; use Override;
final readonly class NoGradesEvaluationGradesChecker implements EvaluationGradesChecker final readonly class NoGradesEvaluationGradesChecker implements EvaluationGradesChecker
{ {
public function __construct(
private GradeRepository $gradeRepository,
) {
}
#[Override] #[Override]
public function hasGrades(EvaluationId $evaluationId, TenantId $tenantId): bool public function hasGrades(EvaluationId $evaluationId, TenantId $tenantId): bool
{ {
return false; return $this->gradeRepository->hasGradesForEvaluation($evaluationId, $tenantId);
} }
} }

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\PublishGrades;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\PublishGrades\PublishGradesCommand;
use App\Scolarite\Application\Command\PublishGrades\PublishGradesHandler;
use App\Scolarite\Domain\Exception\AucuneNoteSaisieException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\Grade\GradeValue;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class PublishGradesHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string EVALUATION_ID = '550e8400-e29b-41d4-a716-446655440040';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
private InMemoryEvaluationRepository $evaluationRepository;
private InMemoryGradeRepository $gradeRepository;
private Clock $clock;
protected function setUp(): void
{
$this->evaluationRepository = new InMemoryEvaluationRepository();
$this->gradeRepository = new InMemoryGradeRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-27 14:00:00');
}
};
$this->seedEvaluation();
}
#[Test]
public function itPublishesGradesWhenGradesExist(): void
{
$this->seedGrade();
$handler = $this->createHandler();
$command = new PublishGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
);
$evaluation = $handler($command);
self::assertTrue($evaluation->notesPubliees());
self::assertEquals(
new DateTimeImmutable('2026-03-27 14:00:00'),
$evaluation->gradesPublishedAt,
);
}
#[Test]
public function itPersistsPublishedEvaluation(): void
{
$this->seedGrade();
$handler = $this->createHandler();
$handler(new PublishGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
));
$evaluation = $this->evaluationRepository->get(
EvaluationId::fromString(self::EVALUATION_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertTrue($evaluation->notesPubliees());
}
#[Test]
public function itThrowsWhenNoGradesExist(): void
{
$handler = $this->createHandler();
$this->expectException(AucuneNoteSaisieException::class);
$handler(new PublishGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
));
}
#[Test]
public function itThrowsWhenTeacherNotOwner(): void
{
$this->seedGrade();
$handler = $this->createHandler();
$otherTeacher = '550e8400-e29b-41d4-a716-446655440099';
$this->expectException(NonProprietaireDeLEvaluationException::class);
$handler(new PublishGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: $otherTeacher,
));
}
#[Test]
public function itThrowsWhenAlreadyPublished(): void
{
$this->seedGrade();
$handler = $this->createHandler();
$command = new PublishGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
);
$handler($command);
$this->expectException(NotesDejaPublieesException::class);
$handler($command);
}
private function createHandler(): PublishGradesHandler
{
return new PublishGradesHandler(
$this->evaluationRepository,
$this->gradeRepository,
$this->clock,
);
}
private function seedEvaluation(): void
{
$evaluation = Evaluation::reconstitute(
id: EvaluationId::fromString(self::EVALUATION_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'Contrôle chapitre 5',
description: null,
evaluationDate: new DateTimeImmutable('2026-04-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
status: EvaluationStatus::PUBLISHED,
createdAt: new DateTimeImmutable('2026-03-12 10:00:00'),
updatedAt: new DateTimeImmutable('2026-03-12 10:00:00'),
);
$this->evaluationRepository->save($evaluation);
}
private function seedGrade(): void
{
$grade = Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: new GradeValue(15.0),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
$this->gradeRepository->save($grade);
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\SaveGrades;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\SaveGrades\SaveGradesCommand;
use App\Scolarite\Application\Command\SaveGrades\SaveGradesHandler;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class SaveGradesHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string EVALUATION_ID = '550e8400-e29b-41d4-a716-446655440040';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string STUDENT_1_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string STUDENT_2_ID = '550e8400-e29b-41d4-a716-446655440051';
private InMemoryEvaluationRepository $evaluationRepository;
private InMemoryGradeRepository $gradeRepository;
private Clock $clock;
protected function setUp(): void
{
$this->evaluationRepository = new InMemoryEvaluationRepository();
$this->gradeRepository = new InMemoryGradeRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-27 10:00:00');
}
};
$this->seedEvaluation();
}
#[Test]
public function itSavesNewGrades(): void
{
$handler = $this->createHandler();
$command = new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
['studentId' => self::STUDENT_2_ID, 'value' => 12.0, 'status' => 'graded'],
],
);
$savedGrades = $handler($command);
self::assertCount(2, $savedGrades);
self::assertSame(15.5, $savedGrades[0]->value->value);
self::assertSame(12.0, $savedGrades[1]->value->value);
}
#[Test]
public function itPersistsGradesInRepository(): void
{
$handler = $this->createHandler();
$command = new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
],
);
$handler($command);
$tenantId = TenantId::fromString(self::TENANT_ID);
$grades = $this->gradeRepository->findByEvaluation(
EvaluationId::fromString(self::EVALUATION_ID),
$tenantId,
);
self::assertCount(1, $grades);
self::assertSame(15.5, $grades[0]->value->value);
}
#[Test]
public function itUpdatesExistingGrades(): void
{
$handler = $this->createHandler();
$handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
],
));
$handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 18.0, 'status' => 'graded'],
],
));
$tenantId = TenantId::fromString(self::TENANT_ID);
$grades = $this->gradeRepository->findByEvaluation(
EvaluationId::fromString(self::EVALUATION_ID),
$tenantId,
);
self::assertCount(1, $grades);
self::assertSame(18.0, $grades[0]->value->value);
}
#[Test]
public function itSavesAbsentGrade(): void
{
$handler = $this->createHandler();
$command = new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'absent'],
],
);
$savedGrades = $handler($command);
self::assertSame(GradeStatus::ABSENT, $savedGrades[0]->status);
self::assertNull($savedGrades[0]->value);
}
#[Test]
public function itSavesDispensedGrade(): void
{
$handler = $this->createHandler();
$command = new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'dispensed'],
],
);
$savedGrades = $handler($command);
self::assertSame(GradeStatus::DISPENSED, $savedGrades[0]->status);
self::assertNull($savedGrades[0]->value);
}
#[Test]
public function itThrowsWhenTeacherNotOwner(): void
{
$handler = $this->createHandler();
$otherTeacher = '550e8400-e29b-41d4-a716-446655440099';
$this->expectException(NonProprietaireDeLEvaluationException::class);
$handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: $otherTeacher,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 15.5, 'status' => 'graded'],
],
));
}
#[Test]
public function itThrowsWhenValueExceedsGradeScale(): void
{
$handler = $this->createHandler();
$this->expectException(ValeurNoteInvalideException::class);
$handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => 25.0, 'status' => 'graded'],
],
));
}
#[Test]
public function itThrowsWhenGradedWithoutValue(): void
{
$handler = $this->createHandler();
$this->expectException(NoteRequiseException::class);
$handler(new SaveGradesCommand(
tenantId: self::TENANT_ID,
evaluationId: self::EVALUATION_ID,
teacherId: self::TEACHER_ID,
grades: [
['studentId' => self::STUDENT_1_ID, 'value' => null, 'status' => 'graded'],
],
));
}
private function createHandler(): SaveGradesHandler
{
return new SaveGradesHandler(
$this->evaluationRepository,
$this->gradeRepository,
$this->clock,
);
}
private function seedEvaluation(): void
{
$evaluation = Evaluation::reconstitute(
id: EvaluationId::fromString(self::EVALUATION_ID),
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'Contrôle chapitre 5',
description: null,
evaluationDate: new DateTimeImmutable('2026-04-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
status: EvaluationStatus::PUBLISHED,
createdAt: new DateTimeImmutable('2026-03-12 10:00:00'),
updatedAt: new DateTimeImmutable('2026-03-12 10:00:00'),
);
$this->evaluationRepository->save($evaluation);
}
}

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Grade;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\NoteModifiee;
use App\Scolarite\Domain\Event\NoteSaisie;
use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Scolarite\Domain\Model\Grade\GradeStatus;
use App\Scolarite\Domain\Model\Grade\GradeValue;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GradeTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string EVALUATION_ID = '550e8400-e29b-41d4-a716-446655440040';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function saisirCreatesGradedGrade(): void
{
$grade = $this->createGrade();
self::assertSame(GradeStatus::GRADED, $grade->status);
self::assertNotNull($grade->value);
self::assertSame(15.5, $grade->value->value);
}
#[Test]
public function saisirRecordsNoteSaisieEvent(): void
{
$grade = $this->createGrade();
$events = $grade->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(NoteSaisie::class, $events[0]);
self::assertSame($grade->id, $events[0]->gradeId);
self::assertSame(15.5, $events[0]->value);
self::assertSame('graded', $events[0]->status);
}
#[Test]
public function saisirSetsAllProperties(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$evaluationId = EvaluationId::fromString(self::EVALUATION_ID);
$studentId = UserId::fromString(self::STUDENT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$now = new DateTimeImmutable('2026-03-27 10:00:00');
$value = new GradeValue(15.5);
$gradeScale = new GradeScale(20);
$grade = Grade::saisir(
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: GradeStatus::GRADED,
gradeScale: $gradeScale,
createdBy: $teacherId,
now: $now,
);
self::assertTrue($grade->tenantId->equals($tenantId));
self::assertTrue($grade->evaluationId->equals($evaluationId));
self::assertTrue($grade->studentId->equals($studentId));
self::assertTrue($grade->createdBy->equals($teacherId));
self::assertSame(15.5, $grade->value->value);
self::assertSame(GradeStatus::GRADED, $grade->status);
self::assertEquals($now, $grade->createdAt);
self::assertEquals($now, $grade->updatedAt);
}
#[Test]
public function saisirCreatesAbsentGrade(): void
{
$grade = Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: null,
status: GradeStatus::ABSENT,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
self::assertSame(GradeStatus::ABSENT, $grade->status);
self::assertNull($grade->value);
}
#[Test]
public function saisirCreatesDispensedGrade(): void
{
$grade = Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: null,
status: GradeStatus::DISPENSED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
self::assertSame(GradeStatus::DISPENSED, $grade->status);
self::assertNull($grade->value);
}
#[Test]
public function saisirThrowsWhenGradedWithoutValue(): void
{
$this->expectException(NoteRequiseException::class);
Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: null,
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
}
#[Test]
public function saisirThrowsWhenValueExceedsGradeScale(): void
{
$this->expectException(ValeurNoteInvalideException::class);
Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: new GradeValue(25.0),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
}
#[Test]
public function modifierUpdatesValueAndRecordsEvent(): void
{
$grade = $this->createGrade();
$grade->pullDomainEvents();
$modifiedAt = new DateTimeImmutable('2026-03-27 14:00:00');
$modifierId = UserId::fromString(self::TEACHER_ID);
$grade->modifier(
value: new GradeValue(18.0),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
modifiedBy: $modifierId,
now: $modifiedAt,
);
self::assertSame(18.0, $grade->value->value);
self::assertEquals($modifiedAt, $grade->updatedAt);
$events = $grade->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(NoteModifiee::class, $events[0]);
self::assertSame(15.5, $events[0]->oldValue);
self::assertSame(18.0, $events[0]->newValue);
self::assertSame('graded', $events[0]->oldStatus);
self::assertSame('graded', $events[0]->newStatus);
self::assertSame(self::TEACHER_ID, $events[0]->modifiedBy);
}
#[Test]
public function modifierChangesToAbsent(): void
{
$grade = $this->createGrade();
$grade->pullDomainEvents();
$grade->modifier(
value: null,
status: GradeStatus::ABSENT,
gradeScale: new GradeScale(20),
modifiedBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 14:00:00'),
);
self::assertSame(GradeStatus::ABSENT, $grade->status);
self::assertNull($grade->value);
}
#[Test]
public function modifierThrowsWhenGradedWithoutValue(): void
{
$grade = $this->createGrade();
$this->expectException(NoteRequiseException::class);
$grade->modifier(
value: null,
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
modifiedBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 14:00:00'),
);
}
#[Test]
public function modifierThrowsWhenValueExceedsGradeScale(): void
{
$grade = $this->createGrade();
$this->expectException(ValeurNoteInvalideException::class);
$grade->modifier(
value: new GradeValue(25.0),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
modifiedBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 14:00:00'),
);
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$gradeId = GradeId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$evaluationId = EvaluationId::fromString(self::EVALUATION_ID);
$studentId = UserId::fromString(self::STUDENT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$createdAt = new DateTimeImmutable('2026-03-27 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-27 14:00:00');
$value = new GradeValue(15.5);
$grade = Grade::reconstitute(
id: $gradeId,
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: GradeStatus::GRADED,
createdBy: $teacherId,
createdAt: $createdAt,
updatedAt: $updatedAt,
);
self::assertTrue($grade->id->equals($gradeId));
self::assertTrue($grade->tenantId->equals($tenantId));
self::assertTrue($grade->evaluationId->equals($evaluationId));
self::assertTrue($grade->studentId->equals($studentId));
self::assertTrue($grade->createdBy->equals($teacherId));
self::assertSame(15.5, $grade->value->value);
self::assertSame(GradeStatus::GRADED, $grade->status);
self::assertEquals($createdAt, $grade->createdAt);
self::assertEquals($updatedAt, $grade->updatedAt);
self::assertEmpty($grade->pullDomainEvents());
}
private function createGrade(): Grade
{
return Grade::saisir(
tenantId: TenantId::fromString(self::TENANT_ID),
evaluationId: EvaluationId::fromString(self::EVALUATION_ID),
studentId: UserId::fromString(self::STUDENT_ID),
value: new GradeValue(15.5),
status: GradeStatus::GRADED,
gradeScale: new GradeScale(20),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-27 10:00:00'),
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Grade;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
use App\Scolarite\Domain\Model\Grade\GradeValue;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GradeValueTest extends TestCase
{
#[Test]
public function acceptsZero(): void
{
self::assertSame(0.0, (new GradeValue(0))->value);
}
#[Test]
public function acceptsPositiveValue(): void
{
self::assertSame(15.5, (new GradeValue(15.5))->value);
}
#[Test]
public function rejectsNegativeValue(): void
{
$this->expectException(ValeurNoteInvalideException::class);
new GradeValue(-1);
}
#[Test]
public function rejectsNegativeDecimal(): void
{
$this->expectException(ValeurNoteInvalideException::class);
new GradeValue(-0.5);
}
#[Test]
public function equalsComparesValues(): void
{
$a = new GradeValue(15.0);
$b = new GradeValue(15.0);
$c = new GradeValue(12.0);
self::assertTrue($a->equals($b));
self::assertFalse($a->equals($c));
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Policy;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Policy\VisibiliteNotesPolicy;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class VisibiliteNotesPolicyTest extends TestCase
{
#[Test]
public function eleveVoitNotesPubliees(): void
{
$policy = $this->createPolicy(new DateTimeImmutable('2026-03-28 10:00:00'));
$evaluation = $this->createPublishedEvaluation(new DateTimeImmutable('2026-03-27 14:00:00'));
self::assertTrue($policy->visiblePourEleve($evaluation));
}
#[Test]
public function eleveNeVoitPasNotesBrouillon(): void
{
$policy = $this->createPolicy(new DateTimeImmutable('2026-03-27 15:00:00'));
$evaluation = $this->createUnpublishedEvaluation();
self::assertFalse($policy->visiblePourEleve($evaluation));
}
#[Test]
public function parentNeVoitPasAvant24h(): void
{
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
$now = new DateTimeImmutable('2026-03-28 13:59:59'); // 23h59 après
$policy = $this->createPolicy($now);
$evaluation = $this->createPublishedEvaluation($publishedAt);
self::assertFalse($policy->visiblePourParent($evaluation));
}
#[Test]
public function parentVoitApres24h(): void
{
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
$now = new DateTimeImmutable('2026-03-28 14:00:00'); // exactement 24h après
$policy = $this->createPolicy($now);
$evaluation = $this->createPublishedEvaluation($publishedAt);
self::assertTrue($policy->visiblePourParent($evaluation));
}
#[Test]
public function parentNeVoitPasNotesBrouillon(): void
{
$policy = $this->createPolicy(new DateTimeImmutable('2026-04-01 10:00:00'));
$evaluation = $this->createUnpublishedEvaluation();
self::assertFalse($policy->visiblePourParent($evaluation));
}
private function createPolicy(DateTimeImmutable $now): VisibiliteNotesPolicy
{
$clock = new class($now) implements Clock {
public function __construct(private readonly DateTimeImmutable $now)
{
}
public function now(): DateTimeImmutable
{
return $this->now;
}
};
return new VisibiliteNotesPolicy($clock);
}
private function createPublishedEvaluation(DateTimeImmutable $publishedAt): Evaluation
{
return Evaluation::reconstitute(
id: EvaluationId::generate(),
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'),
title: 'Test',
description: null,
evaluationDate: new DateTimeImmutable('2026-04-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
status: EvaluationStatus::PUBLISHED,
createdAt: new DateTimeImmutable('2026-03-12 10:00:00'),
updatedAt: $publishedAt,
gradesPublishedAt: $publishedAt,
);
}
private function createUnpublishedEvaluation(): Evaluation
{
return Evaluation::reconstitute(
id: EvaluationId::generate(),
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
teacherId: UserId::fromString('550e8400-e29b-41d4-a716-446655440010'),
title: 'Test',
description: null,
evaluationDate: new DateTimeImmutable('2026-04-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
status: EvaluationStatus::PUBLISHED,
createdAt: new DateTimeImmutable('2026-03-12 10:00:00'),
updatedAt: new DateTimeImmutable('2026-03-12 10:00:00'),
);
}
}

View File

@@ -46,7 +46,9 @@ function cleanupClasses() {
`DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`, `DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`, `DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`, `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`,
// Delete evaluations // Delete evaluations (grades cleaned by global-setup)
`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`,
`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`, `DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`,
// Delete schedule slots (CASCADE on FK, but be explicit) // Delete schedule slots (CASCADE on FK, but be explicit)
`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`, `DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`,

View File

@@ -126,6 +126,8 @@ test.describe('Evaluation Management (Story 6.1)', () => {
// Clean up ALL evaluations for this teacher (not just by tenant, to avoid // Clean up ALL evaluations for this teacher (not just by tenant, to avoid
// stale data from parallel test files with different teachers) // stale data from parallel test files with different teachers)
try { try {
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.tenant_id = '${TENANT_ID}' AND e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`); runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch { } catch {
// Table may not exist // Table may not exist
@@ -435,6 +437,8 @@ test.describe('Evaluation Management (Story 6.1)', () => {
// Cleanup: remove the second class data // Cleanup: remove the second class data
try { try {
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT g.id FROM grades g JOIN evaluations e ON g.evaluation_id = e.id WHERE e.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND e.class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM grades WHERE evaluation_id IN (SELECT id FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'))`);
runSql(`DELETE FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`); runSql(`DELETE FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`); runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'`); runSql(`DELETE FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'`);

View File

@@ -5,21 +5,41 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
/** /**
* Global setup for E2E tests. * Global setup for E2E tests.
* *
* - Resets rate limiter to ensure tests start with clean state * - Cleans transactional data that could cause FK constraint failures
* (each test file handles its own specific cleanup in beforeAll/beforeEach)
* - Resets rate limiter cache
* - Token creation is handled per-browser in test files using beforeAll hooks * - Token creation is handled per-browser in test files using beforeAll hooks
*/ */
async function globalSetup() { async function globalSetup() {
console.warn('🎭 E2E Global setup - tokens are created per browser project'); console.warn('🎭 E2E Global setup - tokens are created per browser project');
// Reset rate limiter to prevent failed login tests from blocking other tests
try {
const projectRoot = join(__dirname, '../..'); const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml'); const composeFile = join(projectRoot, 'compose.yaml');
// Use Symfony cache:pool:clear for more reliable cache clearing function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
// Clean grade data (Story 6.2) to prevent FK constraint failures
// when other tests try to DELETE FROM evaluations
try {
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`);
console.warn('✅ Grade data cleaned');
} catch {
// Tables may not exist yet
}
// Reset rate limiter to prevent failed login tests from blocking other tests
try {
execSync( execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`, `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`,
{ encoding: 'utf-8' } { encoding: 'utf-8' }

383
frontend/e2e/grades.spec.ts Normal file
View File

@@ -0,0 +1,383 @@
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 TEACHER_EMAIL = 'e2e-grade-teacher@example.com';
const TEACHER_PASSWORD = 'GradeTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// Deterministic IDs for test data
let evaluationId: string;
let classId: string;
let student1Id: string;
let student2Id: string;
test.describe('Grade Input Grid (Story 6.2)', () => {
test.beforeAll(async () => {
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Create test class
const classOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-class-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
classId = classOutput;
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-GRADE-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Create test subject
const subjectOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-subject-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const subjectId = subjectOutput;
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-GRADE-Sciences', 'E2GRDSCI', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Create teacher assignment
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Create 2 test students
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student1@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Alice --lastName=Durand 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-grade-student2@example.com --password=Student123 --role=ROLE_ELEVE --firstName=Bob --lastName=Martin 2>&1`,
{ encoding: 'utf-8' }
);
// Assign students to class
const studentIds = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email IN ('e2e-grade-student1@example.com','e2e-grade-student2@example.com') AND tenant_id='${TENANT_ID}' ORDER BY email" 2>&1`,
{ encoding: 'utf-8' }
);
const idMatches = studentIds.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g);
if (idMatches && idMatches.length >= 2) {
student1Id = idMatches[0]!;
student2Id = idMatches[1]!;
}
// Assign students to class
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student1Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
);
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${student2Id}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
);
// Create test evaluation
const evalOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","grade-eval-${TENANT_ID}")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
evaluationId = evalOutput;
clearCache();
});
test.beforeEach(async () => {
// Clean grades and recreate evaluation
runSql(`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE evaluation_id = '${evaluationId}')`);
runSql(`DELETE FROM grades WHERE evaluation_id = '${evaluationId}'`);
runSql(`DELETE FROM evaluations WHERE id = '${evaluationId}'`);
runSql(
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
`SELECT '${evaluationId}', '${TENANT_ID}', '${classId}', ` +
`(SELECT id FROM subjects WHERE code='E2GRDSCI' AND tenant_id='${TENANT_ID}' LIMIT 1), ` +
`u.id, 'E2E Contrôle Sciences', '2026-04-15', 20, 1.0, 'published', NULL, NOW(), NOW() ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (id) DO UPDATE SET grades_published_at = NULL, updated_at = NOW()`
);
clearCache();
});
test.describe('Grade Grid Display', () => {
test('shows grade input grid with students', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
// Should display evaluation title
await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 });
// Should show grade inputs
const gradeInputs = page.locator('.grade-input');
await expect(gradeInputs.first()).toBeVisible({ timeout: 10000 });
});
test('"Saisir les notes" button navigates to grades page', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`);
await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 });
// Wait for evaluation cards to load
await expect(page.getByText('E2E Contrôle Sciences')).toBeVisible({ timeout: 10000 });
await page.getByRole('link', { name: /saisir les notes/i }).first().click();
await expect(page.getByRole('heading', { name: /E2E Contrôle Sciences/i })).toBeVisible({ timeout: 15000 });
});
});
test.describe('Grade Input', () => {
test('can enter a numeric grade', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('15.5');
// Should show the grade in status column
await expect(page.locator('.status-graded').first()).toBeVisible({ timeout: 5000 });
});
test('validates grade against scale maximum', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('25');
// Should show error
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 });
});
});
test.describe('Slash Commands', () => {
test('/abs marks student as absent', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('/abs');
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 5000 });
});
test('/disp marks student as dispensed', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('/disp');
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 5000 });
});
});
test.describe('Keyboard Navigation', () => {
test('Tab moves to next student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.focus();
await firstInput.press('Tab');
// Second input should be focused
const secondInput = page.locator('.grade-input').nth(1);
await expect(secondInput).toBeFocused({ timeout: 3000 });
});
test('Enter moves to next student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const firstInput = page.locator('.grade-input').first();
await firstInput.fill('15');
await firstInput.press('Enter');
// Second input should be focused
const secondInput = page.locator('.grade-input').nth(1);
await expect(secondInput).toBeFocused({ timeout: 3000 });
});
test('Shift+Tab moves to previous student', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
const secondInput = page.locator('.grade-input').nth(1);
await secondInput.focus();
await secondInput.press('Shift+Tab');
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeFocused({ timeout: 3000 });
});
});
test.describe('Publication', () => {
test('publish requires confirmation via modal', async ({ page }) => {
clearCache();
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Start listening for the PUT response BEFORE triggering the save
const savePromise = page.waitForResponse(
(resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Enter grades
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
await firstInput.fill('18');
await expect(page.locator('.status-graded').first()).toContainText('18/20', { timeout: 10000 });
// Wait for the PUT to complete
await savePromise;
// Click publish button — opens confirmation modal
await page.getByRole('button', { name: /publier les notes/i }).click();
// Confirmation dialog should appear
const dialog = page.getByRole('alertdialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible();
// Start listening for POST before clicking confirm
const publishPromise = page.waitForResponse(
(resp) => resp.url().includes('/publish') && resp.request().method() === 'POST',
{ timeout: 30000 }
);
// Confirm publication
await dialog.getByRole('button', { name: /confirmer la publication/i }).click();
// Wait for the POST to complete
await publishPromise;
// Should show published badge
await expect(page.getByText(/notes publiées/i)).toBeVisible({ timeout: 10000 });
});
test('publish modal can be cancelled', async ({ page }) => {
clearCache();
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations/${evaluationId}/grades`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.grade-input').first()).toBeVisible({ timeout: 15000 });
// Start listening for PUT before triggering
const savePromise = page.waitForResponse(
(resp) => resp.url().includes('/grades') && resp.request().method() === 'PUT',
{ timeout: 30000 }
);
// Enter a grade
const firstInput = page.locator('.grade-input').first();
await expect(firstInput).toBeVisible({ timeout: 5000 });
await firstInput.fill('14');
await expect(page.locator('.status-graded').first()).toContainText('14/20', { timeout: 10000 });
// Wait for save to complete
await savePromise;
const publishBtn = page.getByRole('button', { name: /publier les notes/i });
// Open modal then cancel
await publishBtn.click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText(/cette action est irréversible/i)).toBeVisible();
await dialog.getByRole('button', { name: /annuler/i }).click();
// Modal should close, publish button still visible
await expect(dialog).not.toBeVisible({ timeout: 3000 });
await expect(publishBtn).toBeVisible();
});
});
});

View File

@@ -46,7 +46,9 @@ function cleanupSubjects() {
`DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`, `DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`, `DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`, `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`,
// Delete evaluations (subjects FK) // Delete grades and evaluations (grades FK → evaluations, evaluations FK → subjects)
`DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}'`,
`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`, `DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`,
// Delete schedule slots (subjects FK with CASCADE) // Delete schedule slots (subjects FK with CASCADE)
`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`, `DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`,

View File

@@ -0,0 +1,83 @@
const DB_NAME = 'classeo-grades';
const DB_VERSION = 1;
const STORE_NAME = 'pending-grades';
interface PendingGrade {
evaluationId: string;
studentId: string;
value: number | null;
status: string;
savedAt: number;
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: ['evaluationId', 'studentId'],
});
store.createIndex('byEvaluation', 'evaluationId', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function savePendingGrade(grade: PendingGrade): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(grade);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function savePendingGrades(grades: PendingGrade[]): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const grade of grades) {
store.put(grade);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getPendingGrades(evaluationId: string): Promise<PendingGrade[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const index = tx.objectStore(STORE_NAME).index('byEvaluation');
const request = index.getAll(evaluationId);
request.onsuccess = () => resolve(request.result as PendingGrade[]);
request.onerror = () => reject(request.error);
});
}
export async function clearPendingGrades(evaluationId: string): Promise<void> {
const db = await openDb();
const pending = await getPendingGrades(evaluationId);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const grade of pending) {
store.delete([grade.evaluationId, grade.studentId]);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function hasPendingGrades(evaluationId: string): Promise<boolean> {
const pending = await getPendingGrades(evaluationId);
return pending.length > 0;
}

View File

@@ -483,6 +483,9 @@
{#if ev.status === 'published'} {#if ev.status === 'published'}
<div class="evaluation-actions"> <div class="evaluation-actions">
<a class="btn-primary btn-sm" href="/dashboard/teacher/evaluations/{ev.id}/grades">
Saisir les notes
</a>
<button class="btn-secondary btn-sm" onclick={() => openEditModal(ev)}> <button class="btn-secondary btn-sm" onclick={() => openEditModal(ev)}>
Modifier Modifier
</button> </button>

File diff suppressed because it is too large Load Diff