feat: Permettre à l'élève de consulter ses notes et moyennes
L'élève avait accès à ses compétences mais pas à ses notes numériques. Cette fonctionnalité lui donne une vue complète de sa progression scolaire avec moyennes par matière, détail par évaluation, statistiques de classe, et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15). Les notes ne sont visibles qu'après publication par l'enseignant, ce qui garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
This commit is contained in:
@@ -107,6 +107,14 @@ final class SchoolClass extends AggregateRoot
|
||||
|
||||
$this->level = $niveau;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new ClasseModifiee(
|
||||
classId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
ancienNom: $this->name,
|
||||
nouveauNom: $this->name,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour lire le délai de visibilité des notes pour les parents.
|
||||
*
|
||||
* Le délai détermine combien d'heures après publication les notes
|
||||
* deviennent visibles pour les parents (0 = immédiat, max 72h).
|
||||
*/
|
||||
interface ParentGradeDelayReader
|
||||
{
|
||||
public function delayHoursForTenant(TenantId $tenantId): int;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class ChildGradesDto
|
||||
{
|
||||
/**
|
||||
* @param array<ParentGradeDto> $grades
|
||||
*/
|
||||
public function __construct(
|
||||
public string $childId,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public array $grades,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class ChildGradesSummaryDto
|
||||
{
|
||||
/**
|
||||
* @param list<array{subjectId: string, subjectName: string|null, average: float, gradeCount: int}> $subjectAverages
|
||||
*/
|
||||
public function __construct(
|
||||
public string $childId,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public ?string $periodId,
|
||||
public array $subjectAverages,
|
||||
public ?float $generalAverage,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||
use App\Scolarite\Domain\Policy\VisibiliteNotesPolicy;
|
||||
use App\Scolarite\Domain\Repository\EvaluationRepository;
|
||||
use App\Scolarite\Domain\Repository\EvaluationStatisticsRepository;
|
||||
use App\Scolarite\Domain\Repository\GradeRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function array_values;
|
||||
use function usort;
|
||||
|
||||
final readonly class GetChildrenGradesHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ParentChildrenReader $parentChildrenReader,
|
||||
private EvaluationRepository $evaluationRepository,
|
||||
private GradeRepository $gradeRepository,
|
||||
private EvaluationStatisticsRepository $statisticsRepository,
|
||||
private ScheduleDisplayReader $displayReader,
|
||||
private VisibiliteNotesPolicy $visibiliteNotesPolicy,
|
||||
private ParentGradeDelayReader $delayReader,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ChildGradesDto> */
|
||||
public function __invoke(GetChildrenGradesQuery $query): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
$allChildren = $this->parentChildrenReader->childrenOf($query->parentId, $tenantId);
|
||||
|
||||
if ($allChildren === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$children = $query->childId !== null
|
||||
? array_values(array_filter($allChildren, static fn (array $c): bool => $c['studentId'] === $query->childId))
|
||||
: $allChildren;
|
||||
|
||||
if ($children === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$delaiHeures = $this->delayReader->delayHoursForTenant($tenantId);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($children as $child) {
|
||||
$studentId = UserId::fromString($child['studentId']);
|
||||
|
||||
// Get all grades for this student (not filtered by class)
|
||||
$studentGrades = $this->gradeRepository->findByStudent($studentId, $tenantId);
|
||||
|
||||
if ($studentGrades === []) {
|
||||
$result[] = new ChildGradesDto(
|
||||
childId: $child['studentId'],
|
||||
firstName: $child['firstName'],
|
||||
lastName: $child['lastName'],
|
||||
grades: [],
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect unique evaluation IDs and load evaluations
|
||||
$evaluationIds = array_values(array_unique(
|
||||
array_map(static fn (Grade $g): string => (string) $g->evaluationId, $studentGrades),
|
||||
));
|
||||
|
||||
$evaluationsById = [];
|
||||
|
||||
foreach ($evaluationIds as $evalIdStr) {
|
||||
$evaluation = $this->evaluationRepository->findById(
|
||||
EvaluationId::fromString($evalIdStr),
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
if ($evaluation !== null) {
|
||||
$evaluationsById[$evalIdStr] = $evaluation;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter evaluations visible to parents (published + delay elapsed)
|
||||
$visibleEvaluationIds = [];
|
||||
|
||||
foreach ($evaluationsById as $evalIdStr => $evaluation) {
|
||||
if ($this->visibiliteNotesPolicy->visiblePourParent($evaluation, $delaiHeures)) {
|
||||
$visibleEvaluationIds[$evalIdStr] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by subject if requested
|
||||
if ($query->subjectId !== null) {
|
||||
$filterSubjectId = $query->subjectId;
|
||||
|
||||
foreach ($visibleEvaluationIds as $evalIdStr => $_) {
|
||||
$evaluation = $evaluationsById[$evalIdStr];
|
||||
|
||||
if ((string) $evaluation->subjectId !== $filterSubjectId) {
|
||||
unset($visibleEvaluationIds[$evalIdStr]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($visibleEvaluationIds === []) {
|
||||
$result[] = new ChildGradesDto(
|
||||
childId: $child['studentId'],
|
||||
firstName: $child['firstName'],
|
||||
lastName: $child['lastName'],
|
||||
grades: [],
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve display names
|
||||
$visibleEvaluations = array_values(array_filter(
|
||||
$evaluationsById,
|
||||
static fn (Evaluation $e): bool => isset($visibleEvaluationIds[(string) $e->id]),
|
||||
));
|
||||
$subjectIds = array_values(array_unique(
|
||||
array_map(static fn (Evaluation $e): string => (string) $e->subjectId, $visibleEvaluations),
|
||||
));
|
||||
$subjects = $this->displayReader->subjectDisplay($query->tenantId, ...$subjectIds);
|
||||
|
||||
// Build grade DTOs
|
||||
$childGrades = [];
|
||||
|
||||
foreach ($studentGrades as $grade) {
|
||||
$evalIdStr = (string) $grade->evaluationId;
|
||||
|
||||
if (!isset($visibleEvaluationIds[$evalIdStr])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$evaluation = $evaluationsById[$evalIdStr];
|
||||
$subjectInfo = $subjects[(string) $evaluation->subjectId] ?? ['name' => null, 'color' => null];
|
||||
$statistics = $this->statisticsRepository->findByEvaluation($evaluation->id);
|
||||
|
||||
$childGrades[] = new ParentGradeDto(
|
||||
id: (string) $grade->id,
|
||||
evaluationId: $evalIdStr,
|
||||
evaluationTitle: $evaluation->title,
|
||||
evaluationDate: $evaluation->evaluationDate->format('Y-m-d'),
|
||||
gradeScale: $evaluation->gradeScale->maxValue,
|
||||
coefficient: $evaluation->coefficient->value,
|
||||
subjectId: (string) $evaluation->subjectId,
|
||||
subjectName: $subjectInfo['name'],
|
||||
subjectColor: $subjectInfo['color'],
|
||||
value: $grade->value?->value,
|
||||
status: $grade->status->value,
|
||||
appreciation: $grade->appreciation,
|
||||
publishedAt: $evaluation->gradesPublishedAt?->format('Y-m-d\TH:i:sP') ?? '',
|
||||
classAverage: $statistics?->average,
|
||||
classMin: $statistics?->min,
|
||||
classMax: $statistics?->max,
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by evaluation date descending
|
||||
usort($childGrades, static fn (ParentGradeDto $a, ParentGradeDto $b): int => $b->evaluationDate <=> $a->evaluationDate);
|
||||
|
||||
$result[] = new ChildGradesDto(
|
||||
childId: $child['studentId'],
|
||||
firstName: $child['firstName'],
|
||||
lastName: $child['lastName'],
|
||||
grades: $childGrades,
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class GetChildrenGradesQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $parentId,
|
||||
public string $tenantId,
|
||||
public ?string $childId = null,
|
||||
public ?string $subjectId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
use function array_sum;
|
||||
use function count;
|
||||
use function round;
|
||||
|
||||
/**
|
||||
* Calcule les moyennes par matière et la moyenne générale
|
||||
* à partir des notes visibles (respectant le délai parent).
|
||||
*
|
||||
* Ne lit PAS les agrégats pré-calculés (student_averages) car ceux-ci
|
||||
* incluent des notes encore dans la période de délai.
|
||||
*/
|
||||
final readonly class GetChildrenGradesSummaryHandler
|
||||
{
|
||||
public function __construct(
|
||||
private GetChildrenGradesHandler $gradesHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<ChildGradesSummaryDto> */
|
||||
public function __invoke(GetChildrenGradesSummaryQuery $query): array
|
||||
{
|
||||
// Réutilise le handler notes qui applique le délai de visibilité
|
||||
$childrenGrades = ($this->gradesHandler)(new GetChildrenGradesQuery(
|
||||
parentId: $query->parentId,
|
||||
tenantId: $query->tenantId,
|
||||
));
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($childrenGrades as $child) {
|
||||
// Grouper par matière et calculer les moyennes pondérées
|
||||
$subjectData = [];
|
||||
|
||||
foreach ($child->grades as $grade) {
|
||||
if ($grade->value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $grade->subjectId;
|
||||
|
||||
if (!isset($subjectData[$key])) {
|
||||
$subjectData[$key] = [
|
||||
'subjectId' => $grade->subjectId,
|
||||
'subjectName' => $grade->subjectName,
|
||||
'weightedSum' => 0.0,
|
||||
'coefficientSum' => 0.0,
|
||||
'gradeCount' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$subjectData[$key]['weightedSum'] += $grade->value * $grade->coefficient;
|
||||
$subjectData[$key]['coefficientSum'] += $grade->coefficient;
|
||||
++$subjectData[$key]['gradeCount'];
|
||||
}
|
||||
|
||||
$subjectAverages = [];
|
||||
|
||||
foreach ($subjectData as $data) {
|
||||
if ($data['coefficientSum'] > 0) {
|
||||
$subjectAverages[] = [
|
||||
'subjectId' => $data['subjectId'],
|
||||
'subjectName' => $data['subjectName'],
|
||||
'average' => round($data['weightedSum'] / $data['coefficientSum'], 2),
|
||||
'gradeCount' => $data['gradeCount'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$generalAverage = null;
|
||||
|
||||
if ($subjectAverages !== []) {
|
||||
$averages = array_map(static fn (array $a): float => $a['average'], $subjectAverages);
|
||||
$generalAverage = round(array_sum($averages) / count($averages), 2);
|
||||
}
|
||||
|
||||
$result[] = new ChildGradesSummaryDto(
|
||||
childId: $child->childId,
|
||||
firstName: $child->firstName,
|
||||
lastName: $child->lastName,
|
||||
periodId: null,
|
||||
subjectAverages: $subjectAverages,
|
||||
generalAverage: $generalAverage,
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class GetChildrenGradesSummaryQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $parentId,
|
||||
public string $tenantId,
|
||||
public ?string $periodId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetChildrenGrades;
|
||||
|
||||
final readonly class ParentGradeDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $evaluationId,
|
||||
public string $evaluationTitle,
|
||||
public string $evaluationDate,
|
||||
public int $gradeScale,
|
||||
public float $coefficient,
|
||||
public string $subjectId,
|
||||
public ?string $subjectName,
|
||||
public ?string $subjectColor,
|
||||
public ?float $value,
|
||||
public string $status,
|
||||
public ?string $appreciation,
|
||||
public string $publishedAt,
|
||||
public ?float $classAverage,
|
||||
public ?float $classMin,
|
||||
public ?float $classMax,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ namespace App\Scolarite\Domain\Policy;
|
||||
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||
use App\Shared\Domain\Clock;
|
||||
|
||||
use function max;
|
||||
|
||||
final readonly class VisibiliteNotesPolicy
|
||||
{
|
||||
private const int DELAI_PARENTS_HEURES = 24;
|
||||
public const int DELAI_PARENTS_HEURES_DEFAUT = 24;
|
||||
|
||||
public function __construct(
|
||||
private Clock $clock,
|
||||
@@ -21,13 +23,14 @@ final readonly class VisibiliteNotesPolicy
|
||||
return $evaluation->notesPubliees();
|
||||
}
|
||||
|
||||
public function visiblePourParent(Evaluation $evaluation): bool
|
||||
public function visiblePourParent(Evaluation $evaluation, int $delaiHeures = self::DELAI_PARENTS_HEURES_DEFAUT): bool
|
||||
{
|
||||
if (!$evaluation->notesPubliees()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$delai = $evaluation->gradesPublishedAt?->modify('+' . self::DELAI_PARENTS_HEURES . ' hours');
|
||||
$delaiHeures = max(0, $delaiHeures);
|
||||
$delai = $evaluation->gradesPublishedAt?->modify('+' . $delaiHeures . ' hours');
|
||||
|
||||
return $delai !== null && $delai <= $this->clock->now();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Repository;
|
||||
|
||||
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;
|
||||
@@ -32,4 +33,7 @@ interface GradeRepository
|
||||
public function findByEvaluations(array $evaluationIds, TenantId $tenantId): array;
|
||||
|
||||
public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool;
|
||||
|
||||
/** @return array<Grade> */
|
||||
public function findByStudent(UserId $studentId, TenantId $tenantId): array;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\ChildGradesDto;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\ChildGradesSummaryDto;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesQuery;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryHandler;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryQuery;
|
||||
use App\Scolarite\Application\Query\GetChildrenGrades\ParentGradeDto;
|
||||
use App\Scolarite\Infrastructure\Security\GradeParentVoter;
|
||||
|
||||
use function array_map;
|
||||
use function is_string;
|
||||
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Endpoints de consultation des notes des enfants pour le parent connecté.
|
||||
*/
|
||||
#[IsGranted(GradeParentVoter::VIEW)]
|
||||
final readonly class ParentGradeController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private GetChildrenGradesHandler $gradesHandler,
|
||||
private GetChildrenGradesSummaryHandler $summaryHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes d'un enfant spécifique.
|
||||
*/
|
||||
#[Route('/api/me/children/{childId}/grades', name: 'api_parent_child_grades', methods: ['GET'])]
|
||||
public function childGrades(string $childId): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
$children = ($this->gradesHandler)(new GetChildrenGradesQuery(
|
||||
parentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
childId: $childId,
|
||||
));
|
||||
|
||||
if ($children === []) {
|
||||
throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.');
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $this->serializeChild($children[0]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes d'un enfant filtrées par matière.
|
||||
*/
|
||||
#[Route('/api/me/children/{childId}/grades/subject/{subjectId}', name: 'api_parent_child_grades_by_subject', methods: ['GET'])]
|
||||
public function childGradesBySubject(string $childId, string $subjectId): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
$children = ($this->gradesHandler)(new GetChildrenGradesQuery(
|
||||
parentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
childId: $childId,
|
||||
subjectId: $subjectId,
|
||||
));
|
||||
|
||||
if ($children === []) {
|
||||
throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.');
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => $this->serializeChild($children[0]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résumé des moyennes de tous les enfants.
|
||||
*/
|
||||
#[Route('/api/me/children/grades/summary', name: 'api_parent_children_grades_summary', methods: ['GET'])]
|
||||
public function gradesSummary(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
$periodId = $request->query->get('periodId');
|
||||
|
||||
$summaries = ($this->summaryHandler)(new GetChildrenGradesSummaryQuery(
|
||||
parentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
periodId: is_string($periodId) && $periodId !== '' ? $periodId : null,
|
||||
));
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => array_map($this->serializeSummary(...), $summaries),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getSecurityUser(): SecurityUser
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new AccessDeniedHttpException('Authentification requise.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeChild(ChildGradesDto $child): array
|
||||
{
|
||||
return [
|
||||
'childId' => $child->childId,
|
||||
'firstName' => $child->firstName,
|
||||
'lastName' => $child->lastName,
|
||||
'grades' => array_map($this->serializeGrade(...), $child->grades),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeGrade(ParentGradeDto $grade): array
|
||||
{
|
||||
return [
|
||||
'id' => $grade->id,
|
||||
'evaluationId' => $grade->evaluationId,
|
||||
'evaluationTitle' => $grade->evaluationTitle,
|
||||
'evaluationDate' => $grade->evaluationDate,
|
||||
'gradeScale' => $grade->gradeScale,
|
||||
'coefficient' => $grade->coefficient,
|
||||
'subjectId' => $grade->subjectId,
|
||||
'subjectName' => $grade->subjectName,
|
||||
'subjectColor' => $grade->subjectColor,
|
||||
'value' => $grade->value,
|
||||
'status' => $grade->status,
|
||||
'appreciation' => $grade->appreciation,
|
||||
'publishedAt' => $grade->publishedAt,
|
||||
'classAverage' => $grade->classAverage,
|
||||
'classMin' => $grade->classMin,
|
||||
'classMax' => $grade->classMax,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeSummary(ChildGradesSummaryDto $summary): array
|
||||
{
|
||||
return [
|
||||
'childId' => $summary->childId,
|
||||
'firstName' => $summary->firstName,
|
||||
'lastName' => $summary->lastName,
|
||||
'periodId' => $summary->periodId,
|
||||
'subjectAverages' => $summary->subjectAverages,
|
||||
'generalAverage' => $summary->generalAverage,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\StudentGradeResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<StudentGradeResource>
|
||||
*/
|
||||
final readonly class StudentGradeCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TenantContext $tenantContext,
|
||||
private Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<StudentGradeResource> */
|
||||
#[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.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::ELEVE->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux élèves.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$studentId = $user->userId();
|
||||
|
||||
/** @var string|null $subjectId */
|
||||
$subjectId = $uriVariables['subjectId'] ?? null;
|
||||
|
||||
if (is_string($subjectId) && $subjectId === '') {
|
||||
$subjectId = null;
|
||||
}
|
||||
|
||||
$subjectFilter = $subjectId !== null ? 'AND s.id = :subject_id' : '';
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
"SELECT g.id AS grade_id, g.value, g.status AS grade_status, g.appreciation,
|
||||
e.id AS evaluation_id, e.title AS evaluation_title,
|
||||
e.evaluation_date, e.grade_scale, e.coefficient,
|
||||
e.grades_published_at,
|
||||
s.id AS subject_id, s.name AS subject_name,
|
||||
es.average AS class_average, es.min_grade AS class_min, es.max_grade AS class_max
|
||||
FROM grades g
|
||||
JOIN evaluations e ON g.evaluation_id = e.id
|
||||
JOIN subjects s ON e.subject_id = s.id
|
||||
LEFT JOIN evaluation_statistics es ON es.evaluation_id = e.id
|
||||
WHERE g.student_id = :student_id
|
||||
AND g.tenant_id = :tenant_id
|
||||
AND e.grades_published_at IS NOT NULL
|
||||
AND e.status != :deleted_status
|
||||
{$subjectFilter}
|
||||
ORDER BY e.evaluation_date DESC, e.created_at DESC",
|
||||
$subjectId !== null
|
||||
? ['student_id' => $studentId, 'tenant_id' => $tenantId, 'deleted_status' => 'deleted', 'subject_id' => $subjectId]
|
||||
: ['student_id' => $studentId, 'tenant_id' => $tenantId, 'deleted_status' => 'deleted'],
|
||||
);
|
||||
|
||||
return array_map(self::hydrateResource(...), $rows);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private static function hydrateResource(array $row): StudentGradeResource
|
||||
{
|
||||
$resource = new StudentGradeResource();
|
||||
|
||||
/** @var string $gradeId */
|
||||
$gradeId = $row['grade_id'];
|
||||
$resource->id = $gradeId;
|
||||
|
||||
/** @var string $evaluationId */
|
||||
$evaluationId = $row['evaluation_id'];
|
||||
$resource->evaluationId = $evaluationId;
|
||||
|
||||
/** @var string $evaluationTitle */
|
||||
$evaluationTitle = $row['evaluation_title'];
|
||||
$resource->evaluationTitle = $evaluationTitle;
|
||||
|
||||
/** @var string $evaluationDate */
|
||||
$evaluationDate = $row['evaluation_date'];
|
||||
$resource->evaluationDate = $evaluationDate;
|
||||
|
||||
/** @var string|int $gradeScale */
|
||||
$gradeScale = $row['grade_scale'];
|
||||
$resource->gradeScale = (int) $gradeScale;
|
||||
|
||||
/** @var string|float $coefficient */
|
||||
$coefficient = $row['coefficient'];
|
||||
$resource->coefficient = (float) $coefficient;
|
||||
|
||||
/** @var string $subjectIdVal */
|
||||
$subjectIdVal = $row['subject_id'];
|
||||
$resource->subjectId = $subjectIdVal;
|
||||
|
||||
/** @var string|null $subjectName */
|
||||
$subjectName = $row['subject_name'];
|
||||
$resource->subjectName = $subjectName;
|
||||
|
||||
/** @var string|float|null $value */
|
||||
$value = $row['value'];
|
||||
$resource->value = $value !== null ? (float) $value : null;
|
||||
|
||||
/** @var string $gradeStatus */
|
||||
$gradeStatus = $row['grade_status'];
|
||||
$resource->status = $gradeStatus;
|
||||
|
||||
/** @var string|null $appreciation */
|
||||
$appreciation = $row['appreciation'];
|
||||
$resource->appreciation = $appreciation;
|
||||
|
||||
/** @var string|null $publishedAt */
|
||||
$publishedAt = $row['grades_published_at'];
|
||||
$resource->publishedAt = $publishedAt;
|
||||
|
||||
/** @var string|float|null $classAverage */
|
||||
$classAverage = $row['class_average'];
|
||||
$resource->classAverage = $classAverage !== null ? (float) $classAverage : null;
|
||||
|
||||
/** @var string|float|null $classMin */
|
||||
$classMin = $row['class_min'];
|
||||
$resource->classMin = $classMin !== null ? (float) $classMin : null;
|
||||
|
||||
/** @var string|float|null $classMax */
|
||||
$classMax = $row['class_max'];
|
||||
$resource->classMax = $classMax !== null ? (float) $classMax : null;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Domain\Repository\StudentAverageRepository;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\StudentMyAveragesResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<StudentMyAveragesResource>
|
||||
*/
|
||||
final readonly class StudentMyAveragesProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private StudentAverageRepository $studentAverageRepository,
|
||||
private PeriodFinder $periodFinder,
|
||||
private TenantContext $tenantContext,
|
||||
private Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): StudentMyAveragesResource
|
||||
{
|
||||
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.');
|
||||
}
|
||||
|
||||
if (!in_array(Role::ELEVE->value, $user->getRoles(), true)) {
|
||||
throw new AccessDeniedHttpException('Accès réservé aux élèves.');
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||
$studentId = UserId::fromString($user->userId());
|
||||
|
||||
/** @var array<string, mixed> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
/** @var string|null $periodId */
|
||||
$periodId = $filters['periodId'] ?? null;
|
||||
|
||||
// Auto-detect current period if not specified
|
||||
if ($periodId === null) {
|
||||
$periodInfo = $this->periodFinder->findForDate(new DateTimeImmutable(), $tenantId);
|
||||
|
||||
if ($periodInfo !== null) {
|
||||
$periodId = $periodInfo->periodId;
|
||||
}
|
||||
}
|
||||
|
||||
$resource = new StudentMyAveragesResource();
|
||||
$resource->studentId = $user->userId();
|
||||
$resource->periodId = $periodId;
|
||||
|
||||
if ($periodId === null) {
|
||||
return $resource;
|
||||
}
|
||||
|
||||
$resource->subjectAverages = $this->studentAverageRepository->findDetailedSubjectAveragesForStudent(
|
||||
$studentId,
|
||||
$periodId,
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
$resource->generalAverage = $this->studentAverageRepository->findGeneralAverageForStudent(
|
||||
$studentId,
|
||||
$periodId,
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\StudentGradeCollectionProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'StudentGrade',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/me/grades',
|
||||
provider: StudentGradeCollectionProvider::class,
|
||||
name: 'get_my_grades',
|
||||
),
|
||||
new GetCollection(
|
||||
uriTemplate: '/me/grades/subject/{subjectId}',
|
||||
uriVariables: ['subjectId'],
|
||||
provider: StudentGradeCollectionProvider::class,
|
||||
name: 'get_my_grades_by_subject',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class StudentGradeResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
public ?string $evaluationId = null;
|
||||
|
||||
public ?string $evaluationTitle = null;
|
||||
|
||||
public ?string $evaluationDate = null;
|
||||
|
||||
public ?int $gradeScale = null;
|
||||
|
||||
public ?float $coefficient = null;
|
||||
|
||||
public ?string $subjectId = null;
|
||||
|
||||
public ?string $subjectName = null;
|
||||
|
||||
public ?float $value = null;
|
||||
|
||||
public ?string $status = null;
|
||||
|
||||
public ?string $appreciation = null;
|
||||
|
||||
public ?string $publishedAt = null;
|
||||
|
||||
public ?float $classAverage = null;
|
||||
|
||||
public ?float $classMin = null;
|
||||
|
||||
public ?float $classMax = null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\StudentMyAveragesProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'StudentMyAverages',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/me/averages',
|
||||
provider: StudentMyAveragesProvider::class,
|
||||
name: 'get_my_averages',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class StudentMyAveragesResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public string $studentId = 'me';
|
||||
|
||||
public ?string $periodId = null;
|
||||
|
||||
/** @var list<array{subjectId: string, subjectName: string|null, average: float, gradeCount: int}> */
|
||||
public array $subjectAverages = [];
|
||||
|
||||
public ?float $generalAverage = null;
|
||||
}
|
||||
@@ -170,6 +170,23 @@ final readonly class DoctrineGradeRepository implements GradeRepository
|
||||
return (int) $countValue > 0;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByStudent(UserId $studentId, TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM grades
|
||||
WHERE student_id = :student_id
|
||||
AND tenant_id = :tenant_id
|
||||
ORDER BY created_at DESC',
|
||||
[
|
||||
'student_id' => (string) $studentId,
|
||||
'tenant_id' => (string) $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
return array_map($this->hydrate(...), $rows);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function hydrate(array $row): Grade
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||
|
||||
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;
|
||||
@@ -98,4 +99,14 @@ final class InMemoryGradeRepository implements GradeRepository
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByStudent(UserId $studentId, TenantId $tenantId): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (Grade $g): bool => $g->studentId->equals($studentId)
|
||||
&& $g->tenantId->equals($tenantId),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Voter pour la consultation des notes des enfants par le parent.
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
final class GradeParentVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'GRADE_PARENT_VIEW';
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return $attribute === self::VIEW;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(Role::PARENT->value, $user->getRoles(), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function is_numeric;
|
||||
|
||||
use Override;
|
||||
|
||||
final readonly class DatabaseParentGradeDelayReader implements ParentGradeDelayReader
|
||||
{
|
||||
private const int DEFAULT_DELAY_HOURS = 24;
|
||||
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delayHoursForTenant(TenantId $tenantId): int
|
||||
{
|
||||
$result = $this->connection->fetchOne(
|
||||
'SELECT parent_grade_delay_hours FROM school_grading_configurations WHERE tenant_id = :tenant_id LIMIT 1',
|
||||
['tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
if ($result === false || !is_numeric($result)) {
|
||||
return self::DEFAULT_DELAY_HOURS;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user