feat: preparing migration
This commit is contained in:
267
models_refactored.py
Normal file
267
models_refactored.py
Normal 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
1
providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Providers pour l'injection de dépendances
|
||||||
157
providers/concrete_providers.py
Normal file
157
providers/concrete_providers.py
Normal 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
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app
|
||||||
from models import db, Assessment, ClassGroup
|
from models import db, Assessment, ClassGroup
|
||||||
from forms import AssessmentForm
|
from forms import AssessmentForm
|
||||||
|
# Import du service avec rétrocompatibilité gérée dans services/__init__.py
|
||||||
from services import AssessmentService
|
from services import AssessmentService
|
||||||
from utils import handle_db_errors, ValidationError
|
from utils import handle_db_errors, ValidationError
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
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)
|
||||||
332
tests/test_assessment_services.py
Normal file
332
tests/test_assessment_services.py
Normal 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)
|
||||||
Reference in New Issue
Block a user