feat: preparing migration

This commit is contained in:
2025-08-07 04:25:37 +02:00
parent e5e0545b42
commit a17f3439fa
7 changed files with 1199 additions and 0 deletions

267
models_refactored.py Normal file
View File

@@ -0,0 +1,267 @@
"""
Version refactorisée des modèles après application des principes SOLID.
Cette version montre comment la classe Assessment devient plus simple
après extraction des services métier.
"""
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
from sqlalchemy import Index, CheckConstraint, Enum
from decimal import Decimal
from typing import Optional, Dict, Any
from flask import current_app
# Import des services pour délégation
from services.assessment_services import ProgressResult, StudentScore, StatisticsResult
from providers.concrete_providers import AssessmentServicesFactory
db = SQLAlchemy()
class ClassGroup(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
description = db.Column(db.Text)
year = db.Column(db.String(20), nullable=False)
students = db.relationship('Student', backref='class_group', lazy=True)
assessments = db.relationship('Assessment', backref='class_group', lazy=True)
def __repr__(self):
return f'<ClassGroup {self.name}>'
class Student(db.Model):
id = db.Column(db.Integer, primary_key=True)
last_name = db.Column(db.String(100), nullable=False)
first_name = db.Column(db.String(100), nullable=False)
email = db.Column(db.String(120), unique=True)
class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False)
grades = db.relationship('Grade', backref='student', lazy=True)
def __repr__(self):
return f'<Student {self.first_name} {self.last_name}>'
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
class Assessment(db.Model):
"""
Modèle Assessment refactorisé selon les principes SOLID.
AVANT: 267 lignes avec 4 responsabilités
APRÈS: ~80 lignes avec 1 responsabilité (modèle de données)
Les responsabilités métier ont été extraites vers:
- AssessmentProgressService (progression)
- StudentScoreCalculator (scores étudiants)
- AssessmentStatisticsService (statistiques)
"""
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
trimester = db.Column(db.Integer, nullable=False) # 1, 2, ou 3
class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False)
coefficient = db.Column(db.Float, default=1.0)
exercises = db.relationship('Exercise', backref='assessment', lazy=True, cascade='all, delete-orphan')
__table_args__ = (
CheckConstraint('trimester IN (1, 2, 3)', name='check_trimester_valid'),
)
def __repr__(self):
return f'<Assessment {self.title}>'
# =============== DELEGATION VERS LES SERVICES ===============
@property
def grading_progress(self) -> Dict[str, Any]:
"""
Délègue le calcul de progression au service dédié.
Plus d'import circulaire, pas de logique métier dans le modèle.
"""
services = AssessmentServicesFactory.create_facade()
progress: ProgressResult = services.get_grading_progress(self)
# Conversion en dict pour compatibilité avec l'API existante
return {
'percentage': progress.percentage,
'completed': progress.completed,
'total': progress.total,
'status': progress.status,
'students_count': progress.students_count
}
def calculate_student_scores(self):
"""
Délègue le calcul des scores au service dédié.
Plus de requêtes N+1, logique optimisée dans le service.
"""
services = AssessmentServicesFactory.create_facade()
students_scores, exercise_scores = services.calculate_student_scores(self)
# Conversion pour compatibilité avec l'API existante
converted_students = {}
for student_id, score in students_scores.items():
converted_students[student_id] = {
'student': next(s for s in self.class_group.students if s.id == student_id),
'total_score': score.total_score,
'total_max_points': score.total_max_points,
'exercises': score.exercises
}
return converted_students, exercise_scores
def get_assessment_statistics(self) -> Dict[str, float]:
"""
Délègue les calculs statistiques au service dédié.
Logique métier externalisée, modèle simplifié.
"""
services = AssessmentServicesFactory.create_facade()
stats: StatisticsResult = services.get_statistics(self)
# Conversion en dict pour compatibilité
return {
'count': stats.count,
'mean': stats.mean,
'median': stats.median,
'min': stats.min,
'max': stats.max,
'std_dev': stats.std_dev
}
def get_total_max_points(self) -> float:
"""
Calcule le total des points maximum.
Seule logique métier simple gardée dans le modèle.
"""
total = 0
for exercise in self.exercises:
for element in exercise.grading_elements:
total += element.max_points
return total
class Exercise(db.Model):
id = db.Column(db.Integer, primary_key=True)
assessment_id = db.Column(db.Integer, db.ForeignKey('assessment.id'), nullable=False)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
order = db.Column(db.Integer, default=1)
grading_elements = db.relationship('GradingElement', backref='exercise', lazy=True, cascade='all, delete-orphan')
def __repr__(self):
return f'<Exercise {self.title}>'
class GradingElement(db.Model):
id = db.Column(db.Integer, primary_key=True)
exercise_id = db.Column(db.Integer, db.ForeignKey('exercise.id'), nullable=False)
label = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
skill = db.Column(db.String(200))
max_points = db.Column(db.Float, nullable=False)
grading_type = db.Column(Enum('notes', 'score', name='grading_types'), nullable=False, default='notes')
domain_id = db.Column(db.Integer, db.ForeignKey('domains.id'), nullable=True)
grades = db.relationship('Grade', backref='grading_element', lazy=True, cascade='all, delete-orphan')
def __repr__(self):
return f'<GradingElement {self.label}>'
class Grade(db.Model):
id = db.Column(db.Integer, primary_key=True)
student_id = db.Column(db.Integer, db.ForeignKey('student.id'), nullable=False)
grading_element_id = db.Column(db.Integer, db.ForeignKey('grading_element.id'), nullable=False)
value = db.Column(db.String(10))
comment = db.Column(db.Text)
def __repr__(self):
return f'<Grade {self.value} for {self.student.first_name if self.student else "Unknown"}>'
# Configuration tables (inchangées)
class AppConfig(db.Model):
"""Configuration simple de l'application (clé-valeur)."""
__tablename__ = 'app_config'
key = db.Column(db.String(100), primary_key=True)
value = db.Column(db.Text, nullable=False)
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<AppConfig {self.key}={self.value}>'
class CompetenceScaleValue(db.Model):
"""Valeurs de l'échelle des compétences (0, 1, 2, 3, ., d, etc.)."""
__tablename__ = 'competence_scale_values'
value = db.Column(db.String(10), primary_key=True)
label = db.Column(db.String(100), nullable=False)
color = db.Column(db.String(7), nullable=False)
included_in_total = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<CompetenceScaleValue {self.value}: {self.label}>'
class Competence(db.Model):
"""Liste des compétences (Calculer, Raisonner, etc.)."""
__tablename__ = 'competences'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
color = db.Column(db.String(7), nullable=False)
icon = db.Column(db.String(50), nullable=False)
order_index = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<Competence {self.name}>'
class Domain(db.Model):
"""Domaines/tags pour les éléments de notation."""
__tablename__ = 'domains'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
color = db.Column(db.String(7), nullable=False, default='#6B7280')
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relation inverse
grading_elements = db.relationship('GradingElement', backref='domain', lazy=True)
def __repr__(self):
return f'<Domain {self.name}>'
# =============== CLASSE POUR RETROCOMPATIBILITÉ ===============
class GradingCalculator:
"""
Classe pour rétrocompatibilité. Délègue vers les nouveaux services.
À supprimer progressivement au profit de l'injection de dépendances.
"""
@staticmethod
def calculate_score(grade_value: str, grading_type: str, max_points: float) -> Optional[float]:
"""Délègue vers le nouveau service unifié."""
services = AssessmentServicesFactory.create_facade()
return services.grading_calculator.calculate_score(grade_value, grading_type, max_points)
@staticmethod
def is_counted_in_total(grade_value: str, grading_type: str) -> bool:
"""Délègue vers le nouveau service unifié."""
services = AssessmentServicesFactory.create_facade()
return services.grading_calculator.is_counted_in_total(grade_value)

