refactor: extract duplicated patterns into shared helpers

Backend: create api/helpers.py with eligible_enrollment_filter,
count_eligible_students, get_active_enrollment, ensure_unique_name,
upsert_app_configs, and build_heatmap. Add full_name properties to
Student model. Apply across all route files (-481/+184 lines).

Frontend: create stores/helpers.js with withLoading composable,
apply to assessments and classes Pinia stores.

96/96 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 14:05:10 +01:00
parent b1b7d12a9f
commit a0ab7224e1
10 changed files with 402 additions and 481 deletions

196
backend/api/helpers.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Fonctions utilitaires partagées entre les routes API.
Élimine la duplication de code pour les patterns courants.
"""
from datetime import date
from typing import Optional, Dict, List, Tuple, Any, Callable
from fastapi import HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from infrastructure.database.models import (
AppConfig,
StudentEnrollment,
Student,
)
from schemas.assessment import HeatmapCell, HeatmapData
def eligible_enrollment_filter(class_group_id: int, at_date: date):
"""
Retourne les clauses WHERE pour filtrer les inscriptions actives à une date donnée.
Usage:
query = select(StudentEnrollment).where(
*eligible_enrollment_filter(class_id, assessment.date)
)
"""
return (
StudentEnrollment.class_group_id == class_group_id,
StudentEnrollment.enrollment_date <= at_date,
(
StudentEnrollment.departure_date.is_(None)
| (StudentEnrollment.departure_date >= at_date)
),
)
async def count_eligible_students(
session: AsyncSession, class_group_id: int, at_date: date
) -> int:
"""Compte les élèves inscrits dans une classe à une date donnée."""
query = select(func.count(StudentEnrollment.id)).where(
*eligible_enrollment_filter(class_group_id, at_date)
)
result = await session.execute(query)
return result.scalar() or 0
def eligible_student_ids_subquery(class_group_id: int, at_date: date):
"""Retourne une sous-requête des student_id éligibles à une date donnée."""
return select(StudentEnrollment.student_id).where(
*eligible_enrollment_filter(class_group_id, at_date)
)
async def get_eligible_enrollments(
session: AsyncSession, class_group_id: int, at_date: date
):
"""Récupère les inscriptions éligibles avec les étudiants chargés."""
query = (
select(StudentEnrollment)
.options(selectinload(StudentEnrollment.student))
.where(*eligible_enrollment_filter(class_group_id, at_date))
)
result = await session.execute(query)
return result.scalars().all()
def get_active_enrollment(
student: Student,
) -> Optional[StudentEnrollment]:
"""Retourne l'inscription active (sans date de départ) d'un élève."""
for enrollment in student.enrollments:
if enrollment.departure_date is None:
return enrollment
return None
async def ensure_unique_name(
session: AsyncSession,
model_class,
name: str,
*,
field_name: str = "name",
exclude_id: Optional[int] = None,
entity_label: str = "enregistrement",
):
"""
Vérifie qu'un nom est unique pour un modèle donné.
Lève HTTPException 400 si un doublon est trouvé.
"""
field = getattr(model_class, field_name)
query = select(model_class).where(field == name)
if exclude_id is not None:
query = query.where(model_class.id != exclude_id)
result = await session.execute(query)
if result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Un(e) {entity_label} avec le nom '{name}' existe déjà",
)
async def upsert_app_configs(
session: AsyncSession, updates: Dict[str, str]
) -> None:
"""
Met à jour ou crée des entrées AppConfig en lot.
Ne fait PAS de commit — l'appelant doit appeler session.commit().
"""
for key, value in updates.items():
query = select(AppConfig).where(AppConfig.key == key)
result = await session.execute(query)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
session.add(AppConfig(key=key, value=value))
def build_heatmap(
enrollments,
assessment,
items: dict,
item_extractor: Callable,
grading_calc,
sorted_student_names: List[str],
*,
color_map: Optional[Dict[str, str]] = None,
) -> Optional[HeatmapData]:
"""
Construit une HeatmapData pour des compétences ou domaines.
Args:
enrollments: liste d'inscriptions avec .student chargé
assessment: l'évaluation avec .exercises chargés
items: set ou dict des items à évaluer (noms)
item_extractor: fonction (element) -> nom de l'item ou None
grading_calc: instance de GradingCalculator
sorted_student_names: noms triés pour l'axe Y
color_map: optionnel, dict nom -> couleur pour les cellules
"""
if not items:
return None
item_names = set(items) if isinstance(items, dict) else items
cells = []
for enrollment in enrollments:
student = enrollment.student
student_name = student.full_name_reversed
item_scores = {name: {"score": 0.0, "max": 0.0} for name in item_names}
for exercise in assessment.exercises:
for element in exercise.grading_elements:
item_name = item_extractor(element)
if item_name and item_name in item_scores:
for g in element.grades:
if g.student_id == student.id and g.value:
calc_score = grading_calc.calculate_score(
g.value.strip(),
element.grading_type,
element.max_points,
)
if calc_score is not None:
item_scores[item_name]["score"] += calc_score
item_scores[item_name]["max"] += element.max_points
break
for name, data in item_scores.items():
if data["max"] > 0:
pct = round((data["score"] / data["max"]) * 100, 1)
cell_kwargs = dict(
student_id=student.id,
student_name=student_name,
item_name=name,
score=round(data["score"], 2),
max_points=data["max"],
percentage=pct,
)
if color_map and name in color_map:
cell_kwargs["color"] = color_map[name]
cells.append(HeatmapCell(**cell_kwargs))
return HeatmapData(
items=sorted(list(item_names)),
students=sorted_student_names,
cells=cells,
)

View File

@@ -11,6 +11,12 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from api.dependencies import AsyncSessionDep from api.dependencies import AsyncSessionDep
from api.helpers import (
count_eligible_students,
eligible_student_ids_subquery,
get_eligible_enrollments,
build_heatmap,
)
from infrastructure.database.models import ( from infrastructure.database.models import (
Assessment, Assessment,
Exercise, Exercise,
@@ -36,8 +42,6 @@ from schemas.assessment import (
SendReportsRequest, SendReportsRequest,
SendReportResult, SendReportResult,
SendReportsResponse, SendReportsResponse,
HeatmapCell,
HeatmapData,
) )
from schemas.grading import BulkGradeCreate, BulkGradeResponse, GradeRead from schemas.grading import BulkGradeCreate, BulkGradeResponse, GradeRead
from domain.services import GradingCalculator, StatisticsService, StudentReportService, generate_report_html, ConfigService from domain.services import GradingCalculator, StatisticsService, StudentReportService, generate_report_html, ConfigService
@@ -124,21 +128,13 @@ async def get_assessments(
total_elements += 1 total_elements += 1
# Compter les élèves éligibles (inscrits à la date de l'évaluation) # Compter les élèves éligibles (inscrits à la date de l'évaluation)
eligible_query = select(func.count(StudentEnrollment.id)).where( eligible_students_count = await count_eligible_students(
StudentEnrollment.class_group_id == assessment.class_group_id, session, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
eligible_result = await session.execute(eligible_query)
eligible_students_count = eligible_result.scalar() or 0
# Compter les notes saisies uniquement pour les élèves éligibles # Compter les notes saisies uniquement pour les élèves éligibles
eligible_student_ids = select(StudentEnrollment.student_id).where( eligible_student_ids = eligible_student_ids_subquery(
StudentEnrollment.class_group_id == assessment.class_group_id, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
grades_query = select(func.count(Grade.id)).where( grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_( Grade.grading_element_id.in_(
@@ -239,21 +235,13 @@ async def get_assessment(
) )
# Calculer la progression # Calculer la progression
eligible_query = select(func.count(StudentEnrollment.id)).where( eligible_students_count = await count_eligible_students(
StudentEnrollment.class_group_id == assessment.class_group_id, session, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
eligible_result = await session.execute(eligible_query)
eligible_students_count = eligible_result.scalar() or 0
# Compter les notes uniquement pour les élèves éligibles # Compter les notes uniquement pour les élèves éligibles
eligible_student_ids = select(StudentEnrollment.student_id).where( eligible_student_ids = eligible_student_ids_subquery(
StudentEnrollment.class_group_id == assessment.class_group_id, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
grades_query = select(func.count(Grade.id)).where( grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_( Grade.grading_element_id.in_(
@@ -266,11 +254,11 @@ async def get_assessment(
) )
grades_result = await session.execute(grades_query) grades_result = await session.execute(grades_query)
grades_count = grades_result.scalar() or 0 grades_count = grades_result.scalar() or 0
progress = calculate_grading_progress( progress = calculate_grading_progress(
assessment, grades_count, total_elements, eligible_students_count assessment, grades_count, total_elements, eligible_students_count
) )
return AssessmentDetail( return AssessmentDetail(
id=assessment.id, id=assessment.id,
title=assessment.title, title=assessment.title,
@@ -322,18 +310,9 @@ async def get_assessment_results(
raise HTTPException(status_code=404, detail="Évaluation non trouvée") raise HTTPException(status_code=404, detail="Évaluation non trouvée")
# Récupérer les élèves éligibles # Récupérer les élèves éligibles
eligible_query = ( enrollments = await get_eligible_enrollments(
select(StudentEnrollment) session, assessment.class_group_id, assessment.date
.options(selectinload(StudentEnrollment.student))
.where(
StudentEnrollment.class_group_id == assessment.class_group_id,
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
)
) )
eligible_result = await session.execute(eligible_query)
enrollments = eligible_result.scalars().all()
# Calculer le total des points maximum # Calculer le total des points maximum
total_max_points = 0 total_max_points = 0
@@ -392,7 +371,7 @@ async def get_assessment_results(
student_scores[student_id] = StudentScore( student_scores[student_id] = StudentScore(
student_id=student_id, student_id=student_id,
student_name=f"{student.last_name} {student.first_name}", student_name=student.full_name_reversed,
email=student.email, # Ajouter l'email de l'élève email=student.email, # Ajouter l'email de l'élève
total_score=round(total_score, 2), total_score=round(total_score, 2),
total_max_points=counted_max, total_max_points=counted_max,
@@ -440,97 +419,25 @@ async def get_assessment_results(
all_domains[element.domain.name] = element.domain.color all_domains[element.domain.name] = element.domain.color
# Calculer heatmap des compétences si présentes # Calculer heatmap des compétences si présentes
if all_competences: competences_heatmap = build_heatmap(
competences_cells = [] enrollments=enrollments,
assessment=assessment,
for enrollment in enrollments: items=all_competences,
student = enrollment.student item_extractor=lambda el: el.skill,
student_name = f"{student.last_name} {student.first_name}" grading_calc=grading_calc,
sorted_student_names=[s.student_name for s in sorted_scores],
# Calculer score par compétence pour cet élève )
competence_scores = {}
for comp in all_competences:
competence_scores[comp] = {"score": 0.0, "max": 0.0}
for exercise in assessment.exercises:
for element in exercise.grading_elements:
if element.skill and element.skill in competence_scores:
# Trouver la note
for g in element.grades:
if g.student_id == student.id and g.value:
calc_score = grading_calc.calculate_score(
g.value.strip(), element.grading_type, element.max_points
)
if calc_score is not None:
competence_scores[element.skill]["score"] += calc_score
competence_scores[element.skill]["max"] += element.max_points
break
# Créer les cellules
for comp, data in competence_scores.items():
if data["max"] > 0:
pct = round((data["score"] / data["max"]) * 100, 1)
competences_cells.append(HeatmapCell(
student_id=student.id,
student_name=student_name,
item_name=comp,
score=round(data["score"], 2),
max_points=data["max"],
percentage=pct
))
competences_heatmap = HeatmapData(
items=sorted(list(all_competences)),
students=[s.student_name for s in sorted_scores],
cells=competences_cells
)
# Calculer heatmap des domaines si présents # Calculer heatmap des domaines si présents
if all_domains: domains_heatmap = build_heatmap(
domains_cells = [] enrollments=enrollments,
assessment=assessment,
for enrollment in enrollments: items=all_domains,
student = enrollment.student item_extractor=lambda el: el.domain.name if el.domain else None,
student_name = f"{student.last_name} {student.first_name}" grading_calc=grading_calc,
sorted_student_names=[s.student_name for s in sorted_scores],
# Calculer score par domaine pour cet élève color_map=all_domains,
domain_scores = {} )
for dom in all_domains:
domain_scores[dom] = {"score": 0.0, "max": 0.0}
for exercise in assessment.exercises:
for element in exercise.grading_elements:
if element.domain and element.domain.name in domain_scores:
# Trouver la note
for g in element.grades:
if g.student_id == student.id and g.value:
calc_score = grading_calc.calculate_score(
g.value.strip(), element.grading_type, element.max_points
)
if calc_score is not None:
domain_scores[element.domain.name]["score"] += calc_score
domain_scores[element.domain.name]["max"] += element.max_points
break
# Créer les cellules
for dom, data in domain_scores.items():
if data["max"] > 0:
pct = round((data["score"] / data["max"]) * 100, 1)
domains_cells.append(HeatmapCell(
student_id=student.id,
student_name=student_name,
item_name=dom,
score=round(data["score"], 2),
max_points=data["max"],
percentage=pct,
color=all_domains.get(dom)
))
domains_heatmap = HeatmapData(
items=sorted(list(all_domains.keys())),
students=[s.student_name for s in sorted_scores],
cells=domains_cells
)
return AssessmentResults( return AssessmentResults(
assessment_id=assessment.id, assessment_id=assessment.id,
@@ -827,21 +734,13 @@ async def update_assessment(
) )
# Calculer la progression # Calculer la progression
eligible_query = select(func.count(StudentEnrollment.id)).where( eligible_students_count = await count_eligible_students(
StudentEnrollment.class_group_id == assessment.class_group_id, session, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
eligible_result = await session.execute(eligible_query)
eligible_students_count = eligible_result.scalar() or 0
# Compter les notes uniquement pour les élèves éligibles # Compter les notes uniquement pour les élèves éligibles
eligible_student_ids = select(StudentEnrollment.student_id).where( eligible_student_ids = eligible_student_ids_subquery(
StudentEnrollment.class_group_id == assessment.class_group_id, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
grades_query = select(func.count(Grade.id)).where( grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_( Grade.grading_element_id.in_(
@@ -854,11 +753,11 @@ async def update_assessment(
) )
grades_result = await session.execute(grades_query) grades_result = await session.execute(grades_query)
grades_count = grades_result.scalar() or 0 grades_count = grades_result.scalar() or 0
progress = calculate_grading_progress( progress = calculate_grading_progress(
assessment, grades_count, total_elements, eligible_students_count assessment, grades_count, total_elements, eligible_students_count
) )
return AssessmentDetail( return AssessmentDetail(
id=assessment.id, id=assessment.id,
title=assessment.title, title=assessment.title,
@@ -1132,7 +1031,7 @@ async def send_reports(
total_failed = 0 total_failed = 0
for student in students: for student in students:
student_name = f"{student.first_name} {student.last_name}" student_name = student.full_name
# Vérifier que l'élève a une adresse email # Vérifier que l'élève a une adresse email
if not student.email: if not student.email:
@@ -1307,7 +1206,7 @@ async def preview_report(
if student_id not in all_students_grades: if student_id not in all_students_grades:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"L'élève {student.first_name} {student.last_name} n'a pas de notes pour cette évaluation" detail=f"L'élève {student.full_name} n'a pas de notes pour cette évaluation"
) )
# Créer le service de rapport # Créer le service de rapport

View File

@@ -12,6 +12,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from api.dependencies import AsyncSessionDep from api.dependencies import AsyncSessionDep
from api.helpers import ensure_unique_name
from infrastructure.database.models import ( from infrastructure.database.models import (
ClassGroup, ClassGroup,
Student, Student,
@@ -201,7 +202,7 @@ async def get_class_students(
last_name=student.last_name, last_name=student.last_name,
first_name=student.first_name, first_name=student.first_name,
email=student.email, email=student.email,
full_name=f"{student.first_name} {student.last_name}", full_name=student.full_name,
current_class_id=class_id if is_active else None, current_class_id=class_id if is_active else None,
current_class_name=cls.name if is_active else None, current_class_name=cls.name if is_active else None,
enrollment_id=enrollment.id, enrollment_id=enrollment.id,
@@ -419,13 +420,7 @@ async def create_class(
Crée une nouvelle classe. Crée une nouvelle classe.
""" """
# Vérifier l'unicité du nom # Vérifier l'unicité du nom
existing_query = select(ClassGroup).where(ClassGroup.name == class_data.name) await ensure_unique_name(session, ClassGroup, class_data.name, entity_label="classe")
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Une classe avec le nom '{class_data.name}' existe déjà"
)
# Créer la nouvelle classe # Créer la nouvelle classe
new_class = ClassGroup( new_class = ClassGroup(
@@ -466,16 +461,10 @@ async def update_class(
# Vérifier l'unicité du nouveau nom si modifié # Vérifier l'unicité du nouveau nom si modifié
if class_data.name and class_data.name != cls.name: if class_data.name and class_data.name != cls.name:
existing_query = select(ClassGroup).where( await ensure_unique_name(
ClassGroup.name == class_data.name, session, ClassGroup, class_data.name,
ClassGroup.id != class_id exclude_id=class_id, entity_label="classe"
) )
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Une autre classe avec le nom '{class_data.name}' existe déjà"
)
# Appliquer les modifications # Appliquer les modifications
if class_data.name is not None: if class_data.name is not None:

View File

@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException
from sqlalchemy import select, func, delete from sqlalchemy import select, func, delete
from api.dependencies import AsyncSessionDep from api.dependencies import AsyncSessionDep
from api.helpers import ensure_unique_name, upsert_app_configs
from infrastructure.database.models import ( from infrastructure.database.models import (
AppConfig, AppConfig,
Competence, Competence,
@@ -228,13 +229,7 @@ async def create_competence(
Crée une nouvelle compétence. Crée une nouvelle compétence.
""" """
# Vérifier l'unicité du nom # Vérifier l'unicité du nom
existing_query = select(Competence).where(Competence.name == data.name) await ensure_unique_name(session, Competence, data.name, entity_label="compétence")
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Une compétence avec le nom '{data.name}' existe déjà"
)
# Déterminer l'index d'ordre # Déterminer l'index d'ordre
if data.order_index is None: if data.order_index is None:
@@ -284,16 +279,7 @@ async def update_competence(
# Vérifier l'unicité du nouveau nom # Vérifier l'unicité du nouveau nom
if data.name and data.name != competence.name: if data.name and data.name != competence.name:
existing_query = select(Competence).where( await ensure_unique_name(session, Competence, data.name, exclude_id=competence_id, entity_label="compétence")
Competence.name == data.name,
Competence.id != competence_id
)
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Une autre compétence avec le nom '{data.name}' existe déjà"
)
# Appliquer les modifications # Appliquer les modifications
if data.name is not None: if data.name is not None:
@@ -353,13 +339,7 @@ async def create_domain(
Crée un nouveau domaine. Crée un nouveau domaine.
""" """
# Vérifier l'unicité du nom # Vérifier l'unicité du nom
existing_query = select(Domain).where(Domain.name == data.name) await ensure_unique_name(session, Domain, data.name, entity_label="domaine")
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Un domaine avec le nom '{data.name}' existe déjà"
)
# Créer le domaine # Créer le domaine
domain = Domain( domain = Domain(
@@ -398,16 +378,7 @@ async def update_domain(
# Vérifier l'unicité du nouveau nom # Vérifier l'unicité du nouveau nom
if data.name and data.name != domain.name: if data.name and data.name != domain.name:
existing_query = select(Domain).where( await ensure_unique_name(session, Domain, data.name, exclude_id=domain_id, entity_label="domaine")
Domain.name == data.name,
Domain.id != domain_id
)
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Un autre domaine avec le nom '{data.name}' existe déjà"
)
# Appliquer les modifications # Appliquer les modifications
if data.name is not None: if data.name is not None:
@@ -669,18 +640,7 @@ async def update_app_config(
if data.default_grading_system is not None: if data.default_grading_system is not None:
updates.append(("default_grading_system", data.default_grading_system)) updates.append(("default_grading_system", data.default_grading_system))
for key, value in updates: await upsert_app_configs(session, dict(updates))
# Chercher la config existante
query = select(AppConfig).where(AppConfig.key == key)
result = await session.execute(query)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
# Créer si n'existe pas
new_config = AppConfig(key=key, value=value)
session.add(new_config)
await session.commit() await session.commit()
@@ -755,18 +715,7 @@ async def update_smtp_config(
if data.from_address is not None: if data.from_address is not None:
updates.append(("email.from_address", data.from_address)) updates.append(("email.from_address", data.from_address))
for key, value in updates: await upsert_app_configs(session, dict(updates))
# Chercher la config existante
query = select(AppConfig).where(AppConfig.key == key)
result = await session.execute(query)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
# Créer si n'existe pas
new_config = AppConfig(key=key, value=value)
session.add(new_config)
await session.commit() await session.commit()
@@ -843,16 +792,7 @@ async def update_notes_gradient(
if data.enabled is not None: if data.enabled is not None:
updates.append(("grading.notes_gradient.enabled", "true" if data.enabled else "false")) updates.append(("grading.notes_gradient.enabled", "true" if data.enabled else "false"))
for key, value in updates: await upsert_app_configs(session, dict(updates))
query = select(AppConfig).where(AppConfig.key == key)
result = await session.execute(query)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
new_config = AppConfig(key=key, value=value)
session.add(new_config)
await session.commit() await session.commit()

View File

@@ -202,7 +202,7 @@ async def get_council_preparation(
student_id=student.id, student_id=student.id,
first_name=student.first_name, first_name=student.first_name,
last_name=student.last_name, last_name=student.last_name,
full_name=f"{student.first_name} {student.last_name}", full_name=student.full_name,
overall_average=overall_average, overall_average=overall_average,
assessment_count=assessment_count, assessment_count=assessment_count,
grades_by_assessment=grades_by_assessment, grades_by_assessment=grades_by_assessment,

View File

@@ -10,6 +10,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from api.dependencies import AsyncSessionDep from api.dependencies import AsyncSessionDep
from api.helpers import get_active_enrollment
from infrastructure.database.models import ( from infrastructure.database.models import (
Student, Student,
StudentEnrollment, StudentEnrollment,
@@ -62,12 +63,8 @@ async def get_students(
students_list = [] students_list = []
for student in students: for student in students:
# Trouver l'inscription active # Trouver l'inscription active
current_enrollment = None current_enrollment = get_active_enrollment(student)
for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
break
# Filtrer par classe si demandé # Filtrer par classe si demandé
if class_id and (not current_enrollment or current_enrollment.class_group_id != class_id): if class_id and (not current_enrollment or current_enrollment.class_group_id != class_id):
continue continue
@@ -78,7 +75,7 @@ async def get_students(
last_name=student.last_name, last_name=student.last_name,
first_name=student.first_name, first_name=student.first_name,
email=student.email, email=student.email,
full_name=f"{student.first_name} {student.last_name}", full_name=student.full_name,
current_class_id=current_enrollment.class_group_id if current_enrollment else None, current_class_id=current_enrollment.class_group_id if current_enrollment else None,
current_class_name=current_enrollment.class_group.name if current_enrollment else None current_class_name=current_enrollment.class_group.name if current_enrollment else None
) )
@@ -113,13 +110,10 @@ async def get_student(
raise HTTPException(status_code=404, detail="Étudiant non trouvé") raise HTTPException(status_code=404, detail="Étudiant non trouvé")
# Trouver l'inscription active # Trouver l'inscription active
current_enrollment = None current_enrollment = get_active_enrollment(student)
enrollments_list = [] enrollments_list = []
for enrollment in student.enrollments: for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
enrollments_list.append( enrollments_list.append(
EnrollmentRead( EnrollmentRead(
id=enrollment.id, id=enrollment.id,
@@ -144,7 +138,7 @@ async def get_student(
last_name=student.last_name, last_name=student.last_name,
first_name=student.first_name, first_name=student.first_name,
email=student.email, email=student.email,
full_name=f"{student.first_name} {student.last_name}", full_name=student.full_name,
current_class_id=current_enrollment.class_group_id if current_enrollment else None, current_class_id=current_enrollment.class_group_id if current_enrollment else None,
current_class_name=current_enrollment.class_group.name if current_enrollment else None, current_class_name=current_enrollment.class_group.name if current_enrollment else None,
enrollments=enrollments_list enrollments=enrollments_list
@@ -210,7 +204,7 @@ async def create_student(
last_name=new_student.last_name, last_name=new_student.last_name,
first_name=new_student.first_name, first_name=new_student.first_name,
email=new_student.email, email=new_student.email,
full_name=f"{new_student.first_name} {new_student.last_name}", full_name=new_student.full_name,
current_class_id=current_class_id, current_class_id=current_class_id,
current_class_name=current_class_name current_class_name=current_class_name
) )
@@ -264,18 +258,14 @@ async def update_student(
await session.refresh(student) await session.refresh(student)
# Trouver la classe actuelle # Trouver la classe actuelle
current_enrollment = None current_enrollment = get_active_enrollment(student)
for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
break
return StudentWithClass( return StudentWithClass(
id=student.id, id=student.id,
last_name=student.last_name, last_name=student.last_name,
first_name=student.first_name, first_name=student.first_name,
email=student.email, email=student.email,
full_name=f"{student.first_name} {student.last_name}", full_name=student.full_name,
current_class_id=current_enrollment.class_group_id if current_enrollment else None, current_class_id=current_enrollment.class_group_id if current_enrollment else None,
current_class_name=current_enrollment.class_group.name if current_enrollment else None current_class_name=current_enrollment.class_group.name if current_enrollment else None
) )
@@ -325,18 +315,14 @@ async def update_student_email(
await session.refresh(student) await session.refresh(student)
# Trouver la classe actuelle # Trouver la classe actuelle
current_enrollment = None current_enrollment = get_active_enrollment(student)
for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
break
return StudentWithClass( return StudentWithClass(
id=student.id, id=student.id,
last_name=student.last_name, last_name=student.last_name,
first_name=student.first_name, first_name=student.first_name,
email=student.email, email=student.email,
full_name=f"{student.first_name} {student.last_name}", full_name=student.full_name,
current_class_id=current_enrollment.class_group_id if current_enrollment else None, current_class_id=current_enrollment.class_group_id if current_enrollment else None,
current_class_name=current_enrollment.class_group.name if current_enrollment else None current_class_name=current_enrollment.class_group.name if current_enrollment else None
) )
@@ -403,7 +389,7 @@ async def enroll_student(
if active_result.scalar_one_or_none(): if active_result.scalar_one_or_none():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"L'élève {student.first_name} {student.last_name} est déjà inscrit dans une classe" detail=f"L'élève {student.full_name} est déjà inscrit dans une classe"
) )
else: else:
# Nouvel élève # Nouvel élève
@@ -447,9 +433,9 @@ async def enroll_student(
return EnrollmentResponse( return EnrollmentResponse(
enrollment_id=enrollment.id, enrollment_id=enrollment.id,
student_id=student.id, student_id=student.id,
student_name=f"{student.first_name} {student.last_name}", student_name=student.full_name,
class_name=class_group.name, class_name=class_group.name,
message=f"Élève {student.first_name} {student.last_name} inscrit en {class_group.name}", message=f"Élève {student.full_name} inscrit en {class_group.name}",
is_new_student=is_new_student is_new_student=is_new_student
) )
@@ -493,9 +479,9 @@ async def transfer_student(
if not old_enrollment: if not old_enrollment:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Aucune inscription active trouvée pour l'élève {student.first_name} {student.last_name}" detail=f"Aucune inscription active trouvée pour l'élève {student.full_name}"
) )
old_class_name = old_enrollment.class_group.name old_class_name = old_enrollment.class_group.name
# Terminer l'ancienne inscription # Terminer l'ancienne inscription
@@ -516,10 +502,10 @@ async def transfer_student(
return TransferResponse( return TransferResponse(
old_enrollment_id=old_enrollment.id, old_enrollment_id=old_enrollment.id,
new_enrollment_id=new_enrollment.id, new_enrollment_id=new_enrollment.id,
student_name=f"{student.first_name} {student.last_name}", student_name=student.full_name,
old_class_name=old_class_name, old_class_name=old_class_name,
new_class_name=new_class.name, new_class_name=new_class.name,
message=f"Élève {student.first_name} {student.last_name} transféré de {old_class_name} vers {new_class.name}" message=f"Élève {student.full_name} transféré de {old_class_name} vers {new_class.name}"
) )
@@ -554,9 +540,9 @@ async def record_departure(
if not enrollment: if not enrollment:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Aucune inscription active trouvée pour l'élève {student.first_name} {student.last_name}" detail=f"Aucune inscription active trouvée pour l'élève {student.full_name}"
) )
class_name = enrollment.class_group.name class_name = enrollment.class_group.name
# Enregistrer le départ # Enregistrer le départ
@@ -567,7 +553,7 @@ async def record_departure(
return DepartureResponse( return DepartureResponse(
enrollment_id=enrollment.id, enrollment_id=enrollment.id,
student_name=f"{student.first_name} {student.last_name}", student_name=student.full_name,
class_name=class_name, class_name=class_name,
message=f"Départ de {student.first_name} {student.last_name} de {class_name} enregistré" message=f"Départ de {student.full_name} de {class_name} enregistré"
) )

View File

@@ -123,6 +123,16 @@ class Student(Base):
"CouncilAppreciation", back_populates="student", lazy="selectin" "CouncilAppreciation", back_populates="student", lazy="selectin"
) )
@property
def full_name(self) -> str:
"""Prénom Nom"""
return f"{self.first_name} {self.last_name}"
@property
def full_name_reversed(self) -> str:
"""Nom Prénom"""
return f"{self.last_name} {self.first_name}"
def __repr__(self): def __repr__(self):
return f"<Student {self.first_name} {self.last_name}>" return f"<Student {self.first_name} {self.last_name}>"

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import assessmentsService from '@/services/assessments' import assessmentsService from '@/services/assessments'
import { withLoading } from './helpers'
export const useAssessmentsStore = defineStore('assessments', () => { export const useAssessmentsStore = defineStore('assessments', () => {
// State // State
@@ -10,7 +11,7 @@ export const useAssessmentsStore = defineStore('assessments', () => {
const currentGrades = ref([]) const currentGrades = ref([])
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
// Filters // Filters
const filters = ref({ const filters = ref({
trimester: null, trimester: null,
@@ -21,22 +22,22 @@ export const useAssessmentsStore = defineStore('assessments', () => {
// Getters // Getters
const assessmentsCount = computed(() => assessments.value.length) const assessmentsCount = computed(() => assessments.value.length)
const filteredAssessments = computed(() => { const filteredAssessments = computed(() => {
let result = [...assessments.value] let result = [...assessments.value]
if (filters.value.trimester) { if (filters.value.trimester) {
result = result.filter(a => a.trimester === filters.value.trimester) result = result.filter(a => a.trimester === filters.value.trimester)
} }
if (filters.value.class_id) { if (filters.value.class_id) {
result = result.filter(a => a.class_group_id === filters.value.class_id) result = result.filter(a => a.class_group_id === filters.value.class_id)
} }
return result return result
}) })
const incompleteAssessments = computed(() => const incompleteAssessments = computed(() =>
assessments.value.filter(a => { assessments.value.filter(a => {
const progress = a.progress || a.grading_progress const progress = a.progress || a.grading_progress
return progress?.percentage < 100 return progress?.percentage < 100
@@ -44,130 +45,61 @@ export const useAssessmentsStore = defineStore('assessments', () => {
) )
// Actions // Actions
async function fetchAssessments(customFilters = null) { const fetchAssessments = withLoading(loading, error, async (customFilters = null) => {
loading.value = true const queryFilters = customFilters || filters.value
error.value = null assessments.value = await assessmentsService.getAll(queryFilters)
try { })
const queryFilters = customFilters || filters.value
assessments.value = await assessmentsService.getAll(queryFilters)
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchAssessment(id) { const fetchAssessment = withLoading(loading, error, async (id) => {
loading.value = true currentAssessment.value = await assessmentsService.getById(id)
error.value = null return currentAssessment.value
try { })
currentAssessment.value = await assessmentsService.getById(id)
return currentAssessment.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchResults(id) { const fetchResults = withLoading(loading, error, async (id) => {
loading.value = true currentResults.value = await assessmentsService.getResults(id)
error.value = null return currentResults.value
try { })
currentResults.value = await assessmentsService.getResults(id)
return currentResults.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchGrades(id) { const fetchGrades = withLoading(loading, error, async (id) => {
loading.value = true currentGrades.value = await assessmentsService.getGrades(id)
error.value = null return currentGrades.value
try { })
currentGrades.value = await assessmentsService.getGrades(id)
return currentGrades.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function createAssessment(data) { const createAssessment = withLoading(loading, error, async (data) => {
loading.value = true const newAssessment = await assessmentsService.create(data)
error.value = null assessments.value.unshift(newAssessment)
try { return newAssessment
const newAssessment = await assessmentsService.create(data) })
assessments.value.unshift(newAssessment)
return newAssessment
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function updateAssessment(id, data) { const updateAssessment = withLoading(loading, error, async (id, data) => {
loading.value = true const updated = await assessmentsService.update(id, data)
error.value = null const index = assessments.value.findIndex(a => a.id === id)
try { if (index !== -1) {
const updated = await assessmentsService.update(id, data) assessments.value[index] = updated
const index = assessments.value.findIndex(a => a.id === id)
if (index !== -1) {
assessments.value[index] = updated
}
if (currentAssessment.value?.id === id) {
currentAssessment.value = updated
}
return updated
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
} }
} if (currentAssessment.value?.id === id) {
currentAssessment.value = updated
}
return updated
})
async function deleteAssessment(id) { const deleteAssessment = withLoading(loading, error, async (id) => {
loading.value = true await assessmentsService.delete(id)
error.value = null assessments.value = assessments.value.filter(a => a.id !== id)
try { if (currentAssessment.value?.id === id) {
await assessmentsService.delete(id) currentAssessment.value = null
assessments.value = assessments.value.filter(a => a.id !== id)
if (currentAssessment.value?.id === id) {
currentAssessment.value = null
}
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
} }
} })
async function saveGrades(id, grades) { // fetchAssessment is declared above so it can be called here safely.
loading.value = true // withLoading sets loading=false in its finally block, so the inner call
error.value = null // to fetchAssessment runs as a nested operation within the outer wrapper.
try { const saveGrades = withLoading(loading, error, async (id, grades) => {
const result = await assessmentsService.saveGrades(id, grades) const result = await assessmentsService.saveGrades(id, grades)
// Refresh assessment to update progress // Refresh assessment to update progress
await fetchAssessment(id) await fetchAssessment(id)
return result return result
} catch (e) { })
error.value = e.message
throw e
} finally {
loading.value = false
}
}
function setFilters(newFilters) { function setFilters(newFilters) {
filters.value = { ...filters.value, ...newFilters } filters.value = { ...filters.value, ...newFilters }

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import classesService from '@/services/classes' import classesService from '@/services/classes'
import { withLoading } from './helpers'
export const useClassesStore = defineStore('classes', () => { export const useClassesStore = defineStore('classes', () => {
// State // State
@@ -12,104 +13,50 @@ export const useClassesStore = defineStore('classes', () => {
// Getters // Getters
const classesCount = computed(() => classes.value.length) const classesCount = computed(() => classes.value.length)
const totalStudents = computed(() => const totalStudents = computed(() =>
classes.value.reduce((sum, c) => sum + (c.students_count || c.student_count || 0), 0) classes.value.reduce((sum, c) => sum + (c.students_count || c.student_count || 0), 0)
) )
// Actions // Actions
async function fetchClasses() { const fetchClasses = withLoading(loading, error, async () => {
loading.value = true classes.value = await classesService.getAll()
error.value = null })
try {
classes.value = await classesService.getAll()
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchClass(id) { const fetchClass = withLoading(loading, error, async (id) => {
loading.value = true currentClass.value = await classesService.getById(id)
error.value = null return currentClass.value
try { })
currentClass.value = await classesService.getById(id)
return currentClass.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchClassStats(id, trimester = null) { const fetchClassStats = withLoading(loading, error, async (id, trimester = null) => {
loading.value = true currentStats.value = await classesService.getStats(id, trimester)
error.value = null return currentStats.value
try { })
currentStats.value = await classesService.getStats(id, trimester)
return currentStats.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function createClass(data) { const createClass = withLoading(loading, error, async (data) => {
loading.value = true const newClass = await classesService.create(data)
error.value = null classes.value.push(newClass)
try { return newClass
const newClass = await classesService.create(data) })
classes.value.push(newClass)
return newClass
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function updateClass(id, data) { const updateClass = withLoading(loading, error, async (id, data) => {
loading.value = true const updated = await classesService.update(id, data)
error.value = null const index = classes.value.findIndex(c => c.id === id)
try { if (index !== -1) {
const updated = await classesService.update(id, data) classes.value[index] = updated
const index = classes.value.findIndex(c => c.id === id)
if (index !== -1) {
classes.value[index] = updated
}
if (currentClass.value?.id === id) {
currentClass.value = updated
}
return updated
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
} }
} if (currentClass.value?.id === id) {
currentClass.value = updated
}
return updated
})
async function deleteClass(id) { const deleteClass = withLoading(loading, error, async (id) => {
loading.value = true await classesService.delete(id)
error.value = null classes.value = classes.value.filter(c => c.id !== id)
try { if (currentClass.value?.id === id) {
await classesService.delete(id) currentClass.value = null
classes.value = classes.value.filter(c => c.id !== id)
if (currentClass.value?.id === id) {
currentClass.value = null
}
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
} }
} })
function clearCurrent() { function clearCurrent() {
currentClass.value = null currentClass.value = null

View File

@@ -0,0 +1,22 @@
/**
* Wraps an async function with loading/error state management.
*
* @param {import('vue').Ref<boolean>} loading - shared loading ref
* @param {import('vue').Ref<string|null>} error - shared error ref
* @param {Function} fn - async function to wrap
* @returns {Function} wrapped function with identical signature
*/
export function withLoading(loading, error, fn) {
return async (...args) => {
loading.value = true
error.value = null
try {
return await fn(...args)
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
}