""" Services découplés pour les opérations métier sur les évaluations. Ce module applique les principes SOLID en séparant les responsabilités de calcul, statistiques et progression qui étaient auparavant dans le modèle Assessment. """ from abc import ABC, abstractmethod from typing import Dict, Any, List, Optional, Tuple, Protocol from dataclasses import dataclass from collections import defaultdict import statistics import math # Type hints pour améliorer la lisibilité StudentId = int ExerciseId = int GradingElementId = int # =================== INTERFACES (Dependency Inversion Principle) =================== class ConfigProvider(Protocol): """Interface pour l'accès à la configuration.""" def is_special_value(self, value: str) -> bool: """Vérifie si une valeur est spéciale (., d, etc.)""" ... def get_special_values(self) -> Dict[str, Dict[str, Any]]: """Retourne la configuration des valeurs spéciales.""" ... class DatabaseProvider(Protocol): """Interface pour l'accès aux données.""" def get_grades_for_assessment(self, assessment_id: int) -> List[Any]: """Récupère toutes les notes d'une évaluation en une seule requête.""" ... def get_grading_elements_with_students(self, assessment_id: int) -> List[Any]: """Récupère les éléments de notation avec les étudiants associés.""" ... # =================== DATA TRANSFER OBJECTS =================== @dataclass class ProgressResult: """Résultat du calcul de progression.""" percentage: int completed: int total: int status: str students_count: int @dataclass class StudentScore: """Score d'un étudiant pour une évaluation.""" student_id: int student_name: str total_score: float total_max_points: float exercises: Dict[ExerciseId, Dict[str, Any]] @dataclass class StatisticsResult: """Résultat des calculs statistiques.""" count: int mean: float median: float min: float max: float std_dev: float # =================== STRATEGY PATTERN pour les types de notation =================== class GradingStrategy(ABC): """Interface Strategy pour les différents types de notation.""" @abstractmethod def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]: """Calcule le score selon le type de notation.""" pass @abstractmethod def get_grading_type(self) -> str: """Retourne le type de notation.""" pass class NotesStrategy(GradingStrategy): """Strategy pour la notation en points (notes).""" def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]: try: return float(grade_value) except (ValueError, TypeError): return 0.0 def get_grading_type(self) -> str: return 'notes' class ScoreStrategy(GradingStrategy): """Strategy pour la notation par compétences (score 0-3).""" def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]: try: score_int = int(grade_value) if 0 <= score_int <= 3: return (score_int / 3) * max_points return 0.0 except (ValueError, TypeError): return 0.0 def get_grading_type(self) -> str: return 'score' class GradingStrategyFactory: """Factory pour créer les strategies de notation.""" _strategies = { 'notes': NotesStrategy, 'score': ScoreStrategy } @classmethod def create(cls, grading_type: str) -> GradingStrategy: """Crée une strategy selon le type.""" strategy_class = cls._strategies.get(grading_type) if not strategy_class: raise ValueError(f"Type de notation non supporté: {grading_type}") return strategy_class() @classmethod def register_strategy(cls, grading_type: str, strategy_class: type): """Permet d'enregistrer de nouveaux types de notation.""" cls._strategies[grading_type] = strategy_class # =================== SERVICES MÉTIER =================== class UnifiedGradingCalculator: """ Calculateur unifié utilisant le pattern Strategy et l'injection de dépendances. Remplace la classe GradingCalculator du modèle. """ def __init__(self, config_provider: ConfigProvider): self.config_provider = config_provider self._strategies = {} def calculate_score(self, grade_value: str, grading_type: str, max_points: float) -> Optional[float]: """ Point d'entrée unifié pour tous les calculs de score. Utilise l'injection de dépendances pour éviter les imports circulaires. """ # Valeurs spéciales en premier if self.config_provider.is_special_value(grade_value): special_config = self.config_provider.get_special_values()[grade_value] special_value = special_config['value'] if special_value is None: # Dispensé return None return float(special_value) # 0 pour '.', etc. # Utilisation du pattern Strategy strategy = GradingStrategyFactory.create(grading_type) return strategy.calculate_score(grade_value, max_points) def is_counted_in_total(self, grade_value: str) -> bool: """Détermine si une note doit être comptée dans le total.""" if self.config_provider.is_special_value(grade_value): special_config = self.config_provider.get_special_values()[grade_value] return special_config['counts'] return True class AssessmentProgressService: """ Service dédié au calcul de progression des notes. Single Responsibility: calcul et formatage de la progression. """ def __init__(self, db_provider: DatabaseProvider): self.db_provider = db_provider def calculate_grading_progress(self, assessment) -> ProgressResult: """ Calcule la progression de saisie des notes pour une évaluation. Optimisé pour éviter les requêtes N+1. """ total_students = len(assessment.class_group.students) if total_students == 0: return ProgressResult( percentage=0, completed=0, total=0, status='no_students', students_count=0 ) # Requête optimisée : récupération en une seule fois grading_elements_data = self.db_provider.get_grading_elements_with_students(assessment.id) total_elements = 0 completed_elements = 0 for element_data in grading_elements_data: total_elements += total_students completed_elements += element_data['completed_grades_count'] if total_elements == 0: return ProgressResult( percentage=0, completed=0, total=0, status='no_elements', students_count=total_students ) percentage = round((completed_elements / total_elements) * 100) # Détermination du statut status = self._determine_status(percentage) return ProgressResult( percentage=percentage, completed=completed_elements, total=total_elements, status=status, students_count=total_students ) def _determine_status(self, percentage: int) -> str: """Détermine le statut basé sur le pourcentage.""" if percentage == 0: return 'not_started' elif percentage == 100: return 'completed' else: return 'in_progress' class StudentScoreCalculator: """ Service dédié au calcul des scores des étudiants. Single Responsibility: calculs de notes avec logique métier. """ def __init__(self, grading_calculator: UnifiedGradingCalculator, db_provider: DatabaseProvider): self.grading_calculator = grading_calculator self.db_provider = db_provider def calculate_student_scores(self, assessment) -> Tuple[Dict[StudentId, StudentScore], Dict[ExerciseId, Dict[StudentId, float]]]: """ Calcule les scores de tous les étudiants pour une évaluation. Optimisé avec requête unique pour éviter N+1. """ # Requête optimisée : toutes les notes en une fois grades_data = self.db_provider.get_grades_for_assessment(assessment.id) # Organisation des données par étudiant et exercice students_scores = {} exercise_scores = defaultdict(lambda: defaultdict(float)) # Calcul pour chaque étudiant for student in assessment.class_group.students: student_score = self._calculate_single_student_score( student, assessment, grades_data ) students_scores[student.id] = student_score # Mise à jour des scores par exercice for exercise_id, exercise_data in student_score.exercises.items(): exercise_scores[exercise_id][student.id] = exercise_data['score'] return students_scores, dict(exercise_scores) def _calculate_single_student_score(self, student, assessment, grades_data) -> StudentScore: """Calcule le score d'un seul étudiant.""" total_score = 0 total_max_points = 0 student_exercises = {} # Filtrage des notes pour cet étudiant student_grades = { grade['grading_element_id']: grade for grade in grades_data if grade['student_id'] == student.id } for exercise in assessment.exercises: exercise_result = self._calculate_exercise_score( exercise, student_grades ) student_exercises[exercise.id] = exercise_result total_score += exercise_result['score'] total_max_points += exercise_result['max_points'] return StudentScore( student_id=student.id, student_name=f"{student.first_name} {student.last_name}", total_score=round(total_score, 2), total_max_points=total_max_points, exercises=student_exercises ) def _calculate_exercise_score(self, exercise, student_grades) -> Dict[str, Any]: """Calcule le score pour un exercice spécifique.""" exercise_score = 0 exercise_max_points = 0 for element in exercise.grading_elements: grade_data = student_grades.get(element.id) if grade_data and grade_data['value'] and grade_data['value'] != '': calculated_score = self.grading_calculator.calculate_score( grade_data['value'].strip(), element.grading_type, element.max_points ) if self.grading_calculator.is_counted_in_total(grade_data['value'].strip()): if calculated_score is not None: # Pas dispensé exercise_score += calculated_score exercise_max_points += element.max_points return { 'score': exercise_score, 'max_points': exercise_max_points, 'title': exercise.title } class AssessmentStatisticsService: """ Service dédié aux calculs statistiques. Single Responsibility: analyses statistiques des résultats. """ def __init__(self, score_calculator: StudentScoreCalculator): self.score_calculator = score_calculator def get_assessment_statistics(self, assessment) -> StatisticsResult: """Calcule les statistiques descriptives pour une évaluation.""" students_scores, _ = self.score_calculator.calculate_student_scores(assessment) scores = [score.total_score for score in students_scores.values()] if not scores: return StatisticsResult( count=0, mean=0, median=0, min=0, max=0, std_dev=0 ) return StatisticsResult( count=len(scores), mean=round(statistics.mean(scores), 2), median=round(statistics.median(scores), 2), min=min(scores), max=max(scores), std_dev=round(statistics.stdev(scores) if len(scores) > 1 else 0, 2) ) # =================== FACADE pour simplifier l'utilisation =================== class AssessmentServicesFacade: """ Facade qui regroupe tous les services pour faciliter l'utilisation. Point d'entrée unique avec injection de dépendances. """ def __init__(self, config_provider: ConfigProvider, db_provider: DatabaseProvider): # Création des services avec injection de dépendances self.grading_calculator = UnifiedGradingCalculator(config_provider) self.progress_service = AssessmentProgressService(db_provider) self.score_calculator = StudentScoreCalculator(self.grading_calculator, db_provider) self.statistics_service = AssessmentStatisticsService(self.score_calculator) def get_grading_progress(self, assessment) -> ProgressResult: """Point d'entrée pour la progression.""" return self.progress_service.calculate_grading_progress(assessment) def calculate_student_scores(self, assessment) -> Tuple[Dict[StudentId, StudentScore], Dict[ExerciseId, Dict[StudentId, float]]]: """Point d'entrée pour les scores étudiants.""" return self.score_calculator.calculate_student_scores(assessment) def get_statistics(self, assessment) -> StatisticsResult: """Point d'entrée pour les statistiques.""" return self.statistics_service.get_assessment_statistics(assessment) # =================== FACTORY FUNCTION =================== def create_assessment_services() -> AssessmentServicesFacade: """ Factory function pour créer une instance configurée de AssessmentServicesFacade. Point d'entrée standard pour l'utilisation des services refactorisés. """ from app_config import config_manager from models import db config_provider = ConfigProvider(config_manager) db_provider = DatabaseProvider(db) return AssessmentServicesFacade(config_provider, db_provider)