""" Service de calcul des scores de notation. Implémente le Strategy Pattern pour gérer les différents types de notation (notes numériques vs compétences 0-3). """ from abc import ABC, abstractmethod from typing import Optional, Dict, Any from domain.value_objects import GradeValue # =================== STRATEGY PATTERN =================== 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. Args: grade_value: Valeur de la note (string) max_points: Points maximum pour cet élément Returns: Score calculé ou None si invalide """ pass @abstractmethod def get_grading_type(self) -> str: """Retourne l'identifiant du type de notation.""" pass class NotesStrategy(GradingStrategy): """ Strategy pour la notation en points (notes numériques). Les valeurs sont utilisées directement comme points. Ex: "15.5" sur 20 → 15.5 points """ def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]: try: # Normaliser la virgule en point pour les décimaux français normalized = grade_value.replace(',', '.') return float(normalized) 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). Les valeurs sont converties en points proportionnellement. Ex: score 2/3 avec max_points=6 → (2/3) * 6 = 4 points """ def calculate_score(self, grade_value: str, max_points: float) -> Optional[float]: try: score_int = int(grade_value) if 0 <= score_int <= 3: # Formule: (score / 3) * max_points 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: Dict[str, type] = { "notes": NotesStrategy, "score": ScoreStrategy } @classmethod def create(cls, grading_type: str) -> GradingStrategy: """ Crée une strategy selon le type. Args: grading_type: Type de notation ("notes" ou "score") Returns: Instance de la strategy correspondante Raises: ValueError: Si le type n'est pas supporté """ 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) -> None: """ Enregistre un nouveau type de notation. Permet d'étendre le système avec de nouveaux types de notation sans modifier le code existant (Open/Closed Principle). """ cls._strategies[grading_type] = strategy_class # =================== SERVICE PRINCIPAL =================== class GradingCalculator: """ Calculateur de scores unifié. Utilise le Strategy Pattern pour gérer les différents types de notation et gère les valeurs spéciales de manière configurable. """ # Configuration par défaut des valeurs spéciales DEFAULT_SPECIAL_VALUES: Dict[str, Dict[str, Any]] = { ".": { "label": "Pas de réponse", "value": 0, "counts": True }, "d": { "label": "Dispensé", "value": None, "counts": False }, "a": { "label": "Absent", "value": 0, "counts": True } } def __init__(self, special_values: Optional[Dict[str, Dict[str, Any]]] = None): """ Initialise le calculateur. Args: special_values: Configuration des valeurs spéciales (optionnel) Si None, utilise la configuration par défaut """ self.special_values = special_values or self.DEFAULT_SPECIAL_VALUES 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. Args: grade_value: Valeur de la note (string) grading_type: Type de notation ("notes" ou "score") max_points: Points maximum pour cet élément Returns: Score calculé, 0 pour valeurs spéciales comptées, None pour dispensé """ # Normaliser la valeur value = grade_value.strip() if grade_value else "" # Valeurs vides if not value: return None # Valeurs spéciales en premier if self.is_special_value(value): special_config = self.special_values[value] special_result = special_config.get("value") if special_result is None: # Dispensé return None return float(special_result) # 0 pour '.', 'a' # Utilisation du Strategy Pattern pour le calcul normal strategy = GradingStrategyFactory.create(grading_type) return strategy.calculate_score(value, max_points) def is_special_value(self, value: str) -> bool: """ Vérifie si une valeur est une valeur spéciale. Args: value: Valeur à vérifier Returns: True si la valeur est spéciale (., d, a) """ return value in self.special_values def is_counted_in_total(self, grade_value: str) -> bool: """ Détermine si une note doit être comptée dans le total. Args: grade_value: Valeur de la note Returns: True si la note compte dans le total """ value = grade_value.strip() if grade_value else "" if not value: return False if self.is_special_value(value): return self.special_values[value].get("counts", True) return True def parse_grade( self, grade_value: str, grading_type: str, max_points: float ) -> GradeValue: """ Parse une valeur de note en GradeValue avec toutes ses métadonnées. Args: grade_value: Valeur brute de la note grading_type: Type de notation max_points: Points maximum Returns: GradeValue avec toutes les informations """ value = grade_value.strip() if grade_value else "" if not value: return GradeValue.empty() is_special = self.is_special_value(value) counts = self.is_counted_in_total(value) numeric = self.calculate_score(value, grading_type, max_points) return GradeValue( raw_value=value, numeric_value=numeric, is_special=is_special, counts_in_total=counts ) def validate_grade_value( self, value: str, grading_type: str, max_points: Optional[float] = None ) -> bool: """ Valide une valeur de note selon le type de notation. Args: value: Valeur à valider grading_type: Type de notation max_points: Points maximum (optionnel, pour vérifier la limite) Returns: True si la valeur est valide """ if not value or value.strip() == "": return True value = value.strip() # Valeurs spéciales toujours valides if self.is_special_value(value): return True # Validation selon le type if grading_type == "notes": try: normalized = value.replace(",", ".") float_value = float(normalized) if float_value < 0: return False if max_points is not None and float_value > max_points: return False return True except (ValueError, TypeError): return False elif grading_type == "score": try: int_value = int(value) return 0 <= int_value <= 3 except (ValueError, TypeError): return False return False