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:
@@ -53,8 +53,8 @@ development_status:
|
|||||||
1-9-dashboard-placeholder-avec-preview-score-serenite: done
|
1-9-dashboard-placeholder-avec-preview-score-serenite: done
|
||||||
epic-1-retrospective: optional
|
epic-1-retrospective: optional
|
||||||
|
|
||||||
# Epic 2: Configuration Établissement (13 stories)
|
# Epic 2: Configuration Établissement (14 stories)
|
||||||
epic-2: done
|
epic-2: in-progress
|
||||||
2-1-creation-et-gestion-des-classes: done
|
2-1-creation-et-gestion-des-classes: done
|
||||||
2-2-creation-et-gestion-des-matieres: done
|
2-2-creation-et-gestion-des-matieres: done
|
||||||
2-3-gestion-des-periodes-scolaires: done
|
2-3-gestion-des-periodes-scolaires: done
|
||||||
@@ -74,6 +74,7 @@ development_status:
|
|||||||
2-12b-optimistic-update-pages-admin: done
|
2-12b-optimistic-update-pages-admin: done
|
||||||
2-13-personnalisation-visuelle-etablissement: done
|
2-13-personnalisation-visuelle-etablissement: done
|
||||||
2-15-organisation-sections-dashboard-admin: done
|
2-15-organisation-sections-dashboard-admin: done
|
||||||
|
2-17-provisioning-automatique-etablissements: ready-for-dev # Tâches post-MVP différées de 2-10
|
||||||
epic-2-retrospective: done
|
epic-2-retrospective: done
|
||||||
|
|
||||||
# Epic 3: Import & Onboarding (5 stories)
|
# Epic 3: Import & Onboarding (5 stories)
|
||||||
@@ -94,8 +95,8 @@ development_status:
|
|||||||
4-6-recherche-parent-liaison-eleve: done
|
4-6-recherche-parent-liaison-eleve: done
|
||||||
epic-4-retrospective: optional
|
epic-4-retrospective: optional
|
||||||
|
|
||||||
# Epic 5: Devoirs & Règles (8 stories)
|
# Epic 5: Devoirs & Règles (9 stories)
|
||||||
epic-5: done
|
epic-5: in-progress
|
||||||
5-1-creation-de-devoirs: done
|
5-1-creation-de-devoirs: done
|
||||||
5-2-duplication-de-devoirs-multi-classes: done
|
5-2-duplication-de-devoirs-multi-classes: done
|
||||||
5-2b-optimisation-chargement-page-devoirs: done
|
5-2b-optimisation-chargement-page-devoirs: done
|
||||||
@@ -107,18 +108,23 @@ development_status:
|
|||||||
5-8-consultation-des-devoirs-par-le-parent: done
|
5-8-consultation-des-devoirs-par-le-parent: done
|
||||||
5-9-description-enrichie-et-pieces-jointes-enseignant: done
|
5-9-description-enrichie-et-pieces-jointes-enseignant: done
|
||||||
5-10-rendu-de-devoir-par-leleve: done
|
5-10-rendu-de-devoir-par-leleve: done
|
||||||
|
5-11-description-enrichie-upload-calendrier-devoirs: ready-for-dev # Tâches UX différées de 5-1
|
||||||
epic-5-retrospective: optional
|
epic-5-retrospective: optional
|
||||||
|
|
||||||
# Epic 6: Notes & Évaluations (8 stories)
|
# Epic 6: Notes & Évaluations (12 stories)
|
||||||
epic-6: in-progress
|
epic-6: in-progress
|
||||||
6-1-creation-devaluation: done
|
6-1-creation-devaluation: done
|
||||||
6-2-saisie-notes-grille-inline: done
|
6-2-saisie-notes-grille-inline: done
|
||||||
6-3-calcul-automatique-des-moyennes: done
|
6-3-calcul-automatique-des-moyennes: done
|
||||||
6-4-saisie-des-appreciations: done
|
6-4-saisie-des-appreciations: done
|
||||||
6-5-mode-competences: review
|
6-5-mode-competences: done
|
||||||
6-6-consultation-notes-par-leleve: ready-for-dev
|
6-6-consultation-notes-par-leleve: done
|
||||||
6-7-consultation-notes-par-le-parent: ready-for-dev
|
6-7-consultation-notes-par-le-parent: review
|
||||||
6-8-statistiques-enseignant: ready-for-dev
|
6-8-statistiques-enseignant: ready-for-dev
|
||||||
|
6-9-grade-voter-et-acces-notes-affectations: ready-for-dev # Débloque tâches différées de 2-6, 2-8, 2-9
|
||||||
|
6-10-statistiques-notes-par-matiere-admin: ready-for-dev # Débloque tâches différées de 2-2
|
||||||
|
6-11-audit-trail-evenements-notes: ready-for-dev # Débloque tâches différées de 1-7
|
||||||
|
6-12-correctifs-mode-competences: ready-for-dev # Patches critiques review 6-5
|
||||||
epic-6-retrospective: optional
|
epic-6-retrospective: optional
|
||||||
|
|
||||||
# Epic 7: Vie Scolaire (8 stories)
|
# Epic 7: Vie Scolaire (8 stories)
|
||||||
@@ -164,3 +170,7 @@ development_status:
|
|||||||
10-6-droits-rgpd-utilisateurs: ready-for-dev
|
10-6-droits-rgpd-utilisateurs: ready-for-dev
|
||||||
10-7-passage-de-classe-et-cloture-annee: ready-for-dev
|
10-7-passage-de-classe-et-cloture-annee: ready-for-dev
|
||||||
epic-10-retrospective: optional
|
epic-10-retrospective: optional
|
||||||
|
|
||||||
|
# Epic 11: Infrastructure Transversale
|
||||||
|
epic-11: in-progress
|
||||||
|
11-1-infrastructure-cache-offline-pwa: ready-for-dev # Centralise tâches offline de 4-3, 5-7, 5-8, 6-6, 6-7
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ services:
|
|||||||
App\Administration\Domain\Policy\ConsentementParentalPolicy:
|
App\Administration\Domain\Policy\ConsentementParentalPolicy:
|
||||||
autowire: true
|
autowire: true
|
||||||
|
|
||||||
|
App\Scolarite\Domain\Policy\VisibiliteNotesPolicy:
|
||||||
|
autowire: true
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
App\Scolarite\Application\Port\ParentGradeDelayReader:
|
||||||
|
alias: App\Scolarite\Infrastructure\Service\DatabaseParentGradeDelayReader
|
||||||
|
|
||||||
# Email handlers
|
# Email handlers
|
||||||
App\Administration\Infrastructure\Messaging\SendActivationConfirmationHandler:
|
App\Administration\Infrastructure\Messaging\SendActivationConfirmationHandler:
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
30
backend/migrations/Version20260406074932.php
Normal file
30
backend/migrations/Version20260406074932.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260406074932 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Ajouter le délai configurable de visibilité des notes pour les parents';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE school_grading_configurations
|
||||||
|
ADD COLUMN parent_grade_delay_hours SMALLINT NOT NULL DEFAULT 24
|
||||||
|
CHECK (parent_grade_delay_hours >= 0 AND parent_grade_delay_hours <= 72)
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE school_grading_configurations DROP COLUMN parent_grade_delay_hours');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,14 @@ final class SchoolClass extends AggregateRoot
|
|||||||
|
|
||||||
$this->level = $niveau;
|
$this->level = $niveau;
|
||||||
$this->updatedAt = $at;
|
$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\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||||
use App\Shared\Domain\Clock;
|
use App\Shared\Domain\Clock;
|
||||||
|
|
||||||
|
use function max;
|
||||||
|
|
||||||
final readonly class VisibiliteNotesPolicy
|
final readonly class VisibiliteNotesPolicy
|
||||||
{
|
{
|
||||||
private const int DELAI_PARENTS_HEURES = 24;
|
public const int DELAI_PARENTS_HEURES_DEFAUT = 24;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Clock $clock,
|
private Clock $clock,
|
||||||
@@ -21,13 +23,14 @@ final readonly class VisibiliteNotesPolicy
|
|||||||
return $evaluation->notesPubliees();
|
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()) {
|
if (!$evaluation->notesPubliees()) {
|
||||||
return false;
|
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();
|
return $delai !== null && $delai <= $this->clock->now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Scolarite\Domain\Repository;
|
namespace App\Scolarite\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use App\Scolarite\Domain\Exception\GradeNotFoundException;
|
use App\Scolarite\Domain\Exception\GradeNotFoundException;
|
||||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||||
@@ -32,4 +33,7 @@ interface GradeRepository
|
|||||||
public function findByEvaluations(array $evaluationIds, TenantId $tenantId): array;
|
public function findByEvaluations(array $evaluationIds, TenantId $tenantId): array;
|
||||||
|
|
||||||
public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool;
|
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;
|
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 */
|
/** @param array<string, mixed> $row */
|
||||||
private function hydrate(array $row): Grade
|
private function hydrate(array $row): Grade
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
use App\Scolarite\Domain\Exception\GradeNotFoundException;
|
use App\Scolarite\Domain\Exception\GradeNotFoundException;
|
||||||
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||||
use App\Scolarite\Domain\Model\Grade\Grade;
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||||
@@ -98,4 +99,14 @@ final class InMemoryGradeRepository implements GradeRepository
|
|||||||
|
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Scolarite\Api;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||||
|
use App\Scolarite\Domain\Repository\EvaluationRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\EvaluationStatisticsRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\GradeRepository;
|
||||||
|
use App\Scolarite\Domain\Service\AverageCalculator;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
final class ParentGradeEndpointsTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
protected static ?bool $alwaysBootKernel = true;
|
||||||
|
|
||||||
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
private const string PARENT_ID = '99990001-0001-0001-0001-000000000001';
|
||||||
|
private const string STUDENT_ID = '99990001-0001-0001-0001-000000000002';
|
||||||
|
private const string TEACHER_ID = '99990001-0001-0001-0001-000000000003';
|
||||||
|
private const string CLASS_ID = '99990001-0001-0001-0001-000000000010';
|
||||||
|
private const string SUBJECT_ID = '99990001-0001-0001-0001-000000000020';
|
||||||
|
private const string SUBJECT2_ID = '99990001-0001-0001-0001-000000000021';
|
||||||
|
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seedFixtures();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
/** @var Connection $connection */
|
||||||
|
$connection = static::getContainer()->get(Connection::class);
|
||||||
|
$connection->executeStatement('DELETE FROM evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid AND class_id = :cid)', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid AND evaluation_id IN (SELECT id FROM evaluations WHERE class_id = :cid))', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid AND evaluation_id IN (SELECT id FROM evaluations WHERE class_id = :cid)', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid AND class_id = :cid', ['tid' => self::TENANT_ID, 'cid' => self::CLASS_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM student_guardians WHERE guardian_id = :gid', ['gid' => self::PARENT_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM class_assignments WHERE user_id = :uid', ['uid' => self::STUDENT_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM users WHERE id IN (:p, :s, :t)', ['p' => self::PARENT_ID, 's' => self::STUDENT_ID, 't' => self::TEACHER_ID]);
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /api/me/children/{childId}/grades — Auth & Access
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesReturns401WithoutAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesReturns403ForStudent(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesReturns403ForTeacher(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesReturns404ForUnlinkedChild(): void
|
||||||
|
{
|
||||||
|
$unlinkedChildId = '99990001-0001-0001-0001-000000000099';
|
||||||
|
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . $unlinkedChildId . '/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /api/me/children/{childId}/grades — Happy path
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesReturnsGradesForLinkedChild(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var array{data: array{childId: string, grades: list<array<string, mixed>>}} $json */
|
||||||
|
$json = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertSame(self::STUDENT_ID, $json['data']['childId']);
|
||||||
|
self::assertNotEmpty($json['data']['grades']);
|
||||||
|
|
||||||
|
$grade = $json['data']['grades'][0];
|
||||||
|
self::assertArrayHasKey('evaluationTitle', $grade);
|
||||||
|
self::assertArrayHasKey('value', $grade);
|
||||||
|
self::assertArrayHasKey('status', $grade);
|
||||||
|
self::assertArrayHasKey('classAverage', $grade);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /api/me/children/{childId}/grades/subject/{subjectId} — Auth & Access
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesBySubjectReturns401WithoutAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesBySubjectReturns403ForStudent(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesBySubjectReturns403ForTeacher(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesBySubjectReturns404ForUnlinkedChild(): void
|
||||||
|
{
|
||||||
|
$unlinkedChildId = '99990001-0001-0001-0001-000000000099';
|
||||||
|
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . $unlinkedChildId . '/grades/subject/' . self::SUBJECT_ID, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /api/me/children/{childId}/grades/subject/{subjectId} — Happy path
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getChildGradesBySubjectFiltersCorrectly(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/' . self::STUDENT_ID . '/grades/subject/' . self::SUBJECT_ID, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var array{data: array{grades: list<array<string, mixed>>}} $json */
|
||||||
|
$json = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
foreach ($json['data']['grades'] as $grade) {
|
||||||
|
self::assertSame(self::SUBJECT_ID, $grade['subjectId']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /api/me/children/grades/summary — Auth & Access
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getGradesSummaryReturns401WithoutAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/grades/summary', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getGradesSummaryReturns403ForStudent(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/grades/summary', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /api/me/children/grades/summary — Happy path
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getGradesSummaryReturnsAveragesForParent(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/grades/summary', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var array{data: list<array{childId: string, generalAverage: float|null, subjectAverages: list<mixed>}>} $json */
|
||||||
|
$json = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertNotEmpty($json['data']);
|
||||||
|
self::assertSame(self::STUDENT_ID, $json['data'][0]['childId']);
|
||||||
|
self::assertNotNull($json['data'][0]['generalAverage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getGradesSummaryAcceptsPeriodIdQueryParameter(): void
|
||||||
|
{
|
||||||
|
$periodId = '99990001-0001-0001-0001-000000000050';
|
||||||
|
$client = $this->createAuthenticatedClient(self::PARENT_ID, ['ROLE_PARENT']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/grades/summary?periodId=' . $periodId, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var array{data: list<mixed>} $json */
|
||||||
|
$json = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// With a non-existent period, the response should still be 200 but with
|
||||||
|
// empty or zero averages (no grades match). The key assertion is that the
|
||||||
|
// endpoint accepts the parameter without error.
|
||||||
|
self::assertIsArray($json['data']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getGradesSummaryReturns403ForTeacher(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/children/grades/summary', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roles
|
||||||
|
*/
|
||||||
|
private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$user = new SecurityUser(
|
||||||
|
userId: UserId::fromString($userId),
|
||||||
|
email: 'test-pg@classeo.local',
|
||||||
|
hashedPassword: '',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
roles: $roles,
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->loginUser($user, 'api');
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedFixtures(): void
|
||||||
|
{
|
||||||
|
$container = static::getContainer();
|
||||||
|
/** @var Connection $connection */
|
||||||
|
$connection = $container->get(Connection::class);
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$now = new DateTimeImmutable('2026-03-15 10:00:00');
|
||||||
|
|
||||||
|
$schoolId = '550e8400-e29b-41d4-a716-ff6655440001';
|
||||||
|
$academicYearId = '550e8400-e29b-41d4-a716-ff6655440002';
|
||||||
|
|
||||||
|
// Seed users
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, 'parent-pg@test.local', '', 'Marie', 'Dupont', '[\"ROLE_PARENT\"]', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::PARENT_ID, 'tid' => self::TENANT_ID],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, 'student-pg@test.local', '', 'Emma', 'Dupont', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::STUDENT_ID, 'tid' => self::TENANT_ID],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, 'teacher-pg@test.local', '', 'Jean', 'Martin', '[\"ROLE_PROF\"]', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link parent to student
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at)
|
||||||
|
VALUES (gen_random_uuid(), :tid, :sid, :gid, 'mère', NOW())
|
||||||
|
ON CONFLICT DO NOTHING",
|
||||||
|
['tid' => self::TENANT_ID, 'sid' => self::STUDENT_ID, 'gid' => self::PARENT_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed class and subjects
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, :sid, :ayid, 'Test-PG-Class', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, :sid, 'PG-Mathématiques', 'PGMATH', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, :sid, 'PG-Français', 'PGFRA', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::SUBJECT2_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assign student to class
|
||||||
|
$connection->executeStatement(
|
||||||
|
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at)
|
||||||
|
VALUES (gen_random_uuid(), :tid, :uid, :cid, :ayid, NOW(), NOW(), NOW())
|
||||||
|
ON CONFLICT (user_id, academic_year_id) DO NOTHING',
|
||||||
|
['tid' => self::TENANT_ID, 'uid' => self::STUDENT_ID, 'cid' => self::CLASS_ID, 'ayid' => $academicYearId],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var EvaluationRepository $evalRepo */
|
||||||
|
$evalRepo = $container->get(EvaluationRepository::class);
|
||||||
|
/** @var GradeRepository $gradeRepo */
|
||||||
|
$gradeRepo = $container->get(GradeRepository::class);
|
||||||
|
/** @var AverageCalculator $calculator */
|
||||||
|
$calculator = $container->get(AverageCalculator::class);
|
||||||
|
/** @var EvaluationStatisticsRepository $statsRepo */
|
||||||
|
$statsRepo = $container->get(EvaluationStatisticsRepository::class);
|
||||||
|
|
||||||
|
// Published evaluation (well past 24h delay)
|
||||||
|
$eval1 = Evaluation::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
classId: ClassId::fromString(self::CLASS_ID),
|
||||||
|
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
title: 'DS Maths PG',
|
||||||
|
description: null,
|
||||||
|
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
coefficient: new Coefficient(1.0),
|
||||||
|
now: new DateTimeImmutable('2026-02-10'),
|
||||||
|
);
|
||||||
|
$eval1->publierNotes(new DateTimeImmutable('2026-02-16 10:00:00'));
|
||||||
|
$eval1->pullDomainEvents();
|
||||||
|
$evalRepo->save($eval1);
|
||||||
|
|
||||||
|
$grade1 = Grade::saisir(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
evaluationId: $eval1->id,
|
||||||
|
studentId: UserId::fromString(self::STUDENT_ID),
|
||||||
|
value: new GradeValue(15.0),
|
||||||
|
status: GradeStatus::GRADED,
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
$grade1->pullDomainEvents();
|
||||||
|
$gradeRepo->save($grade1);
|
||||||
|
|
||||||
|
$stats1 = $calculator->calculateClassStatistics([15.0, 12.0, 18.0]);
|
||||||
|
$statsRepo->save($eval1->id, $stats1);
|
||||||
|
|
||||||
|
// Second evaluation, different subject
|
||||||
|
$eval2 = Evaluation::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
classId: ClassId::fromString(self::CLASS_ID),
|
||||||
|
subjectId: SubjectId::fromString(self::SUBJECT2_ID),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
title: 'Dictée PG',
|
||||||
|
description: null,
|
||||||
|
evaluationDate: new DateTimeImmutable('2026-03-01'),
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
coefficient: new Coefficient(2.0),
|
||||||
|
now: new DateTimeImmutable('2026-02-25'),
|
||||||
|
);
|
||||||
|
$eval2->publierNotes(new DateTimeImmutable('2026-03-02 10:00:00'));
|
||||||
|
$eval2->pullDomainEvents();
|
||||||
|
$evalRepo->save($eval2);
|
||||||
|
|
||||||
|
$grade2 = Grade::saisir(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
evaluationId: $eval2->id,
|
||||||
|
studentId: UserId::fromString(self::STUDENT_ID),
|
||||||
|
value: new GradeValue(14.0),
|
||||||
|
status: GradeStatus::GRADED,
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
$grade2->pullDomainEvents();
|
||||||
|
$gradeRepo->save($grade2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Scolarite\Api;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||||
|
use App\Scolarite\Domain\Repository\EvaluationRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\EvaluationStatisticsRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\GradeRepository;
|
||||||
|
use App\Scolarite\Domain\Repository\StudentAverageRepository;
|
||||||
|
use App\Scolarite\Domain\Service\AverageCalculator;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
final class StudentGradeEndpointsTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
protected static ?bool $alwaysBootKernel = true;
|
||||||
|
|
||||||
|
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
private const string TEACHER_ID = '44444444-4444-4444-4444-444444444444';
|
||||||
|
private const string STUDENT_ID = '22222222-2222-2222-2222-222222222222';
|
||||||
|
private const string STUDENT2_ID = '33333333-3333-3333-3333-333333333333';
|
||||||
|
private const string CLASS_ID = '55555555-5555-5555-5555-555555555555';
|
||||||
|
private const string SUBJECT_ID = '66666666-6666-6666-6666-666666666666';
|
||||||
|
private const string SUBJECT2_ID = '66666666-6666-6666-6666-666666666667';
|
||||||
|
private const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
private const string BASE_URL = 'http://ecole-alpha.classeo.local/api';
|
||||||
|
|
||||||
|
private ?EvaluationId $unpublishedEvalId = null;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seedFixtures();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
/** @var Connection $connection */
|
||||||
|
$connection = static::getContainer()->get(Connection::class);
|
||||||
|
$connection->executeStatement('DELETE FROM evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid)', ['tid' => self::TENANT_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid)', ['tid' => self::TENANT_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||||
|
$connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /me/grades — Auth & Access
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesReturns401WithoutAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesReturns403ForTeacher(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesReturns403ForParent(): void
|
||||||
|
{
|
||||||
|
$parentId = '88888888-8888-8888-8888-888888888888';
|
||||||
|
$client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /me/grades — Happy path
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesReturnsPublishedGradesForStudent(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<array<string, mixed>> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// Only published grades should be returned (not unpublished)
|
||||||
|
self::assertCount(2, $data);
|
||||||
|
|
||||||
|
// First grade (sorted by eval date DESC, subject2 is more recent)
|
||||||
|
self::assertSame(self::SUBJECT2_ID, $data[0]['subjectId']);
|
||||||
|
self::assertSame(14.0, $data[0]['value']);
|
||||||
|
self::assertSame('graded', $data[0]['status']);
|
||||||
|
self::assertNotNull($data[0]['publishedAt']);
|
||||||
|
|
||||||
|
// Second grade
|
||||||
|
self::assertSame(self::SUBJECT_ID, $data[1]['subjectId']);
|
||||||
|
self::assertSame(16.0, $data[1]['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesDoesNotReturnUnpublishedGrades(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<array<string, mixed>> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// The unpublished evaluation grade should not appear
|
||||||
|
foreach ($data as $grade) {
|
||||||
|
self::assertNotSame((string) $this->unpublishedEvalId, $grade['evaluationId']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesIncludesClassStatistics(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<array<string, mixed>> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// First grade should have class statistics
|
||||||
|
self::assertArrayHasKey('classAverage', $data[0]);
|
||||||
|
self::assertArrayHasKey('classMin', $data[0]);
|
||||||
|
self::assertArrayHasKey('classMax', $data[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesReturnsEmptyForStudentWithNoGrades(): void
|
||||||
|
{
|
||||||
|
$noGradeStudentId = '77777777-7777-7777-7777-777777777777';
|
||||||
|
/** @var Connection $connection */
|
||||||
|
$connection = static::getContainer()->get(Connection::class);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, 'no-grade@test.local', '', 'No', 'Grades', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => $noGradeStudentId, 'tid' => self::TENANT_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
$client = $this->createAuthenticatedClient($noGradeStudentId, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<mixed> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertCount(0, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /me/grades/subject/{subjectId} — Happy path
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesBySubjectFiltersCorrectly(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades/subject/' . self::SUBJECT_ID, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<array<string, mixed>> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertCount(1, $data);
|
||||||
|
self::assertSame(self::SUBJECT_ID, $data[0]['subjectId']);
|
||||||
|
self::assertSame(16.0, $data[0]['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesBySubjectReturnsEmptyForUnknownSubject(): void
|
||||||
|
{
|
||||||
|
$unknownSubjectId = '99999999-9999-9999-9999-999999999999';
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades/subject/' . $unknownSubjectId, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<mixed> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertCount(0, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /me/averages — Auth & Access
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyAveragesReturns401WithoutAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/averages', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyAveragesReturns403ForTeacher(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/averages', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /me/averages — Happy path
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyAveragesReturnsAveragesForStudent(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertJsonContains([
|
||||||
|
'studentId' => self::STUDENT_ID,
|
||||||
|
'periodId' => self::PERIOD_ID,
|
||||||
|
'generalAverage' => 16.0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyAveragesReturnsSubjectAverages(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var array{subjectAverages: list<array<string, mixed>>, generalAverage: float|null} $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertNotEmpty($data['subjectAverages']);
|
||||||
|
self::assertSame(self::SUBJECT_ID, $data['subjectAverages'][0]['subjectId']);
|
||||||
|
self::assertSame(16.0, $data['subjectAverages'][0]['average']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /me/grades — Student isolation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesReturnsOnlyCurrentStudentGrades(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT2_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<array<string, mixed>> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// Student2 only has 1 grade (eval1, Maths), not the eval2/eval3 grades
|
||||||
|
self::assertCount(1, $data);
|
||||||
|
self::assertSame(12.0, $data[0]['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /me/grades — Response completeness
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesReturnsAllExpectedFields(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<array<string, mixed>> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// First grade (eval2 — Français, more recent)
|
||||||
|
$grade = $data[0];
|
||||||
|
self::assertArrayHasKey('id', $grade);
|
||||||
|
self::assertArrayHasKey('evaluationId', $grade);
|
||||||
|
self::assertArrayHasKey('evaluationTitle', $grade);
|
||||||
|
self::assertArrayHasKey('evaluationDate', $grade);
|
||||||
|
self::assertArrayHasKey('gradeScale', $grade);
|
||||||
|
self::assertArrayHasKey('coefficient', $grade);
|
||||||
|
self::assertArrayHasKey('subjectId', $grade);
|
||||||
|
self::assertArrayHasKey('value', $grade);
|
||||||
|
self::assertArrayHasKey('status', $grade);
|
||||||
|
self::assertArrayHasKey('publishedAt', $grade);
|
||||||
|
|
||||||
|
self::assertSame('Dictée', $grade['evaluationTitle']);
|
||||||
|
self::assertSame(20, $grade['gradeScale']);
|
||||||
|
self::assertSame(2.0, $grade['coefficient']);
|
||||||
|
self::assertSame('Français', $grade['subjectName'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyGradesIncludesAppreciationWhenSet(): void
|
||||||
|
{
|
||||||
|
// Add appreciation to eval1 grade
|
||||||
|
/** @var Connection $connection */
|
||||||
|
$connection = static::getContainer()->get(Connection::class);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"UPDATE grades SET appreciation = 'Excellent travail' WHERE student_id = :sid AND evaluation_id IN (SELECT id FROM evaluations WHERE title = 'DS Mathématiques' AND tenant_id = :tid)",
|
||||||
|
['sid' => self::STUDENT_ID, 'tid' => self::TENANT_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/grades', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var list<array<string, mixed>> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// Find the Maths grade (eval1)
|
||||||
|
$mathsGrade = null;
|
||||||
|
foreach ($data as $grade) {
|
||||||
|
if (($grade['evaluationTitle'] ?? null) === 'DS Mathématiques') {
|
||||||
|
$mathsGrade = $grade;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertNotNull($mathsGrade, 'DS Mathématiques grade not found');
|
||||||
|
self::assertSame('Excellent travail', $mathsGrade['appreciation']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// GET /me/averages — Auto-detect period
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getMyAveragesReturnsEmptyWhenNoPeriodCoversCurrentDate(): void
|
||||||
|
{
|
||||||
|
// The seeded period (2026-01-01 to 2026-03-31) does not cover today (2026-04-04)
|
||||||
|
// So auto-detect returns no period → empty averages
|
||||||
|
$client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']);
|
||||||
|
$client->request('GET', self::BASE_URL . '/me/averages', [
|
||||||
|
'headers' => ['Accept' => 'application/json'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
/** @var string $content */
|
||||||
|
$content = $client->getResponse()->getContent();
|
||||||
|
/** @var array<string, mixed> $data */
|
||||||
|
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
self::assertArrayHasKey('studentId', $data);
|
||||||
|
self::assertEmpty($data['subjectAverages'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roles
|
||||||
|
*/
|
||||||
|
private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$user = new SecurityUser(
|
||||||
|
userId: UserId::fromString($userId),
|
||||||
|
email: 'test@classeo.local',
|
||||||
|
hashedPassword: '',
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
roles: $roles,
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->loginUser($user, 'api');
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedFixtures(): void
|
||||||
|
{
|
||||||
|
$container = static::getContainer();
|
||||||
|
/** @var Connection $connection */
|
||||||
|
$connection = $container->get(Connection::class);
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
|
||||||
|
$schoolId = '550e8400-e29b-41d4-a716-ff6655440001';
|
||||||
|
$academicYearId = '550e8400-e29b-41d4-a716-ff6655440002';
|
||||||
|
|
||||||
|
// Seed users
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, 'teacher-sg@test.local', '', 'Test', 'Teacher', '[\"ROLE_PROF\"]', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, 'student-sg@test.local', '', 'Alice', 'Durand', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::STUDENT_ID, 'tid' => self::TENANT_ID],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, 'student2-sg@test.local', '', 'Bob', 'Martin', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::STUDENT2_ID, 'tid' => self::TENANT_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed class and subjects
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, :sid, :ayid, 'Test-SG-Class', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, :sid, 'Mathématiques', 'MATH', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at)
|
||||||
|
VALUES (:id, :tid, :sid, 'Français', 'FRA', 'active', NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::SUBJECT2_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId],
|
||||||
|
);
|
||||||
|
$connection->executeStatement(
|
||||||
|
"INSERT INTO academic_periods (id, tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date)
|
||||||
|
VALUES (:id, :tid, :ayid, 'trimester', 2, 'Trimestre 2', '2026-01-01', '2026-03-31')
|
||||||
|
ON CONFLICT (id) DO NOTHING",
|
||||||
|
['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var EvaluationRepository $evalRepo */
|
||||||
|
$evalRepo = $container->get(EvaluationRepository::class);
|
||||||
|
/** @var GradeRepository $gradeRepo */
|
||||||
|
$gradeRepo = $container->get(GradeRepository::class);
|
||||||
|
/** @var AverageCalculator $calculator */
|
||||||
|
$calculator = $container->get(AverageCalculator::class);
|
||||||
|
/** @var EvaluationStatisticsRepository $statsRepo */
|
||||||
|
$statsRepo = $container->get(EvaluationStatisticsRepository::class);
|
||||||
|
|
||||||
|
// Evaluation 1: Published, Subject 1 (Maths), older date
|
||||||
|
$eval1 = Evaluation::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
classId: ClassId::fromString(self::CLASS_ID),
|
||||||
|
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
title: 'DS Mathématiques',
|
||||||
|
description: null,
|
||||||
|
evaluationDate: new DateTimeImmutable('2026-02-15'),
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
coefficient: new Coefficient(1.0),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
$eval1->publierNotes($now);
|
||||||
|
$eval1->pullDomainEvents();
|
||||||
|
$evalRepo->save($eval1);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
[self::STUDENT_ID, 16.0],
|
||||||
|
[self::STUDENT2_ID, 12.0],
|
||||||
|
] as [$studentId, $value]) {
|
||||||
|
$grade = Grade::saisir(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
evaluationId: $eval1->id,
|
||||||
|
studentId: UserId::fromString($studentId),
|
||||||
|
value: new GradeValue($value),
|
||||||
|
status: GradeStatus::GRADED,
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
$grade->pullDomainEvents();
|
||||||
|
$gradeRepo->save($grade);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats1 = $calculator->calculateClassStatistics([16.0, 12.0]);
|
||||||
|
$statsRepo->save($eval1->id, $stats1);
|
||||||
|
|
||||||
|
// Evaluation 2: Published, Subject 2 (Français), more recent date
|
||||||
|
$eval2 = Evaluation::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
classId: ClassId::fromString(self::CLASS_ID),
|
||||||
|
subjectId: SubjectId::fromString(self::SUBJECT2_ID),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
title: 'Dictée',
|
||||||
|
description: null,
|
||||||
|
evaluationDate: new DateTimeImmutable('2026-03-01'),
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
coefficient: new Coefficient(2.0),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
$eval2->publierNotes($now);
|
||||||
|
$eval2->pullDomainEvents();
|
||||||
|
$evalRepo->save($eval2);
|
||||||
|
|
||||||
|
$grade2 = Grade::saisir(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
evaluationId: $eval2->id,
|
||||||
|
studentId: UserId::fromString(self::STUDENT_ID),
|
||||||
|
value: new GradeValue(14.0),
|
||||||
|
status: GradeStatus::GRADED,
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
$grade2->pullDomainEvents();
|
||||||
|
$gradeRepo->save($grade2);
|
||||||
|
|
||||||
|
$stats2 = $calculator->calculateClassStatistics([14.0]);
|
||||||
|
$statsRepo->save($eval2->id, $stats2);
|
||||||
|
|
||||||
|
// Evaluation 3: NOT published (grades should NOT appear for student)
|
||||||
|
$eval3 = Evaluation::creer(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
classId: ClassId::fromString(self::CLASS_ID),
|
||||||
|
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
title: 'Contrôle surprise',
|
||||||
|
description: null,
|
||||||
|
evaluationDate: new DateTimeImmutable('2026-03-10'),
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
coefficient: new Coefficient(0.5),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
// NOT published - don't call publierNotes()
|
||||||
|
$eval3->pullDomainEvents();
|
||||||
|
$evalRepo->save($eval3);
|
||||||
|
$this->unpublishedEvalId = $eval3->id;
|
||||||
|
|
||||||
|
$grade3 = Grade::saisir(
|
||||||
|
tenantId: $tenantId,
|
||||||
|
evaluationId: $eval3->id,
|
||||||
|
studentId: UserId::fromString(self::STUDENT_ID),
|
||||||
|
value: new GradeValue(8.0),
|
||||||
|
status: GradeStatus::GRADED,
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: $now,
|
||||||
|
);
|
||||||
|
$grade3->pullDomainEvents();
|
||||||
|
$gradeRepo->save($grade3);
|
||||||
|
|
||||||
|
// Save student averages for /me/averages endpoint
|
||||||
|
/** @var StudentAverageRepository $avgRepo */
|
||||||
|
$avgRepo = $container->get(StudentAverageRepository::class);
|
||||||
|
$avgRepo->saveSubjectAverage(
|
||||||
|
$tenantId,
|
||||||
|
UserId::fromString(self::STUDENT_ID),
|
||||||
|
SubjectId::fromString(self::SUBJECT_ID),
|
||||||
|
self::PERIOD_ID,
|
||||||
|
16.0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
$avgRepo->saveGeneralAverage(
|
||||||
|
$tenantId,
|
||||||
|
UserId::fromString(self::STUDENT_ID),
|
||||||
|
self::PERIOD_ID,
|
||||||
|
16.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenGrades;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||||
|
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
||||||
|
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesQuery;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\ClassStatistics;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||||
|
use App\Scolarite\Domain\Policy\VisibiliteNotesPolicy;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationStatisticsRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class GetChildrenGradesHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
use ParentGradeTestHelper;
|
||||||
|
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||||
|
private const string CHILD_A_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||||
|
private const string CHILD_B_ID = '550e8400-e29b-41d4-a716-446655440051';
|
||||||
|
private const string CLASS_A_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||||
|
private const string CLASS_B_ID = '550e8400-e29b-41d4-a716-446655440021';
|
||||||
|
private const string SUBJECT_MATH_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||||
|
private const string SUBJECT_FRENCH_ID = '550e8400-e29b-41d4-a716-446655440031';
|
||||||
|
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||||
|
|
||||||
|
private InMemoryEvaluationRepository $evaluationRepository;
|
||||||
|
private InMemoryGradeRepository $gradeRepository;
|
||||||
|
private InMemoryEvaluationStatisticsRepository $statisticsRepository;
|
||||||
|
private DateTimeImmutable $now;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->evaluationRepository = new InMemoryEvaluationRepository();
|
||||||
|
$this->gradeRepository = new InMemoryGradeRepository();
|
||||||
|
$this->statisticsRepository = new InMemoryEvaluationStatisticsRepository();
|
||||||
|
$this->now = new DateTimeImmutable('2026-04-06 14:00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsEmptyWhenParentHasNoChildren(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(children: []);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame([], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsGradesForSingleChild(): void
|
||||||
|
{
|
||||||
|
$evaluation = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Contrôle chapitre 5',
|
||||||
|
publishedAt: '2026-04-04 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evaluation, self::CHILD_A_ID, 15.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame(self::CHILD_A_ID, $result[0]->childId);
|
||||||
|
self::assertSame('Emma', $result[0]->firstName);
|
||||||
|
self::assertSame('Dupont', $result[0]->lastName);
|
||||||
|
self::assertCount(1, $result[0]->grades);
|
||||||
|
self::assertSame(15.0, $result[0]->grades[0]->value);
|
||||||
|
self::assertSame('Contrôle chapitre 5', $result[0]->grades[0]->evaluationTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itFiltersOutGradesWithinDelayPeriod(): void
|
||||||
|
{
|
||||||
|
// Published 12h ago — within the 24h delay
|
||||||
|
$evaluation = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Récent',
|
||||||
|
publishedAt: '2026-04-06 02:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evaluation, self::CHILD_A_ID, 10.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame([], $result[0]->grades);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itIncludesGradesPastDelayPeriod(): void
|
||||||
|
{
|
||||||
|
$evaluation = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Ancien',
|
||||||
|
publishedAt: '2026-04-04 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evaluation, self::CHILD_A_ID, 12.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertCount(1, $result[0]->grades);
|
||||||
|
self::assertSame(12.0, $result[0]->grades[0]->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsGradesForMultipleChildren(): void
|
||||||
|
{
|
||||||
|
$evalA = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Maths 6A',
|
||||||
|
classId: self::CLASS_A_ID,
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalA, self::CHILD_A_ID, 14.0);
|
||||||
|
|
||||||
|
$evalB = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Maths 6B',
|
||||||
|
classId: self::CLASS_B_ID,
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalB, self::CHILD_B_ID, 16.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(2, $result);
|
||||||
|
self::assertSame('Emma', $result[0]->firstName);
|
||||||
|
self::assertCount(1, $result[0]->grades);
|
||||||
|
self::assertSame(14.0, $result[0]->grades[0]->value);
|
||||||
|
self::assertSame('Lucas', $result[1]->firstName);
|
||||||
|
self::assertCount(1, $result[1]->grades);
|
||||||
|
self::assertSame(16.0, $result[1]->grades[0]->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itFiltersToSpecificChild(): void
|
||||||
|
{
|
||||||
|
$evalA = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Maths 6A',
|
||||||
|
classId: self::CLASS_A_ID,
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalA, self::CHILD_A_ID, 14.0);
|
||||||
|
|
||||||
|
$evalB = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Maths 6B',
|
||||||
|
classId: self::CLASS_B_ID,
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalB, self::CHILD_B_ID, 16.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
childId: self::CHILD_B_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame('Lucas', $result[0]->firstName);
|
||||||
|
self::assertCount(1, $result[0]->grades);
|
||||||
|
self::assertSame(16.0, $result[0]->grades[0]->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsEmptyGradesWhenChildHasNoGrades(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame([], $result[0]->grades);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itIncludesClassStatistics(): void
|
||||||
|
{
|
||||||
|
$evaluation = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Stats test',
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evaluation, self::CHILD_A_ID, 14.0);
|
||||||
|
$this->statisticsRepository->save(
|
||||||
|
$evaluation->id,
|
||||||
|
new ClassStatistics(average: 12.5, min: 6.0, max: 18.0, median: 13.0, gradedCount: 25),
|
||||||
|
);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result[0]->grades);
|
||||||
|
self::assertSame(12.5, $result[0]->grades[0]->classAverage);
|
||||||
|
self::assertSame(6.0, $result[0]->grades[0]->classMin);
|
||||||
|
self::assertSame(18.0, $result[0]->grades[0]->classMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itFiltersBySubject(): void
|
||||||
|
{
|
||||||
|
$evalMath = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Maths',
|
||||||
|
subjectId: self::SUBJECT_MATH_ID,
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalMath, self::CHILD_A_ID, 15.0);
|
||||||
|
|
||||||
|
$evalFrench = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Français',
|
||||||
|
subjectId: self::SUBJECT_FRENCH_ID,
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalFrench, self::CHILD_A_ID, 12.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
subjectId: self::SUBJECT_MATH_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertCount(1, $result[0]->grades);
|
||||||
|
self::assertSame(15.0, $result[0]->grades[0]->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itFiltersOutUnpublishedEvaluations(): void
|
||||||
|
{
|
||||||
|
$unpublished = Evaluation::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
classId: ClassId::fromString(self::CLASS_A_ID),
|
||||||
|
subjectId: SubjectId::fromString(self::SUBJECT_MATH_ID),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
title: 'Non publié',
|
||||||
|
description: null,
|
||||||
|
evaluationDate: new DateTimeImmutable('2026-04-01'),
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
coefficient: new Coefficient(1.0),
|
||||||
|
now: new DateTimeImmutable('2026-03-25'),
|
||||||
|
);
|
||||||
|
$this->evaluationRepository->save($unpublished);
|
||||||
|
// Grade exists but evaluation not published → should not appear
|
||||||
|
$this->givenGrade($unpublished, self::CHILD_A_ID, 10.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame([], $result[0]->grades);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itSortsGradesByEvaluationDateDescending(): void
|
||||||
|
{
|
||||||
|
$evalOld = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Ancien',
|
||||||
|
publishedAt: '2026-04-01 10:00:00',
|
||||||
|
evaluationDate: '2026-03-20',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalOld, self::CHILD_A_ID, 10.0);
|
||||||
|
|
||||||
|
$evalNew = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Récent',
|
||||||
|
publishedAt: '2026-04-02 10:00:00',
|
||||||
|
evaluationDate: '2026-04-01',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalNew, self::CHILD_A_ID, 16.0);
|
||||||
|
|
||||||
|
$evalMid = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Milieu',
|
||||||
|
publishedAt: '2026-04-01 12:00:00',
|
||||||
|
evaluationDate: '2026-03-25',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalMid, self::CHILD_A_ID, 13.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
$titles = array_map(static fn ($g) => $g->evaluationTitle, $result[0]->grades);
|
||||||
|
self::assertSame(['Récent', 'Milieu', 'Ancien'], $titles);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUsesConfigurableDelayOf0HoursForImmediateVisibility(): void
|
||||||
|
{
|
||||||
|
$evaluation = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Immédiat',
|
||||||
|
publishedAt: '2026-04-06 13:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evaluation, self::CHILD_A_ID, 18.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
delayHours: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertCount(1, $result[0]->grades);
|
||||||
|
self::assertSame(18.0, $result[0]->grades[0]->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itIncludesAbsentAndDispensedGrades(): void
|
||||||
|
{
|
||||||
|
$evaluation = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Contrôle mixte',
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Absent grade (no value)
|
||||||
|
$absentGrade = Grade::saisir(
|
||||||
|
tenantId: $evaluation->tenantId,
|
||||||
|
evaluationId: $evaluation->id,
|
||||||
|
studentId: UserId::fromString(self::CHILD_A_ID),
|
||||||
|
value: null,
|
||||||
|
status: GradeStatus::ABSENT,
|
||||||
|
gradeScale: $evaluation->gradeScale,
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-26 10:00:00'),
|
||||||
|
);
|
||||||
|
$this->gradeRepository->save($absentGrade);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertCount(1, $result[0]->grades);
|
||||||
|
self::assertNull($result[0]->grades[0]->value);
|
||||||
|
self::assertSame('absent', $result[0]->grades[0]->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUsesConfigurableDelayOf48Hours(): void
|
||||||
|
{
|
||||||
|
$evaluation = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Lent',
|
||||||
|
publishedAt: '2026-04-05 08:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evaluation, self::CHILD_A_ID, 11.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
delayHours: 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame([], $result[0]->grades);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array{studentId: string, firstName: string, lastName: string}> $children
|
||||||
|
*/
|
||||||
|
private function createHandler(
|
||||||
|
array $children = [],
|
||||||
|
int $delayHours = 24,
|
||||||
|
): GetChildrenGradesHandler {
|
||||||
|
$parentChildrenReader = new class($children) implements ParentChildrenReader {
|
||||||
|
/** @param array<array{studentId: string, firstName: string, lastName: string}> $children */
|
||||||
|
public function __construct(private readonly array $children)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function childrenOf(string $guardianId, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
return $this->children;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$displayReader = new class implements ScheduleDisplayReader {
|
||||||
|
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
foreach ($subjectIds as $id) {
|
||||||
|
$map[$id] = ['name' => 'Mathématiques', 'color' => '#3b82f6'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
foreach ($teacherIds as $id) {
|
||||||
|
$map[$id] = 'Jean Dupont';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$clock = new class($this->now) implements Clock {
|
||||||
|
public function __construct(private readonly DateTimeImmutable $now)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$policy = new VisibiliteNotesPolicy($clock);
|
||||||
|
|
||||||
|
$delayReader = new class($delayHours) implements ParentGradeDelayReader {
|
||||||
|
public function __construct(private readonly int $hours)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delayHoursForTenant(TenantId $tenantId): int
|
||||||
|
{
|
||||||
|
return $this->hours;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new GetChildrenGradesHandler(
|
||||||
|
$parentChildrenReader,
|
||||||
|
$this->evaluationRepository,
|
||||||
|
$this->gradeRepository,
|
||||||
|
$this->statisticsRepository,
|
||||||
|
$displayReader,
|
||||||
|
$policy,
|
||||||
|
$delayReader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function evaluationRepository(): InMemoryEvaluationRepository
|
||||||
|
{
|
||||||
|
return $this->evaluationRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function gradeRepository(): InMemoryGradeRepository
|
||||||
|
{
|
||||||
|
return $this->gradeRepository;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenGrades;
|
||||||
|
|
||||||
|
use App\Scolarite\Application\Port\ParentChildrenReader;
|
||||||
|
use App\Scolarite\Application\Port\ParentGradeDelayReader;
|
||||||
|
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesHandler;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryHandler;
|
||||||
|
use App\Scolarite\Application\Query\GetChildrenGrades\GetChildrenGradesSummaryQuery;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||||
|
use App\Scolarite\Domain\Policy\VisibiliteNotesPolicy;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationStatisticsRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class GetChildrenGradesSummaryHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
use ParentGradeTestHelper;
|
||||||
|
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||||
|
private const string CHILD_A_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||||
|
private const string CHILD_B_ID = '550e8400-e29b-41d4-a716-446655440051';
|
||||||
|
private const string CLASS_A_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||||
|
private const string SUBJECT_MATH_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||||
|
private const string SUBJECT_FRENCH_ID = '550e8400-e29b-41d4-a716-446655440031';
|
||||||
|
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||||
|
|
||||||
|
private InMemoryEvaluationRepository $evaluationRepository;
|
||||||
|
private InMemoryGradeRepository $gradeRepository;
|
||||||
|
private InMemoryEvaluationStatisticsRepository $statisticsRepository;
|
||||||
|
private DateTimeImmutable $now;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->evaluationRepository = new InMemoryEvaluationRepository();
|
||||||
|
$this->gradeRepository = new InMemoryGradeRepository();
|
||||||
|
$this->statisticsRepository = new InMemoryEvaluationStatisticsRepository();
|
||||||
|
$this->now = new DateTimeImmutable('2026-04-06 14:00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsEmptyWhenParentHasNoChildren(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(children: []);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame([], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itComputesAveragesFromVisibleGrades(): void
|
||||||
|
{
|
||||||
|
// Maths: 16/20 coeff 2 + 12/20 coeff 1 → weighted = (32+12)/3 = 14.67
|
||||||
|
$eval1 = $this->givenPublishedEvaluation(
|
||||||
|
title: 'DS Maths',
|
||||||
|
subjectId: self::SUBJECT_MATH_ID,
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
coefficient: 2.0,
|
||||||
|
);
|
||||||
|
$this->givenGrade($eval1, self::CHILD_A_ID, 16.0);
|
||||||
|
|
||||||
|
$eval2 = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Contrôle Maths',
|
||||||
|
subjectId: self::SUBJECT_MATH_ID,
|
||||||
|
publishedAt: '2026-04-03 12:00:00',
|
||||||
|
coefficient: 1.0,
|
||||||
|
);
|
||||||
|
$this->givenGrade($eval2, self::CHILD_A_ID, 12.0);
|
||||||
|
|
||||||
|
// Français: 15/20 coeff 1 → 15.0
|
||||||
|
$eval3 = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Dictée',
|
||||||
|
subjectId: self::SUBJECT_FRENCH_ID,
|
||||||
|
publishedAt: '2026-04-03 14:00:00',
|
||||||
|
coefficient: 1.0,
|
||||||
|
);
|
||||||
|
$this->givenGrade($eval3, self::CHILD_A_ID, 15.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame(self::CHILD_A_ID, $result[0]->childId);
|
||||||
|
self::assertCount(2, $result[0]->subjectAverages);
|
||||||
|
|
||||||
|
// General = mean of subject averages = (14.67 + 15.0) / 2 = 14.84
|
||||||
|
self::assertNotNull($result[0]->generalAverage);
|
||||||
|
self::assertEqualsWithDelta(14.84, $result[0]->generalAverage, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRespectsDelayAndExcludesRecentGradesFromAverages(): void
|
||||||
|
{
|
||||||
|
// Visible grade (48h ago)
|
||||||
|
$evalOld = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Ancien',
|
||||||
|
subjectId: self::SUBJECT_MATH_ID,
|
||||||
|
publishedAt: '2026-04-04 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalOld, self::CHILD_A_ID, 10.0);
|
||||||
|
|
||||||
|
// Not yet visible (12h ago, within 24h delay)
|
||||||
|
$evalRecent = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Récent',
|
||||||
|
subjectId: self::SUBJECT_MATH_ID,
|
||||||
|
publishedAt: '2026-04-06 02:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalRecent, self::CHILD_A_ID, 20.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
// Only the old grade (10.0) should be in the average, not the recent one (20.0)
|
||||||
|
self::assertSame(10.0, $result[0]->generalAverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsNullAverageWhenNoVisibleGrades(): void
|
||||||
|
{
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame([], $result[0]->subjectAverages);
|
||||||
|
self::assertNull($result[0]->generalAverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsNullAverageWhenAllGradesHaveNullValue(): void
|
||||||
|
{
|
||||||
|
// Grades with null value (status ABSENT / DISPENSED) should not contribute to averages
|
||||||
|
$eval1 = $this->givenPublishedEvaluation(
|
||||||
|
title: 'DS Maths',
|
||||||
|
subjectId: self::SUBJECT_MATH_ID,
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGradeWithStatus($eval1, self::CHILD_A_ID, GradeStatus::ABSENT);
|
||||||
|
|
||||||
|
$eval2 = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Dictee',
|
||||||
|
subjectId: self::SUBJECT_FRENCH_ID,
|
||||||
|
publishedAt: '2026-04-03 12:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGradeWithStatus($eval2, self::CHILD_A_ID, GradeStatus::DISPENSED);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertSame([], $result[0]->subjectAverages);
|
||||||
|
self::assertNull($result[0]->generalAverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsAveragesForMultipleChildren(): void
|
||||||
|
{
|
||||||
|
$evalA = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Maths A',
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalA, self::CHILD_A_ID, 16.0);
|
||||||
|
|
||||||
|
$evalB = $this->givenPublishedEvaluation(
|
||||||
|
title: 'Maths B',
|
||||||
|
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||||
|
publishedAt: '2026-04-03 10:00:00',
|
||||||
|
);
|
||||||
|
$this->givenGrade($evalB, self::CHILD_B_ID, 8.0);
|
||||||
|
|
||||||
|
$handler = $this->createHandler(
|
||||||
|
children: [
|
||||||
|
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
|
||||||
|
['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $handler(new GetChildrenGradesSummaryQuery(
|
||||||
|
parentId: self::PARENT_ID,
|
||||||
|
tenantId: self::TENANT_ID,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertCount(2, $result);
|
||||||
|
self::assertSame(16.0, $result[0]->generalAverage);
|
||||||
|
self::assertSame(8.0, $result[1]->generalAverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array{studentId: string, firstName: string, lastName: string}> $children
|
||||||
|
*/
|
||||||
|
private function createHandler(
|
||||||
|
array $children = [],
|
||||||
|
): GetChildrenGradesSummaryHandler {
|
||||||
|
$parentChildrenReader = new class($children) implements ParentChildrenReader {
|
||||||
|
/** @param array<array{studentId: string, firstName: string, lastName: string}> $children */
|
||||||
|
public function __construct(private readonly array $children)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function childrenOf(string $guardianId, TenantId $tenantId): array
|
||||||
|
{
|
||||||
|
return $this->children;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$displayReader = new class implements ScheduleDisplayReader {
|
||||||
|
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
foreach ($subjectIds as $id) {
|
||||||
|
$map[$id] = ['name' => 'Mathématiques', 'color' => '#3b82f6'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$clock = new class($this->now) implements Clock {
|
||||||
|
public function __construct(private readonly DateTimeImmutable $now)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$delayReader = new class implements ParentGradeDelayReader {
|
||||||
|
public function delayHoursForTenant(TenantId $tenantId): int
|
||||||
|
{
|
||||||
|
return 24;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$gradesHandler = new GetChildrenGradesHandler(
|
||||||
|
$parentChildrenReader,
|
||||||
|
$this->evaluationRepository,
|
||||||
|
$this->gradeRepository,
|
||||||
|
$this->statisticsRepository,
|
||||||
|
$displayReader,
|
||||||
|
new VisibiliteNotesPolicy($clock),
|
||||||
|
$delayReader,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new GetChildrenGradesSummaryHandler($gradesHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function evaluationRepository(): InMemoryEvaluationRepository
|
||||||
|
{
|
||||||
|
return $this->evaluationRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function gradeRepository(): InMemoryGradeRepository
|
||||||
|
{
|
||||||
|
return $this->gradeRepository;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenGrades;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
trait ParentGradeTestHelper
|
||||||
|
{
|
||||||
|
abstract protected function evaluationRepository(): InMemoryEvaluationRepository;
|
||||||
|
|
||||||
|
abstract protected function gradeRepository(): InMemoryGradeRepository;
|
||||||
|
|
||||||
|
private function givenPublishedEvaluation(
|
||||||
|
string $title,
|
||||||
|
string $publishedAt,
|
||||||
|
string $classId = self::CLASS_A_ID,
|
||||||
|
string $subjectId = self::SUBJECT_MATH_ID,
|
||||||
|
string $evaluationDate = '2026-04-01',
|
||||||
|
float $coefficient = 1.0,
|
||||||
|
): Evaluation {
|
||||||
|
$evaluation = Evaluation::creer(
|
||||||
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||||
|
classId: ClassId::fromString($classId),
|
||||||
|
subjectId: SubjectId::fromString($subjectId),
|
||||||
|
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||||
|
title: $title,
|
||||||
|
description: null,
|
||||||
|
evaluationDate: new DateTimeImmutable($evaluationDate),
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
coefficient: new Coefficient($coefficient),
|
||||||
|
now: new DateTimeImmutable('2026-03-25 08:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$evaluation->publierNotes(new DateTimeImmutable($publishedAt));
|
||||||
|
$this->evaluationRepository()->save($evaluation);
|
||||||
|
|
||||||
|
return $evaluation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function givenGrade(
|
||||||
|
Evaluation $evaluation,
|
||||||
|
string $studentId,
|
||||||
|
float $value,
|
||||||
|
): Grade {
|
||||||
|
$grade = Grade::saisir(
|
||||||
|
tenantId: $evaluation->tenantId,
|
||||||
|
evaluationId: $evaluation->id,
|
||||||
|
studentId: UserId::fromString($studentId),
|
||||||
|
value: new GradeValue($value),
|
||||||
|
status: GradeStatus::GRADED,
|
||||||
|
gradeScale: $evaluation->gradeScale,
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-26 10:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->gradeRepository()->save($grade);
|
||||||
|
|
||||||
|
return $grade;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function givenGradeWithStatus(
|
||||||
|
Evaluation $evaluation,
|
||||||
|
string $studentId,
|
||||||
|
GradeStatus $status,
|
||||||
|
): Grade {
|
||||||
|
$grade = Grade::saisir(
|
||||||
|
tenantId: $evaluation->tenantId,
|
||||||
|
evaluationId: $evaluation->id,
|
||||||
|
studentId: UserId::fromString($studentId),
|
||||||
|
value: null,
|
||||||
|
status: $status,
|
||||||
|
gradeScale: $evaluation->gradeScale,
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-26 10:00:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->gradeRepository()->save($grade);
|
||||||
|
|
||||||
|
return $grade;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,50 @@ final class VisibiliteNotesPolicyTest extends TestCase
|
|||||||
self::assertFalse($policy->visiblePourParent($evaluation));
|
self::assertFalse($policy->visiblePourParent($evaluation));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function parentVoitImmediatementAvecDelaiZero(): void
|
||||||
|
{
|
||||||
|
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||||
|
$now = new DateTimeImmutable('2026-03-27 14:00:01'); // 1 seconde après
|
||||||
|
$policy = $this->createPolicy($now);
|
||||||
|
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||||
|
|
||||||
|
self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function parentNeVoitPasAvec48hDeDelai(): void
|
||||||
|
{
|
||||||
|
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||||
|
$now = new DateTimeImmutable('2026-03-29 13:59:59'); // 47h59 après
|
||||||
|
$policy = $this->createPolicy($now);
|
||||||
|
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||||
|
|
||||||
|
self::assertFalse($policy->visiblePourParent($evaluation, delaiHeures: 48));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function parentVoitApres48hDeDelai(): void
|
||||||
|
{
|
||||||
|
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||||
|
$now = new DateTimeImmutable('2026-03-29 14:00:00'); // exactement 48h après
|
||||||
|
$policy = $this->createPolicy($now);
|
||||||
|
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||||
|
|
||||||
|
self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: 48));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function parentVoitImmediatementAvecDelaiNegatifTraiteCommeZero(): void
|
||||||
|
{
|
||||||
|
$publishedAt = new DateTimeImmutable('2026-03-27 14:00:00');
|
||||||
|
$now = new DateTimeImmutable('2026-03-27 14:00:01');
|
||||||
|
$policy = $this->createPolicy($now);
|
||||||
|
$evaluation = $this->createPublishedEvaluation($publishedAt);
|
||||||
|
|
||||||
|
self::assertTrue($policy->visiblePourParent($evaluation, delaiHeures: -5));
|
||||||
|
}
|
||||||
|
|
||||||
private function createPolicy(DateTimeImmutable $now): VisibiliteNotesPolicy
|
private function createPolicy(DateTimeImmutable $now): VisibiliteNotesPolicy
|
||||||
{
|
{
|
||||||
$clock = new class($now) implements Clock {
|
$clock = new class($now) implements Clock {
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Application\Port\PeriodFinder;
|
||||||
|
use App\Scolarite\Application\Port\PeriodInfo;
|
||||||
|
use App\Scolarite\Infrastructure\Api\Provider\StudentMyAveragesProvider;
|
||||||
|
use App\Scolarite\Infrastructure\Api\Resource\StudentMyAveragesResource;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryStudentAverageRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||||
|
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
|
||||||
|
final class StudentMyAveragesProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
private const string STUDENT_UUID = '22222222-2222-2222-2222-222222222222';
|
||||||
|
private const string SUBJECT_UUID = '66666666-6666-6666-6666-666666666666';
|
||||||
|
private const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
private InMemoryStudentAverageRepository $averageRepository;
|
||||||
|
private TenantContext $tenantContext;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->averageRepository = new InMemoryStudentAverageRepository();
|
||||||
|
$this->tenantContext = new TenantContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Auth & Tenant Guards
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRejects401WhenNoTenant(): void
|
||||||
|
{
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->studentUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(UnauthorizedHttpException::class);
|
||||||
|
$provider->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRejects401WhenNoUser(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: null,
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(UnauthorizedHttpException::class);
|
||||||
|
$provider->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRejects403ForTeacher(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->teacherUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
|
$provider->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRejects403ForParent(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->parentUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
|
$provider->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itRejects403ForAdmin(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->adminUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
|
$provider->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Period auto-detection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAutoDetectsCurrentPeriodWhenNoPeriodIdInFilters(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$this->seedAverages();
|
||||||
|
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->studentUser(),
|
||||||
|
periodForDate: new PeriodInfo(self::PERIOD_ID, new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-03-31')),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $provider->provide(new Get());
|
||||||
|
|
||||||
|
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||||
|
self::assertSame(self::PERIOD_ID, $result->periodId);
|
||||||
|
self::assertNotEmpty($result->subjectAverages);
|
||||||
|
self::assertSame(16.0, $result->generalAverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsEmptyResourceWhenNoPeriodDetected(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$this->seedAverages();
|
||||||
|
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->studentUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $provider->provide(new Get());
|
||||||
|
|
||||||
|
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||||
|
self::assertNull($result->periodId);
|
||||||
|
self::assertEmpty($result->subjectAverages);
|
||||||
|
self::assertNull($result->generalAverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Explicit periodId from filters
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itUsesExplicitPeriodIdFromFilters(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$this->seedAverages();
|
||||||
|
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->studentUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $provider->provide(new Get(), [], [
|
||||||
|
'filters' => ['periodId' => self::PERIOD_ID],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||||
|
self::assertSame(self::PERIOD_ID, $result->periodId);
|
||||||
|
self::assertNotEmpty($result->subjectAverages);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsEmptySubjectAveragesForUnknownPeriod(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$this->seedAverages();
|
||||||
|
|
||||||
|
$unknownPeriod = '99999999-9999-9999-9999-999999999999';
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->studentUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $provider->provide(new Get(), [], [
|
||||||
|
'filters' => ['periodId' => $unknownPeriod],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||||
|
self::assertSame($unknownPeriod, $result->periodId);
|
||||||
|
self::assertEmpty($result->subjectAverages);
|
||||||
|
self::assertNull($result->generalAverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Response shape
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsStudentIdInResource(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->studentUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $provider->provide(new Get(), [], [
|
||||||
|
'filters' => ['periodId' => self::PERIOD_ID],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||||
|
self::assertSame(self::STUDENT_UUID, $result->studentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsSubjectAverageShape(): void
|
||||||
|
{
|
||||||
|
$this->setTenant();
|
||||||
|
$this->seedAverages();
|
||||||
|
|
||||||
|
$provider = $this->createProvider(
|
||||||
|
user: $this->studentUser(),
|
||||||
|
periodForDate: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $provider->provide(new Get(), [], [
|
||||||
|
'filters' => ['periodId' => self::PERIOD_ID],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||||
|
self::assertCount(1, $result->subjectAverages);
|
||||||
|
|
||||||
|
$avg = $result->subjectAverages[0];
|
||||||
|
self::assertSame(self::SUBJECT_UUID, $avg['subjectId']);
|
||||||
|
self::assertSame(16.0, $avg['average']);
|
||||||
|
self::assertSame(1, $avg['gradeCount']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private function setTenant(): void
|
||||||
|
{
|
||||||
|
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||||
|
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||||
|
subdomain: 'ecole-alpha',
|
||||||
|
databaseUrl: 'sqlite:///:memory:',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedAverages(): void
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_UUID);
|
||||||
|
$studentId = UserId::fromString(self::STUDENT_UUID);
|
||||||
|
|
||||||
|
$this->averageRepository->saveSubjectAverage(
|
||||||
|
$tenantId,
|
||||||
|
$studentId,
|
||||||
|
SubjectId::fromString(self::SUBJECT_UUID),
|
||||||
|
self::PERIOD_ID,
|
||||||
|
16.0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->averageRepository->saveGeneralAverage(
|
||||||
|
$tenantId,
|
||||||
|
$studentId,
|
||||||
|
self::PERIOD_ID,
|
||||||
|
16.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createProvider(?SecurityUser $user, ?PeriodInfo $periodForDate): StudentMyAveragesProvider
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn($user);
|
||||||
|
|
||||||
|
$periodFinder = new class($periodForDate) implements PeriodFinder {
|
||||||
|
public function __construct(private readonly ?PeriodInfo $info)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||||
|
{
|
||||||
|
return $this->info;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new StudentMyAveragesProvider(
|
||||||
|
$this->averageRepository,
|
||||||
|
$periodFinder,
|
||||||
|
$this->tenantContext,
|
||||||
|
$security,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function studentUser(): SecurityUser
|
||||||
|
{
|
||||||
|
return new SecurityUser(
|
||||||
|
userId: UserId::fromString(self::STUDENT_UUID),
|
||||||
|
email: 'student@test.local',
|
||||||
|
hashedPassword: '',
|
||||||
|
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||||
|
roles: ['ROLE_ELEVE'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function teacherUser(): SecurityUser
|
||||||
|
{
|
||||||
|
return new SecurityUser(
|
||||||
|
userId: UserId::fromString('44444444-4444-4444-4444-444444444444'),
|
||||||
|
email: 'teacher@test.local',
|
||||||
|
hashedPassword: '',
|
||||||
|
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||||
|
roles: ['ROLE_PROF'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parentUser(): SecurityUser
|
||||||
|
{
|
||||||
|
return new SecurityUser(
|
||||||
|
userId: UserId::fromString('88888888-8888-8888-8888-888888888888'),
|
||||||
|
email: 'parent@test.local',
|
||||||
|
hashedPassword: '',
|
||||||
|
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||||
|
roles: ['ROLE_PARENT'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function adminUser(): SecurityUser
|
||||||
|
{
|
||||||
|
return new SecurityUser(
|
||||||
|
userId: UserId::fromString('33333333-3333-3333-3333-333333333333'),
|
||||||
|
email: 'admin@test.local',
|
||||||
|
hashedPassword: '',
|
||||||
|
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||||
|
roles: ['ROLE_ADMIN'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
|
||||||
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\Grade;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeStatus;
|
||||||
|
use App\Scolarite\Domain\Model\Grade\GradeValue;
|
||||||
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryGradeRepository;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class InMemoryGradeRepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
private const string EVALUATION_A_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||||
|
private const string EVALUATION_B_ID = '550e8400-e29b-41d4-a716-446655440011';
|
||||||
|
private const string EVALUATION_C_ID = '550e8400-e29b-41d4-a716-446655440012';
|
||||||
|
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||||
|
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||||
|
|
||||||
|
private InMemoryGradeRepository $repository;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->repository = new InMemoryGradeRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByEvaluationsReturnsGroupedResults(): void
|
||||||
|
{
|
||||||
|
$gradeA1 = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0);
|
||||||
|
$gradeA2 = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 16.0);
|
||||||
|
$gradeB1 = $this->createGrade(self::EVALUATION_B_ID, self::TENANT_ID, 12.0);
|
||||||
|
|
||||||
|
$this->repository->save($gradeA1);
|
||||||
|
$this->repository->save($gradeA2);
|
||||||
|
$this->repository->save($gradeB1);
|
||||||
|
|
||||||
|
$result = $this->repository->findByEvaluations(
|
||||||
|
[
|
||||||
|
EvaluationId::fromString(self::EVALUATION_A_ID),
|
||||||
|
EvaluationId::fromString(self::EVALUATION_B_ID),
|
||||||
|
],
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(2, $result);
|
||||||
|
self::assertArrayHasKey(self::EVALUATION_A_ID, $result);
|
||||||
|
self::assertArrayHasKey(self::EVALUATION_B_ID, $result);
|
||||||
|
self::assertCount(2, $result[self::EVALUATION_A_ID]);
|
||||||
|
self::assertCount(1, $result[self::EVALUATION_B_ID]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByEvaluationsReturnsEmptyArrayWhenNoEvaluationIds(): void
|
||||||
|
{
|
||||||
|
$grade = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0);
|
||||||
|
$this->repository->save($grade);
|
||||||
|
|
||||||
|
$result = $this->repository->findByEvaluations(
|
||||||
|
[],
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByEvaluationsExcludesGradesFromDifferentTenant(): void
|
||||||
|
{
|
||||||
|
$gradeOwnTenant = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0);
|
||||||
|
$gradeOtherTenant = $this->createGrade(self::EVALUATION_A_ID, self::OTHER_TENANT_ID, 18.0);
|
||||||
|
|
||||||
|
$this->repository->save($gradeOwnTenant);
|
||||||
|
$this->repository->save($gradeOtherTenant);
|
||||||
|
|
||||||
|
$result = $this->repository->findByEvaluations(
|
||||||
|
[EvaluationId::fromString(self::EVALUATION_A_ID)],
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertArrayHasKey(self::EVALUATION_A_ID, $result);
|
||||||
|
self::assertCount(1, $result[self::EVALUATION_A_ID]);
|
||||||
|
self::assertSame($gradeOwnTenant, $result[self::EVALUATION_A_ID][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function findByEvaluationsExcludesGradesForUnrequestedEvaluations(): void
|
||||||
|
{
|
||||||
|
$gradeA = $this->createGrade(self::EVALUATION_A_ID, self::TENANT_ID, 14.0);
|
||||||
|
$gradeC = $this->createGrade(self::EVALUATION_C_ID, self::TENANT_ID, 10.0);
|
||||||
|
|
||||||
|
$this->repository->save($gradeA);
|
||||||
|
$this->repository->save($gradeC);
|
||||||
|
|
||||||
|
$result = $this->repository->findByEvaluations(
|
||||||
|
[EvaluationId::fromString(self::EVALUATION_A_ID)],
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertArrayHasKey(self::EVALUATION_A_ID, $result);
|
||||||
|
self::assertArrayNotHasKey(self::EVALUATION_C_ID, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createGrade(
|
||||||
|
string $evaluationId,
|
||||||
|
string $tenantId,
|
||||||
|
float $value,
|
||||||
|
): Grade {
|
||||||
|
return Grade::saisir(
|
||||||
|
tenantId: TenantId::fromString($tenantId),
|
||||||
|
evaluationId: EvaluationId::fromString($evaluationId),
|
||||||
|
studentId: UserId::fromString(self::STUDENT_ID),
|
||||||
|
value: new GradeValue($value),
|
||||||
|
status: GradeStatus::GRADED,
|
||||||
|
gradeScale: new GradeScale(20),
|
||||||
|
createdBy: UserId::fromString(self::TEACHER_ID),
|
||||||
|
now: new DateTimeImmutable('2026-03-26 10:00:00'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Scolarite\Infrastructure\Security\GradeParentVoter;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
final class GradeParentVoterTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
|
private GradeParentVoter $voter;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->voter = new GradeParentVoter();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itAbstainsForUnrelatedAttributes(): void
|
||||||
|
{
|
||||||
|
$token = $this->tokenWithSecurityUser(Role::PARENT->value);
|
||||||
|
|
||||||
|
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
|
||||||
|
|
||||||
|
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDeniesAccessToUnauthenticatedUsers(): void
|
||||||
|
{
|
||||||
|
$token = $this->createMock(TokenInterface::class);
|
||||||
|
$token->method('getUser')->willReturn(null);
|
||||||
|
|
||||||
|
$result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]);
|
||||||
|
|
||||||
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itDeniesAccessToNonSecurityUserInstances(): void
|
||||||
|
{
|
||||||
|
$user = $this->createMock(UserInterface::class);
|
||||||
|
$user->method('getRoles')->willReturn([Role::PARENT->value]);
|
||||||
|
|
||||||
|
$token = $this->createMock(TokenInterface::class);
|
||||||
|
$token->method('getUser')->willReturn($user);
|
||||||
|
|
||||||
|
$result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]);
|
||||||
|
|
||||||
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itGrantsViewToParent(): void
|
||||||
|
{
|
||||||
|
$token = $this->tokenWithSecurityUser(Role::PARENT->value);
|
||||||
|
|
||||||
|
$result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]);
|
||||||
|
|
||||||
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[DataProvider('nonParentRolesProvider')]
|
||||||
|
public function itDeniesViewToNonParentRoles(string $role): void
|
||||||
|
{
|
||||||
|
$token = $this->tokenWithSecurityUser($role);
|
||||||
|
|
||||||
|
$result = $this->voter->vote($token, null, [GradeParentVoter::VIEW]);
|
||||||
|
|
||||||
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return iterable<string, array{string}> */
|
||||||
|
public static function nonParentRolesProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||||
|
yield 'ADMIN' => [Role::ADMIN->value];
|
||||||
|
yield 'PROF' => [Role::PROF->value];
|
||||||
|
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||||
|
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||||
|
yield 'ELEVE' => [Role::ELEVE->value];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tokenWithSecurityUser(
|
||||||
|
string $role,
|
||||||
|
string $userId = '550e8400-e29b-41d4-a716-446655440001',
|
||||||
|
): TokenInterface {
|
||||||
|
$securityUser = new SecurityUser(
|
||||||
|
UserId::fromString($userId),
|
||||||
|
'test@example.com',
|
||||||
|
'hashed_password',
|
||||||
|
TenantId::fromString(self::TENANT_ID),
|
||||||
|
[$role],
|
||||||
|
);
|
||||||
|
|
||||||
|
$token = $this->createMock(TokenInterface::class);
|
||||||
|
$token->method('getUser')->willReturn($securityUser);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Scolarite\Infrastructure\Service\DatabaseParentGradeDelayReader;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class DatabaseParentGradeDelayReaderTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsConfiguredDelayWhenRowExists(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection
|
||||||
|
->method('fetchOne')
|
||||||
|
->willReturn('48');
|
||||||
|
|
||||||
|
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||||
|
|
||||||
|
$result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID));
|
||||||
|
|
||||||
|
self::assertSame(48, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsDefault24HoursWhenNoRowFound(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection
|
||||||
|
->method('fetchOne')
|
||||||
|
->willReturn(false);
|
||||||
|
|
||||||
|
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||||
|
|
||||||
|
$result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID));
|
||||||
|
|
||||||
|
self::assertSame(24, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itReturnsDefault24HoursWhenResultIsNonNumeric(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection
|
||||||
|
->method('fetchOne')
|
||||||
|
->willReturn('not-a-number');
|
||||||
|
|
||||||
|
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||||
|
|
||||||
|
$result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID));
|
||||||
|
|
||||||
|
self::assertSame(24, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itPassesTenantIdToQuery(): void
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||||
|
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection
|
||||||
|
->expects(self::once())
|
||||||
|
->method('fetchOne')
|
||||||
|
->with(
|
||||||
|
self::stringContains('tenant_id'),
|
||||||
|
self::equalTo(['tenant_id' => (string) $tenantId]),
|
||||||
|
)
|
||||||
|
->willReturn('12');
|
||||||
|
|
||||||
|
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||||
|
|
||||||
|
$result = $reader->delayHoursForTenant($tenantId);
|
||||||
|
|
||||||
|
self::assertSame(12, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function itCastsNumericStringToInt(): void
|
||||||
|
{
|
||||||
|
$connection = $this->createMock(Connection::class);
|
||||||
|
$connection
|
||||||
|
->method('fetchOne')
|
||||||
|
->willReturn('0');
|
||||||
|
|
||||||
|
$reader = new DatabaseParentGradeDelayReader($connection);
|
||||||
|
|
||||||
|
$result = $reader->delayHoursForTenant(TenantId::fromString(self::TENANT_ID));
|
||||||
|
|
||||||
|
self::assertSame(0, $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ test.describe('Admin Search & Pagination (Story 2.8b)', () => {
|
|||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// URL should contain search param
|
// URL should contain search param
|
||||||
await expect(page).toHaveURL(/[?&]search=test-search/);
|
await expect(page).toHaveURL(/[?&]search=test-search/, { timeout: 15000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search term from URL is restored on page load', async ({ page }) => {
|
test('search term from URL is restored on page load', async ({ page }) => {
|
||||||
|
|||||||
@@ -165,10 +165,14 @@ test.describe('Appreciations (Story 6.4)', () => {
|
|||||||
await page.locator('.btn-appreciation').first().click();
|
await page.locator('.btn-appreciation').first().click();
|
||||||
await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 });
|
await expect(page.locator('.appreciation-textarea')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Type appreciation text
|
// Type appreciation text (pressSequentially to reliably trigger Svelte bind + oninput)
|
||||||
await page.locator('.appreciation-textarea').fill('Très bon travail ce trimestre');
|
const textarea = page.locator('.appreciation-textarea');
|
||||||
|
await textarea.click();
|
||||||
|
await expect(textarea).toBeFocused();
|
||||||
|
await textarea.pressSequentially('Bon travail', { delay: 50 });
|
||||||
|
await expect(textarea).not.toHaveValue('');
|
||||||
|
|
||||||
// Wait for auto-save by checking the UI status indicator
|
// Wait for auto-save by checking the UI status indicator (1s debounce + network)
|
||||||
await expect(page.getByText('Sauvegardé')).toBeVisible({ timeout: 15000 });
|
await expect(page.getByText('Sauvegardé')).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ test.describe('Branding Visual Customization', () => {
|
|||||||
await responsePromise;
|
await responsePromise;
|
||||||
|
|
||||||
// Success message
|
// Success message
|
||||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 15000 });
|
||||||
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
|
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
|
||||||
|
|
||||||
// CSS variables applied to document root
|
// CSS variables applied to document root
|
||||||
|
|||||||
@@ -321,8 +321,8 @@ test.describe('Calendar Management (Story 2.11)', () => {
|
|||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Verify specific imported holiday entries are displayed
|
// Verify specific imported holiday entries are displayed
|
||||||
await expect(page.getByText('Toussaint', { exact: true })).toBeVisible();
|
await expect(page.getByText('Toussaint', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||||
await expect(page.getByText('Noël', { exact: true })).toBeVisible();
|
await expect(page.getByText('Noël', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// Verify entry cards exist (not just the heading)
|
// Verify entry cards exist (not just the heading)
|
||||||
const holidaySection = page.locator('.entry-section').filter({
|
const holidaySection = page.locator('.entry-section').filter({
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ test.describe('Admin Class Detail Page [P1]', () => {
|
|||||||
await page.getByRole('button', { name: /créer la classe/i }).click();
|
await page.getByRole('button', { name: /créer la classe/i }).click();
|
||||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Search for the class (it may be on page 2 due to pagination)
|
||||||
|
const searchInput2 = page.locator('input[type="search"]');
|
||||||
|
await searchInput2.fill(className);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Navigate to edit page
|
// Navigate to edit page
|
||||||
const classCard = page.locator('.class-card', { hasText: className });
|
const classCard = page.locator('.class-card', { hasText: className });
|
||||||
await classCard.getByRole('button', { name: /modifier/i }).click();
|
await classCard.getByRole('button', { name: /modifier/i }).click();
|
||||||
|
|||||||
@@ -237,11 +237,11 @@ test.describe('Competencies Mode (Story 6.5)', () => {
|
|||||||
|
|
||||||
// Click to set level
|
// Click to set level
|
||||||
await levelBtn.click();
|
await levelBtn.click();
|
||||||
await expect(levelBtn).toHaveClass(/active/, { timeout: 5000 });
|
await expect(levelBtn).toHaveClass(/active/, { timeout: 15000 });
|
||||||
|
|
||||||
// Click same button immediately to toggle off (no wait for save)
|
// Click same button immediately to toggle off (no wait for save)
|
||||||
await levelBtn.click();
|
await levelBtn.click();
|
||||||
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 5000 });
|
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 15000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -106,10 +106,10 @@ test.describe('Dashboard Responsive Navigation', () => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||||
await expect(drawer).toBeVisible();
|
await expect(drawer).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const logoutButton = drawer.locator('.mobile-logout');
|
const logoutButton = drawer.locator('.mobile-logout');
|
||||||
await expect(logoutButton).toBeVisible();
|
await expect(logoutButton).toBeVisible({ timeout: 10000 });
|
||||||
await expect(logoutButton).toHaveText(/déconnexion/i);
|
await expect(logoutButton).toHaveText(/déconnexion/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
|||||||
|
|
||||||
const firstInput = page.locator('.grade-input').first();
|
const firstInput = page.locator('.grade-input').first();
|
||||||
await firstInput.clear();
|
await firstInput.clear();
|
||||||
await firstInput.pressSequentially('/abs');
|
await firstInput.fill('/abs');
|
||||||
|
|
||||||
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('.status-absent').first()).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
@@ -195,7 +195,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
|||||||
|
|
||||||
const firstInput = page.locator('.grade-input').first();
|
const firstInput = page.locator('.grade-input').first();
|
||||||
await firstInput.clear();
|
await firstInput.clear();
|
||||||
await firstInput.pressSequentially('/disp');
|
await firstInput.fill('/disp');
|
||||||
|
|
||||||
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('.status-dispensed').first()).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
|
|||||||
241
frontend/e2e/parent-grades.spec.ts
Normal file
241
frontend/e2e/parent-grades.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
execWithRetry,
|
||||||
|
runSql,
|
||||||
|
clearCache,
|
||||||
|
resolveDeterministicIds,
|
||||||
|
createTestUser,
|
||||||
|
composeFile
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const PARENT_EMAIL = 'e2e-parent-grades@example.com';
|
||||||
|
const PARENT_PASSWORD = 'ParentGrades123';
|
||||||
|
const TEACHER_EMAIL = 'e2e-pg-teacher@example.com';
|
||||||
|
const TEACHER_PASSWORD = 'TeacherPG123';
|
||||||
|
const STUDENT_EMAIL = 'e2e-pg-student@example.com';
|
||||||
|
const STUDENT_PASSWORD = 'StudentPG123';
|
||||||
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
|
||||||
|
let parentId: string;
|
||||||
|
let studentId: string;
|
||||||
|
let classId: string;
|
||||||
|
let subjectId: string;
|
||||||
|
let evalId: string;
|
||||||
|
let periodId: string;
|
||||||
|
|
||||||
|
function uuid5(name: string): string {
|
||||||
|
return execWithRetry(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||||
|
`require "/app/vendor/autoload.php"; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","${name}")->toString();` +
|
||||||
|
`' 2>&1`
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAsParent(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(PARENT_EMAIL);
|
||||||
|
await page.locator('#password').fill(PARENT_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||||
|
page.getByRole('button', { name: /se connecter/i }).click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Parent Grade Consultation (Story 6.7)', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
// Create users
|
||||||
|
createTestUser(
|
||||||
|
'ecole-alpha',
|
||||||
|
PARENT_EMAIL,
|
||||||
|
PARENT_PASSWORD,
|
||||||
|
'ROLE_PARENT --firstName=Marie --lastName=Dupont'
|
||||||
|
);
|
||||||
|
createTestUser(
|
||||||
|
'ecole-alpha',
|
||||||
|
TEACHER_EMAIL,
|
||||||
|
TEACHER_PASSWORD,
|
||||||
|
'ROLE_PROF --firstName=Jean --lastName=Martin'
|
||||||
|
);
|
||||||
|
createTestUser(
|
||||||
|
'ecole-alpha',
|
||||||
|
STUDENT_EMAIL,
|
||||||
|
STUDENT_PASSWORD,
|
||||||
|
'ROLE_ELEVE --firstName=Emma --lastName=Dupont'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||||
|
|
||||||
|
// Resolve user IDs
|
||||||
|
const parentOutput = execWithRetry(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${PARENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1`
|
||||||
|
);
|
||||||
|
parentId = parentOutput.match(
|
||||||
|
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
|
||||||
|
)![0]!;
|
||||||
|
|
||||||
|
const studentOutput = execWithRetry(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${STUDENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1`
|
||||||
|
);
|
||||||
|
studentId = studentOutput.match(
|
||||||
|
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
|
||||||
|
)![0]!;
|
||||||
|
|
||||||
|
// Create deterministic IDs
|
||||||
|
classId = uuid5(`pg-class-${TENANT_ID}`);
|
||||||
|
subjectId = uuid5(`pg-subject-${TENANT_ID}`);
|
||||||
|
evalId = uuid5(`pg-eval-${TENANT_ID}`);
|
||||||
|
|
||||||
|
// Create class
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||||
|
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-PG-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create subject
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||||
|
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-PG-Mathématiques', 'E2EPGMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assign student to class
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link parent to student
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${parentId}', 'mère', NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create teacher assignment
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create published evaluation (published 48h ago so delay is passed)
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
|
||||||
|
`SELECT '${evalId}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'DS Maths Parent', '2026-03-01', 20, 2.0, 'published', NOW() - INTERVAL '48 hours', NOW() - INTERVAL '48 hours', NOW() ` +
|
||||||
|
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT (id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert grade for student
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId}', '${studentId}', 15.5, 'graded', u.id, NOW(), NOW(), 'Bon travail' ` +
|
||||||
|
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT (evaluation_id, student_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert class statistics
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` +
|
||||||
|
`VALUES ('${evalId}', 13.5, 7.0, 18.0, 13.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find academic period
|
||||||
|
const periodOutput = execWithRetry(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM academic_periods WHERE tenant_id='${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1" 2>&1`
|
||||||
|
);
|
||||||
|
const periodMatch = periodOutput.match(
|
||||||
|
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
|
||||||
|
);
|
||||||
|
periodId = periodMatch ? periodMatch[0]! : uuid5(`pg-period-${TENANT_ID}`);
|
||||||
|
|
||||||
|
// Insert student averages
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${subjectId}', '${periodId}', 15.5, 1, NOW()) ` +
|
||||||
|
`ON CONFLICT (student_id, subject_id, period_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${periodId}', 15.5, NOW()) ` +
|
||||||
|
`ON CONFLICT (student_id, period_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC2: Parent can see child's grades and averages
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('AC2: parent navigates to grades page', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Notes des enfants' })).toBeVisible({
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC2: parent sees child selector', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
|
||||||
|
|
||||||
|
// Child selector should be visible with the child's name
|
||||||
|
await expect(page.getByText('Emma Dupont')).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AC2: parent sees child's grade card", async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
|
||||||
|
|
||||||
|
// Wait for grades to load (single child auto-selected)
|
||||||
|
await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.locator('.grade-value', { hasText: '15.5/20' }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC4: Subject detail with class statistics
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('AC4: parent sees class statistics on grade card', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
|
||||||
|
|
||||||
|
await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.getByText(/Moy\. classe/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC4: parent opens subject detail modal', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/parent-grades`);
|
||||||
|
|
||||||
|
// Wait for grade cards to appear
|
||||||
|
await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Click on a grade card to open subject detail
|
||||||
|
await page.getByRole('button', { name: /DS Maths Parent/ }).click();
|
||||||
|
|
||||||
|
// Modal should appear with subject name and grade details
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(modal.locator('.grade-value', { hasText: '15.5/20' }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Navigation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('navigation: parent sees Notes link in nav', async ({ page }) => {
|
||||||
|
await loginAsParent(page);
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: 'Notes' })).toBeVisible({ timeout: 15000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -397,12 +397,17 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
|||||||
timeout: 20000
|
timeout: 20000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switch to week view
|
// Switch to week view (retry click if view doesn't switch — Svelte hydration race)
|
||||||
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
||||||
|
await expect(weekButton).toBeVisible({ timeout: 10000 });
|
||||||
await weekButton.click();
|
await weekButton.click();
|
||||||
|
const lunHeader = page.getByText('Lun', { exact: true });
|
||||||
// Week headers should show
|
try {
|
||||||
await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 });
|
await expect(lunHeader).toBeVisible({ timeout: 10000 });
|
||||||
|
} catch {
|
||||||
|
await weekButton.click();
|
||||||
|
await expect(lunHeader).toBeVisible({ timeout: 30000 });
|
||||||
|
}
|
||||||
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
|
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -422,7 +427,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
|
|||||||
// Navigate forward and wait for the new day to load
|
// Navigate forward and wait for the new day to load
|
||||||
await page.getByLabel('Suivant').click();
|
await page.getByLabel('Suivant').click();
|
||||||
// Wait for the day title to change, confirming navigation completed
|
// Wait for the day title to change, confirming navigation completed
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
// Navigate back to the original day
|
// Navigate back to the original day
|
||||||
await page.getByLabel('Précédent').click();
|
await page.getByLabel('Précédent').click();
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ test.describe('Sessions Management', () => {
|
|||||||
await page.goto(getTenantUrl('/settings/sessions'));
|
await page.goto(getTenantUrl('/settings/sessions'));
|
||||||
|
|
||||||
// Should redirect to login
|
// Should redirect to login
|
||||||
await expect(page).toHaveURL(/login/, { timeout: 5000 });
|
await expect(page).toHaveURL(/login/, { timeout: 30000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ test.describe('Sessions Management', () => {
|
|||||||
await page.locator('.back-button').click();
|
await page.locator('.back-button').click();
|
||||||
|
|
||||||
// Wait for navigation - URL should no longer contain /sessions
|
// Wait for navigation - URL should no longer contain /sessions
|
||||||
await expect(page).not.toHaveURL(/\/sessions/);
|
await expect(page).not.toHaveURL(/\/sessions/, { timeout: 15000 });
|
||||||
|
|
||||||
// Verify we're on the main settings page
|
// Verify we're on the main settings page
|
||||||
await expect(page.getByText(/paramètres|mes sessions/i).first()).toBeVisible();
|
await expect(page.getByText(/paramètres|mes sessions/i).first()).toBeVisible();
|
||||||
|
|||||||
@@ -116,10 +116,19 @@ test.describe('Settings Page [P1]', () => {
|
|||||||
|
|
||||||
await page.goto(getTenantUrl('/settings'));
|
await page.goto(getTenantUrl('/settings'));
|
||||||
|
|
||||||
// Click on the Sessions card (it's a button with heading text)
|
// Wait for the settings page to be fully interactive before clicking
|
||||||
await page.getByText(/mes sessions/i).click();
|
const sessionsCard = page.getByText(/mes sessions/i);
|
||||||
|
await expect(sessionsCard).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/settings\/sessions/);
|
// Click and retry once if navigation doesn't happen (Svelte hydration race)
|
||||||
|
await sessionsCard.click();
|
||||||
|
try {
|
||||||
|
await page.waitForURL(/\/settings\/sessions/, { timeout: 10000 });
|
||||||
|
} catch {
|
||||||
|
// Retry click in case hydration wasn't complete
|
||||||
|
await sessionsCard.click();
|
||||||
|
await page.waitForURL(/\/settings\/sessions/, { timeout: 30000 });
|
||||||
|
}
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: /mes sessions/i })
|
page.getByRole('heading', { name: /mes sessions/i })
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
581
frontend/e2e/student-grades.spec.ts
Normal file
581
frontend/e2e/student-grades.spec.ts
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
|
||||||
|
|
||||||
|
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||||
|
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||||
|
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||||
|
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||||
|
|
||||||
|
const STUDENT_EMAIL = 'e2e-student-grades@example.com';
|
||||||
|
const STUDENT_PASSWORD = 'StudentGrades123';
|
||||||
|
const TEACHER_EMAIL = 'e2e-sg-teacher@example.com';
|
||||||
|
const TEACHER_PASSWORD = 'TeacherGrades123';
|
||||||
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||||
|
|
||||||
|
let classId: string;
|
||||||
|
let subjectId: string;
|
||||||
|
let subject2Id: string;
|
||||||
|
let studentId: string;
|
||||||
|
let evalId1: string;
|
||||||
|
let evalId2: string;
|
||||||
|
let periodId: string;
|
||||||
|
|
||||||
|
function uuid5(name: string): string {
|
||||||
|
return execWithRetry(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||||
|
`require "/app/vendor/autoload.php"; ` +
|
||||||
|
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","${name}")->toString();` +
|
||||||
|
`' 2>&1`
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAsStudent(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.locator('#email').fill(STUDENT_EMAIL);
|
||||||
|
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||||
|
page.getByRole('button', { name: /se connecter/i }).click()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Student Grade Consultation (Story 6.6)', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
// Create users
|
||||||
|
createTestUser('ecole-alpha', STUDENT_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Émilie --lastName=Dubois');
|
||||||
|
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
||||||
|
|
||||||
|
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||||
|
|
||||||
|
// Resolve student ID
|
||||||
|
const idOutput = execWithRetry(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${STUDENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1`
|
||||||
|
);
|
||||||
|
const idMatch = idOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
|
||||||
|
studentId = idMatch![0]!;
|
||||||
|
|
||||||
|
// Create deterministic IDs
|
||||||
|
classId = uuid5(`sg-class-${TENANT_ID}`);
|
||||||
|
subjectId = uuid5(`sg-subject1-${TENANT_ID}`);
|
||||||
|
subject2Id = uuid5(`sg-subject2-${TENANT_ID}`);
|
||||||
|
evalId1 = uuid5(`sg-eval1-${TENANT_ID}`);
|
||||||
|
evalId2 = uuid5(`sg-eval2-${TENANT_ID}`);
|
||||||
|
periodId = uuid5(`sg-period-${TENANT_ID}`);
|
||||||
|
|
||||||
|
// Create class
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||||
|
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-SG-4A', '4ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create subjects
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||||
|
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-SG-Mathématiques', 'E2ESGMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||||
|
`VALUES ('${subject2Id}', '${TENANT_ID}', '${schoolId}', 'E2E-SG-Français', 'E2ESGFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assign student to class
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create teacher assignment
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subject2Id}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create published evaluation 1 (Maths - older)
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
|
||||||
|
`SELECT '${evalId1}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'DS Mathématiques', '2026-03-01', 20, 2.0, 'published', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT (id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create published evaluation 2 (Français - more recent)
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
|
||||||
|
`SELECT '${evalId2}', '${TENANT_ID}', '${classId}', '${subject2Id}', u.id, 'Dictée', '2026-03-15', 20, 1.0, 'published', NOW(), NOW(), NOW() ` +
|
||||||
|
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT (id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert grades
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId1}', '${studentId}', 16.5, 'graded', u.id, NOW(), NOW(), 'Très bon travail' ` +
|
||||||
|
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT (evaluation_id, student_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` +
|
||||||
|
`SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId2}', '${studentId}', 14.0, 'graded', u.id, NOW(), NOW() ` +
|
||||||
|
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
|
||||||
|
`ON CONFLICT (evaluation_id, student_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert class statistics
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` +
|
||||||
|
`VALUES ('${evalId1}', 14.2, 8.0, 18.5, 14.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` +
|
||||||
|
`VALUES ('${evalId2}', 12.8, 6.0, 17.0, 13.0, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the academic period covering the current date (needed for /me/averages auto-detection)
|
||||||
|
const periodOutput = execWithRetry(
|
||||||
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM academic_periods WHERE tenant_id='${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1" 2>&1`
|
||||||
|
);
|
||||||
|
const periodMatch = periodOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
|
||||||
|
periodId = periodMatch ? periodMatch[0]! : uuid5(`sg-period-${TENANT_ID}`);
|
||||||
|
|
||||||
|
// Insert student averages (subject + general)
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${subjectId}', '${periodId}', 16.5, 1, NOW()) ` +
|
||||||
|
`ON CONFLICT (student_id, subject_id, period_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${subject2Id}', '${periodId}', 14.0, 1, NOW()) ` +
|
||||||
|
`ON CONFLICT (student_id, subject_id, period_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
runSql(
|
||||||
|
`INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) ` +
|
||||||
|
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${periodId}', 15.25, NOW()) ` +
|
||||||
|
`ON CONFLICT (student_id, period_id) DO NOTHING`
|
||||||
|
);
|
||||||
|
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC2: Dashboard notes — grades and averages visible
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('AC2: student sees recent grades on dashboard', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
// Dashboard should show grades widget
|
||||||
|
const gradesSection = page.locator('.grades-list');
|
||||||
|
await expect(gradesSection).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Should show at least one grade
|
||||||
|
const gradeItems = page.locator('.grade-item');
|
||||||
|
await expect(gradeItems.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC2: student navigates to full grades page', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
// Page title
|
||||||
|
await expect(page.getByRole('heading', { name: 'Mes notes' })).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Should show grade cards
|
||||||
|
const gradeCards = page.locator('.grade-card');
|
||||||
|
await expect(gradeCards.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Should show both grades (Dictée more recent first)
|
||||||
|
await expect(gradeCards).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC2: grades show value, subject, and evaluation title', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Check first grade (Dictée - more recent)
|
||||||
|
const firstCard = page.locator('.grade-card').first();
|
||||||
|
await expect(firstCard.locator('.grade-subject')).toContainText('E2E-SG-Français');
|
||||||
|
await expect(firstCard.locator('.grade-eval-title')).toContainText('Dictée');
|
||||||
|
await expect(firstCard.locator('.grade-value')).toContainText('14/20');
|
||||||
|
|
||||||
|
// Check second grade (DS Maths)
|
||||||
|
const secondCard = page.locator('.grade-card').nth(1);
|
||||||
|
await expect(secondCard.locator('.grade-subject')).toContainText('E2E-SG-Mathématiques');
|
||||||
|
await expect(secondCard.locator('.grade-value')).toContainText('16.5/20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC2: class statistics visible on grades', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const firstStats = page.locator('.grade-card').first().locator('.grade-card-stats');
|
||||||
|
await expect(firstStats).toContainText('Moy. classe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC2: appreciation visible on grade', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// The Maths grade has an appreciation
|
||||||
|
await expect(page.locator('.grade-appreciation').first()).toContainText('Très bon travail');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC3: Subject detail — click on subject shows all evaluations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('AC3: click on subject shows detail modal', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
// Wait for averages section
|
||||||
|
const avgCard = page.locator('.average-card').first();
|
||||||
|
await expect(avgCard).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Click on first subject average card
|
||||||
|
await avgCard.click();
|
||||||
|
|
||||||
|
// Modal should appear
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Modal should show grade details
|
||||||
|
await expect(modal.locator('.detail-item')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC3: subject detail modal closes with Escape', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
const avgCard = page.locator('.average-card').first();
|
||||||
|
await expect(avgCard).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await avgCard.click();
|
||||||
|
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC4: Discover mode — notes hidden by default, click to reveal
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('AC4: discover mode toggle exists', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
await expect(page.locator('.discover-toggle')).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.locator('.toggle-label')).toContainText('Mode découverte');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC4: enabling discover mode hides grade values', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Enable discover mode
|
||||||
|
const toggle = page.locator('.discover-toggle input');
|
||||||
|
await toggle.check();
|
||||||
|
|
||||||
|
// Grade values should be blurred and reveal hint visible
|
||||||
|
await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('.reveal-hint').first()).toContainText('Cliquer pour révéler');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC4: clicking card in discover mode reveals the grade', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Enable discover mode
|
||||||
|
await page.locator('.discover-toggle input').check();
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Click the card to reveal
|
||||||
|
await page.locator('.grade-card-btn').first().click();
|
||||||
|
|
||||||
|
// Grade value should now be visible (no longer blurred)
|
||||||
|
const firstCard = page.locator('.grade-card').first();
|
||||||
|
await expect(firstCard.locator('.grade-value:not(.grade-blur)')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC5: Badge "Nouveau" on recent grades
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('AC5: new grades show Nouveau badge', async ({ page }) => {
|
||||||
|
// Clear localStorage to simulate fresh session
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem('classeo_grades_seen');
|
||||||
|
localStorage.removeItem('classeo_grade_preferences');
|
||||||
|
localStorage.removeItem('classeo_grades_revealed');
|
||||||
|
});
|
||||||
|
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Badges should be visible on new grades
|
||||||
|
await expect(page.locator('.badge-new').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC2: Averages section visible
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('AC2: subject averages section displays correctly', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
// Wait for averages section
|
||||||
|
const avgSection = page.locator('.averages-section');
|
||||||
|
await expect(avgSection).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Should show heading
|
||||||
|
await expect(avgSection.getByRole('heading', { name: 'Moyennes par matière' })).toBeVisible();
|
||||||
|
|
||||||
|
// Should show at least one average card
|
||||||
|
const avgCards = page.locator('.average-card');
|
||||||
|
await expect(avgCards.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC2: general average visible on grades page', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
const generalAvg = page.locator('.general-average');
|
||||||
|
await expect(generalAvg).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(generalAvg).toContainText('Moyenne générale');
|
||||||
|
await expect(generalAvg.locator('.avg-value')).toContainText('/20');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AC4: Discover mode persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('AC4: discover mode persists after page reload', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Enable discover mode
|
||||||
|
await page.locator('.discover-toggle input').check();
|
||||||
|
await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Discover mode should still be active after reload
|
||||||
|
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.locator('.grade-blur').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Disable discover mode for cleanup
|
||||||
|
await page.locator('.discover-toggle input').uncheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dashboard: grade card pop-in
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('dashboard: clicking a grade card opens detail pop-in', async ({ page }) => {
|
||||||
|
// Ensure discover mode is off
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.evaluate(() => localStorage.setItem('classeo_grade_preferences', '{"revealMode":"immediate"}'));
|
||||||
|
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
const gradeBtn = page.locator('.grade-item-btn').first();
|
||||||
|
await expect(gradeBtn).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await gradeBtn.click();
|
||||||
|
|
||||||
|
// Detail modal should appear
|
||||||
|
const modal = page.locator('.grade-detail-modal');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Modal shows evaluation title
|
||||||
|
await expect(modal.locator('.grade-detail-title')).toBeVisible();
|
||||||
|
|
||||||
|
// Modal shows grade value
|
||||||
|
await expect(modal.locator('.grade-detail-value')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard: grade pop-in shows appreciation', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
const gradeItems = page.locator('.grade-item-btn');
|
||||||
|
await expect(gradeItems.first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Click the Maths grade which has an appreciation
|
||||||
|
// Maths is second in the list (Dictée/Français is more recent)
|
||||||
|
await gradeItems.nth(1).click();
|
||||||
|
|
||||||
|
const modal = page.locator('.grade-detail-modal');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(modal.locator('.grade-detail-appreciation')).toContainText('Très bon travail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard: grade pop-in shows class statistics', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
const gradeBtn = page.locator('.grade-item-btn').first();
|
||||||
|
await expect(gradeBtn).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await gradeBtn.click();
|
||||||
|
|
||||||
|
const modal = page.locator('.grade-detail-modal');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Stats grid with Moyenne, Min, Max
|
||||||
|
await expect(modal.locator('.grade-detail-stats')).toBeVisible();
|
||||||
|
await expect(modal.locator('.stat-label').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard: grade pop-in closes with Escape', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
const gradeBtn = page.locator('.grade-item-btn').first();
|
||||||
|
await expect(gradeBtn).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await gradeBtn.click();
|
||||||
|
|
||||||
|
const modal = page.locator('.grade-detail-modal');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dashboard: discover mode
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('dashboard: discover mode toggle exists', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
const toggle = page.locator('.widget-discover-toggle');
|
||||||
|
await expect(toggle).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(toggle).toContainText('Mode découverte');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard: discover mode blurs grades', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
const toggle = page.locator('.widget-discover-toggle input');
|
||||||
|
await expect(toggle).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await toggle.check();
|
||||||
|
|
||||||
|
// Grades should be blurred
|
||||||
|
await expect(page.locator('.grade-item-btn .grade-blur').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('.grade-reveal-hint').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await toggle.uncheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard: clicking blurred card reveals grade', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
// Enable discover mode
|
||||||
|
const toggle = page.locator('.widget-discover-toggle input');
|
||||||
|
await expect(toggle).toBeVisible({ timeout: 15000 });
|
||||||
|
await toggle.check();
|
||||||
|
|
||||||
|
await expect(page.locator('.grade-item-btn .grade-blur').first()).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Click to reveal
|
||||||
|
await page.locator('.grade-item-btn').first().click();
|
||||||
|
|
||||||
|
// Grade value should now be visible (no blur), and no pop-in should open
|
||||||
|
const firstItem = page.locator('.grade-item-btn').first();
|
||||||
|
await expect(firstItem.locator('.grade-value:not(.grade-blur)')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('.grade-detail-modal')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await toggle.uncheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Full page: clickable grade cards
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('grades page: clicking a grade card opens subject detail modal', async ({ page }) => {
|
||||||
|
// Ensure discover mode is off
|
||||||
|
await page.goto(`${ALPHA_URL}/login`);
|
||||||
|
await page.evaluate(() => localStorage.setItem('classeo_grade_preferences', '{"revealMode":"immediate"}'));
|
||||||
|
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
const gradeCard = page.locator('.grade-card-btn').first();
|
||||||
|
await expect(gradeCard).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await gradeCard.click();
|
||||||
|
|
||||||
|
// Subject detail modal should appear
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Should show detail items
|
||||||
|
await expect(modal.locator('.detail-item')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grades page: clicking second card opens correct subject modal', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
|
||||||
|
|
||||||
|
const secondCard = page.locator('.grade-card-btn').nth(1);
|
||||||
|
await expect(secondCard).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await secondCard.click();
|
||||||
|
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Should show the Maths subject detail
|
||||||
|
await expect(modal.locator('.detail-item')).toHaveCount(1);
|
||||||
|
await expect(modal.locator('.detail-title')).toContainText('DS Mathématiques');
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Navigation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
test('student can navigate to grades page from nav bar', async ({ page }) => {
|
||||||
|
await loginAsStudent(page);
|
||||||
|
|
||||||
|
const navLink = page.getByRole('link', { name: /mes notes/i });
|
||||||
|
await expect(navLink).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await navLink.click();
|
||||||
|
await page.waitForURL(/student-grades/, { timeout: 10000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Mes notes' })).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -350,9 +350,9 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
|||||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||||
await navigateToSeededDay(page);
|
await navigateToSeededDay(page);
|
||||||
|
|
||||||
// Wait for day view to load
|
// Wait for day view to load (may need extra time for navigation on slow CI)
|
||||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||||
timeout: 15000
|
timeout: 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switch to week view
|
// Switch to week view
|
||||||
@@ -419,7 +419,7 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
|||||||
// Desktop grid should be visible, mobile list should be hidden
|
// Desktop grid should be visible, mobile list should be hidden
|
||||||
const weekList = page.locator('.week-list');
|
const weekList = page.locator('.week-list');
|
||||||
const weekGrid = page.locator('.week-grid');
|
const weekGrid = page.locator('.week-grid');
|
||||||
await expect(weekGrid).toBeVisible({ timeout: 15000 });
|
await expect(weekGrid).toBeVisible({ timeout: 30000 });
|
||||||
await expect(weekList).not.toBeVisible();
|
await expect(weekList).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -270,8 +270,8 @@ test.describe('Teacher Replacements (Story 2.9)', () => {
|
|||||||
|
|
||||||
await confirmDialog.getByRole('button', { name: /terminer/i }).click();
|
await confirmDialog.getByRole('button', { name: /terminer/i }).click();
|
||||||
|
|
||||||
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
|
await expect(confirmDialog).not.toBeVisible({ timeout: 15000 });
|
||||||
await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 10000 });
|
await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
|
|||||||
|
|
||||||
// Should see a suspended account error, not the generic credentials error
|
// Should see a suspended account error, not the generic credentials error
|
||||||
const errorBanner = page.locator('.error-banner.account-suspended');
|
const errorBanner = page.locator('.error-banner.account-suspended');
|
||||||
await expect(errorBanner).toBeVisible({ timeout: 5000 });
|
await expect(errorBanner).toBeVisible({ timeout: 15000 });
|
||||||
await expect(errorBanner).toContainText(/suspendu|contactez/i);
|
await expect(errorBanner).toContainText(/suspendu|contactez/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const config: PlaywrightTestConfig = {
|
|||||||
// Use 1 worker in CI to ensure no parallel execution across different browser projects
|
// Use 1 worker in CI to ensure no parallel execution across different browser projects
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
// Long sequential CI runs (~3h) cause sporadic slowdowns across all browsers
|
// Long sequential CI runs (~3h) cause sporadic slowdowns across all browsers
|
||||||
expect: process.env.CI ? { timeout: 15000 } : undefined,
|
expect: process.env.CI ? { timeout: 20000 } : undefined,
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
@@ -45,7 +45,8 @@ const config: PlaywrightTestConfig = {
|
|||||||
use: {
|
use: {
|
||||||
browserName: 'firefox'
|
browserName: 'firefox'
|
||||||
},
|
},
|
||||||
timeout: process.env.CI ? 60000 : undefined
|
timeout: process.env.CI ? 90000 : undefined,
|
||||||
|
expect: process.env.CI ? { timeout: 25000 } : undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'webkit',
|
name: 'webkit',
|
||||||
|
|||||||
@@ -2,8 +2,19 @@
|
|||||||
import type { DemoData } from '$types';
|
import type { DemoData } from '$types';
|
||||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||||
import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
|
import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
|
||||||
|
import type { StudentGrade } from '$lib/features/grades/api/studentGrades';
|
||||||
import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
||||||
import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework';
|
import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework';
|
||||||
|
import type { StudentAverages } from '$lib/features/grades/api/studentGrades';
|
||||||
|
import { fetchMyGrades, fetchMyAverages } from '$lib/features/grades/api/studentGrades';
|
||||||
|
import {
|
||||||
|
isGradeNew,
|
||||||
|
markGradesSeen,
|
||||||
|
isDiscoverMode,
|
||||||
|
setRevealMode,
|
||||||
|
isGradeRevealed,
|
||||||
|
revealGrade
|
||||||
|
} from '$lib/features/grades/stores/gradePreferences.svelte';
|
||||||
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
|
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
|
||||||
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||||
import { getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
|
import { getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
|
||||||
@@ -36,6 +47,11 @@
|
|||||||
let studentHomeworks = $state<StudentHomework[]>([]);
|
let studentHomeworks = $state<StudentHomework[]>([]);
|
||||||
let homeworkLoading = $state(false);
|
let homeworkLoading = $state(false);
|
||||||
|
|
||||||
|
// Grades widget state
|
||||||
|
let recentGrades = $state<StudentGrade[]>([]);
|
||||||
|
let studentAverages = $state<StudentAverages | null>(null);
|
||||||
|
let gradesLoading = $state(false);
|
||||||
|
|
||||||
let hwStatuses = $derived(getHomeworkStatuses());
|
let hwStatuses = $derived(getHomeworkStatuses());
|
||||||
|
|
||||||
let pendingHomeworks = $derived(
|
let pendingHomeworks = $derived(
|
||||||
@@ -88,6 +104,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let gradeSeenTimerId: number | null = null;
|
||||||
|
|
||||||
|
async function loadGrades() {
|
||||||
|
gradesLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [all, avgs] = await Promise.all([fetchMyGrades(), fetchMyAverages()]);
|
||||||
|
recentGrades = all.slice(0, 5);
|
||||||
|
studentAverages = avgs;
|
||||||
|
|
||||||
|
const ids = all.map((g) => g.id);
|
||||||
|
gradeSeenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000);
|
||||||
|
} catch {
|
||||||
|
// Silently fail on dashboard widget
|
||||||
|
} finally {
|
||||||
|
gradesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradeColor(value: number | null, scale: number): string {
|
||||||
|
if (value === null || scale <= 0) return '#6b7280';
|
||||||
|
const normalized = (value / scale) * 20;
|
||||||
|
if (normalized >= 14) return '#22c55e';
|
||||||
|
if (normalized >= 10) return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
// Homework detail modal
|
// Homework detail modal
|
||||||
let selectedHomeworkDetail = $state<StudentHomeworkDetail | null>(null);
|
let selectedHomeworkDetail = $state<StudentHomeworkDetail | null>(null);
|
||||||
|
|
||||||
@@ -105,17 +148,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOverlayClick(e: MouseEvent) {
|
function handleOverlayClick(e: MouseEvent) {
|
||||||
if (e.target === e.currentTarget) closeHomeworkDetail();
|
if (e.target === e.currentTarget) {
|
||||||
|
closeHomeworkDetail();
|
||||||
|
closeGradeDetail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleModalKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') closeHomeworkDetail();
|
if (e.key === 'Escape') {
|
||||||
|
closeHomeworkDetail();
|
||||||
|
closeGradeDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grade detail modal
|
||||||
|
let selectedGrade = $state<StudentGrade | null>(null);
|
||||||
|
|
||||||
|
function openGradeDetail(grade: StudentGrade) {
|
||||||
|
selectedGrade = grade;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGradeDetail() {
|
||||||
|
selectedGrade = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDiscoverMode() {
|
||||||
|
setRevealMode(isDiscoverMode() ? 'immediate' : 'discover');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReveal(gradeId: string) {
|
||||||
|
revealGrade(gradeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!isEleve) return;
|
if (!isEleve) return;
|
||||||
void loadTodaySchedule();
|
void loadTodaySchedule();
|
||||||
void loadHomeworks();
|
void loadHomeworks();
|
||||||
|
void loadGrades();
|
||||||
|
return () => {
|
||||||
|
if (gradeSeenTimerId !== null) window.clearTimeout(gradeSeenTimerId);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -179,11 +256,65 @@
|
|||||||
<!-- Notes Section -->
|
<!-- Notes Section -->
|
||||||
<DashboardSection
|
<DashboardSection
|
||||||
title="Mes notes"
|
title="Mes notes"
|
||||||
subtitle={hasRealData ? "Dernières notes" : undefined}
|
subtitle={isEleve ? "Dernières notes" : (hasRealData ? "Dernières notes" : undefined)}
|
||||||
isPlaceholder={!hasRealData}
|
isPlaceholder={!isEleve && !hasRealData}
|
||||||
placeholderMessage={isMinor ? "Tes notes apparaîtront ici" : "Vos notes apparaîtront ici"}
|
placeholderMessage={isMinor ? "Tes notes apparaîtront ici" : "Vos notes apparaîtront ici"}
|
||||||
>
|
>
|
||||||
{#if hasRealData}
|
{#if isEleve}
|
||||||
|
{#if gradesLoading}
|
||||||
|
<SkeletonList items={3} message="Chargement des notes..." />
|
||||||
|
{:else if recentGrades.length === 0}
|
||||||
|
<p class="empty-grades">Aucune note publiée</p>
|
||||||
|
{:else}
|
||||||
|
<div class="widget-grades-header">
|
||||||
|
{#if studentAverages?.generalAverage != null}
|
||||||
|
<div class="widget-general-avg">
|
||||||
|
<span class="widget-avg-label">Moyenne générale</span>
|
||||||
|
<span class="widget-avg-value" style:color={gradeColor(studentAverages.generalAverage, 20)}>
|
||||||
|
{studentAverages.generalAverage.toFixed(1)}/20
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<label class="widget-discover-toggle">
|
||||||
|
<input type="checkbox" checked={isDiscoverMode()} onchange={toggleDiscoverMode} />
|
||||||
|
<span>Mode découverte</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<ul class="grades-list">
|
||||||
|
{#each recentGrades as grade}
|
||||||
|
{@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)}
|
||||||
|
<li>
|
||||||
|
<button class="grade-item grade-item-btn" onclick={() => discover ? handleReveal(grade.id) : openGradeDetail(grade)}>
|
||||||
|
<div class="grade-header">
|
||||||
|
<span class="grade-subject">{grade.subjectName ?? 'Matière'}</span>
|
||||||
|
{#if isGradeNew(grade.id)}
|
||||||
|
<span class="grade-badge-new">Nouveau</span>
|
||||||
|
{/if}
|
||||||
|
{#if discover}
|
||||||
|
<span class="grade-value grade-blur">??/{grade.gradeScale}</span>
|
||||||
|
{:else if grade.status === 'graded' && grade.value != null}
|
||||||
|
<span class="grade-value" style:color={gradeColor(grade.value, grade.gradeScale)}>
|
||||||
|
{grade.value}/{grade.gradeScale}
|
||||||
|
</span>
|
||||||
|
{:else if grade.status === 'absent'}
|
||||||
|
<span class="grade-value" style:color="#f59e0b">Absent</span>
|
||||||
|
{:else if grade.status === 'dispensed'}
|
||||||
|
<span class="grade-value" style:color="#6b7280">Dispensé</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="grade-eval">{grade.evaluationTitle}</span>
|
||||||
|
{#if discover}
|
||||||
|
<span class="grade-reveal-hint">Cliquer pour révéler</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<a href="/dashboard/student-grades" class="view-all-link">
|
||||||
|
Voir toutes les notes →
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{:else if hasRealData}
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<SkeletonList items={3} message="Chargement des notes..." />
|
<SkeletonList items={3} message="Chargement des notes..." />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -258,9 +389,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
{#if selectedHomeworkDetail}
|
{#if selectedHomeworkDetail}
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
<div class="homework-modal-overlay" onclick={handleOverlayClick} onkeydown={handleModalKeydown} role="presentation">
|
<div class="homework-modal-overlay" onclick={handleOverlayClick} role="presentation">
|
||||||
<div class="homework-modal" role="dialog" aria-modal="true" aria-label="Détail du devoir">
|
<div class="homework-modal" role="dialog" aria-modal="true" aria-label="Détail du devoir">
|
||||||
<button class="homework-modal-close" onclick={closeHomeworkDetail} aria-label="Fermer">×</button>
|
<button class="homework-modal-close" onclick={closeHomeworkDetail} aria-label="Fermer">×</button>
|
||||||
<HomeworkDetail detail={selectedHomeworkDetail} onBack={closeHomeworkDetail} />
|
<HomeworkDetail detail={selectedHomeworkDetail} onBack={closeHomeworkDetail} />
|
||||||
@@ -268,6 +401,61 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedGrade}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div class="homework-modal-overlay" onclick={handleOverlayClick} role="presentation">
|
||||||
|
<div class="grade-detail-modal" role="dialog" aria-modal="true" aria-label="Détail de la note">
|
||||||
|
<button class="homework-modal-close" onclick={closeGradeDetail} aria-label="Fermer">×</button>
|
||||||
|
<div class="grade-detail-header">
|
||||||
|
<span class="grade-detail-subject">{selectedGrade.subjectName ?? 'Matière'}</span>
|
||||||
|
<span class="grade-detail-date">{formatDate(selectedGrade.evaluationDate)}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="grade-detail-title">{selectedGrade.evaluationTitle}</h3>
|
||||||
|
<div class="grade-detail-score">
|
||||||
|
{#if selectedGrade.status === 'graded' && selectedGrade.value != null}
|
||||||
|
<span class="grade-detail-value" style:color={gradeColor(selectedGrade.value, selectedGrade.gradeScale)}>
|
||||||
|
{selectedGrade.value}/{selectedGrade.gradeScale}
|
||||||
|
</span>
|
||||||
|
{:else if selectedGrade.status === 'absent'}
|
||||||
|
<span class="grade-detail-value" style:color="#f59e0b">Absent</span>
|
||||||
|
{:else if selectedGrade.status === 'dispensed'}
|
||||||
|
<span class="grade-detail-value" style:color="#6b7280">Dispensé</span>
|
||||||
|
{/if}
|
||||||
|
<span class="grade-detail-coeff">Coeff. {selectedGrade.coefficient}</span>
|
||||||
|
</div>
|
||||||
|
{#if selectedGrade.appreciation}
|
||||||
|
<div class="grade-detail-appreciation">
|
||||||
|
<span class="grade-detail-label">Appréciation</span>
|
||||||
|
<p>{selectedGrade.appreciation}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if selectedGrade.classAverage != null}
|
||||||
|
<div class="grade-detail-stats">
|
||||||
|
<span class="grade-detail-label">Statistiques de la classe</span>
|
||||||
|
<div class="grade-detail-stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{selectedGrade.classAverage.toFixed(1)}</span>
|
||||||
|
<span class="stat-label">Moyenne</span>
|
||||||
|
</div>
|
||||||
|
{#if selectedGrade.classMin != null}
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{selectedGrade.classMin.toFixed(1)}</span>
|
||||||
|
<span class="stat-label">Min</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if selectedGrade.classMax != null}
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value">{selectedGrade.classMax.toFixed(1)}</span>
|
||||||
|
<span class="stat-label">Max</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.dashboard-student {
|
.dashboard-student {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -383,6 +571,20 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grade-item-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-item-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
.grade-header {
|
.grade-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -406,6 +608,188 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grade-badge-new {
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-grades {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-grades-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-general-avg {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-avg-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-avg-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-discover-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-discover-toggle input {
|
||||||
|
accent-color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-blur {
|
||||||
|
filter: blur(4px);
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-reveal-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grade detail modal */
|
||||||
|
.grade-detail-modal {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-subject {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-title {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-score {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-coeff {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-appreciation {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-appreciation p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b5563;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-stats {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-detail-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
/* Homework List */
|
/* Homework List */
|
||||||
.homework-list {
|
.homework-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
|
||||||
|
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||||
|
import type { StudentGrade } from '$lib/features/grades/api/studentGrades';
|
||||||
|
import {
|
||||||
|
fetchChildGrades,
|
||||||
|
fetchChildrenGradesSummary,
|
||||||
|
type ChildGrades,
|
||||||
|
type ChildGradesSummary
|
||||||
|
} from '$lib/features/grades/api/parentGrades';
|
||||||
|
import { isGradeNew, markGradesSeen } from '$lib/features/grades/stores/gradePreferences.svelte';
|
||||||
|
|
||||||
|
let selectedChildId: string | null = $state(null);
|
||||||
|
let childrenGrades: ChildGrades | null = $state(null);
|
||||||
|
let summaries: ChildGradesSummary[] = $state([]);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
let selectedSubjectId: string | null = $state(null);
|
||||||
|
let seenTimerId: number | null = $state(null);
|
||||||
|
let loadGeneration = $state(0);
|
||||||
|
|
||||||
|
// Group grades by subject for the selected child
|
||||||
|
let subjectGroups = $derived.by(() => {
|
||||||
|
if (!childrenGrades) return new Map<string, { subjectName: string; grades: StudentGrade[] }>();
|
||||||
|
const map = new Map<string, { subjectName: string; grades: StudentGrade[] }>();
|
||||||
|
for (const g of childrenGrades.grades) {
|
||||||
|
const key = g.subjectId;
|
||||||
|
const existing = map.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.grades.push(g);
|
||||||
|
} else {
|
||||||
|
map.set(key, { subjectName: g.subjectName ?? 'Matière inconnue', grades: [g] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current child summary (averages)
|
||||||
|
let currentSummary = $derived.by(() => {
|
||||||
|
if (!selectedChildId) return null;
|
||||||
|
return summaries.find((s) => s.childId === selectedChildId) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered grades for subject detail modal
|
||||||
|
let detailGrades = $derived.by(() => {
|
||||||
|
if (!selectedSubjectId || !childrenGrades) return [];
|
||||||
|
return childrenGrades.grades.filter((g) => g.subjectId === selectedSubjectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
let detailSubjectName = $derived.by(() => {
|
||||||
|
if (!selectedSubjectId) return '';
|
||||||
|
return subjectGroups.get(selectedSubjectId)?.subjectName ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
let detailAverage = $derived.by(() => {
|
||||||
|
if (!selectedSubjectId || !currentSummary) return undefined;
|
||||||
|
return currentSummary.subjectAverages.find((a) => a.subjectId === selectedSubjectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function gradeColor(value: number | null, scale: number): string {
|
||||||
|
if (value === null || scale <= 0) return '#6b7280';
|
||||||
|
const normalized = (value / scale) * 20;
|
||||||
|
if (normalized >= 14) return '#22c55e';
|
||||||
|
if (normalized >= 10) return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSeenTimer() {
|
||||||
|
if (seenTimerId !== null) {
|
||||||
|
window.clearTimeout(seenTimerId);
|
||||||
|
seenTimerId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChildSelected(childId: string | null) {
|
||||||
|
selectedChildId = childId;
|
||||||
|
selectedSubjectId = null;
|
||||||
|
cancelSeenTimer();
|
||||||
|
if (childId) {
|
||||||
|
loadChildGrades(childId);
|
||||||
|
} else {
|
||||||
|
childrenGrades = null;
|
||||||
|
loadSummaries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummaries() {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
summaries = await fetchChildrenGradesSummary();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChildGrades(childId: string) {
|
||||||
|
cancelSeenTimer();
|
||||||
|
const generation = ++loadGeneration;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
// Grades are primary — summary failure must not block them
|
||||||
|
const gradesData = await fetchChildGrades(childId);
|
||||||
|
|
||||||
|
if (generation !== loadGeneration) return;
|
||||||
|
|
||||||
|
childrenGrades = gradesData;
|
||||||
|
|
||||||
|
const ids = gradesData.grades.map((g) => g.id);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
seenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary is secondary — fetch independently
|
||||||
|
fetchChildrenGradesSummary()
|
||||||
|
.then((data) => {
|
||||||
|
if (generation === loadGeneration) summaries = data;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* summary failure is non-blocking */
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (generation !== loadGeneration) return;
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
if (generation === loadGeneration) {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load summaries on mount (for multi-children view before any child is selected)
|
||||||
|
$effect(() => {
|
||||||
|
loadSummaries();
|
||||||
|
return () => cancelSeenTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openSubjectDetail(subjectId: string) {
|
||||||
|
selectedSubjectId = subjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
selectedSubjectId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) closeDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && selectedSubjectId) closeDetail();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="parent-grades">
|
||||||
|
<ChildSelector {onChildSelected} />
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-banner" role="alert">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button onclick={() => (error = null)}>Fermer</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !selectedChildId}
|
||||||
|
<!-- Multi-children summary view -->
|
||||||
|
{#if summaries.length > 0}
|
||||||
|
<section class="summary-section">
|
||||||
|
{#each summaries as child}
|
||||||
|
<div class="child-summary-card">
|
||||||
|
<h3>{child.firstName} {child.lastName}</h3>
|
||||||
|
{#if child.generalAverage != null}
|
||||||
|
<div class="general-average">
|
||||||
|
<span class="avg-label">Moyenne générale</span>
|
||||||
|
<span class="avg-value" style:color={gradeColor(child.generalAverage, 20)}>
|
||||||
|
{child.generalAverage.toFixed(1)}/20
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if child.subjectAverages.length > 0}
|
||||||
|
<div class="averages-grid">
|
||||||
|
{#each child.subjectAverages as avg}
|
||||||
|
<div class="average-card">
|
||||||
|
<span class="avg-subject">{avg.subjectName ?? 'Matière'}</span>
|
||||||
|
<span class="avg-score" style:color={gradeColor(avg.average, 20)}>
|
||||||
|
{avg.average.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span class="avg-count"
|
||||||
|
>{avg.gradeCount} note{avg.gradeCount > 1 ? 's' : ''}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="no-data">Aucune moyenne disponible.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{:else if !isLoading}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Sélectionnez un enfant pour voir ses notes.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if isLoading}
|
||||||
|
<SkeletonList items={5} message="Chargement des notes..." />
|
||||||
|
{:else if childrenGrades && childrenGrades.grades.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>
|
||||||
|
Aucune note publiée pour {childrenGrades.firstName} pour le moment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if childrenGrades}
|
||||||
|
<!-- General average -->
|
||||||
|
{#if currentSummary?.generalAverage != null}
|
||||||
|
<div class="general-average">
|
||||||
|
<span class="avg-label">Moyenne générale de {childrenGrades.firstName}</span>
|
||||||
|
<span class="avg-value" style:color={gradeColor(currentSummary.generalAverage, 20)}>
|
||||||
|
{currentSummary.generalAverage.toFixed(1)}/20
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Subject averages -->
|
||||||
|
{#if currentSummary && currentSummary.subjectAverages.length > 0}
|
||||||
|
<section class="averages-section">
|
||||||
|
<h2>Moyennes par matière</h2>
|
||||||
|
<div class="averages-grid">
|
||||||
|
{#each currentSummary.subjectAverages as avg}
|
||||||
|
<button class="average-card" onclick={() => openSubjectDetail(avg.subjectId)}>
|
||||||
|
<span class="avg-subject">{avg.subjectName ?? 'Matière'}</span>
|
||||||
|
<span class="avg-score" style:color={gradeColor(avg.average, 20)}>
|
||||||
|
{avg.average.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span class="avg-count"
|
||||||
|
>{avg.gradeCount} note{avg.gradeCount > 1 ? 's' : ''}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recent grades list -->
|
||||||
|
<section class="recent-section">
|
||||||
|
<h2>Dernières notes</h2>
|
||||||
|
<ul class="grades-list">
|
||||||
|
{#each childrenGrades.grades as grade (grade.id)}
|
||||||
|
{@const isNew = isGradeNew(grade.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="grade-card grade-card-btn"
|
||||||
|
onclick={() => openSubjectDetail(grade.subjectId)}
|
||||||
|
>
|
||||||
|
<div class="grade-card-header">
|
||||||
|
<span class="grade-subject">{grade.subjectName ?? 'Matière'}</span>
|
||||||
|
<span class="grade-date">{formatDate(grade.evaluationDate)}</span>
|
||||||
|
{#if isNew}
|
||||||
|
<span class="badge-new">Nouveau</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="grade-card-body">
|
||||||
|
<span class="grade-eval-title">{grade.evaluationTitle}</span>
|
||||||
|
{#if grade.status === 'graded' && grade.value != null}
|
||||||
|
<span
|
||||||
|
class="grade-value"
|
||||||
|
style:color={gradeColor(grade.value, grade.gradeScale)}
|
||||||
|
>
|
||||||
|
{grade.value}/{grade.gradeScale}
|
||||||
|
</span>
|
||||||
|
{:else if grade.status === 'absent'}
|
||||||
|
<span class="grade-status absent">Absent</span>
|
||||||
|
{:else if grade.status === 'dispensed'}
|
||||||
|
<span class="grade-status dispensed">Dispensé</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if grade.classAverage != null}
|
||||||
|
<div class="grade-card-stats">
|
||||||
|
<span>Moy. classe : {grade.classAverage.toFixed(1)}</span>
|
||||||
|
{#if grade.classMin != null && grade.classMax != null}
|
||||||
|
<span
|
||||||
|
>Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if grade.appreciation}
|
||||||
|
<p class="grade-appreciation">{grade.appreciation}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="grade-card-meta">
|
||||||
|
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subject detail modal -->
|
||||||
|
{#if selectedSubjectId}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div class="modal-overlay" onclick={handleOverlayClick} role="presentation">
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Détail matière {detailSubjectName}"
|
||||||
|
>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{detailSubjectName}</h2>
|
||||||
|
<button class="modal-close" onclick={closeDetail} aria-label="Fermer">×</button>
|
||||||
|
</div>
|
||||||
|
{#if detailAverage}
|
||||||
|
<div class="modal-average">
|
||||||
|
<span class="avg-label">Moyenne</span>
|
||||||
|
<span class="avg-value" style:color={gradeColor(detailAverage.average, 20)}>
|
||||||
|
{detailAverage.average.toFixed(1)}/20
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ul class="detail-list">
|
||||||
|
{#each detailGrades as grade (grade.id)}
|
||||||
|
<li class="detail-item">
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-title">{grade.evaluationTitle}</span>
|
||||||
|
<span class="detail-date">{formatDate(grade.evaluationDate)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
{#if grade.status === 'graded' && grade.value != null}
|
||||||
|
<span
|
||||||
|
class="grade-value"
|
||||||
|
style:color={gradeColor(grade.value, grade.gradeScale)}
|
||||||
|
>
|
||||||
|
{grade.value}/{grade.gradeScale}
|
||||||
|
</span>
|
||||||
|
{:else if grade.status === 'absent'}
|
||||||
|
<span class="grade-status absent">Absent</span>
|
||||||
|
{:else if grade.status === 'dispensed'}
|
||||||
|
<span class="grade-status dispensed">Dispensé</span>
|
||||||
|
{/if}
|
||||||
|
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
|
||||||
|
</div>
|
||||||
|
{#if grade.classAverage != null}
|
||||||
|
<div class="grade-card-stats">
|
||||||
|
<span>Moy. classe : {grade.classAverage.toFixed(1)}</span>
|
||||||
|
{#if grade.classMin != null && grade.classMax != null}
|
||||||
|
<span
|
||||||
|
>Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if grade.appreciation}
|
||||||
|
<p class="grade-appreciation">{grade.appreciation}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.parent-grades {
|
||||||
|
max-width: 64rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #991b1b;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary for multi-children */
|
||||||
|
.summary-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-summary-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-summary-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* General average */
|
||||||
|
.general-average {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Averages grid */
|
||||||
|
.averages-section h2,
|
||||||
|
.recent-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.averages-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.average-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.average-card:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-subject {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-score {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grades list */
|
||||||
|
.grades-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-btn:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-subject {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-new {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-eval-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-value {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-status {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-status.absent {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-status.dispensed {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-appreciation {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #4b5563;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-coeff {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 36rem;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-average {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
frontend/src/lib/features/grades/api/parentGrades.ts
Normal file
74
frontend/src/lib/features/grades/api/parentGrades.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { getApiBaseUrl } from '$lib/api';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
import type { StudentGrade, SubjectAverage } from './studentGrades';
|
||||||
|
|
||||||
|
export interface ChildGrades {
|
||||||
|
childId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
grades: StudentGrade[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildGradesSummary {
|
||||||
|
childId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
periodId: string | null;
|
||||||
|
subjectAverages: SubjectAverage[];
|
||||||
|
generalAverage: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les notes d'un enfant spécifique.
|
||||||
|
*/
|
||||||
|
export async function fetchChildGrades(childId: string): Promise<ChildGrades> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${apiUrl}/me/children/${encodeURIComponent(childId)}/grades`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les notes d'un enfant filtrées par matière.
|
||||||
|
*/
|
||||||
|
export async function fetchChildGradesBySubject(
|
||||||
|
childId: string,
|
||||||
|
subjectId: string
|
||||||
|
): Promise<ChildGrades> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${apiUrl}/me/children/${encodeURIComponent(childId)}/grades/subject/${encodeURIComponent(subjectId)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le résumé des moyennes de tous les enfants.
|
||||||
|
*/
|
||||||
|
export async function fetchChildrenGradesSummary(
|
||||||
|
periodId?: string
|
||||||
|
): Promise<ChildGradesSummary[]> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const params = periodId ? `?periodId=${encodeURIComponent(periodId)}` : '';
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/me/children/grades/summary${params}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement des moyennes (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json.data ?? [];
|
||||||
|
}
|
||||||
83
frontend/src/lib/features/grades/api/studentGrades.ts
Normal file
83
frontend/src/lib/features/grades/api/studentGrades.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { getApiBaseUrl } from '$lib/api';
|
||||||
|
import { authenticatedFetch } from '$lib/auth';
|
||||||
|
|
||||||
|
export interface StudentGrade {
|
||||||
|
id: string;
|
||||||
|
evaluationId: string;
|
||||||
|
evaluationTitle: string;
|
||||||
|
evaluationDate: string;
|
||||||
|
gradeScale: number;
|
||||||
|
coefficient: number;
|
||||||
|
subjectId: string;
|
||||||
|
subjectName: string | null;
|
||||||
|
subjectColor: string | null;
|
||||||
|
value: number | null;
|
||||||
|
status: string;
|
||||||
|
appreciation: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
classAverage: number | null;
|
||||||
|
classMin: number | null;
|
||||||
|
classMax: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubjectAverage {
|
||||||
|
subjectId: string;
|
||||||
|
subjectName: string | null;
|
||||||
|
average: number;
|
||||||
|
gradeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentAverages {
|
||||||
|
studentId: string;
|
||||||
|
periodId: string | null;
|
||||||
|
subjectAverages: SubjectAverage[];
|
||||||
|
generalAverage: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère toutes les notes publiées de l'élève connecté.
|
||||||
|
*/
|
||||||
|
export async function fetchMyGrades(): Promise<StudentGrade[]> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/me/grades`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
// API Platform returns hydra:member or raw array
|
||||||
|
return json['hydra:member'] ?? json.member ?? json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les notes de l'élève pour une matière.
|
||||||
|
*/
|
||||||
|
export async function fetchMyGradesBySubject(subjectId: string): Promise<StudentGrade[]> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${apiUrl}/me/grades/subject/${encodeURIComponent(subjectId)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return json['hydra:member'] ?? json.member ?? json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les moyennes de l'élève connecté.
|
||||||
|
*/
|
||||||
|
export async function fetchMyAverages(periodId?: string): Promise<StudentAverages> {
|
||||||
|
const apiUrl = getApiBaseUrl();
|
||||||
|
const params = periodId ? `?periodId=${encodeURIComponent(periodId)}` : '';
|
||||||
|
const response = await authenticatedFetch(`${apiUrl}/me/averages${params}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Erreur lors du chargement des moyennes (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
const PREFS_KEY = 'classeo_grade_preferences';
|
||||||
|
const SEEN_KEY = 'classeo_grades_seen';
|
||||||
|
const REVEALED_KEY = 'classeo_grades_revealed';
|
||||||
|
|
||||||
|
export type RevealMode = 'immediate' | 'discover';
|
||||||
|
|
||||||
|
interface GradePreferences {
|
||||||
|
revealMode: RevealMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
let revealMode = $state<RevealMode>('immediate');
|
||||||
|
let seenGradeIds = $state<Set<string>>(new Set());
|
||||||
|
let revealedGradeIds = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Load from localStorage on init
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(PREFS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const prefs = JSON.parse(stored) as GradePreferences;
|
||||||
|
revealMode = prefs.revealMode ?? 'immediate';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SEEN_KEY);
|
||||||
|
if (stored) {
|
||||||
|
seenGradeIds = new Set(JSON.parse(stored) as string[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(REVEALED_KEY);
|
||||||
|
if (stored) {
|
||||||
|
revealedGradeIds = new Set(JSON.parse(stored) as string[]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrefs(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREFS_KEY, JSON.stringify({ revealMode }));
|
||||||
|
} catch {
|
||||||
|
// QuotaExceededError — preference still active in memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSeen(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SEEN_KEY, JSON.stringify([...seenGradeIds]));
|
||||||
|
} catch {
|
||||||
|
// QuotaExceededError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRevealed(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(REVEALED_KEY, JSON.stringify([...revealedGradeIds]));
|
||||||
|
} catch {
|
||||||
|
// QuotaExceededError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRevealMode(): RevealMode {
|
||||||
|
return revealMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRevealMode(mode: RevealMode): void {
|
||||||
|
revealMode = mode;
|
||||||
|
savePrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGradeNew(gradeId: string): boolean {
|
||||||
|
return !seenGradeIds.has(gradeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markGradesSeen(gradeIds: string[]): void {
|
||||||
|
seenGradeIds = new Set([...seenGradeIds, ...gradeIds]);
|
||||||
|
saveSeen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGradeRevealed(gradeId: string): boolean {
|
||||||
|
return revealedGradeIds.has(gradeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revealGrade(gradeId: string): void {
|
||||||
|
revealedGradeIds = new Set([...revealedGradeIds, gradeId]);
|
||||||
|
saveRevealed();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDiscoverMode(): boolean {
|
||||||
|
return revealMode === 'discover';
|
||||||
|
}
|
||||||
@@ -109,11 +109,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if isEleve}
|
{#if isEleve}
|
||||||
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
|
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
|
||||||
|
<a href="/dashboard/student-grades" class="nav-link" class:active={pathname === '/dashboard/student-grades'}>Mes notes</a>
|
||||||
<a href="/dashboard/student-competencies" class="nav-link" class:active={pathname === '/dashboard/student-competencies'}>Compétences</a>
|
<a href="/dashboard/student-competencies" class="nav-link" class:active={pathname === '/dashboard/student-competencies'}>Compétences</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isParent}
|
{#if isParent}
|
||||||
<a href="/dashboard/parent-schedule" class="nav-link" class:active={pathname === '/dashboard/parent-schedule'}>EDT enfants</a>
|
<a href="/dashboard/parent-schedule" class="nav-link" class:active={pathname === '/dashboard/parent-schedule'}>EDT enfants</a>
|
||||||
<a href="/dashboard/parent-homework" class="nav-link" class:active={pathname === '/dashboard/parent-homework'}>Devoirs</a>
|
<a href="/dashboard/parent-homework" class="nav-link" class:active={pathname === '/dashboard/parent-homework'}>Devoirs</a>
|
||||||
|
<a href="/dashboard/parent-grades" class="nav-link" class:active={pathname === '/dashboard/parent-grades'}>Notes</a>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
||||||
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||||
@@ -164,6 +166,9 @@
|
|||||||
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
|
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
|
||||||
Mon emploi du temps
|
Mon emploi du temps
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/dashboard/student-grades" class="mobile-nav-link" class:active={pathname === '/dashboard/student-grades'}>
|
||||||
|
Mes notes
|
||||||
|
</a>
|
||||||
<a href="/dashboard/student-competencies" class="mobile-nav-link" class:active={pathname === '/dashboard/student-competencies'}>
|
<a href="/dashboard/student-competencies" class="mobile-nav-link" class:active={pathname === '/dashboard/student-competencies'}>
|
||||||
Compétences
|
Compétences
|
||||||
</a>
|
</a>
|
||||||
@@ -175,6 +180,9 @@
|
|||||||
<a href="/dashboard/parent-homework" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-homework'}>
|
<a href="/dashboard/parent-homework" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-homework'}>
|
||||||
Devoirs
|
Devoirs
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/dashboard/parent-grades" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-grades'}>
|
||||||
|
Notes
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
|
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
29
frontend/src/routes/dashboard/parent-grades/+page.svelte
Normal file
29
frontend/src/routes/dashboard/parent-grades/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ParentGradesView from '$lib/components/organisms/ParentGrades/ParentGradesView.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Notes des enfants - Classeo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="grades-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Notes des enfants</h1>
|
||||||
|
</header>
|
||||||
|
<ParentGradesView />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grades-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
695
frontend/src/routes/dashboard/student-grades/+page.svelte
Normal file
695
frontend/src/routes/dashboard/student-grades/+page.svelte
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { StudentGrade, StudentAverages, SubjectAverage } from '$lib/features/grades/api/studentGrades';
|
||||||
|
import { fetchMyGrades, fetchMyAverages } from '$lib/features/grades/api/studentGrades';
|
||||||
|
import {
|
||||||
|
setRevealMode,
|
||||||
|
isGradeNew,
|
||||||
|
markGradesSeen,
|
||||||
|
isGradeRevealed,
|
||||||
|
revealGrade,
|
||||||
|
isDiscoverMode
|
||||||
|
} from '$lib/features/grades/stores/gradePreferences.svelte';
|
||||||
|
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||||
|
|
||||||
|
let grades: StudentGrade[] = $state([]);
|
||||||
|
let averages: StudentAverages | null = $state(null);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
let selectedSubjectId: string | null = $state(null);
|
||||||
|
|
||||||
|
// Group grades by subject
|
||||||
|
let subjectGroups = $derived.by(() => {
|
||||||
|
const map = new Map<string, { subjectName: string; grades: StudentGrade[] }>();
|
||||||
|
for (const g of grades) {
|
||||||
|
const key = g.subjectId;
|
||||||
|
const existing = map.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.grades.push(g);
|
||||||
|
} else {
|
||||||
|
map.set(key, { subjectName: g.subjectName ?? 'Matière inconnue', grades: [g] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find average for a subject
|
||||||
|
function subjectAverage(subjectId: string): SubjectAverage | undefined {
|
||||||
|
return averages?.subjectAverages.find((a) => a.subjectId === subjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtered grades for selected subject detail
|
||||||
|
let detailGrades = $derived.by(() => {
|
||||||
|
if (!selectedSubjectId) return [];
|
||||||
|
return grades.filter((g) => g.subjectId === selectedSubjectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
let detailSubjectName = $derived.by(() => {
|
||||||
|
if (!selectedSubjectId) return '';
|
||||||
|
return subjectGroups.get(selectedSubjectId)?.subjectName ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
let detailAverage = $derived.by(() => {
|
||||||
|
if (!selectedSubjectId) return undefined;
|
||||||
|
return subjectAverage(selectedSubjectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color based on grade value (green ≥ 14, orange 10-14, red < 10, on /20 scale)
|
||||||
|
function gradeColor(value: number | null, scale: number): string {
|
||||||
|
if (value === null || scale <= 0) return '#6b7280';
|
||||||
|
const normalized = (value / scale) * 20;
|
||||||
|
if (normalized >= 14) return '#22c55e';
|
||||||
|
if (normalized >= 10) return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReveal(gradeId: string) {
|
||||||
|
revealGrade(gradeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDiscoverMode() {
|
||||||
|
const newMode = isDiscoverMode() ? 'immediate' : 'discover';
|
||||||
|
setRevealMode(newMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seenTimerId: number | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadData();
|
||||||
|
return () => {
|
||||||
|
if (seenTimerId !== null) window.clearTimeout(seenTimerId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
const [gradesData, averagesData] = await Promise.all([fetchMyGrades(), fetchMyAverages()]);
|
||||||
|
|
||||||
|
grades = gradesData;
|
||||||
|
averages = averagesData;
|
||||||
|
|
||||||
|
// Mark all loaded grades as seen (for "Nouveau" badge)
|
||||||
|
const ids = gradesData.map((g) => g.id);
|
||||||
|
// Delay marking to let the badge show briefly
|
||||||
|
seenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubjectDetail(subjectId: string) {
|
||||||
|
selectedSubjectId = subjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
selectedSubjectId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) closeDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && selectedSubjectId) closeDetail();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="student-grades">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-top">
|
||||||
|
<h1>Mes notes</h1>
|
||||||
|
<label class="discover-toggle">
|
||||||
|
<input type="checkbox" checked={isDiscoverMode()} onchange={toggleDiscoverMode} />
|
||||||
|
<span class="toggle-label">Mode découverte</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if averages?.generalAverage != null}
|
||||||
|
<div class="general-average">
|
||||||
|
<span class="avg-label">Moyenne générale</span>
|
||||||
|
<span class="avg-value" style:color={gradeColor(averages.generalAverage, 20)}>
|
||||||
|
{averages.generalAverage.toFixed(1)}/20
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-banner" role="alert">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button onclick={() => (error = null)}>Fermer</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<SkeletonList items={5} message="Chargement des notes..." />
|
||||||
|
{:else if grades.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Aucune note publiée pour le moment.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Subject averages summary -->
|
||||||
|
{#if averages && averages.subjectAverages.length > 0}
|
||||||
|
<section class="averages-section">
|
||||||
|
<h2>Moyennes par matière</h2>
|
||||||
|
<div class="averages-grid">
|
||||||
|
{#each averages.subjectAverages as avg}
|
||||||
|
<button
|
||||||
|
class="average-card"
|
||||||
|
onclick={() => openSubjectDetail(avg.subjectId)}
|
||||||
|
>
|
||||||
|
<span class="avg-subject">{avg.subjectName ?? 'Matière'}</span>
|
||||||
|
<span class="avg-score" style:color={gradeColor(avg.average, 20)}>
|
||||||
|
{avg.average.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span class="avg-count">{avg.gradeCount} note{avg.gradeCount > 1 ? 's' : ''}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recent grades -->
|
||||||
|
<section class="recent-section">
|
||||||
|
<h2>Dernières notes</h2>
|
||||||
|
<ul class="grades-list">
|
||||||
|
{#each grades as grade (grade.id)}
|
||||||
|
{@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)}
|
||||||
|
{@const isNew = isGradeNew(grade.id)}
|
||||||
|
<li>
|
||||||
|
<button class="grade-card grade-card-btn" onclick={() => discover ? handleReveal(grade.id) : openSubjectDetail(grade.subjectId)}>
|
||||||
|
<div class="grade-card-header">
|
||||||
|
<span class="grade-subject">{grade.subjectName ?? 'Matière'}</span>
|
||||||
|
<span class="grade-date">{formatDate(grade.evaluationDate)}</span>
|
||||||
|
{#if isNew}
|
||||||
|
<span class="badge-new">Nouveau</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="grade-card-body">
|
||||||
|
<span class="grade-eval-title">{grade.evaluationTitle}</span>
|
||||||
|
{#if discover}
|
||||||
|
<span class="grade-value grade-blur">??/{grade.gradeScale}</span>
|
||||||
|
{:else if grade.status === 'graded' && grade.value != null}
|
||||||
|
<span class="grade-value" style:color={gradeColor(grade.value, grade.gradeScale)}>
|
||||||
|
{grade.value}/{grade.gradeScale}
|
||||||
|
</span>
|
||||||
|
{:else if grade.status === 'absent'}
|
||||||
|
<span class="grade-status absent">Absent</span>
|
||||||
|
{:else if grade.status === 'dispensed'}
|
||||||
|
<span class="grade-status dispensed">Dispensé</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !discover && grade.classAverage != null}
|
||||||
|
<div class="grade-card-stats">
|
||||||
|
<span>Moy. classe : {grade.classAverage.toFixed(1)}</span>
|
||||||
|
{#if grade.classMin != null && grade.classMax != null}
|
||||||
|
<span>Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !discover && grade.appreciation}
|
||||||
|
<p class="grade-appreciation">{grade.appreciation}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="grade-card-meta">
|
||||||
|
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
|
||||||
|
</div>
|
||||||
|
{#if discover}
|
||||||
|
<span class="reveal-hint">Cliquer pour révéler</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subject detail modal -->
|
||||||
|
{#if selectedSubjectId}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div class="modal-overlay" onclick={handleOverlayClick} role="presentation">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Détail matière {detailSubjectName}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{detailSubjectName}</h2>
|
||||||
|
<button class="modal-close" onclick={closeDetail} aria-label="Fermer">×</button>
|
||||||
|
</div>
|
||||||
|
{#if detailAverage}
|
||||||
|
<div class="modal-average">
|
||||||
|
<span class="avg-label">Moyenne</span>
|
||||||
|
<span class="avg-value" style:color={gradeColor(detailAverage.average, 20)}>
|
||||||
|
{detailAverage.average.toFixed(1)}/20
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ul class="detail-list">
|
||||||
|
{#each detailGrades as grade (grade.id)}
|
||||||
|
{@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)}
|
||||||
|
<li class="detail-item">
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-title">{grade.evaluationTitle}</span>
|
||||||
|
<span class="detail-date">{formatDate(grade.evaluationDate)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
{#if discover}
|
||||||
|
<button class="detail-reveal-btn" onclick={() => handleReveal(grade.id)}>
|
||||||
|
<span class="grade-blur">??/{grade.gradeScale}</span>
|
||||||
|
<span class="reveal-hint">Révéler</span>
|
||||||
|
</button>
|
||||||
|
{:else if grade.status === 'graded' && grade.value != null}
|
||||||
|
<span class="grade-value" style:color={gradeColor(grade.value, grade.gradeScale)}>
|
||||||
|
{grade.value}/{grade.gradeScale}
|
||||||
|
</span>
|
||||||
|
{:else if grade.status === 'absent'}
|
||||||
|
<span class="grade-status absent">Absent</span>
|
||||||
|
{:else if grade.status === 'dispensed'}
|
||||||
|
<span class="grade-status dispensed">Dispensé</span>
|
||||||
|
{/if}
|
||||||
|
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
|
||||||
|
</div>
|
||||||
|
{#if !discover && grade.classAverage != null}
|
||||||
|
<div class="grade-card-stats">
|
||||||
|
<span>Moy. classe : {grade.classAverage.toFixed(1)}</span>
|
||||||
|
{#if grade.classMin != null && grade.classMax != null}
|
||||||
|
<span>Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !discover && grade.appreciation}
|
||||||
|
<p class="grade-appreciation">{grade.appreciation}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.student-grades {
|
||||||
|
max-width: 64rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discover-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discover-toggle input {
|
||||||
|
accent-color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-average {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #991b1b;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Averages section */
|
||||||
|
.averages-section h2,
|
||||||
|
.recent-section h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.averages-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.average-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.average-card:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-subject {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-score {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avg-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grade cards */
|
||||||
|
.grades-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card {
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-btn:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-subject {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-new {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-eval-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-value {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-status {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-status.absent {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-status.dispensed {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-appreciation {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #4b5563;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-card-meta {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-coeff {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-blur {
|
||||||
|
filter: blur(4px);
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-reveal-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px dashed #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-reveal-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 40rem;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-average {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
scripts/seed-parent-grades.sh
Executable file
153
scripts/seed-parent-grades.sh
Executable file
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Seed data for manual testing of Story 6.7 — Parent Grade Consultation
|
||||||
|
# Usage: ./scripts/seed-parent-grades.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
PHP="$COMPOSE exec -T php"
|
||||||
|
TENANT="ecole-alpha"
|
||||||
|
TENANT_ID="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
|
||||||
|
# --- Credentials ---
|
||||||
|
PARENT_EMAIL="demo-parent-grades@example.com"
|
||||||
|
PARENT_PASSWORD="Parent123"
|
||||||
|
TEACHER_EMAIL="demo-pg-teacher@example.com"
|
||||||
|
TEACHER_PASSWORD="Teacher123"
|
||||||
|
STUDENT1_EMAIL="demo-pg-emma@example.com"
|
||||||
|
STUDENT1_PASSWORD="Student123"
|
||||||
|
STUDENT2_EMAIL="demo-pg-lucas@example.com"
|
||||||
|
STUDENT2_PASSWORD="Student123"
|
||||||
|
|
||||||
|
echo "=== Création des utilisateurs ==="
|
||||||
|
$PHP php bin/console app:dev:create-test-user --tenant=$TENANT --email=$PARENT_EMAIL --password=$PARENT_PASSWORD --role=PARENT --firstName=Marie --lastName=Dupont 2>&1 || true
|
||||||
|
$PHP php bin/console app:dev:create-test-user --tenant=$TENANT --email=$TEACHER_EMAIL --password=$TEACHER_PASSWORD --role=PROF --firstName=Jean --lastName=Martin 2>&1 || true
|
||||||
|
$PHP php bin/console app:dev:create-test-user --tenant=$TENANT --email=$STUDENT1_EMAIL --password=$STUDENT1_PASSWORD --role=ELEVE --firstName=Emma --lastName=Dupont 2>&1 || true
|
||||||
|
$PHP php bin/console app:dev:create-test-user --tenant=$TENANT --email=$STUDENT2_EMAIL --password=$STUDENT2_PASSWORD --role=ELEVE --firstName=Lucas --lastName=Dupont 2>&1 || true
|
||||||
|
|
||||||
|
echo "=== Résolution des IDs ==="
|
||||||
|
PARENT_ID=$($PHP php bin/console dbal:run-sql "SELECT id FROM users WHERE email='$PARENT_EMAIL' AND tenant_id='$TENANT_ID'" 2>&1 | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
|
||||||
|
STUDENT1_ID=$($PHP php bin/console dbal:run-sql "SELECT id FROM users WHERE email='$STUDENT1_EMAIL' AND tenant_id='$TENANT_ID'" 2>&1 | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
|
||||||
|
STUDENT2_ID=$($PHP php bin/console dbal:run-sql "SELECT id FROM users WHERE email='$STUDENT2_EMAIL' AND tenant_id='$TENANT_ID'" 2>&1 | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
|
||||||
|
TEACHER_ID=$($PHP php bin/console dbal:run-sql "SELECT id FROM users WHERE email='$TEACHER_EMAIL' AND tenant_id='$TENANT_ID'" 2>&1 | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
|
||||||
|
|
||||||
|
# Deterministic IDs
|
||||||
|
SCHOOL_ID=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','school-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
ACAD_YEAR_ID=$($PHP php -r "require '/app/vendor/autoload.php'; \$m=(int)date('n'); \$s=\$m>=9?(int)date('Y'):(int)date('Y')-1; \$e=\$s+1; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','$TENANT_ID:\$s-\$e')->toString();" 2>&1)
|
||||||
|
|
||||||
|
# UUIDs déterministes pour les fixtures
|
||||||
|
CLASS_6A=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-class6a-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
CLASS_5B=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-class5b-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
SUBJECT_MATH=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-math-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
SUBJECT_FRENCH=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-french-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
SUBJECT_HISTORY=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-history-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
EVAL1=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-eval1-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
EVAL2=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-eval2-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
EVAL3=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-eval3-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
EVAL4=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-eval4-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
EVAL5=$($PHP php -r "require '/app/vendor/autoload.php'; echo Ramsey\Uuid\Uuid::uuid5('6ba7b814-9dad-11d1-80b4-00c04fd430c8','demo-pg-eval5-$TENANT_ID')->toString();" 2>&1)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Parent: $PARENT_ID ($PARENT_EMAIL)"
|
||||||
|
echo " Emma: $STUDENT1_ID ($STUDENT1_EMAIL)"
|
||||||
|
echo " Lucas: $STUDENT2_ID ($STUDENT2_EMAIL)"
|
||||||
|
echo " Teacher: $TEACHER_ID ($TEACHER_EMAIL)"
|
||||||
|
echo " School: $SCHOOL_ID"
|
||||||
|
echo " AcadYear: $ACAD_YEAR_ID"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SQL="$PHP php bin/console dbal:run-sql"
|
||||||
|
|
||||||
|
echo "=== Classes ==="
|
||||||
|
$SQL "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES ('$CLASS_6A', '$TENANT_ID', '$SCHOOL_ID', '$ACAD_YEAR_ID', 'Demo-PG 6ème A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES ('$CLASS_5B', '$TENANT_ID', '$SCHOOL_ID', '$ACAD_YEAR_ID', 'Demo-PG 5ème B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
echo "=== Matières ==="
|
||||||
|
$SQL "INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES ('$SUBJECT_MATH', '$TENANT_ID', '$SCHOOL_ID', 'Mathématiques', 'DPGMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES ('$SUBJECT_FRENCH', '$TENANT_ID', '$SCHOOL_ID', 'Français', 'DPGFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES ('$SUBJECT_HISTORY', '$TENANT_ID', '$SCHOOL_ID', 'Histoire-Géo', 'DPGHIST', '#f59e0b', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
echo "=== Affectation élèves aux classes ==="
|
||||||
|
$SQL "INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT1_ID', '$CLASS_6A', '$ACAD_YEAR_ID', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT2_ID', '$CLASS_5B', '$ACAD_YEAR_ID', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
echo "=== Liaison parent-enfants ==="
|
||||||
|
$SQL "INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT1_ID', '$PARENT_ID', 'parent', NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT2_ID', '$PARENT_ID', 'parent', NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
echo "=== Affectation enseignant ==="
|
||||||
|
$SQL "INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$TEACHER_ID', '$CLASS_6A', '$SUBJECT_MATH', '$ACAD_YEAR_ID', 'active', NOW(), NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$TEACHER_ID', '$CLASS_6A', '$SUBJECT_FRENCH', '$ACAD_YEAR_ID', 'active', NOW(), NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$TEACHER_ID', '$CLASS_5B', '$SUBJECT_MATH', '$ACAD_YEAR_ID', 'active', NOW(), NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$TEACHER_ID', '$CLASS_5B', '$SUBJECT_HISTORY', '$ACAD_YEAR_ID', 'active', NOW(), NOW(), NOW()) ON CONFLICT DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
echo "=== Évaluations publiées (> 48h pour passer le délai parent) ==="
|
||||||
|
# Emma (6A) - Maths: DS Algèbre (15.5/20, coeff 2)
|
||||||
|
$SQL "INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) VALUES ('$EVAL1', '$TENANT_ID', '$CLASS_6A', '$SUBJECT_MATH', '$TEACHER_ID', 'DS Algèbre', '2026-03-20', 20, 2.0, 'published', NOW() - INTERVAL '72 hours', NOW() - INTERVAL '72 hours', NOW()) ON CONFLICT (id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
# Emma (6A) - Maths: Contrôle Géométrie (12/20, coeff 1)
|
||||||
|
$SQL "INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) VALUES ('$EVAL2', '$TENANT_ID', '$CLASS_6A', '$SUBJECT_MATH', '$TEACHER_ID', 'Contrôle Géométrie', '2026-04-01', 20, 1.0, 'published', NOW() - INTERVAL '48 hours', NOW() - INTERVAL '48 hours', NOW()) ON CONFLICT (id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
# Emma (6A) - Français: Dictée (17/20, coeff 1)
|
||||||
|
$SQL "INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) VALUES ('$EVAL3', '$TENANT_ID', '$CLASS_6A', '$SUBJECT_FRENCH', '$TEACHER_ID', 'Dictée préparée', '2026-03-25', 20, 1.0, 'published', NOW() - INTERVAL '60 hours', NOW() - INTERVAL '60 hours', NOW()) ON CONFLICT (id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
# Lucas (5B) - Maths: Fractions (8.5/20, coeff 2)
|
||||||
|
$SQL "INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) VALUES ('$EVAL4', '$TENANT_ID', '$CLASS_5B', '$SUBJECT_MATH', '$TEACHER_ID', 'DS Fractions', '2026-03-22', 20, 2.0, 'published', NOW() - INTERVAL '72 hours', NOW() - INTERVAL '72 hours', NOW()) ON CONFLICT (id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
# Lucas (5B) - Histoire: La Révolution (14/20, coeff 1)
|
||||||
|
$SQL "INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) VALUES ('$EVAL5', '$TENANT_ID', '$CLASS_5B', '$SUBJECT_HISTORY', '$TEACHER_ID', 'La Révolution française', '2026-04-02', 20, 1.0, 'published', NOW() - INTERVAL '48 hours', NOW() - INTERVAL '48 hours', NOW()) ON CONFLICT (id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
echo "=== Notes ==="
|
||||||
|
# Emma
|
||||||
|
$SQL "INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) VALUES (gen_random_uuid(), '$TENANT_ID', '$EVAL1', '$STUDENT1_ID', 15.5, 'graded', '$TEACHER_ID', NOW(), NOW(), 'Très bon travail, continue ainsi !') ON CONFLICT (evaluation_id, student_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) VALUES (gen_random_uuid(), '$TENANT_ID', '$EVAL2', '$STUDENT1_ID', 12.0, 'graded', '$TEACHER_ID', NOW(), NOW(), 'Des progrès à faire sur les angles') ON CONFLICT (evaluation_id, student_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) VALUES (gen_random_uuid(), '$TENANT_ID', '$EVAL3', '$STUDENT1_ID', 17.0, 'graded', '$TEACHER_ID', NOW(), NOW(), 'Excellente orthographe') ON CONFLICT (evaluation_id, student_id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
# Lucas
|
||||||
|
$SQL "INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) VALUES (gen_random_uuid(), '$TENANT_ID', '$EVAL4', '$STUDENT2_ID', 8.5, 'graded', '$TEACHER_ID', NOW(), NOW(), 'Revoir les fractions décimales') ON CONFLICT (evaluation_id, student_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$EVAL5', '$STUDENT2_ID', 14.0, 'graded', '$TEACHER_ID', NOW(), NOW()) ON CONFLICT (evaluation_id, student_id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
echo "=== Statistiques classe ==="
|
||||||
|
$SQL "INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) VALUES ('$EVAL1', 13.2, 6.0, 18.5, 13.5, 28, NOW()) ON CONFLICT (evaluation_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) VALUES ('$EVAL2', 11.8, 4.5, 19.0, 12.0, 28, NOW()) ON CONFLICT (evaluation_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) VALUES ('$EVAL3', 14.5, 8.0, 20.0, 15.0, 28, NOW()) ON CONFLICT (evaluation_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) VALUES ('$EVAL4', 10.3, 3.0, 17.5, 10.0, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) VALUES ('$EVAL5', 12.1, 5.5, 18.0, 12.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
echo "=== Périodes et moyennes ==="
|
||||||
|
# Trouver la période académique en cours
|
||||||
|
PERIOD_ID=$($PHP php bin/console dbal:run-sql "SELECT id FROM academic_periods WHERE tenant_id='$TENANT_ID' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1" 2>&1 | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1 || true)
|
||||||
|
|
||||||
|
if [ -n "$PERIOD_ID" ]; then
|
||||||
|
echo " Period ID: $PERIOD_ID"
|
||||||
|
|
||||||
|
# Moyennes Emma
|
||||||
|
$SQL "INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT1_ID', '$SUBJECT_MATH', '$PERIOD_ID', 14.33, 2, NOW()) ON CONFLICT (student_id, subject_id, period_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT1_ID', '$SUBJECT_FRENCH', '$PERIOD_ID', 17.0, 1, NOW()) ON CONFLICT (student_id, subject_id, period_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT1_ID', '$PERIOD_ID', 15.22, NOW()) ON CONFLICT (student_id, period_id) DO NOTHING" 2>&1
|
||||||
|
|
||||||
|
# Moyennes Lucas
|
||||||
|
$SQL "INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT2_ID', '$SUBJECT_MATH', '$PERIOD_ID', 8.5, 1, NOW()) ON CONFLICT (student_id, subject_id, period_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT2_ID', '$SUBJECT_HISTORY', '$PERIOD_ID', 14.0, 1, NOW()) ON CONFLICT (student_id, subject_id, period_id) DO NOTHING" 2>&1
|
||||||
|
$SQL "INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) VALUES (gen_random_uuid(), '$TENANT_ID', '$STUDENT2_ID', '$PERIOD_ID', 10.33, NOW()) ON CONFLICT (student_id, period_id) DO NOTHING" 2>&1
|
||||||
|
else
|
||||||
|
echo " ⚠️ Aucune période académique trouvée — moyennes non insérées"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Cache clear ==="
|
||||||
|
$PHP php bin/console cache:pool:clear paginated_queries.cache 2>&1 || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " ✅ Données de test prêtes !"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo " Connexion parent : $PARENT_EMAIL / $PARENT_PASSWORD"
|
||||||
|
echo " URL : http://ecole-alpha.classeo.local:<port>/dashboard/parent-grades"
|
||||||
|
echo ""
|
||||||
|
echo " Enfants :"
|
||||||
|
echo " Emma Dupont (6ème A) — Maths 15.5 + 12.0, Français 17.0 → moy 15.22"
|
||||||
|
echo " Lucas Dupont (5ème B) — Maths 8.5, Histoire 14.0 → moy 10.33"
|
||||||
|
echo ""
|
||||||
|
echo " Les notes sont publiées depuis > 48h (délai parent 24h OK)"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user