feat: Permettre à l'enseignant de saisir les notes dans une grille inline
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:
@@ -262,6 +262,9 @@ services:
|
||||
App\Scolarite\Domain\Repository\EvaluationRepository:
|
||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationRepository
|
||||
|
||||
App\Scolarite\Domain\Repository\GradeRepository:
|
||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineGradeRepository
|
||||
|
||||
App\Scolarite\Application\Port\EvaluationGradesChecker:
|
||||
alias: App\Scolarite\Infrastructure\Service\NoGradesEvaluationGradesChecker
|
||||
|
||||
|
||||
70
backend/migrations/Version20260327211549.php
Normal file
70
backend/migrations/Version20260327211549.php
Normal 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');
|
||||
}
|
||||
}
|
||||
28
backend/migrations/Version20260327212231.php
Normal file
28
backend/migrations/Version20260327212231.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
38
backend/src/Scolarite/Domain/Event/NoteModifiee.php
Normal file
38
backend/src/Scolarite/Domain/Event/NoteModifiee.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
backend/src/Scolarite/Domain/Event/NoteSaisie.php
Normal file
37
backend/src/Scolarite/Domain/Event/NoteSaisie.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
backend/src/Scolarite/Domain/Event/NotesPubliees.php
Normal file
32
backend/src/Scolarite/Domain/Event/NotesPubliees.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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".',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Domain\Event\EvaluationCreee;
|
||||
use App\Scolarite\Domain\Event\EvaluationModifiee;
|
||||
use App\Scolarite\Domain\Event\EvaluationSupprimee;
|
||||
use App\Scolarite\Domain\Event\NotesPubliees;
|
||||
use App\Scolarite\Domain\Exception\BaremeNonModifiableException;
|
||||
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
|
||||
use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
@@ -19,6 +21,7 @@ use DateTimeImmutable;
|
||||
final class Evaluation extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
public private(set) ?DateTimeImmutable $gradesPublishedAt = null;
|
||||
|
||||
private function __construct(
|
||||
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
|
||||
{
|
||||
if ($this->status === EvaluationStatus::DELETED) {
|
||||
@@ -145,6 +172,7 @@ final class Evaluation extends AggregateRoot
|
||||
EvaluationStatus $status,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
?DateTimeImmutable $gradesPublishedAt = null,
|
||||
): self {
|
||||
$evaluation = new self(
|
||||
id: $id,
|
||||
@@ -162,6 +190,7 @@ final class Evaluation extends AggregateRoot
|
||||
);
|
||||
|
||||
$evaluation->updatedAt = $updatedAt;
|
||||
$evaluation->gradesPublishedAt = $gradesPublishedAt;
|
||||
|
||||
return $evaluation;
|
||||
}
|
||||
|
||||
142
backend/src/Scolarite/Domain/Model/Grade/Grade.php
Normal file
142
backend/src/Scolarite/Domain/Model/Grade/Grade.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/src/Scolarite/Domain/Model/Grade/GradeId.php
Normal file
11
backend/src/Scolarite/Domain/Model/Grade/GradeId.php
Normal 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
|
||||
{
|
||||
}
|
||||
12
backend/src/Scolarite/Domain/Model/Grade/GradeStatus.php
Normal file
12
backend/src/Scolarite/Domain/Model/Grade/GradeStatus.php
Normal 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';
|
||||
}
|
||||
25
backend/src/Scolarite/Domain/Model/Grade/GradeValue.php
Normal file
25
backend/src/Scolarite/Domain/Model/Grade/GradeValue.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
26
backend/src/Scolarite/Domain/Repository/GradeRepository.php
Normal file
26
backend/src/Scolarite/Domain/Repository/GradeRepository.php
Normal 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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,8 @@ final class EvaluationResource
|
||||
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public ?DateTimeImmutable $gradesPublishedAt = null;
|
||||
|
||||
public static function fromDomain(
|
||||
Evaluation $evaluation,
|
||||
?string $className = null,
|
||||
@@ -114,6 +116,7 @@ final class EvaluationResource
|
||||
$resource->subjectName = $subjectName;
|
||||
$resource->createdAt = $evaluation->createdAt;
|
||||
$resource->updatedAt = $evaluation->updatedAt;
|
||||
$resource->gradesPublishedAt = $evaluation->gradesPublishedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
|
||||
public function save(Evaluation $evaluation): void
|
||||
{
|
||||
$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)
|
||||
VALUES (: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, :grades_published_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
@@ -42,7 +42,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
|
||||
grade_scale = EXCLUDED.grade_scale,
|
||||
coefficient = EXCLUDED.coefficient,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
grades_published_at = EXCLUDED.grades_published_at',
|
||||
[
|
||||
'id' => (string) $evaluation->id,
|
||||
'tenant_id' => (string) $evaluation->tenantId,
|
||||
@@ -57,6 +58,7 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
|
||||
'status' => $evaluation->status->value,
|
||||
'created_at' => $evaluation->createdAt->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'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
/** @var string|null $gradesPublishedAt */
|
||||
$gradesPublishedAt = $row['grades_published_at'] ?? null;
|
||||
|
||||
return Evaluation::reconstitute(
|
||||
id: EvaluationId::fromString($id),
|
||||
@@ -202,6 +206,7 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
|
||||
status: EvaluationStatus::from($status),
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
gradesPublishedAt: $gradesPublishedAt !== null ? new DateTimeImmutable($gradesPublishedAt) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,20 @@ namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Application\Port\EvaluationGradesChecker;
|
||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||
use App\Scolarite\Domain\Repository\GradeRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final readonly class NoGradesEvaluationGradesChecker implements EvaluationGradesChecker
|
||||
{
|
||||
public function __construct(
|
||||
private GradeRepository $gradeRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function hasGrades(EvaluationId $evaluationId, TenantId $tenantId): bool
|
||||
{
|
||||
return false;
|
||||
return $this->gradeRepository->hasGradesForEvaluation($evaluationId, $tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
285
backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php
Normal file
285
backend/tests/Unit/Scolarite/Domain/Model/Grade/GradeTest.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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_attachments WHERE homework_id IN (SELECT id 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 schedule slots (CASCADE on FK, but be explicit)
|
||||
`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`,
|
||||
|
||||
@@ -126,6 +126,8 @@ test.describe('Evaluation Management (Story 6.1)', () => {
|
||||
// Clean up ALL evaluations for this teacher (not just by tenant, to avoid
|
||||
// stale data from parallel test files with different teachers)
|
||||
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}')`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
@@ -435,6 +437,8 @@ test.describe('Evaluation Management (Story 6.1)', () => {
|
||||
|
||||
// Cleanup: remove the second class data
|
||||
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 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}'`);
|
||||
|
||||
@@ -5,21 +5,41 @@ import { fileURLToPath } from 'url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async function globalSetup() {
|
||||
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 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(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
|
||||
383
frontend/e2e/grades.spec.ts
Normal file
383
frontend/e2e/grades.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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_attachments WHERE homework_id IN (SELECT id 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 schedule slots (subjects FK with CASCADE)
|
||||
`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`,
|
||||
|
||||
83
frontend/src/lib/stores/gradeOfflineStore.ts
Normal file
83
frontend/src/lib/stores/gradeOfflineStore.ts
Normal 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;
|
||||
}
|
||||
@@ -483,6 +483,9 @@
|
||||
|
||||
{#if ev.status === 'published'}
|
||||
<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)}>
|
||||
Modifier
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user