refact: clean code and update doc
This commit is contained in:
		| @@ -405,6 +405,485 @@ class AssessmentServicesFacade: | ||||
|         return self.statistics_service.get_assessment_statistics(assessment) | ||||
|  | ||||
|  | ||||
| # =================== SERVICES pour ClassGroup =================== | ||||
|  | ||||
| class ClassStatisticsService: | ||||
|     """ | ||||
|     Service dédié aux statistiques de classe (get_trimester_statistics, get_class_results). | ||||
|     Single Responsibility: calculs statistiques au niveau classe. | ||||
|     """ | ||||
|      | ||||
|     def __init__(self, db_provider: DatabaseProvider): | ||||
|         self.db_provider = db_provider | ||||
|      | ||||
|     def get_trimester_statistics(self, class_group, trimester=None) -> Dict[str, Any]: | ||||
|         """ | ||||
|         Retourne les statistiques globales pour un trimestre ou toutes les évaluations. | ||||
|          | ||||
|         Args: | ||||
|             class_group: Instance de ClassGroup | ||||
|             trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations | ||||
|              | ||||
|         Returns: | ||||
|             Dict avec nombre total, répartition par statut (terminées/en cours/non commencées) | ||||
|         """ | ||||
|         try: | ||||
|             # Utiliser les évaluations filtrées si disponibles depuis le repository | ||||
|             if hasattr(class_group, '_filtered_assessments'): | ||||
|                 assessments = class_group._filtered_assessments | ||||
|             else: | ||||
|                 # Import ici pour éviter la dépendance circulaire | ||||
|                 from models import Assessment, db | ||||
|                  | ||||
|                 # Construire la requête de base avec jointures optimisées | ||||
|                 query = Assessment.query.filter(Assessment.class_group_id == class_group.id) | ||||
|                  | ||||
|                 # Filtrage par trimestre si spécifié | ||||
|                 if trimester is not None: | ||||
|                     query = query.filter(Assessment.trimester == trimester) | ||||
|                  | ||||
|                 # Récupérer toutes les évaluations avec leurs exercices et éléments | ||||
|                 assessments = query.options( | ||||
|                     db.joinedload(Assessment.exercises).joinedload('grading_elements') | ||||
|                 ).all() | ||||
|              | ||||
|             # Compter le nombre d'élèves dans la classe | ||||
|             students_count = len(class_group.students) | ||||
|              | ||||
|             # Initialiser les compteurs | ||||
|             total_assessments = len(assessments) | ||||
|             completed_count = 0 | ||||
|             in_progress_count = 0 | ||||
|             not_started_count = 0 | ||||
|              | ||||
|             # Analyser le statut de chaque évaluation | ||||
|             for assessment in assessments: | ||||
|                 # Utiliser la propriété grading_progress existante | ||||
|                 progress = assessment.grading_progress | ||||
|                 status = progress['status'] | ||||
|                  | ||||
|                 if status == 'completed': | ||||
|                     completed_count += 1 | ||||
|                 elif status in ['in_progress']: | ||||
|                     in_progress_count += 1 | ||||
|                 else:  # not_started, no_students, no_elements | ||||
|                     not_started_count += 1 | ||||
|              | ||||
|             return { | ||||
|                 'total': total_assessments, | ||||
|                 'completed': completed_count, | ||||
|                 'in_progress': in_progress_count, | ||||
|                 'not_started': not_started_count, | ||||
|                 'students_count': students_count, | ||||
|                 'trimester': trimester | ||||
|             } | ||||
|          | ||||
|         except Exception as e: | ||||
|             from flask import current_app | ||||
|             current_app.logger.error(f"Erreur dans get_trimester_statistics: {e}", exc_info=True) | ||||
|             return { | ||||
|                 'total': 0, | ||||
|                 'completed': 0, | ||||
|                 'in_progress': 0, | ||||
|                 'not_started': 0, | ||||
|                 'students_count': 0, | ||||
|                 'trimester': trimester | ||||
|             } | ||||
|      | ||||
|     def get_class_results(self, class_group, trimester=None) -> Dict[str, Any]: | ||||
|         """ | ||||
|         Statistiques de résultats pour la classe sur un trimestre. | ||||
|          | ||||
|         Args: | ||||
|             class_group: Instance de ClassGroup | ||||
|             trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations | ||||
|              | ||||
|         Returns: | ||||
|             Dict avec moyennes, distribution des notes et métriques statistiques | ||||
|         """ | ||||
|         try: | ||||
|             # Utiliser les évaluations filtrées si disponibles | ||||
|             if hasattr(class_group, '_filtered_assessments'): | ||||
|                 assessments = class_group._filtered_assessments | ||||
|             else: | ||||
|                 # Import ici pour éviter la dépendance circulaire | ||||
|                 from models import Assessment | ||||
|                  | ||||
|                 # Construire la requête des évaluations avec filtres | ||||
|                 assessments_query = Assessment.query.filter(Assessment.class_group_id == class_group.id) | ||||
|                  | ||||
|                 if trimester is not None: | ||||
|                     assessments_query = assessments_query.filter(Assessment.trimester == trimester) | ||||
|                  | ||||
|                 assessments = assessments_query.all() | ||||
|              | ||||
|             if not assessments: | ||||
|                 return self._empty_class_results(class_group, trimester) | ||||
|              | ||||
|             # Calculer les moyennes par évaluation et par élève | ||||
|             class_averages = [] | ||||
|             all_individual_scores = []  # Toutes les notes individuelles pour statistiques globales | ||||
|             student_averages = {}  # Moyennes par élève {student_id: [scores]} | ||||
|              | ||||
|             for assessment in assessments: | ||||
|                 # Utiliser la méthode existante calculate_student_scores | ||||
|                 students_scores, _ = assessment.calculate_student_scores() | ||||
|                  | ||||
|                 # Extraire les scores individuels | ||||
|                 individual_scores = [] | ||||
|                 for student_id, student_data in students_scores.items(): | ||||
|                     score = student_data['total_score'] | ||||
|                     max_points = student_data['total_max_points'] | ||||
|                      | ||||
|                     if max_points > 0:  # Éviter la division par zéro | ||||
|                         # Normaliser sur 20 pour comparaison | ||||
|                         normalized_score = (score / max_points) * 20 | ||||
|                         individual_scores.append(normalized_score) | ||||
|                         all_individual_scores.append(normalized_score) | ||||
|                          | ||||
|                         # Ajouter à la moyenne de l'élève | ||||
|                         if student_id not in student_averages: | ||||
|                             student_averages[student_id] = [] | ||||
|                         student_averages[student_id].append(normalized_score) | ||||
|                  | ||||
|                 # Calculer la moyenne de classe pour cette évaluation | ||||
|                 if individual_scores: | ||||
|                     import statistics | ||||
|                     class_average = statistics.mean(individual_scores) | ||||
|                     class_averages.append({ | ||||
|                         'assessment_id': assessment.id, | ||||
|                         'assessment_title': assessment.title, | ||||
|                         'date': assessment.date.isoformat() if assessment.date else None, | ||||
|                         'class_average': round(class_average, 2), | ||||
|                         'students_evaluated': len(individual_scores), | ||||
|                         'max_possible': 20  # Normalisé sur 20 | ||||
|                     }) | ||||
|              | ||||
|             # Calculer les moyennes finales des élèves | ||||
|             student_final_averages = [] | ||||
|             for student_id, scores in student_averages.items(): | ||||
|                 if scores: | ||||
|                     import statistics | ||||
|                     avg = statistics.mean(scores) | ||||
|                     student_final_averages.append(round(avg, 2)) | ||||
|              | ||||
|             # Statistiques globales et distributions | ||||
|             overall_stats, distribution, student_averages_distribution = self._calculate_statistics_and_distribution( | ||||
|                 student_final_averages | ||||
|             ) | ||||
|              | ||||
|             return { | ||||
|                 'trimester': trimester, | ||||
|                 'assessments_count': len(assessments), | ||||
|                 'students_count': len(class_group.students), | ||||
|                 'class_averages': class_averages, | ||||
|                 'student_averages': student_final_averages, | ||||
|                 'overall_statistics': overall_stats, | ||||
|                 'distribution': distribution, | ||||
|                 'student_averages_distribution': student_averages_distribution | ||||
|             } | ||||
|          | ||||
|         except Exception as e: | ||||
|             from flask import current_app | ||||
|             current_app.logger.error(f"Erreur dans get_class_results: {e}", exc_info=True) | ||||
|             return self._empty_class_results(class_group, trimester) | ||||
|      | ||||
|     def _empty_class_results(self, class_group, trimester) -> Dict[str, Any]: | ||||
|         """Retourne un résultat vide pour get_class_results.""" | ||||
|         return { | ||||
|             'trimester': trimester, | ||||
|             'assessments_count': 0, | ||||
|             'students_count': len(class_group.students), | ||||
|             'class_averages': [], | ||||
|             'student_averages': [], | ||||
|             'overall_statistics': { | ||||
|                 'count': 0, | ||||
|                 'mean': 0, | ||||
|                 'median': 0, | ||||
|                 'min': 0, | ||||
|                 'max': 0, | ||||
|                 'std_dev': 0 | ||||
|             }, | ||||
|             'distribution': [], | ||||
|             'student_averages_distribution': [] | ||||
|         } | ||||
|      | ||||
|     def _calculate_statistics_and_distribution(self, student_final_averages) -> Tuple[Dict[str, Any], List[Dict], List[Dict]]: | ||||
|         """Calcule les statistiques et la distribution des moyennes.""" | ||||
|         overall_stats = { | ||||
|             'count': 0, | ||||
|             'mean': 0, | ||||
|             'median': 0, | ||||
|             'min': 0, | ||||
|             'max': 0, | ||||
|             'std_dev': 0 | ||||
|         } | ||||
|          | ||||
|         distribution = [] | ||||
|         student_averages_distribution = [] | ||||
|          | ||||
|         # Utiliser les moyennes des élèves pour les statistiques (cohérent avec l'histogramme) | ||||
|         if student_final_averages: | ||||
|             import statistics | ||||
|              | ||||
|             overall_stats = { | ||||
|                 'count': len(student_final_averages), | ||||
|                 'mean': round(statistics.mean(student_final_averages), 2), | ||||
|                 'median': round(statistics.median(student_final_averages), 2), | ||||
|                 'min': round(min(student_final_averages), 2), | ||||
|                 'max': round(max(student_final_averages), 2), | ||||
|                 'std_dev': round(statistics.stdev(student_final_averages) if len(student_final_averages) > 1 else 0, 2) | ||||
|             } | ||||
|          | ||||
|         # Créer l'histogramme des moyennes des élèves (distribution principale) | ||||
|         if student_final_averages: | ||||
|             # Bins pour les moyennes des élèves (de 0 à 20) | ||||
|             avg_bins = list(range(0, 22)) | ||||
|             avg_bin_counts = [0] * (len(avg_bins) - 1) | ||||
|              | ||||
|             for avg in student_final_averages: | ||||
|                 # Trouver le bon bin | ||||
|                 bin_index = min(int(avg), len(avg_bin_counts) - 1) | ||||
|                 avg_bin_counts[bin_index] += 1 | ||||
|              | ||||
|             # Formatage pour Chart.js | ||||
|             for i in range(len(avg_bin_counts)): | ||||
|                 if i == len(avg_bin_counts) - 1: | ||||
|                     label = f"{avg_bins[i]}+" | ||||
|                 else: | ||||
|                     label = f"{avg_bins[i]}-{avg_bins[i+1]}" | ||||
|                  | ||||
|                 bin_data = { | ||||
|                     'range': label, | ||||
|                     'count': avg_bin_counts[i] | ||||
|                 } | ||||
|                 student_averages_distribution.append(bin_data) | ||||
|                 # Maintenir la compatibilité avec distribution (même données maintenant) | ||||
|                 distribution.append(bin_data.copy()) | ||||
|          | ||||
|         return overall_stats, distribution, student_averages_distribution | ||||
|  | ||||
|  | ||||
| class ClassAnalysisService: | ||||
|     """ | ||||
|     Service dédié aux analyses de classe (get_domain_analysis, get_competence_analysis). | ||||
|     Single Responsibility: analyses métier des domaines et compétences. | ||||
|     """ | ||||
|      | ||||
|     def __init__(self, db_provider: DatabaseProvider): | ||||
|         self.db_provider = db_provider | ||||
|      | ||||
|     def get_domain_analysis(self, class_group, trimester=None) -> Dict[str, Any]: | ||||
|         """ | ||||
|         Analyse les domaines couverts dans les évaluations d'un trimestre. | ||||
|          | ||||
|         Args: | ||||
|             class_group: Instance de ClassGroup | ||||
|             trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations | ||||
|              | ||||
|         Returns: | ||||
|             Dict avec liste des domaines, points totaux et nombre d'éléments par domaine | ||||
|         """ | ||||
|         try: | ||||
|             # Import ici pour éviter la dépendance circulaire | ||||
|             from models import db, GradingElement, Exercise, Assessment, Domain | ||||
|              | ||||
|             # Utiliser les évaluations filtrées si disponibles | ||||
|             if hasattr(class_group, '_filtered_assessments'): | ||||
|                 assessment_ids = [a.id for a in class_group._filtered_assessments] | ||||
|                 if not assessment_ids: | ||||
|                     return {'domains': [], 'trimester': trimester} | ||||
|                  | ||||
|                 query = db.session.query( | ||||
|                     GradingElement.domain_id, | ||||
|                     Domain.name.label('domain_name'), | ||||
|                     Domain.color.label('domain_color'), | ||||
|                     db.func.sum(GradingElement.max_points).label('total_points'), | ||||
|                     db.func.count(GradingElement.id).label('elements_count') | ||||
|                 ).select_from(GradingElement)\ | ||||
|                 .join(Exercise, GradingElement.exercise_id == Exercise.id)\ | ||||
|                 .outerjoin(Domain, GradingElement.domain_id == Domain.id)\ | ||||
|                 .filter(Exercise.assessment_id.in_(assessment_ids)) | ||||
|             else: | ||||
|                 # Requête originale avec toutes les jointures nécessaires | ||||
|                 query = db.session.query( | ||||
|                     GradingElement.domain_id, | ||||
|                     Domain.name.label('domain_name'), | ||||
|                     Domain.color.label('domain_color'), | ||||
|                     db.func.sum(GradingElement.max_points).label('total_points'), | ||||
|                     db.func.count(GradingElement.id).label('elements_count') | ||||
|                 ).select_from(GradingElement)\ | ||||
|                 .join(Exercise, GradingElement.exercise_id == Exercise.id)\ | ||||
|                 .join(Assessment, Exercise.assessment_id == Assessment.id)\ | ||||
|                 .outerjoin(Domain, GradingElement.domain_id == Domain.id)\ | ||||
|                 .filter(Assessment.class_group_id == class_group.id) | ||||
|                  | ||||
|                 # Filtrage par trimestre si spécifié | ||||
|                 if trimester is not None: | ||||
|                     query = query.filter(Assessment.trimester == trimester) | ||||
|              | ||||
|             # Grouper par domaine (y compris les éléments sans domaine) | ||||
|             query = query.group_by( | ||||
|                 GradingElement.domain_id, | ||||
|                 Domain.name, | ||||
|                 Domain.color | ||||
|             ) | ||||
|              | ||||
|             results = query.all() | ||||
|             domains = [] | ||||
|              | ||||
|             for result in results: | ||||
|                 if result.domain_id is not None: | ||||
|                     # Domaine défini | ||||
|                     domains.append({ | ||||
|                         'id': result.domain_id, | ||||
|                         'name': result.domain_name, | ||||
|                         'color': result.domain_color, | ||||
|                         'total_points': float(result.total_points) if result.total_points else 0.0, | ||||
|                         'elements_count': result.elements_count | ||||
|                     }) | ||||
|                 else: | ||||
|                     # Éléments sans domaine assigné | ||||
|                     domains.append({ | ||||
|                         'id': None, | ||||
|                         'name': 'Sans domaine', | ||||
|                         'color': '#6B7280',  # Gris neutre | ||||
|                         'total_points': float(result.total_points) if result.total_points else 0.0, | ||||
|                         'elements_count': result.elements_count | ||||
|                     }) | ||||
|              | ||||
|             # Trier par ordre alphabétique, avec "Sans domaine" en dernier | ||||
|             domains.sort(key=lambda x: (x['name'] == 'Sans domaine', x['name'].lower())) | ||||
|              | ||||
|             return { | ||||
|                 'domains': domains, | ||||
|                 'trimester': trimester | ||||
|             } | ||||
|          | ||||
|         except Exception as e: | ||||
|             from flask import current_app | ||||
|             current_app.logger.error(f"Erreur dans get_domain_analysis: {e}", exc_info=True) | ||||
|             return { | ||||
|                 'domains': [], | ||||
|                 'trimester': trimester | ||||
|             } | ||||
|      | ||||
|     def get_competence_analysis(self, class_group, trimester=None) -> Dict[str, Any]: | ||||
|         """ | ||||
|         Analyse les compétences évaluées dans un trimestre. | ||||
|          | ||||
|         Args: | ||||
|             class_group: Instance de ClassGroup | ||||
|             trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations | ||||
|              | ||||
|         Returns: | ||||
|             Dict avec liste des compétences, points totaux et nombre d'éléments par compétence | ||||
|         """ | ||||
|         try: | ||||
|             # Import ici pour éviter la dépendance circulaire | ||||
|             from models import db, GradingElement, Exercise, Assessment | ||||
|              | ||||
|             # Utiliser les évaluations filtrées si disponibles | ||||
|             if hasattr(class_group, '_filtered_assessments'): | ||||
|                 assessment_ids = [a.id for a in class_group._filtered_assessments] | ||||
|                 if not assessment_ids: | ||||
|                     return {'competences': [], 'trimester': trimester} | ||||
|                  | ||||
|                 query = db.session.query( | ||||
|                     GradingElement.skill.label('skill_name'), | ||||
|                     db.func.sum(GradingElement.max_points).label('total_points'), | ||||
|                     db.func.count(GradingElement.id).label('elements_count') | ||||
|                 ).select_from(GradingElement)\ | ||||
|                 .join(Exercise, GradingElement.exercise_id == Exercise.id)\ | ||||
|                 .filter(Exercise.assessment_id.in_(assessment_ids))\ | ||||
|                 .filter(GradingElement.skill.isnot(None))\ | ||||
|                 .filter(GradingElement.skill != '') | ||||
|             else: | ||||
|                 # Requête optimisée pour analyser les compétences | ||||
|                 query = db.session.query( | ||||
|                     GradingElement.skill.label('skill_name'), | ||||
|                     db.func.sum(GradingElement.max_points).label('total_points'), | ||||
|                     db.func.count(GradingElement.id).label('elements_count') | ||||
|                 ).select_from(GradingElement)\ | ||||
|                 .join(Exercise, GradingElement.exercise_id == Exercise.id)\ | ||||
|                 .join(Assessment, Exercise.assessment_id == Assessment.id)\ | ||||
|                 .filter(Assessment.class_group_id == class_group.id)\ | ||||
|                 .filter(GradingElement.skill.isnot(None))\ | ||||
|                 .filter(GradingElement.skill != '') | ||||
|                  | ||||
|                 # Filtrage par trimestre si spécifié | ||||
|                 if trimester is not None: | ||||
|                     query = query.filter(Assessment.trimester == trimester) | ||||
|              | ||||
|             # Grouper par compétence | ||||
|             query = query.group_by(GradingElement.skill) | ||||
|              | ||||
|             results = query.all() | ||||
|              | ||||
|             # 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()} | ||||
|              | ||||
|             competences = [] | ||||
|             for result in results: | ||||
|                 skill_name = result.skill_name | ||||
|                 # Récupérer la couleur depuis la configuration ou utiliser une couleur par défaut | ||||
|                 config = competences_config.get(skill_name, {}) | ||||
|                 color = config.get('color', '#6B7280')  # Gris neutre par défaut | ||||
|                  | ||||
|                 competences.append({ | ||||
|                     'name': skill_name, | ||||
|                     'color': color, | ||||
|                     'total_points': float(result.total_points) if result.total_points else 0.0, | ||||
|                     'elements_count': result.elements_count | ||||
|                 }) | ||||
|              | ||||
|             # Trier par ordre alphabétique | ||||
|             competences.sort(key=lambda x: x['name'].lower()) | ||||
|              | ||||
|             return { | ||||
|                 'competences': competences, | ||||
|                 'trimester': trimester | ||||
|             } | ||||
|          | ||||
|         except Exception as e: | ||||
|             from flask import current_app | ||||
|             current_app.logger.error(f"Erreur dans get_competence_analysis: {e}", exc_info=True) | ||||
|             return { | ||||
|                 'competences': [], | ||||
|                 'trimester': trimester | ||||
|             } | ||||
|  | ||||
|  | ||||
| # =================== FACADE ÉTENDUE =================== | ||||
|  | ||||
| class ClassServicesFacade: | ||||
|     """ | ||||
|     Facade qui regroupe tous les services pour les classes. | ||||
|     Point d'entrée unique pour les méthodes de ClassGroup. | ||||
|     """ | ||||
|      | ||||
|     def __init__(self, db_provider: DatabaseProvider): | ||||
|         self.statistics_service = ClassStatisticsService(db_provider) | ||||
|         self.analysis_service = ClassAnalysisService(db_provider) | ||||
|      | ||||
|     def get_trimester_statistics(self, class_group, trimester=None) -> Dict[str, Any]: | ||||
|         """Point d'entrée pour les statistiques trimestrielles.""" | ||||
|         return self.statistics_service.get_trimester_statistics(class_group, trimester) | ||||
|      | ||||
|     def get_class_results(self, class_group, trimester=None) -> Dict[str, Any]: | ||||
|         """Point d'entrée pour les résultats de classe.""" | ||||
|         return self.statistics_service.get_class_results(class_group, trimester) | ||||
|      | ||||
|     def get_domain_analysis(self, class_group, trimester=None) -> Dict[str, Any]: | ||||
|         """Point d'entrée pour l'analyse des domaines.""" | ||||
|         return self.analysis_service.get_domain_analysis(class_group, trimester) | ||||
|      | ||||
|     def get_competence_analysis(self, class_group, trimester=None) -> Dict[str, Any]: | ||||
|         """Point d'entrée pour l'analyse des compétences.""" | ||||
|         return self.analysis_service.get_competence_analysis(class_group, trimester) | ||||
|  | ||||
|  | ||||
| # =================== FACTORY FUNCTION =================== | ||||
|  | ||||
| def create_assessment_services() -> AssessmentServicesFacade: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user