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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,10 @@ use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\EvaluationCreee;
use App\Scolarite\Domain\Event\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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -94,6 +94,8 @@ final class EvaluationResource
public ?DateTimeImmutable $updatedAt = null;
public ?DateTimeImmutable $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;
}

View File

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

View File

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

View File

@@ -33,8 +33,8 @@ final readonly class DoctrineEvaluationRepository implements EvaluationRepositor
public function save(Evaluation $evaluation): void
{
$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,
);
}
}

View File

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

View File

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

View File

@@ -6,14 +6,20 @@ namespace App\Scolarite\Infrastructure\Service;
use App\Scolarite\Application\Port\EvaluationGradesChecker;
use App\Scolarite\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);
}
}