""" Routes API pour le conseil de classe (préparation et appréciations). """ from typing import Optional from fastapi import APIRouter, HTTPException, Query from sqlalchemy import select, func from sqlalchemy.orm import selectinload from api.dependencies import AsyncSessionDep from infrastructure.database.models import ( ClassGroup, Student, StudentEnrollment, Assessment, Exercise, GradingElement, Grade, CouncilAppreciation, ) from domain.services.grading_calculator import GradingCalculator from schemas.council import ( CouncilPreparationRead, StudentTrimesterSummaryRead, ClassStatisticsRead, AppreciationStatsRead, PerformanceDistribution, AssessmentGradeInfo, AppreciationCreate, AppreciationRead, AppreciationResponse, ) router = APIRouter(prefix="/council", tags=["Conseil de Classe"]) async def get_current_students_in_class(session, class_id: int): """Récupère les élèves actuellement inscrits dans une classe.""" query = ( select(Student) .join(StudentEnrollment, Student.id == StudentEnrollment.student_id) .where( StudentEnrollment.class_group_id == class_id, StudentEnrollment.departure_date.is_(None) ) .order_by(Student.last_name, Student.first_name) ) result = await session.execute(query) return result.scalars().all() async def calculate_student_assessment_score(session, student_id: int, assessment_id: int) -> Optional[float]: """Calcule le score d'un élève pour une évaluation, ramené sur 20.""" # Récupérer les notes de l'élève pour cette évaluation query = ( select(Grade, GradingElement) .join(GradingElement, Grade.grading_element_id == GradingElement.id) .join(Exercise, GradingElement.exercise_id == Exercise.id) .where( Exercise.assessment_id == assessment_id, Grade.student_id == student_id ) ) result = await session.execute(query) grades_data = result.all() if not grades_data: return None calculator = GradingCalculator() total_score = 0.0 total_max_points = 0.0 for grade, element in grades_data: if grade.value: score = calculator.calculate_score( grade.value, element.grading_type, element.max_points ) if score is not None and calculator.is_counted_in_total(grade.value): total_score += score total_max_points += element.max_points if total_max_points > 0: return round(total_score / total_max_points * 20, 2) return None async def get_student_trimester_average(session, student_id: int, class_id: int, trimester: int) -> Optional[float]: """Calcule la moyenne pondérée d'un élève pour un trimestre.""" # Récupérer les évaluations du trimestre avec leurs coefficients assessments_query = select(Assessment).where( Assessment.class_group_id == class_id, Assessment.trimester == trimester ) result = await session.execute(assessments_query) assessments = result.scalars().all() if not assessments: return None weighted_sum = 0.0 total_coefficient = 0.0 for assessment in assessments: score = await calculate_student_assessment_score(session, student_id, assessment.id) if score is not None: weighted_sum += score * assessment.coefficient total_coefficient += assessment.coefficient if total_coefficient > 0: return round(weighted_sum / total_coefficient, 2) return None def determine_performance_status(average: Optional[float]) -> str: """Détermine le statut de performance d'un élève.""" if average is None: return "no_data" if average >= 16: return "excellent" elif average >= 14: return "good" elif average >= 10: return "average" else: return "struggling" @router.get("/classes/{class_id}", response_model=CouncilPreparationRead) async def get_council_preparation( class_id: int, trimester: int, session: AsyncSessionDep, ): """ Récupère les données complètes pour la préparation du conseil de classe. Inclut: - Résumé de chaque élève (moyenne, nombre d'évaluations, performances) - Statistiques de classe (moyenne, médiane, écart-type) - État des appréciations (complétées/en cours) """ # Vérifier que la classe existe class_query = select(ClassGroup).where(ClassGroup.id == class_id) class_result = await session.execute(class_query) cls = class_result.scalar_one_or_none() if not cls: raise HTTPException(status_code=404, detail="Classe non trouvée") # Récupérer les élèves actuellement dans la classe students = await get_current_students_in_class(session, class_id) # Récupérer les évaluations du trimestre assessments_query = select(Assessment).where( Assessment.class_group_id == class_id, Assessment.trimester == trimester ).order_by(Assessment.date) assessments_result = await session.execute(assessments_query) assessments = assessments_result.scalars().all() # Récupérer les appréciations existantes appreciations_query = select(CouncilAppreciation).where( CouncilAppreciation.class_group_id == class_id, CouncilAppreciation.trimester == trimester ) appreciations_result = await session.execute(appreciations_query) appreciations = {a.student_id: a for a in appreciations_result.scalars().all()} # Générer les résumés par élève student_summaries = [] all_averages = [] for student in students: # Calculer les notes par évaluation grades_by_assessment = {} assessment_count = 0 for assessment in assessments: score = await calculate_student_assessment_score(session, student.id, assessment.id) if score is not None: assessment_count += 1 grades_by_assessment[assessment.id] = AssessmentGradeInfo( id=assessment.id, title=assessment.title, date=assessment.date, score=score, max_score=20.0, coefficient=assessment.coefficient ) # Calculer la moyenne générale overall_average = await get_student_trimester_average(session, student.id, class_id, trimester) if overall_average is not None: all_averages.append(overall_average) # Récupérer l'appréciation appreciation = appreciations.get(student.id) student_summaries.append(StudentTrimesterSummaryRead( student_id=student.id, first_name=student.first_name, last_name=student.last_name, full_name=f"{student.first_name} {student.last_name}", overall_average=overall_average, assessment_count=assessment_count, grades_by_assessment=grades_by_assessment, performance_status=determine_performance_status(overall_average), has_appreciation=appreciation is not None and ( appreciation.general_appreciation or appreciation.strengths or appreciation.areas_for_improvement ), appreciation_status=appreciation.status if appreciation else None )) # Calculer les statistiques de classe if all_averages: mean = round(sum(all_averages) / len(all_averages), 2) sorted_averages = sorted(all_averages) n = len(sorted_averages) if n % 2 == 0: median = (sorted_averages[n//2 - 1] + sorted_averages[n//2]) / 2 else: median = sorted_averages[n//2] median = round(median, 2) min_avg = min(all_averages) max_avg = max(all_averages) # Écart-type variance = sum((x - mean) ** 2 for x in all_averages) / len(all_averages) std_dev = round(variance ** 0.5, 2) else: mean = median = min_avg = max_avg = std_dev = None # Distribution des performances performance_dist = { "excellent": 0, "good": 0, "average": 0, "struggling": 0, "no_data": 0 } for summary in student_summaries: performance_dist[summary.performance_status] += 1 class_statistics = ClassStatisticsRead( mean=mean, median=median, min=min_avg, max=max_avg, std_dev=std_dev, performance_distribution=PerformanceDistribution(**performance_dist), student_count_with_data=len(all_averages), total_students=len(students) ) # Statistiques des appréciations draft_count = sum(1 for a in appreciations.values() if a.status == "draft") finalized_count = sum(1 for a in appreciations.values() if a.status == "finalized") completed_count = sum(1 for s in student_summaries if s.has_appreciation) appreciation_stats = AppreciationStatsRead( total_students=len(students), completed_appreciations=completed_count, draft_appreciations=draft_count, finalized_appreciations=finalized_count, completion_percentage=round(completed_count / len(students) * 100, 1) if students else 0 ) return CouncilPreparationRead( class_group_id=class_id, class_name=cls.name, trimester=trimester, student_summaries=student_summaries, class_statistics=class_statistics, appreciation_stats=appreciation_stats, total_students=len(students), completed_appreciations=completed_count ) @router.get("/classes/{class_id}/appreciations/{student_id}", response_model=AppreciationRead) async def get_appreciation( class_id: int, student_id: int, trimester: int, session: AsyncSessionDep, ): """ Récupère l'appréciation d'un élève pour un trimestre. """ query = select(CouncilAppreciation).where( CouncilAppreciation.class_group_id == class_id, CouncilAppreciation.student_id == student_id, CouncilAppreciation.trimester == trimester ) result = await session.execute(query) appreciation = result.scalar_one_or_none() if not appreciation: raise HTTPException(status_code=404, detail="Appréciation non trouvée") return AppreciationRead( id=appreciation.id, student_id=appreciation.student_id, class_group_id=appreciation.class_group_id, trimester=appreciation.trimester, general_appreciation=appreciation.general_appreciation, strengths=appreciation.strengths, areas_for_improvement=appreciation.areas_for_improvement, status=appreciation.status, last_modified=appreciation.last_modified, created_at=appreciation.created_at ) @router.post("/classes/{class_id}/appreciations/{student_id}", response_model=AppreciationResponse) async def create_or_update_appreciation( class_id: int, student_id: int, trimester: int, data: AppreciationCreate, session: AsyncSessionDep, ): """ Crée ou met à jour l'appréciation d'un élève pour un trimestre. """ # Vérifier que la classe existe class_query = select(ClassGroup).where(ClassGroup.id == class_id) class_result = await session.execute(class_query) cls = class_result.scalar_one_or_none() if not cls: raise HTTPException(status_code=404, detail="Classe non trouvée") # Vérifier que l'élève existe student_query = select(Student).where(Student.id == student_id) student_result = await session.execute(student_query) student = student_result.scalar_one_or_none() if not student: raise HTTPException(status_code=404, detail="Élève non trouvé") # Chercher une appréciation existante existing_query = select(CouncilAppreciation).where( CouncilAppreciation.class_group_id == class_id, CouncilAppreciation.student_id == student_id, CouncilAppreciation.trimester == trimester ) existing_result = await session.execute(existing_query) appreciation = existing_result.scalar_one_or_none() if appreciation: # Mettre à jour l'existante appreciation.general_appreciation = data.general_appreciation appreciation.strengths = data.strengths appreciation.areas_for_improvement = data.areas_for_improvement appreciation.status = data.status message = "Appréciation mise à jour" else: # Créer une nouvelle appreciation = CouncilAppreciation( student_id=student_id, class_group_id=class_id, trimester=trimester, general_appreciation=data.general_appreciation, strengths=data.strengths, areas_for_improvement=data.areas_for_improvement, status=data.status ) session.add(appreciation) message = "Appréciation créée" await session.commit() await session.refresh(appreciation) return AppreciationResponse( success=True, message=message, appreciation=AppreciationRead( id=appreciation.id, student_id=appreciation.student_id, class_group_id=appreciation.class_group_id, trimester=appreciation.trimester, general_appreciation=appreciation.general_appreciation, strengths=appreciation.strengths, areas_for_improvement=appreciation.areas_for_improvement, status=appreciation.status, last_modified=appreciation.last_modified, created_at=appreciation.created_at ) ) @router.put("/classes/{class_id}/appreciations/{student_id}/finalize", response_model=AppreciationResponse) async def finalize_appreciation( class_id: int, student_id: int, trimester: int, session: AsyncSessionDep, ): """ Finalise une appréciation (change le statut à 'finalized'). """ query = select(CouncilAppreciation).where( CouncilAppreciation.class_group_id == class_id, CouncilAppreciation.student_id == student_id, CouncilAppreciation.trimester == trimester ) result = await session.execute(query) appreciation = result.scalar_one_or_none() if not appreciation: raise HTTPException(status_code=404, detail="Appréciation non trouvée") appreciation.status = "finalized" await session.commit() await session.refresh(appreciation) return AppreciationResponse( success=True, message="Appréciation finalisée", appreciation=AppreciationRead( id=appreciation.id, student_id=appreciation.student_id, class_group_id=appreciation.class_group_id, trimester=appreciation.trimester, general_appreciation=appreciation.general_appreciation, strengths=appreciation.strengths, areas_for_improvement=appreciation.areas_for_improvement, status=appreciation.status, last_modified=appreciation.last_modified, created_at=appreciation.created_at ) )