""" Services pour la préparation du conseil de classe. Comprend CouncilPreparationService, StudentEvaluationService, AppreciationService. """ from dataclasses import dataclass from typing import Dict, List, Optional, Tuple, Any from datetime import datetime from repositories.appreciation_repository import AppreciationRepository from repositories.grade_repository import GradeRepository from repositories.assessment_repository import AssessmentRepository from repositories.student_repository import StudentRepository from models import Student, Assessment, CouncilAppreciation, GradingCalculator @dataclass class StudentTrimesterSummary: """Résumé d'un élève pour un trimestre.""" student: Student overall_average: Optional[float] assessment_count: int grades_by_assessment: Dict[int, Dict] # assessment_id -> {'score': float, 'max': float, 'title': str} appreciation: Optional[CouncilAppreciation] performance_status: str # 'excellent', 'good', 'average', 'struggling' competence_domain_breakdown: Optional[Dict] = None # Données des compétences et domaines special_values_summary: Optional[Dict] = None # Résumé des valeurs spéciales @property def has_appreciation(self) -> bool: """Vérifie si l'élève a une appréciation avec contenu.""" return self.appreciation and self.appreciation.has_content @dataclass class CouncilPreparationData: """Données complètes pour la préparation du conseil de classe.""" class_group_id: int trimester: int student_summaries: List[StudentTrimesterSummary] class_statistics: Dict appreciation_stats: Dict total_students: int completed_appreciations: int class StudentEvaluationService: """Service spécialisé dans l'évaluation des performances étudiantes.""" def __init__(self, grade_repo: GradeRepository, assessment_repo: AssessmentRepository): self.grade_repo = grade_repo self.assessment_repo = assessment_repo def calculate_student_trimester_average(self, student_id: int, trimester: int) -> Optional[float]: """Calcule la moyenne d'un élève pour un trimestre donné.""" assessments = self.assessment_repo.find_completed_by_class_trimester( # On récupère d'abord la classe de l'élève Student.query.get(student_id).class_group_id, trimester ) if not assessments: return None weighted_sum = 0.0 total_coefficient = 0.0 for assessment in assessments: student_score = self._calculate_assessment_score_for_student(assessment, student_id) if student_score is not None: weighted_sum += student_score * assessment.coefficient total_coefficient += assessment.coefficient return round(weighted_sum / total_coefficient, 2) if total_coefficient > 0 else None def get_student_trimester_summary(self, student_id: int, trimester: int) -> StudentTrimesterSummary: """Génère le résumé d'un élève pour un trimestre.""" student = Student.query.get(student_id) # Récupérer les évaluations du trimestre assessments = self.assessment_repo.find_by_class_trimester_with_details( student.class_group_id, trimester ) # Calculer les scores par évaluation grades_by_assessment = {} for assessment in assessments: score_data = self._get_student_assessment_data(student_id, assessment) if score_data: grades_by_assessment[assessment.id] = score_data # Calculer la moyenne générale overall_average = self.calculate_student_trimester_average(student_id, trimester) # Déterminer le statut de performance performance_status = self._determine_performance_status(overall_average, grades_by_assessment) # Récupérer l'appréciation existante appreciation_repo = AppreciationRepository() appreciation = appreciation_repo.find_by_student_trimester( student_id, student.class_group_id, trimester ) # Calculer les données de compétences et domaines competence_domain_breakdown = self.get_student_competence_domain_breakdown(student_id, trimester) # Calculer le résumé des valeurs spéciales special_values_summary = self.get_student_special_values_summary(student_id, trimester) # Ajouter tous les commentaires organisés par évaluations all_comments_data = self.grade_repo.get_all_comments_by_student_trimester(student_id, trimester) special_values_summary['comments_by_assessments'] = all_comments_data return StudentTrimesterSummary( student=student, overall_average=overall_average, assessment_count=len([a for a in assessments if self._has_grades(student_id, a)]), grades_by_assessment=grades_by_assessment, appreciation=appreciation, performance_status=performance_status, competence_domain_breakdown=competence_domain_breakdown, special_values_summary=special_values_summary ) def get_students_summaries(self, class_group_id: int, trimester: int) -> List[StudentTrimesterSummary]: """Génère les résumés de tous les élèves d'une classe pour un trimestre.""" student_repo = StudentRepository() students = student_repo.find_by_class_group(class_group_id) summaries = [] for student in students: summary = self.get_student_trimester_summary(student.id, trimester) summaries.append(summary) # Trier par nom de famille puis prénom summaries.sort(key=lambda s: (s.student.last_name, s.student.first_name)) return summaries def get_student_competence_domain_breakdown(self, student_id: int, trimester: int) -> Dict[str, List[Dict]]: """ Calcule la répartition des points par compétence et domaine pour un élève. Returns: Dict avec 'competences' et 'domains', chacun contenant les points accumulés par évaluation """ from models import Student, db, GradingCalculator student = Student.query.get(student_id) if not student: return {'competences': [], 'domains': []} # Récupérer toutes les évaluations du trimestre assessments = self.assessment_repo.find_by_class_trimester_with_details( student.class_group_id, trimester ) # Structures pour accumuler les données competences_data = {} domains_data = {} # Parcourir chaque évaluation for assessment in assessments: grades = self.grade_repo.get_student_grades_by_assessment(student_id, assessment.id) for grade in grades: element = grade.grading_element # Calculer le score pour cet élément if grade.value and GradingCalculator.is_counted_in_total(grade.value, element.grading_type): score = GradingCalculator.calculate_score( grade.value, element.grading_type, element.max_points ) if score is not None: # Traiter les compétences if element.skill: if element.skill not in competences_data: competences_data[element.skill] = { 'name': element.skill, 'earned_points': 0.0, 'total_points': 0.0, 'assessments': {} } # Accumuler les points competences_data[element.skill]['earned_points'] += score competences_data[element.skill]['total_points'] += element.max_points # Grouper par évaluation if assessment.id not in competences_data[element.skill]['assessments']: competences_data[element.skill]['assessments'][assessment.id] = { 'id': assessment.id, 'title': assessment.title, 'earned': 0.0, 'max': 0.0, 'date': assessment.date } competences_data[element.skill]['assessments'][assessment.id]['earned'] += score competences_data[element.skill]['assessments'][assessment.id]['max'] += element.max_points # Traiter les domaines if element.domain_id: domain = element.domain domain_name = domain.name if domain else f"Domaine {element.domain_id}" if domain_name not in domains_data: domains_data[domain_name] = { 'id': element.domain_id, 'name': domain_name, 'color': domain.color if domain else '#6B7280', 'earned_points': 0.0, 'total_points': 0.0, 'assessments': {} } # Accumuler les points domains_data[domain_name]['earned_points'] += score domains_data[domain_name]['total_points'] += element.max_points # Grouper par évaluation if assessment.id not in domains_data[domain_name]['assessments']: domains_data[domain_name]['assessments'][assessment.id] = { 'id': assessment.id, 'title': assessment.title, 'earned': 0.0, 'max': 0.0, 'date': assessment.date } domains_data[domain_name]['assessments'][assessment.id]['earned'] += score domains_data[domain_name]['assessments'][assessment.id]['max'] += element.max_points # Récupérer la configuration des compétences pour les couleurs from app_config import config_manager competences_config = {comp['name']: comp for comp in config_manager.get_competences_list()} # Finaliser les données des compétences competences = [] for comp_name, comp_data in competences_data.items(): config = competences_config.get(comp_name, {}) # Calculer le pourcentage percentage = (comp_data['earned_points'] / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0 # Convertir les assessments en liste avec calculs d'accumulation assessments_list = [] # Palette de couleurs moderne et contrastée colors_palette = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] # D'abord, trier par date pour avoir l'ordre chronologique sorted_assessments = sorted(comp_data['assessments'].values(), key=lambda x: x['date'] if x['date'] else datetime.min) # Calculer l'accumulation progressive cumulative_earned = 0 cumulative_max = 0 for i, assessment_data in enumerate(sorted_assessments): # Ajouter les points de cette évaluation à l'accumulation cumulative_earned += assessment_data['earned'] cumulative_max += assessment_data['max'] # Calculer les pourcentages cumulative_percentage = (cumulative_earned / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0 assessment_performance = (assessment_data['earned'] / assessment_data['max'] * 100) if assessment_data['max'] > 0 else 0 # Calculer le pourcentage de contribution de cette évaluation par rapport au total final contribution_percentage = (assessment_data['earned'] / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0 # Couleur cohérente basée sur l'ID de l'évaluation pour garantir la cohérence entre compétences/domaines assessment_color = colors_palette[assessment_data['id'] % len(colors_palette)] assessments_list.append({ 'id': assessment_data['id'], 'title': assessment_data['title'], 'earned_this': round(assessment_data['earned'], 2), # Points de cette évaluation 'max_this': round(assessment_data['max'], 2), # Max de cette évaluation 'earned_cumulative': round(cumulative_earned, 2), # Total cumulé jusqu'à cette éval 'max_cumulative': round(cumulative_max, 2), # Max cumulé jusqu'à cette éval 'date': assessment_data['date'], 'percentage_cumulative': round(cumulative_percentage, 1), # % cumulé par rapport au total 'percentage_contribution': round(contribution_percentage, 1), # % de contribution individuelle 'performance': round(assessment_performance, 1), # % de réussite de cette évaluation seule 'color': assessment_color # Couleur cohérente basée sur l'ID }) competences.append({ 'name': comp_name, 'color': config.get('color', '#6B7280'), 'earned_points': round(comp_data['earned_points'], 2), 'total_points': round(comp_data['total_points'], 2), 'percentage': round(percentage, 1), 'assessments': assessments_list }) # Finaliser les données des domaines domains = [] for domain_data in domains_data.values(): # Calculer le pourcentage percentage = (domain_data['earned_points'] / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0 # Convertir les assessments en liste avec calculs d'accumulation assessments_list = [] # Utiliser la même palette que les compétences pour la cohérence colors_palette = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] # D'abord, trier par date pour avoir l'ordre chronologique sorted_assessments = sorted(domain_data['assessments'].values(), key=lambda x: x['date'] if x['date'] else datetime.min) # Calculer l'accumulation progressive cumulative_earned = 0 cumulative_max = 0 for i, assessment_data in enumerate(sorted_assessments): # Ajouter les points de cette évaluation à l'accumulation cumulative_earned += assessment_data['earned'] cumulative_max += assessment_data['max'] # Calculer les pourcentages cumulative_percentage = (cumulative_earned / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0 assessment_performance = (assessment_data['earned'] / assessment_data['max'] * 100) if assessment_data['max'] > 0 else 0 # Calculer le pourcentage de contribution de cette évaluation par rapport au total final contribution_percentage = (assessment_data['earned'] / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0 # Couleur cohérente basée sur l'ID de l'évaluation (même logique que les compétences) assessment_color = colors_palette[assessment_data['id'] % len(colors_palette)] assessments_list.append({ 'id': assessment_data['id'], 'title': assessment_data['title'], 'earned_this': round(assessment_data['earned'], 2), # Points de cette évaluation 'max_this': round(assessment_data['max'], 2), # Max de cette évaluation 'earned_cumulative': round(cumulative_earned, 2), # Total cumulé jusqu'à cette éval 'max_cumulative': round(cumulative_max, 2), # Max cumulé jusqu'à cette éval 'date': assessment_data['date'], 'percentage_cumulative': round(cumulative_percentage, 1), # % cumulé par rapport au total 'percentage_contribution': round(contribution_percentage, 1), # % de contribution individuelle 'performance': round(assessment_performance, 1), # % de réussite de cette évaluation seule 'color': assessment_color # Couleur cohérente basée sur l'ID }) domains.append({ 'id': domain_data['id'], 'name': domain_data['name'], 'color': domain_data['color'], 'earned_points': round(domain_data['earned_points'], 2), 'total_points': round(domain_data['total_points'], 2), 'percentage': round(percentage, 1), 'assessments': assessments_list }) # Trier par nom competences.sort(key=lambda x: x['name'].lower()) domains.sort(key=lambda x: x['name'].lower()) return { 'competences': competences, 'domains': domains } def get_student_special_values_summary(self, student_id: int, trimester: int) -> Dict[str, Any]: """ Calcule le résumé des valeurs spéciales pour un élève sur un trimestre. Returns: Dict avec 'global' (total par trimestre) et 'by_assessment' (détail par évaluation) """ from app_config import config_manager # Récupérer les valeurs spéciales configurées special_values_config = config_manager.get_special_values() # 1. Comptes globaux par trimestre global_counts = self.grade_repo.get_special_values_counts_by_student_trimester(student_id, trimester) global_details = self.grade_repo.get_special_values_details_by_student_trimester(student_id, trimester) # 2. Comptes par évaluation assessments = self.assessment_repo.find_by_class_trimester_with_details( Student.query.get(student_id).class_group_id, trimester ) by_assessment = {} for assessment in assessments: assessment_counts = self.grade_repo.get_special_values_counts_by_student_assessment( student_id, assessment.id ) assessment_details = self.grade_repo.get_special_values_details_by_student_assessment( student_id, assessment.id ) # Ajouter seulement si l'élève a des valeurs spéciales dans cette évaluation if any(count > 0 for count in assessment_counts.values()): by_assessment[assessment.id] = { 'title': assessment.title, 'date': assessment.date, 'counts': assessment_counts, 'details': assessment_details } # 3. Enrichir avec les métadonnées de configuration enriched_global = {} enriched_by_assessment = {} for special_value, config in special_values_config.items(): enriched_global[special_value] = { 'count': global_counts.get(special_value, 0), 'label': config['label'], 'color': config['color'], 'counts_in_total': config['counts'], 'details': global_details.get(special_value, []) } for assessment_id, assessment_data in by_assessment.items(): enriched_by_assessment[assessment_id] = { 'title': assessment_data['title'], 'date': assessment_data['date'], 'special_values': {} } for special_value, config in special_values_config.items(): enriched_by_assessment[assessment_id]['special_values'][special_value] = { 'count': assessment_data['counts'].get(special_value, 0), 'label': config['label'], 'color': config['color'], 'details': assessment_data['details'].get(special_value, []) } return { 'global': enriched_global, 'by_assessment': enriched_by_assessment, 'total_special_values': sum(global_counts.values()), 'has_special_values': sum(global_counts.values()) > 0 } def _calculate_assessment_score_for_student(self, assessment: Assessment, student_id: int) -> Optional[float]: """Calcule le score d'un élève pour une évaluation.""" grades = self.grade_repo.get_student_grades_by_assessment(student_id, assessment.id) if not grades: return None total_score = 0.0 total_max_points = 0.0 for grade in grades: element = grade.grading_element if grade.value: score = GradingCalculator.calculate_score( grade.value, element.grading_type, element.max_points ) if score is not None and GradingCalculator.is_counted_in_total(grade.value, element.grading_type): total_score += score total_max_points += element.max_points return round(total_score / total_max_points * 20, 2) if total_max_points > 0 else None def _get_student_assessment_data(self, student_id: int, assessment: Assessment) -> Optional[Dict]: """Récupère les données d'évaluation d'un élève pour une évaluation.""" score = self._calculate_assessment_score_for_student(assessment, student_id) if score is None: return None return { 'score': score, 'max': 20.0, # Score ramené sur 20 'title': assessment.title, 'date': assessment.date, 'coefficient': assessment.coefficient } def _has_grades(self, student_id: int, assessment: Assessment) -> bool: """Vérifie si un élève a des notes pour une évaluation.""" grades = self.grade_repo.get_student_grades_by_assessment(student_id, assessment.id) return any(grade.value for grade in grades) def _determine_performance_status(self, average: Optional[float], grades_by_assessment: Dict) -> str: """Détermine le statut de performance d'un élève.""" if not average: return 'no_data' if average >= 16: return 'excellent' elif average >= 14: return 'good' elif average >= 10: return 'average' else: return 'struggling' class AppreciationService: """Service pour la gestion des appréciations du conseil de classe.""" def __init__(self, appreciation_repo: AppreciationRepository): self.appreciation_repo = appreciation_repo def save_appreciation(self, data: Dict) -> CouncilAppreciation: """Sauvegarde ou met à jour une appréciation.""" return self.appreciation_repo.create_or_update( student_id=data['student_id'], class_group_id=data['class_group_id'], trimester=data['trimester'], data={ 'general_appreciation': data.get('general_appreciation'), 'strengths': data.get('strengths'), 'areas_for_improvement': data.get('areas_for_improvement'), 'status': data.get('status', 'draft') } ) def auto_save_appreciation(self, data: Dict) -> CouncilAppreciation: """Sauvegarde automatique en mode brouillon.""" data['status'] = 'draft' return self.save_appreciation(data) def finalize_appreciation(self, student_id: int, class_group_id: int, trimester: int) -> CouncilAppreciation: """Finalise une appréciation (change le statut à 'finalized').""" appreciation = self.appreciation_repo.find_by_student_trimester( student_id, class_group_id, trimester ) if not appreciation: raise ValueError("Aucune appréciation trouvée pour finalisation") appreciation.status = 'finalized' return self.appreciation_repo.update(appreciation) def get_class_appreciations(self, class_group_id: int, trimester: int) -> List[CouncilAppreciation]: """Récupère toutes les appréciations d'une classe pour un trimestre.""" return self.appreciation_repo.find_by_class_trimester(class_group_id, trimester) def get_completion_stats(self, class_group_id: int, trimester: int) -> Dict: """Statistiques de completion des appréciations.""" return self.appreciation_repo.get_completion_stats(class_group_id, trimester) class CouncilPreparationService: """Service principal pour la préparation du conseil de classe.""" def __init__(self, student_evaluation_service: StudentEvaluationService, appreciation_service: AppreciationService, assessment_repo: AssessmentRepository): self.student_evaluation = student_evaluation_service self.appreciation = appreciation_service self.assessment_repo = assessment_repo def prepare_council_data(self, class_group_id: int, trimester: int) -> CouncilPreparationData: """Prépare toutes les données nécessaires au conseil de classe.""" # 1. Résumés par élève student_summaries = self.student_evaluation.get_students_summaries(class_group_id, trimester) # 2. Statistiques générales de la classe class_statistics = self._calculate_class_statistics(student_summaries) # 3. Statistiques des appréciations appreciation_stats = self.appreciation.get_completion_stats(class_group_id, trimester) return CouncilPreparationData( class_group_id=class_group_id, trimester=trimester, student_summaries=student_summaries, class_statistics=class_statistics, appreciation_stats=appreciation_stats, total_students=len(student_summaries), completed_appreciations=appreciation_stats['completed_appreciations'] ) def _calculate_class_statistics(self, student_summaries: List[StudentTrimesterSummary]) -> Dict: """Calcule les statistiques de la classe.""" averages = [s.overall_average for s in student_summaries if s.overall_average is not None] if not averages: return { 'mean': None, 'median': None, 'min': None, 'max': None, 'std_dev': None, 'performance_distribution': { 'excellent': 0, 'good': 0, 'average': 0, 'struggling': 0, 'no_data': len(student_summaries) } } # Calculs statistiques mean = round(sum(averages) / len(averages), 2) sorted_averages = sorted(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(averages) max_avg = max(averages) # Écart-type variance = sum((x - mean) ** 2 for x in averages) / len(averages) std_dev = round(variance ** 0.5, 2) # Distribution des performances performance_distribution = { 'excellent': 0, 'good': 0, 'average': 0, 'struggling': 0, 'no_data': 0 } for summary in student_summaries: performance_distribution[summary.performance_status] += 1 return { 'mean': mean, 'median': median, 'min': min_avg, 'max': max_avg, 'std_dev': std_dev, 'performance_distribution': performance_distribution, 'student_count_with_data': len(averages), 'total_students': len(student_summaries) } # Factory pour créer les services avec injection de dépendances class CouncilServiceFactory: """Factory pour créer les services du conseil de classe.""" @staticmethod def create_council_preparation_service() -> CouncilPreparationService: """Crée le service principal avec toutes ses dépendances.""" # Repositories grade_repo = GradeRepository() assessment_repo = AssessmentRepository() appreciation_repo = AppreciationRepository() # Services student_evaluation_service = StudentEvaluationService(grade_repo, assessment_repo) appreciation_service = AppreciationService(appreciation_repo) return CouncilPreparationService( student_evaluation_service, appreciation_service, assessment_repo ) @staticmethod def create_appreciation_service() -> AppreciationService: """Crée le service d'appréciations.""" appreciation_repo = AppreciationRepository() return AppreciationService(appreciation_repo)