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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user