feat: preparing migration
This commit is contained in:
36
services/__init__.py
Normal file
36
services/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Services pour la logique métier
|
||||
|
||||
# Import des nouveaux services refactorisés
|
||||
from .assessment_services import (
|
||||
AssessmentProgressService,
|
||||
StudentScoreCalculator,
|
||||
AssessmentStatisticsService,
|
||||
UnifiedGradingCalculator,
|
||||
GradingStrategyFactory
|
||||
)
|
||||
|
||||
# Import de l'ancien service pour rétrocompatibilité
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
# Charger AssessmentService depuis services.py
|
||||
try:
|
||||
services_file_path = os.path.join(os.path.dirname(__file__), '..', 'services.py')
|
||||
spec = importlib.util.spec_from_file_location("legacy_services", services_file_path)
|
||||
legacy_services = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(legacy_services)
|
||||
AssessmentService = legacy_services.AssessmentService
|
||||
except Exception:
|
||||
# Fallback en cas d'erreur
|
||||
class AssessmentService:
|
||||
"""Fallback pour AssessmentService"""
|
||||
pass
|
||||
|
||||
__all__ = [
|
||||
'AssessmentService', # Service legacy
|
||||
'AssessmentProgressService',
|
||||
'StudentScoreCalculator',
|
||||
'AssessmentStatisticsService',
|
||||
'UnifiedGradingCalculator',
|
||||
'GradingStrategyFactory'
|
||||
]
|
||||
405
services/assessment_services.py
Normal file
405
services/assessment_services.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user