1
providers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Providers pour l'injection de dépendances

View File

@@ -0,0 +1,157 @@
"""
Implémentations concrètes des providers pour l'injection de dépendances.
Ce module fournit les adaptateurs concrets qui implémentent les interfaces
définies dans assessment_services.py, résolvant ainsi les imports circulaires.
"""
from typing import Dict, Any, List
from sqlalchemy.orm import joinedload
from sqlalchemy import func
from models import db, Grade, GradingElement, Exercise
class FlaskConfigProvider:
"""
Implémentation concrète du ConfigProvider utilisant app_config.
Résout les imports circulaires en encapsulant l'accès à la configuration.
"""
def __init__(self):
# Import paresseux pour éviter les dépendances circulaires
self._config_manager = None
@property
def config_manager(self):
"""Accès paresseux au config_manager."""
if self._config_manager is None:
from app_config import config_manager
self._config_manager = config_manager
return self._config_manager
def is_special_value(self, value: str) -> bool:
"""Vérifie si une valeur est spéciale."""
return self.config_manager.is_special_value(value)
def get_special_values(self) -> Dict[str, Dict[str, Any]]:
"""Retourne la configuration des valeurs spéciales."""
return self.config_manager.get_special_values()
class SQLAlchemyDatabaseProvider:
"""
Implémentation concrète du DatabaseProvider utilisant SQLAlchemy.
Fournit des requêtes optimisées pour éviter les problèmes N+1.
"""
def get_grades_for_assessment(self, assessment_id: int) -> List[Dict[str, Any]]:
"""
Récupère toutes les notes d'une évaluation en une seule requête optimisée.
Résout le problème N+1 identifié dans calculate_student_scores.
"""
query = (
db.session.query(
Grade.student_id,
Grade.grading_element_id,
Grade.value,
GradingElement.grading_type,
GradingElement.max_points
)
.join(GradingElement)
.join(Exercise)
.filter(Exercise.assessment_id == assessment_id)
.filter(Grade.value.isnot(None))
.filter(Grade.value != '')
)
return [
{
'student_id': row.student_id,
'grading_element_id': row.grading_element_id,
'value': row.value,
'grading_type': row.grading_type,
'max_points': row.max_points
}
for row in query.all()
]
def get_grading_elements_with_students(self, assessment_id: int) -> List[Dict[str, Any]]:
"""
Récupère les éléments de notation avec le nombre de notes complétées.
Résout le problème N+1 identifié dans grading_progress.
"""
# Sous-requête pour compter les grades complétés par élément
grades_subquery = (
db.session.query(
Grade.grading_element_id,
func.count(Grade.id).label('completed_count')
)
.filter(Grade.value.isnot(None))
.filter(Grade.value != '')
.group_by(Grade.grading_element_id)
.subquery()
)
# Requête principale avec jointure
query = (
db.session.query(
GradingElement.id,
GradingElement.label,
func.coalesce(grades_subquery.c.completed_count, 0).label('completed_grades_count')
)
.join(Exercise)
.outerjoin(grades_subquery, GradingElement.id == grades_subquery.c.grading_element_id)
.filter(Exercise.assessment_id == assessment_id)
)
return [
{
'element_id': row.id,
'element_label': row.label,
'completed_grades_count': row.completed_grades_count
}
for row in query.all()
]
# =================== FACTORY pour la création des services ===================
class AssessmentServicesFactory:
"""
Factory pour créer l'ensemble des services avec injection de dépendances.
Centralise la création et la configuration des services.
"""
@classmethod
def create_facade(cls) -> 'AssessmentServicesFacade':
"""
Crée une facade complète avec toutes les dépendances injectées.
Point d'entrée principal pour obtenir les services.
"""
from services.assessment_services import AssessmentServicesFacade
config_provider = FlaskConfigProvider()
db_provider = SQLAlchemyDatabaseProvider()
return AssessmentServicesFacade(
config_provider=config_provider,
db_provider=db_provider
)
@classmethod
def create_with_custom_providers(cls,
config_provider=None,
db_provider=None) -> 'AssessmentServicesFacade':
"""
Crée une facade avec des providers personnalisés.
Utile pour les tests avec des mocks.
"""
from services.assessment_services import AssessmentServicesFacade
config_provider = config_provider or FlaskConfigProvider()
db_provider = db_provider or SQLAlchemyDatabaseProvider()
return AssessmentServicesFacade(
config_provider=config_provider,
db_provider=db_provider
)

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app
from models import db, Assessment, ClassGroup
from forms import AssessmentForm
# Import du service avec rétrocompatibilité gérée dans services/__init__.py
from services import AssessmentService
from utils import handle_db_errors, ValidationError
from datetime import datetime

