""" 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, )