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

View File

@@ -11,6 +11,12 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
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 (
Assessment,
Exercise,
@@ -36,8 +42,6 @@ from schemas.assessment import (
SendReportsRequest,
SendReportResult,
SendReportsResponse,
HeatmapCell,
HeatmapData,
)
from schemas.grading import BulkGradeCreate, BulkGradeResponse, GradeRead
from domain.services import GradingCalculator, StatisticsService, StudentReportService, generate_report_html, ConfigService
@@ -124,21 +128,13 @@ async def get_assessments(
total_elements += 1
# Compter les élèves éligibles (inscrits à la date de l'évaluation)
eligible_query = select(func.count(StudentEnrollment.id)).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_students_count = await count_eligible_students(
session, assessment.class_group_id, 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
eligible_student_ids = select(StudentEnrollment.student_id).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_student_ids = eligible_student_ids_subquery(
assessment.class_group_id, assessment.date
)
grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_(
@@ -239,21 +235,13 @@ async def get_assessment(
)
# Calculer la progression
eligible_query = select(func.count(StudentEnrollment.id)).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_students_count = await count_eligible_students(
session, assessment.class_group_id, 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
eligible_student_ids = select(StudentEnrollment.student_id).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_student_ids = eligible_student_ids_subquery(
assessment.class_group_id, assessment.date
)
grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_(
@@ -266,11 +254,11 @@ async def get_assessment(
)
grades_result = await session.execute(grades_query)
grades_count = grades_result.scalar() or 0
progress = calculate_grading_progress(
assessment, grades_count, total_elements, eligible_students_count
)
return AssessmentDetail(
id=assessment.id,
title=assessment.title,
@@ -322,18 +310,9 @@ async def get_assessment_results(
raise HTTPException(status_code=404, detail="Évaluation non trouvée")
# Récupérer les élèves éligibles
eligible_query = (
select(StudentEnrollment)
.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))
)
enrollments = await get_eligible_enrollments(
session, assessment.class_group_id, assessment.date
)
eligible_result = await session.execute(eligible_query)
enrollments = eligible_result.scalars().all()
# Calculer le total des points maximum
total_max_points = 0
@@ -392,7 +371,7 @@ async def get_assessment_results(
student_scores[student_id] = StudentScore(
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
total_score=round(total_score, 2),
total_max_points=counted_max,
@@ -440,97 +419,25 @@ async def get_assessment_results(
all_domains[element.domain.name] = element.domain.color
# Calculer heatmap des compétences si présentes
if all_competences:
competences_cells = []
for enrollment in enrollments:
student = enrollment.student
student_name = f"{student.last_name} {student.first_name}"
# 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
)
competences_heatmap = build_heatmap(
enrollments=enrollments,
assessment=assessment,
items=all_competences,
item_extractor=lambda el: el.skill,
grading_calc=grading_calc,
sorted_student_names=[s.student_name for s in sorted_scores],
)
# Calculer heatmap des domaines si présents
if all_domains:
domains_cells = []
for enrollment in enrollments:
student = enrollment.student
student_name = f"{student.last_name} {student.first_name}"
# Calculer score par domaine pour cet élève
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
)
domains_heatmap = build_heatmap(
enrollments=enrollments,
assessment=assessment,
items=all_domains,
item_extractor=lambda el: el.domain.name if el.domain else None,
grading_calc=grading_calc,
sorted_student_names=[s.student_name for s in sorted_scores],
color_map=all_domains,
)
return AssessmentResults(
assessment_id=assessment.id,
@@ -827,21 +734,13 @@ async def update_assessment(
)
# Calculer la progression
eligible_query = select(func.count(StudentEnrollment.id)).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_students_count = await count_eligible_students(
session, assessment.class_group_id, 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
eligible_student_ids = select(StudentEnrollment.student_id).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_student_ids = eligible_student_ids_subquery(
assessment.class_group_id, assessment.date
)
grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_(
@@ -854,11 +753,11 @@ async def update_assessment(
)
grades_result = await session.execute(grades_query)
grades_count = grades_result.scalar() or 0
progress = calculate_grading_progress(
assessment, grades_count, total_elements, eligible_students_count
)
return AssessmentDetail(
id=assessment.id,
title=assessment.title,
@@ -1132,7 +1031,7 @@ async def send_reports(
total_failed = 0
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
if not student.email:
@@ -1307,7 +1206,7 @@ async def preview_report(
if student_id not in all_students_grades:
raise HTTPException(
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