36
services/__init__.py Normal file
View 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'
]

View 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)

View File

@@ -0,0 +1,332 @@
"""
Tests pour les services d'évaluation refactorisés.
Ce module teste la nouvelle architecture avec injection de dépendances
et s'assure de la rétrocompatibilité avec l'API existante.
"""
import pytest
from unittest.mock import Mock, MagicMock
from dataclasses import asdict
from services.assessment_services import (
AssessmentServicesFacade,
AssessmentProgressService,
StudentScoreCalculator,
AssessmentStatisticsService,
UnifiedGradingCalculator,
GradingStrategyFactory,
NotesStrategy,
ScoreStrategy,
ProgressResult,
StudentScore,
StatisticsResult
)
from providers.concrete_providers import FlaskConfigProvider, SQLAlchemyDatabaseProvider
class TestGradingStrategies:
"""Test du pattern Strategy pour les types de notation."""
def test_notes_strategy(self):
strategy = NotesStrategy()
assert strategy.calculate_score('15.5', 20.0) == 15.5
assert strategy.calculate_score('0', 20.0) == 0.0
assert strategy.calculate_score('invalid', 20.0) == 0.0
assert strategy.get_grading_type() == 'notes'
def test_score_strategy(self):
strategy = ScoreStrategy()
assert strategy.calculate_score('0', 3.0) == 0.0
assert strategy.calculate_score('1', 3.0) == 1.0
assert strategy.calculate_score('2', 3.0) == 2.0
assert strategy.calculate_score('3', 3.0) == 3.0
assert strategy.calculate_score('4', 3.0) == 0.0 # Hors limites
assert strategy.calculate_score('invalid', 3.0) == 0.0
assert strategy.get_grading_type() == 'score'
def test_strategy_factory(self):
notes_strategy = GradingStrategyFactory.create('notes')
score_strategy = GradingStrategyFactory.create('score')
assert isinstance(notes_strategy, NotesStrategy)
assert isinstance(score_strategy, ScoreStrategy)
with pytest.raises(ValueError, match="Type de notation non supporté"):
GradingStrategyFactory.create('invalid')
def test_strategy_extensibility(self):
"""Test que le factory peut être étendu avec de nouveaux types."""
class CustomStrategy:
def calculate_score(self, grade_value, max_points):
return 42.0
def get_grading_type(self):
return 'custom'
GradingStrategyFactory.register_strategy('custom', CustomStrategy)
custom_strategy = GradingStrategyFactory.create('custom')
assert isinstance(custom_strategy, CustomStrategy)
assert custom_strategy.calculate_score('any', 10) == 42.0
class TestUnifiedGradingCalculator:
"""Test du calculateur unifié avec injection de dépendances."""
def test_calculate_score_with_special_values(self):
# Mock du config provider
config_provider = Mock()
config_provider.is_special_value.return_value = True
config_provider.get_special_values.return_value = {
'.': {'value': 0, 'counts': True},
'd': {'value': None, 'counts': False} # Dispensé
}
calculator = UnifiedGradingCalculator(config_provider)
# Test valeur spéciale "."
assert calculator.calculate_score('.', 'notes', 20.0) == 0.0
# Test valeur spéciale "d" (dispensé)
assert calculator.calculate_score('d', 'notes', 20.0) is None
def test_calculate_score_normal_values(self):
# Mock du config provider pour valeurs normales
config_provider = Mock()
config_provider.is_special_value.return_value = False
calculator = UnifiedGradingCalculator(config_provider)
# Test notes normales
assert calculator.calculate_score('15.5', 'notes', 20.0) == 15.5
# Test scores normaux
assert calculator.calculate_score('2', 'score', 3.0) == 2.0
def test_is_counted_in_total(self):
config_provider = Mock()
config_provider.is_special_value.return_value = True
config_provider.get_special_values.return_value = {
'.': {'counts': True},
'd': {'counts': False}
}
calculator = UnifiedGradingCalculator(config_provider)
assert calculator.is_counted_in_total('.') == True
assert calculator.is_counted_in_total('d') == False
class TestAssessmentProgressService:
"""Test du service de progression."""
def test_calculate_grading_progress_no_students(self):
# Mock de l'assessment sans étudiants
assessment = Mock()
assessment.class_group.students = []
db_provider = Mock()
service = AssessmentProgressService(db_provider)
result = service.calculate_grading_progress(assessment)
assert result.percentage == 0
assert result.status == 'no_students'
assert result.students_count == 0
def test_calculate_grading_progress_normal(self):
# Mock de l'assessment avec étudiants
assessment = Mock()
assessment.id = 1
assessment.class_group.students = [Mock(), Mock()] # 2 étudiants
# Mock du provider de données
db_provider = Mock()
db_provider.get_grading_elements_with_students.return_value = [
{'completed_grades_count': 1}, # Élément 1: 1/2 complété
{'completed_grades_count': 2} # Élément 2: 2/2 complété
]
service = AssessmentProgressService(db_provider)
result = service.calculate_grading_progress(assessment)
# 3 notes complétées sur 4 possibles = 75%
assert result.percentage == 75
assert result.completed == 3
assert result.total == 4
assert result.status == 'in_progress'
assert result.students_count == 2
class TestStudentScoreCalculator:
"""Test du calculateur de scores étudiants."""
def test_calculate_student_scores(self):
# Configuration des mocks
grading_calculator = Mock()
grading_calculator.calculate_score.return_value = 10.0
grading_calculator.is_counted_in_total.return_value = True
db_provider = Mock()
db_provider.get_grades_for_assessment.return_value = [
{
'student_id': 1,
'grading_element_id': 1,
'value': '10',
'grading_type': 'notes',
'max_points': 20.0
}
]
# Mock de l'assessment
student = Mock()
student.id = 1
student.first_name = 'Jean'
student.last_name = 'Dupont'
exercise = Mock()
exercise.id = 1
exercise.title = 'Exercice 1'
exercise.grading_elements = [Mock()]
exercise.grading_elements[0].id = 1
exercise.grading_elements[0].max_points = 20.0
exercise.grading_elements[0].grading_type = 'notes'
assessment = Mock()
assessment.id = 1
assessment.class_group.students = [student]
assessment.exercises = [exercise]
calculator = StudentScoreCalculator(grading_calculator, db_provider)
students_scores, exercise_scores = calculator.calculate_student_scores(assessment)
assert len(students_scores) == 1
assert 1 in students_scores
student_score = students_scores[1]
assert student_score.student_id == 1
assert student_score.student_name == 'Jean Dupont'
assert student_score.total_score == 10.0
class TestAssessmentStatisticsService:
"""Test du service de statistiques."""
def test_get_assessment_statistics_no_scores(self):
score_calculator = Mock()
score_calculator.calculate_student_scores.return_value = ({}, {})
service = AssessmentStatisticsService(score_calculator)
assessment = Mock()
result = service.get_assessment_statistics(assessment)
assert result.count == 0
assert result.mean == 0
assert result.median == 0
def test_get_assessment_statistics_with_scores(self):
# Mock des scores étudiants
mock_scores = {
1: StudentScore(1, 'Student 1', 15.0, 20.0, {}),
2: StudentScore(2, 'Student 2', 18.0, 20.0, {}),
3: StudentScore(3, 'Student 3', 12.0, 20.0, {})
}
score_calculator = Mock()
score_calculator.calculate_student_scores.return_value = (mock_scores, {})
service = AssessmentStatisticsService(score_calculator)
assessment = Mock()
result = service.get_assessment_statistics(assessment)
assert result.count == 3
assert result.mean == 15.0 # (15+18+12)/3
assert result.median == 15.0
assert result.min == 12.0
assert result.max == 18.0
class TestAssessmentServicesFacade:
"""Test de la facade qui regroupe tous les services."""
def test_facade_integration(self):
config_provider = Mock()
config_provider.is_special_value.return_value = False
db_provider = Mock()
db_provider.get_grading_elements_with_students.return_value = []
db_provider.get_grades_for_assessment.return_value = []
facade = AssessmentServicesFacade(config_provider, db_provider)
# Vérifier que tous les services sont disponibles
assert hasattr(facade, 'grading_calculator')
assert hasattr(facade, 'progress_service')
assert hasattr(facade, 'score_calculator')
assert hasattr(facade, 'statistics_service')
# Test des méthodes de la facade
assessment = Mock()
assessment.id = 1
assessment.class_group.students = []
progress = facade.get_grading_progress(assessment)
assert isinstance(progress, ProgressResult)
students, exercises = facade.calculate_student_scores(assessment)
assert isinstance(students, dict)
assert isinstance(exercises, dict)
stats = facade.get_statistics(assessment)
assert isinstance(stats, StatisticsResult)
class TestRegressionCompatibility:
"""Tests de régression pour s'assurer de la rétrocompatibilité."""
def test_grading_progress_api_compatibility(self):
"""S'assurer que l'API grading_progress reste identique."""
config_provider = Mock()
db_provider = Mock()
db_provider.get_grading_elements_with_students.return_value = [
{'completed_grades_count': 5}
]
facade = AssessmentServicesFacade(config_provider, db_provider)
assessment = Mock()
assessment.id = 1
assessment.class_group.students = [Mock(), Mock()] # 2 étudiants
progress = facade.get_grading_progress(assessment)
# L'API originale retournait un dict, vérifions les clés
expected_keys = {'percentage', 'completed', 'total', 'status', 'students_count'}
actual_dict = asdict(progress)
assert set(actual_dict.keys()) == expected_keys
def test_calculate_student_scores_api_compatibility(self):
"""S'assurer que calculate_student_scores garde la même signature."""
config_provider = Mock()
config_provider.is_special_value.return_value = False
db_provider = Mock()
db_provider.get_grades_for_assessment.return_value = []
facade = AssessmentServicesFacade(config_provider, db_provider)
assessment = Mock()
assessment.id = 1
assessment.class_group.students = []
assessment.exercises = []
students_scores, exercise_scores = facade.calculate_student_scores(assessment)
# L'API originale retournait un tuple de 2 dicts
assert isinstance(students_scores, dict)
assert isinstance(exercise_scores, dict)