diff --git a/models_refactored.py b/models_refactored.py new file mode 100644 index 0000000..2e8284a --- /dev/null +++ b/models_refactored.py @@ -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'' + + +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'' + + @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'' + + # =============== 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'' + + +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'' + + +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'' + + +# 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'' + + +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'' + + +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'' + + +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'' + + +# =============== 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) \ No newline at end of file diff --git a/providers/__init__.py b/providers/__init__.py new file mode 100644 index 0000000..8a02a9b --- /dev/null +++ b/providers/__init__.py @@ -0,0 +1 @@ +# Providers pour l'injection de dépendances \ No newline at end of file diff --git a/providers/concrete_providers.py b/providers/concrete_providers.py new file mode 100644 index 0000000..b4c49bc --- /dev/null +++ b/providers/concrete_providers.py @@ -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 + ) \ No newline at end of file diff --git a/routes/assessments.py b/routes/assessments.py index 550e39e..6b19372 100644 --- a/routes/assessments.py +++ b/routes/assessments.py @@ -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 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..43cd6f3 --- /dev/null +++ b/services/__init__.py @@ -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' +] \ No newline at end of file diff --git a/services/assessment_services.py b/services/assessment_services.py new file mode 100644 index 0000000..6049d51 --- /dev/null +++ b/services/assessment_services.py @@ -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) \ No newline at end of file diff --git a/tests/test_assessment_services.py b/tests/test_assessment_services.py new file mode 100644 index 0000000..989ccba --- /dev/null +++ b/tests/test_assessment_services.py @@ -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) \ No newline at end of file