Files
notytex/backend/domain/services/grading_calculator.py
Bertrand Benjamin 2b08eb534a Migration v1 (Flask) -> v2 (FastAPI + Vue.js) complétée
 Changements majeurs:
- Suppression complète du code Flask legacy
- Migration backend FastAPI vers racine /backend
- Migration frontend Vue.js vers racine /frontend
- Suppression de notytex-v2/ (code monté à la racine)

 Validations:
- Backend démarre correctement (port 8000)
- API /api/v2/health répond healthy
- 99/99 tests unitaires passent
- Frontend configuré avec proxy Vite

📝 Documentation:
- README.md réécrit pour v2
- Instructions de démarrage mises à jour
- .gitignore adapté pour backend/frontend/

🎯 Architecture finale:
notytex/
├── backend/     # FastAPI + SQLAlchemy + Pydantic
├── frontend/    # Vue 3 + Vite + TailwindCSS
├── docs/        # Documentation
└── school_management.db  # Base de données (inchangée)

Jalon 6 complété: Application v2 prête pour utilisation!
2025-11-25 21:09:47 +01:00

305 lines
9.0 KiB
Python

"""
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