From a0ab7224e14e3d491e5d7d792205d7019a4e390e Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Thu, 19 Feb 2026 14:05:10 +0100 Subject: [PATCH] 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 --- backend/api/helpers.py | 196 ++++++++++++++++++++++ backend/api/routes/assessments.py | 195 ++++++--------------- backend/api/routes/classes.py | 23 +-- backend/api/routes/config.py | 76 +-------- backend/api/routes/council.py | 2 +- backend/api/routes/students.py | 64 +++---- backend/infrastructure/database/models.py | 10 ++ frontend/src/stores/assessments.js | 174 ++++++------------- frontend/src/stores/classes.js | 121 ++++--------- frontend/src/stores/helpers.js | 22 +++ 10 files changed, 402 insertions(+), 481 deletions(-) create mode 100644 backend/api/helpers.py create mode 100644 frontend/src/stores/helpers.js diff --git a/backend/api/helpers.py b/backend/api/helpers.py new file mode 100644 index 0000000..2327933 --- /dev/null +++ b/backend/api/helpers.py @@ -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, + ) diff --git a/backend/api/routes/assessments.py b/backend/api/routes/assessments.py index cdcac38..a59bf24 100644 --- a/backend/api/routes/assessments.py +++ b/backend/api/routes/assessments.py @@ -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 diff --git a/backend/api/routes/classes.py b/backend/api/routes/classes.py index 4a9d75e..676d912 100644 --- a/backend/api/routes/classes.py +++ b/backend/api/routes/classes.py @@ -12,6 +12,7 @@ from sqlalchemy import select, func from sqlalchemy.orm import selectinload from api.dependencies import AsyncSessionDep +from api.helpers import ensure_unique_name from infrastructure.database.models import ( ClassGroup, Student, @@ -201,7 +202,7 @@ async def get_class_students( last_name=student.last_name, first_name=student.first_name, 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_name=cls.name if is_active else None, enrollment_id=enrollment.id, @@ -419,13 +420,7 @@ async def create_class( Crée une nouvelle classe. """ # Vérifier l'unicité du nom - existing_query = select(ClassGroup).where(ClassGroup.name == class_data.name) - 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à" - ) + await ensure_unique_name(session, ClassGroup, class_data.name, entity_label="classe") # Créer la nouvelle classe new_class = ClassGroup( @@ -466,16 +461,10 @@ async def update_class( # Vérifier l'unicité du nouveau nom si modifié if class_data.name and class_data.name != cls.name: - existing_query = select(ClassGroup).where( - ClassGroup.name == class_data.name, - ClassGroup.id != class_id + await ensure_unique_name( + session, ClassGroup, class_data.name, + 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 if class_data.name is not None: diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index 5218496..64c990c 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException from sqlalchemy import select, func, delete from api.dependencies import AsyncSessionDep +from api.helpers import ensure_unique_name, upsert_app_configs from infrastructure.database.models import ( AppConfig, Competence, @@ -228,13 +229,7 @@ async def create_competence( Crée une nouvelle compétence. """ # Vérifier l'unicité du nom - existing_query = select(Competence).where(Competence.name == data.name) - 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à" - ) + await ensure_unique_name(session, Competence, data.name, entity_label="compétence") # Déterminer l'index d'ordre if data.order_index is None: @@ -284,16 +279,7 @@ async def update_competence( # Vérifier l'unicité du nouveau nom if data.name and data.name != competence.name: - existing_query = select(Competence).where( - 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à" - ) + await ensure_unique_name(session, Competence, data.name, exclude_id=competence_id, entity_label="compétence") # Appliquer les modifications if data.name is not None: @@ -353,13 +339,7 @@ async def create_domain( Crée un nouveau domaine. """ # Vérifier l'unicité du nom - existing_query = select(Domain).where(Domain.name == data.name) - 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à" - ) + await ensure_unique_name(session, Domain, data.name, entity_label="domaine") # Créer le domaine domain = Domain( @@ -398,16 +378,7 @@ async def update_domain( # Vérifier l'unicité du nouveau nom if data.name and data.name != domain.name: - existing_query = select(Domain).where( - 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à" - ) + await ensure_unique_name(session, Domain, data.name, exclude_id=domain_id, entity_label="domaine") # Appliquer les modifications if data.name is not None: @@ -669,18 +640,7 @@ async def update_app_config( if data.default_grading_system is not None: updates.append(("default_grading_system", data.default_grading_system)) - for key, value in 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 upsert_app_configs(session, dict(updates)) await session.commit() @@ -755,18 +715,7 @@ async def update_smtp_config( if data.from_address is not None: updates.append(("email.from_address", data.from_address)) - for key, value in 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 upsert_app_configs(session, dict(updates)) await session.commit() @@ -843,16 +792,7 @@ async def update_notes_gradient( if data.enabled is not None: updates.append(("grading.notes_gradient.enabled", "true" if data.enabled else "false")) - for key, value in 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 upsert_app_configs(session, dict(updates)) await session.commit() diff --git a/backend/api/routes/council.py b/backend/api/routes/council.py index 047d794..ea79b31 100644 --- a/backend/api/routes/council.py +++ b/backend/api/routes/council.py @@ -202,7 +202,7 @@ async def get_council_preparation( student_id=student.id, first_name=student.first_name, last_name=student.last_name, - full_name=f"{student.first_name} {student.last_name}", + full_name=student.full_name, overall_average=overall_average, assessment_count=assessment_count, grades_by_assessment=grades_by_assessment, diff --git a/backend/api/routes/students.py b/backend/api/routes/students.py index 32d2ea8..55cfe4e 100644 --- a/backend/api/routes/students.py +++ b/backend/api/routes/students.py @@ -10,6 +10,7 @@ from sqlalchemy import select, func from sqlalchemy.orm import selectinload from api.dependencies import AsyncSessionDep +from api.helpers import get_active_enrollment from infrastructure.database.models import ( Student, StudentEnrollment, @@ -62,12 +63,8 @@ async def get_students( students_list = [] for student in students: # Trouver l'inscription active - current_enrollment = None - for enrollment in student.enrollments: - if enrollment.departure_date is None: - current_enrollment = enrollment - break - + current_enrollment = get_active_enrollment(student) + # Filtrer par classe si demandé if class_id and (not current_enrollment or current_enrollment.class_group_id != class_id): continue @@ -78,7 +75,7 @@ async def get_students( last_name=student.last_name, first_name=student.first_name, 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_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é") # Trouver l'inscription active - current_enrollment = None + current_enrollment = get_active_enrollment(student) enrollments_list = [] - + for enrollment in student.enrollments: - if enrollment.departure_date is None: - current_enrollment = enrollment - enrollments_list.append( EnrollmentRead( id=enrollment.id, @@ -144,7 +138,7 @@ async def get_student( last_name=student.last_name, first_name=student.first_name, 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_name=current_enrollment.class_group.name if current_enrollment else None, enrollments=enrollments_list @@ -210,7 +204,7 @@ async def create_student( last_name=new_student.last_name, first_name=new_student.first_name, 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_name=current_class_name ) @@ -264,18 +258,14 @@ async def update_student( await session.refresh(student) # Trouver la classe actuelle - current_enrollment = None - for enrollment in student.enrollments: - if enrollment.departure_date is None: - current_enrollment = enrollment - break - + current_enrollment = get_active_enrollment(student) + return StudentWithClass( id=student.id, last_name=student.last_name, first_name=student.first_name, 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_name=current_enrollment.class_group.name if current_enrollment else None ) @@ -325,18 +315,14 @@ async def update_student_email( await session.refresh(student) # Trouver la classe actuelle - current_enrollment = None - for enrollment in student.enrollments: - if enrollment.departure_date is None: - current_enrollment = enrollment - break - + current_enrollment = get_active_enrollment(student) + return StudentWithClass( id=student.id, last_name=student.last_name, first_name=student.first_name, 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_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(): raise HTTPException( 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: # Nouvel élève @@ -447,9 +433,9 @@ async def enroll_student( return EnrollmentResponse( enrollment_id=enrollment.id, student_id=student.id, - student_name=f"{student.first_name} {student.last_name}", + student_name=student.full_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 ) @@ -493,9 +479,9 @@ async def transfer_student( if not old_enrollment: raise HTTPException( 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 # Terminer l'ancienne inscription @@ -516,10 +502,10 @@ async def transfer_student( return TransferResponse( old_enrollment_id=old_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, 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: raise HTTPException( 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 # Enregistrer le départ @@ -567,7 +553,7 @@ async def record_departure( return DepartureResponse( enrollment_id=enrollment.id, - student_name=f"{student.first_name} {student.last_name}", + student_name=student.full_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é" ) diff --git a/backend/infrastructure/database/models.py b/backend/infrastructure/database/models.py index 4d9f0ee..7c2aeba 100644 --- a/backend/infrastructure/database/models.py +++ b/backend/infrastructure/database/models.py @@ -123,6 +123,16 @@ class Student(Base): "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): return f"" diff --git a/frontend/src/stores/assessments.js b/frontend/src/stores/assessments.js index a63659f..aaa2e78 100644 --- a/frontend/src/stores/assessments.js +++ b/frontend/src/stores/assessments.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import assessmentsService from '@/services/assessments' +import { withLoading } from './helpers' export const useAssessmentsStore = defineStore('assessments', () => { // State @@ -10,7 +11,7 @@ export const useAssessmentsStore = defineStore('assessments', () => { const currentGrades = ref([]) const loading = ref(false) const error = ref(null) - + // Filters const filters = ref({ trimester: null, @@ -21,22 +22,22 @@ export const useAssessmentsStore = defineStore('assessments', () => { // Getters const assessmentsCount = computed(() => assessments.value.length) - + const filteredAssessments = computed(() => { let result = [...assessments.value] - + if (filters.value.trimester) { result = result.filter(a => a.trimester === filters.value.trimester) } - + if (filters.value.class_id) { result = result.filter(a => a.class_group_id === filters.value.class_id) } - + return result }) - const incompleteAssessments = computed(() => + const incompleteAssessments = computed(() => assessments.value.filter(a => { const progress = a.progress || a.grading_progress return progress?.percentage < 100 @@ -44,130 +45,61 @@ export const useAssessmentsStore = defineStore('assessments', () => { ) // Actions - async function fetchAssessments(customFilters = null) { - loading.value = true - error.value = null - try { - const queryFilters = customFilters || filters.value - assessments.value = await assessmentsService.getAll(queryFilters) - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const fetchAssessments = withLoading(loading, error, async (customFilters = null) => { + const queryFilters = customFilters || filters.value + assessments.value = await assessmentsService.getAll(queryFilters) + }) - async function fetchAssessment(id) { - loading.value = true - error.value = null - try { - currentAssessment.value = await assessmentsService.getById(id) - return currentAssessment.value - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const fetchAssessment = withLoading(loading, error, async (id) => { + currentAssessment.value = await assessmentsService.getById(id) + return currentAssessment.value + }) - async function fetchResults(id) { - loading.value = true - error.value = null - try { - currentResults.value = await assessmentsService.getResults(id) - return currentResults.value - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const fetchResults = withLoading(loading, error, async (id) => { + currentResults.value = await assessmentsService.getResults(id) + return currentResults.value + }) - async function fetchGrades(id) { - loading.value = true - error.value = null - try { - currentGrades.value = await assessmentsService.getGrades(id) - return currentGrades.value - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const fetchGrades = withLoading(loading, error, async (id) => { + currentGrades.value = await assessmentsService.getGrades(id) + return currentGrades.value + }) - async function createAssessment(data) { - loading.value = true - error.value = null - try { - const newAssessment = await assessmentsService.create(data) - assessments.value.unshift(newAssessment) - return newAssessment - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const createAssessment = withLoading(loading, error, async (data) => { + const newAssessment = await assessmentsService.create(data) + assessments.value.unshift(newAssessment) + return newAssessment + }) - async function updateAssessment(id, data) { - loading.value = true - error.value = null - try { - const updated = await assessmentsService.update(id, data) - 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 + const updateAssessment = withLoading(loading, error, async (id, data) => { + const updated = await assessmentsService.update(id, data) + 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 + }) - async function deleteAssessment(id) { - loading.value = true - error.value = null - try { - await assessmentsService.delete(id) - 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 + const deleteAssessment = withLoading(loading, error, async (id) => { + await assessmentsService.delete(id) + assessments.value = assessments.value.filter(a => a.id !== id) + if (currentAssessment.value?.id === id) { + currentAssessment.value = null } - } + }) - async function saveGrades(id, grades) { - loading.value = true - error.value = null - try { - const result = await assessmentsService.saveGrades(id, grades) - // Refresh assessment to update progress - await fetchAssessment(id) - return result - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + // fetchAssessment is declared above so it can be called here safely. + // withLoading sets loading=false in its finally block, so the inner call + // to fetchAssessment runs as a nested operation within the outer wrapper. + const saveGrades = withLoading(loading, error, async (id, grades) => { + const result = await assessmentsService.saveGrades(id, grades) + // Refresh assessment to update progress + await fetchAssessment(id) + return result + }) function setFilters(newFilters) { filters.value = { ...filters.value, ...newFilters } diff --git a/frontend/src/stores/classes.js b/frontend/src/stores/classes.js index 39697f2..bf094b4 100644 --- a/frontend/src/stores/classes.js +++ b/frontend/src/stores/classes.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import classesService from '@/services/classes' +import { withLoading } from './helpers' export const useClassesStore = defineStore('classes', () => { // State @@ -12,104 +13,50 @@ export const useClassesStore = defineStore('classes', () => { // Getters 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) ) // Actions - async function fetchClasses() { - loading.value = true - error.value = null - try { - classes.value = await classesService.getAll() - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const fetchClasses = withLoading(loading, error, async () => { + classes.value = await classesService.getAll() + }) - async function fetchClass(id) { - loading.value = true - error.value = null - try { - currentClass.value = await classesService.getById(id) - return currentClass.value - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const fetchClass = withLoading(loading, error, async (id) => { + currentClass.value = await classesService.getById(id) + return currentClass.value + }) - async function fetchClassStats(id, trimester = null) { - loading.value = true - error.value = null - try { - currentStats.value = await classesService.getStats(id, trimester) - return currentStats.value - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const fetchClassStats = withLoading(loading, error, async (id, trimester = null) => { + currentStats.value = await classesService.getStats(id, trimester) + return currentStats.value + }) - async function createClass(data) { - loading.value = true - error.value = null - try { - const newClass = await classesService.create(data) - classes.value.push(newClass) - return newClass - } catch (e) { - error.value = e.message - throw e - } finally { - loading.value = false - } - } + const createClass = withLoading(loading, error, async (data) => { + const newClass = await classesService.create(data) + classes.value.push(newClass) + return newClass + }) - async function updateClass(id, data) { - loading.value = true - error.value = null - try { - const updated = await classesService.update(id, data) - 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 + const updateClass = withLoading(loading, error, async (id, data) => { + const updated = await classesService.update(id, data) + 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 + }) - async function deleteClass(id) { - loading.value = true - error.value = null - try { - await classesService.delete(id) - 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 + const deleteClass = withLoading(loading, error, async (id) => { + await classesService.delete(id) + classes.value = classes.value.filter(c => c.id !== id) + if (currentClass.value?.id === id) { + currentClass.value = null } - } + }) function clearCurrent() { currentClass.value = null diff --git a/frontend/src/stores/helpers.js b/frontend/src/stores/helpers.js new file mode 100644 index 0000000..360051e --- /dev/null +++ b/frontend/src/stores/helpers.js @@ -0,0 +1,22 @@ +/** + * Wraps an async function with loading/error state management. + * + * @param {import('vue').Ref} loading - shared loading ref + * @param {import('vue').Ref} 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 + } + } +}