feat: improve assessments filters and cleaning
This commit is contained in:
18
CLAUDE.md
18
CLAUDE.md
@@ -62,6 +62,24 @@ Grade (Note attribuée à chaque élève)
|
||||
- **Règles de calcul** : Logique unifiée pour tous les types de notation
|
||||
- **Interface d'administration** : Gestion complète des paramètres de notation
|
||||
|
||||
### **Filtrage Avancé des Évaluations**
|
||||
|
||||
**Filtres Dynamiques Disponibles :**
|
||||
|
||||
- **Trimestre** : Filtrage par trimestre (1, 2, 3) pour organiser par période scolaire
|
||||
- **Classe** : Filtrage par groupe de classe pour se concentrer sur une classe spécifique
|
||||
- **Statut de Correction** : **NOUVEAU** - Filtre essentiel pour la gestion des corrections :
|
||||
- **Non terminées** : Évaluations partiellement corrigées ou non commencées (idéal pour mode midyear)
|
||||
- **Terminées** : Évaluations 100% corrigées
|
||||
- **Non commencées** : Évaluations sans aucune note saisie
|
||||
- **Tri** : Organisation par date (récent/ancien), titre alphabétique, ou classe
|
||||
|
||||
**JavaScript Dynamique :**
|
||||
|
||||
- **Filtrage temps réel** : Les filtres s'appliquent automatiquement au changement
|
||||
- **Persistence des filtres** : État maintenu dans l'URL pour navigation intuitive
|
||||
- **Interface responsive** : Adaptée aux appareils mobiles et desktop
|
||||
|
||||
### **Interface Utilisateur & UX Moderne (Phase 2 - Décembre 2024)**
|
||||
|
||||
- **Dashboard avec statistiques en temps réel** : Cartes cliquables avec animations et gradients
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
# =================== FACTORY FUNCTION ===================
|
||||
|
||||
def create_assessment_services() -> AssessmentServicesFacade:
|
||||
"""
|
||||
Factory function pour créer une instance configurée de AssessmentServicesFacade.
|
||||
Point d'entrée standard pour l'utilisation des services refactorisés.
|
||||
"""
|
||||
from app_config import config_manager
|
||||
from models import db
|
||||
|
||||
config_provider = ConfigProvider(config_manager)
|
||||
db_provider = DatabaseProvider(db)
|
||||
|
||||
return AssessmentServicesFacade(config_provider, db_provider)
|
||||
@@ -1,388 +0,0 @@
|
||||
"""
|
||||
Système de Feature Flags pour Migration Progressive (JOUR 1-2)
|
||||
|
||||
Ce module implémente un système de feature flags robust pour permettre
|
||||
l'activation/désactivation contrôlée des nouvelles fonctionnalités pendant
|
||||
la migration vers l'architecture refactorisée.
|
||||
|
||||
Architecture:
|
||||
- Enum typé pour toutes les feature flags
|
||||
- Configuration centralisée avec validation
|
||||
- Support pour rollback instantané
|
||||
- Logging automatique des changements d'état
|
||||
|
||||
Utilisé pour la migration progressive selon MIGRATION_PROGRESSIVE.md
|
||||
"""
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureFlag(Enum):
|
||||
"""
|
||||
Énumération de tous les feature flags disponibles.
|
||||
|
||||
Conventions de nommage:
|
||||
- USE_NEW_<SERVICE_NAME> pour les migrations de services
|
||||
- ENABLE_<FEATURE_NAME> pour les nouvelles fonctionnalités
|
||||
"""
|
||||
|
||||
# === MIGRATION PROGRESSIVE SERVICES ===
|
||||
|
||||
# JOUR 3-4: Migration Services Core
|
||||
USE_STRATEGY_PATTERN = "use_strategy_pattern"
|
||||
USE_REFACTORED_ASSESSMENT = "use_refactored_assessment"
|
||||
|
||||
# JOUR 5-6: Services Avancés
|
||||
USE_NEW_STUDENT_SCORE_CALCULATOR = "use_new_student_score_calculator"
|
||||
USE_NEW_ASSESSMENT_STATISTICS_SERVICE = "use_new_assessment_statistics_service"
|
||||
|
||||
# === FONCTIONNALITÉS AVANCÉES ===
|
||||
|
||||
# Performance et monitoring
|
||||
ENABLE_PERFORMANCE_MONITORING = "enable_performance_monitoring"
|
||||
ENABLE_QUERY_OPTIMIZATION = "enable_query_optimization"
|
||||
|
||||
# Interface utilisateur
|
||||
ENABLE_BULK_OPERATIONS = "enable_bulk_operations"
|
||||
ENABLE_ADVANCED_FILTERS = "enable_advanced_filters"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureFlagConfig:
|
||||
"""Configuration d'un feature flag avec métadonnées."""
|
||||
|
||||
enabled: bool
|
||||
description: str
|
||||
migration_day: Optional[int] = None # Jour de migration selon le plan (1-7)
|
||||
rollback_safe: bool = True # Peut être désactivé sans risque
|
||||
created_at: datetime = None
|
||||
updated_at: datetime = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.utcnow()
|
||||
if self.updated_at is None:
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
class FeatureFlagManager:
|
||||
"""
|
||||
Gestionnaire centralisé des feature flags.
|
||||
|
||||
Fonctionnalités:
|
||||
- Configuration via variables d'environnement
|
||||
- Fallback vers configuration par défaut
|
||||
- Logging des changements d'état
|
||||
- Validation des flags
|
||||
- Support pour tests unitaires
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._flags: Dict[FeatureFlag, FeatureFlagConfig] = {}
|
||||
self._initialize_defaults()
|
||||
self._load_from_environment()
|
||||
|
||||
def _initialize_defaults(self) -> None:
|
||||
"""Initialise la configuration par défaut des feature flags."""
|
||||
|
||||
# Configuration par défaut - TOUT DÉSACTIVÉ pour sécurité maximale
|
||||
default_configs = {
|
||||
# MIGRATION PROGRESSIVE - JOUR 3-4
|
||||
FeatureFlag.USE_STRATEGY_PATTERN: FeatureFlagConfig(
|
||||
enabled=False,
|
||||
description="Utilise les nouvelles stratégies de notation (Pattern Strategy)",
|
||||
migration_day=3,
|
||||
rollback_safe=True
|
||||
),
|
||||
FeatureFlag.USE_REFACTORED_ASSESSMENT: FeatureFlagConfig(
|
||||
enabled=False,
|
||||
description="Utilise le nouveau service de calcul de progression",
|
||||
migration_day=4,
|
||||
rollback_safe=True
|
||||
),
|
||||
|
||||
# MIGRATION PROGRESSIVE - JOUR 5-6
|
||||
FeatureFlag.USE_NEW_STUDENT_SCORE_CALCULATOR: FeatureFlagConfig(
|
||||
enabled=False,
|
||||
description="Utilise le nouveau calculateur de scores étudiants",
|
||||
migration_day=5,
|
||||
rollback_safe=True
|
||||
),
|
||||
FeatureFlag.USE_NEW_ASSESSMENT_STATISTICS_SERVICE: FeatureFlagConfig(
|
||||
enabled=False,
|
||||
description="Utilise le nouveau service de statistiques d'évaluation",
|
||||
migration_day=6,
|
||||
rollback_safe=True
|
||||
),
|
||||
|
||||
# FONCTIONNALITÉS AVANCÉES
|
||||
FeatureFlag.ENABLE_PERFORMANCE_MONITORING: FeatureFlagConfig(
|
||||
enabled=False,
|
||||
description="Active le monitoring des performances",
|
||||
rollback_safe=True
|
||||
),
|
||||
FeatureFlag.ENABLE_QUERY_OPTIMIZATION: FeatureFlagConfig(
|
||||
enabled=False,
|
||||
description="Active les optimisations de requêtes",
|
||||
rollback_safe=True
|
||||
),
|
||||
FeatureFlag.ENABLE_BULK_OPERATIONS: FeatureFlagConfig(
|
||||
enabled=False,
|
||||
description="Active les opérations en masse",
|
||||
rollback_safe=True
|
||||
),
|
||||
FeatureFlag.ENABLE_ADVANCED_FILTERS: FeatureFlagConfig(
|
||||
enabled=False,
|
||||
description="Active les filtres avancés",
|
||||
rollback_safe=True
|
||||
),
|
||||
}
|
||||
|
||||
self._flags.update(default_configs)
|
||||
logger.info("Feature flags initialisés avec configuration par défaut")
|
||||
|
||||
def _load_from_environment(self) -> None:
|
||||
"""Charge la configuration depuis les variables d'environnement."""
|
||||
|
||||
for flag in FeatureFlag:
|
||||
env_var = f"FEATURE_FLAG_{flag.value.upper()}"
|
||||
env_value = os.environ.get(env_var)
|
||||
|
||||
if env_value is not None:
|
||||
# Parse boolean depuis l'environnement
|
||||
enabled = env_value.lower() in ('true', '1', 'yes', 'on', 'enabled')
|
||||
|
||||
if flag in self._flags:
|
||||
old_state = self._flags[flag].enabled
|
||||
self._flags[flag].enabled = enabled
|
||||
self._flags[flag].updated_at = datetime.utcnow()
|
||||
|
||||
if old_state != enabled:
|
||||
logger.info(
|
||||
f"Feature flag {flag.value} modifié par env: {old_state} -> {enabled}",
|
||||
extra={
|
||||
'event_type': 'feature_flag_changed',
|
||||
'flag_name': flag.value,
|
||||
'old_value': old_state,
|
||||
'new_value': enabled,
|
||||
'source': 'environment'
|
||||
}
|
||||
)
|
||||
|
||||
def is_enabled(self, flag: FeatureFlag) -> bool:
|
||||
"""
|
||||
Vérifie si un feature flag est activé.
|
||||
|
||||
Args:
|
||||
flag: Le feature flag à vérifier
|
||||
|
||||
Returns:
|
||||
bool: True si le flag est activé, False sinon
|
||||
"""
|
||||
if flag not in self._flags:
|
||||
logger.warning(
|
||||
f"Feature flag inconnu: {flag.value}. Retour False par défaut.",
|
||||
extra={'event_type': 'unknown_feature_flag', 'flag_name': flag.value}
|
||||
)
|
||||
return False
|
||||
|
||||
return self._flags[flag].enabled
|
||||
|
||||
def enable(self, flag: FeatureFlag, reason: str = "") -> bool:
|
||||
"""
|
||||
Active un feature flag.
|
||||
|
||||
Args:
|
||||
flag: Le feature flag à activer
|
||||
reason: Raison de l'activation (pour logs)
|
||||
|
||||
Returns:
|
||||
bool: True si l'activation a réussi
|
||||
"""
|
||||
if flag not in self._flags:
|
||||
logger.error(f"Impossible d'activer un feature flag inconnu: {flag.value}")
|
||||
return False
|
||||
|
||||
old_state = self._flags[flag].enabled
|
||||
self._flags[flag].enabled = True
|
||||
self._flags[flag].updated_at = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
f"Feature flag {flag.value} activé. Raison: {reason}",
|
||||
extra={
|
||||
'event_type': 'feature_flag_enabled',
|
||||
'flag_name': flag.value,
|
||||
'old_value': old_state,
|
||||
'new_value': True,
|
||||
'reason': reason,
|
||||
'migration_day': self._flags[flag].migration_day
|
||||
}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def disable(self, flag: FeatureFlag, reason: str = "") -> bool:
|
||||
"""
|
||||
Désactive un feature flag.
|
||||
|
||||
Args:
|
||||
flag: Le feature flag à désactiver
|
||||
reason: Raison de la désactivation (pour logs)
|
||||
|
||||
Returns:
|
||||
bool: True si la désactivation a réussi
|
||||
"""
|
||||
if flag not in self._flags:
|
||||
logger.error(f"Impossible de désactiver un feature flag inconnu: {flag.value}")
|
||||
return False
|
||||
|
||||
if not self._flags[flag].rollback_safe:
|
||||
logger.warning(
|
||||
f"Désactivation d'un flag non-rollback-safe: {flag.value}",
|
||||
extra={'event_type': 'unsafe_rollback_attempt', 'flag_name': flag.value}
|
||||
)
|
||||
|
||||
old_state = self._flags[flag].enabled
|
||||
self._flags[flag].enabled = False
|
||||
self._flags[flag].updated_at = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
f"Feature flag {flag.value} désactivé. Raison: {reason}",
|
||||
extra={
|
||||
'event_type': 'feature_flag_disabled',
|
||||
'flag_name': flag.value,
|
||||
'old_value': old_state,
|
||||
'new_value': False,
|
||||
'reason': reason,
|
||||
'rollback_safe': self._flags[flag].rollback_safe
|
||||
}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def get_config(self, flag: FeatureFlag) -> Optional[FeatureFlagConfig]:
|
||||
"""Récupère la configuration complète d'un feature flag."""
|
||||
return self._flags.get(flag)
|
||||
|
||||
def get_status_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Retourne un résumé de l'état de tous les feature flags.
|
||||
|
||||
Returns:
|
||||
Dict contenant le statut de chaque flag avec métadonnées
|
||||
"""
|
||||
summary = {
|
||||
'flags': {},
|
||||
'migration_status': {
|
||||
'day_3_ready': False,
|
||||
'day_4_ready': False,
|
||||
'day_5_ready': False,
|
||||
'day_6_ready': False
|
||||
},
|
||||
'total_enabled': 0,
|
||||
'last_updated': None
|
||||
}
|
||||
|
||||
latest_update = None
|
||||
enabled_count = 0
|
||||
|
||||
for flag, config in self._flags.items():
|
||||
summary['flags'][flag.value] = {
|
||||
'enabled': config.enabled,
|
||||
'description': config.description,
|
||||
'migration_day': config.migration_day,
|
||||
'rollback_safe': config.rollback_safe,
|
||||
'updated_at': config.updated_at.isoformat() if config.updated_at else None
|
||||
}
|
||||
|
||||
if config.enabled:
|
||||
enabled_count += 1
|
||||
|
||||
if latest_update is None or (config.updated_at and config.updated_at > latest_update):
|
||||
latest_update = config.updated_at
|
||||
|
||||
# Calcul du statut de migration par jour
|
||||
day_3_flags = [FeatureFlag.USE_STRATEGY_PATTERN]
|
||||
day_4_flags = [FeatureFlag.USE_REFACTORED_ASSESSMENT]
|
||||
day_5_flags = [FeatureFlag.USE_NEW_STUDENT_SCORE_CALCULATOR]
|
||||
day_6_flags = [FeatureFlag.USE_NEW_ASSESSMENT_STATISTICS_SERVICE]
|
||||
|
||||
summary['migration_status']['day_3_ready'] = all(self.is_enabled(flag) for flag in day_3_flags)
|
||||
summary['migration_status']['day_4_ready'] = all(self.is_enabled(flag) for flag in day_4_flags)
|
||||
summary['migration_status']['day_5_ready'] = all(self.is_enabled(flag) for flag in day_5_flags)
|
||||
summary['migration_status']['day_6_ready'] = all(self.is_enabled(flag) for flag in day_6_flags)
|
||||
|
||||
summary['total_enabled'] = enabled_count
|
||||
summary['last_updated'] = latest_update.isoformat() if latest_update else None
|
||||
|
||||
return summary
|
||||
|
||||
def enable_migration_day(self, day: int, reason: str = "") -> Dict[str, bool]:
|
||||
"""
|
||||
Active tous les feature flags pour un jour de migration donné.
|
||||
|
||||
Args:
|
||||
day: Numéro du jour de migration (3-6)
|
||||
reason: Raison de l'activation
|
||||
|
||||
Returns:
|
||||
Dict[flag_name, success] indiquant quels flags ont été activés
|
||||
"""
|
||||
day_flags_map = {
|
||||
3: [FeatureFlag.USE_STRATEGY_PATTERN],
|
||||
4: [FeatureFlag.USE_REFACTORED_ASSESSMENT],
|
||||
5: [FeatureFlag.USE_NEW_STUDENT_SCORE_CALCULATOR],
|
||||
6: [FeatureFlag.USE_NEW_ASSESSMENT_STATISTICS_SERVICE]
|
||||
}
|
||||
|
||||
if day not in day_flags_map:
|
||||
logger.error(f"Jour de migration invalide: {day}. Jours supportés: 3-6")
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
migration_reason = f"Migration Jour {day}: {reason}" if reason else f"Migration Jour {day}"
|
||||
|
||||
for flag in day_flags_map[day]:
|
||||
success = self.enable(flag, migration_reason)
|
||||
results[flag.value] = success
|
||||
|
||||
logger.info(
|
||||
f"Activation des flags pour le jour {day} terminée",
|
||||
extra={
|
||||
'event_type': 'migration_day_activation',
|
||||
'migration_day': day,
|
||||
'results': results,
|
||||
'reason': reason
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Instance globale du gestionnaire de feature flags
|
||||
feature_flags = FeatureFlagManager()
|
||||
|
||||
|
||||
def is_feature_enabled(flag: FeatureFlag) -> bool:
|
||||
"""
|
||||
Fonction utilitaire pour vérifier l'état d'un feature flag.
|
||||
|
||||
Usage dans le code:
|
||||
from config.feature_flags import is_feature_enabled, FeatureFlag
|
||||
|
||||
if is_feature_enabled(FeatureFlag.USE_NEW_GRADING_STRATEGIES):
|
||||
# Utiliser la nouvelle implémentation
|
||||
result = new_grading_service.calculate()
|
||||
else:
|
||||
# Utiliser l'ancienne implémentation
|
||||
result = old_grading_method()
|
||||
"""
|
||||
return feature_flags.is_enabled(flag)
|
||||
@@ -1,531 +0,0 @@
|
||||
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
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class GradingCalculator:
|
||||
"""
|
||||
Calculateur unifié pour tous types de notation.
|
||||
Utilise le feature flag USE_STRATEGY_PATTERN pour basculer entre
|
||||
l'ancienne logique conditionnelle et le nouveau Pattern Strategy.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_score(grade_value: str, grading_type: str, max_points: float) -> Optional[float]:
|
||||
"""
|
||||
UN seul point d'entrée pour tous les calculs de score.
|
||||
|
||||
Args:
|
||||
grade_value: Valeur de la note (ex: '15.5', '2', '.', 'd')
|
||||
grading_type: Type de notation ('notes' ou 'score')
|
||||
max_points: Points maximum de l'élément de notation
|
||||
|
||||
Returns:
|
||||
Score calculé ou None pour les valeurs dispensées
|
||||
"""
|
||||
# Feature flag pour basculer vers le Pattern Strategy
|
||||
from config.feature_flags import is_feature_enabled, FeatureFlag
|
||||
|
||||
if is_feature_enabled(FeatureFlag.USE_STRATEGY_PATTERN):
|
||||
# === NOUVELLE IMPLÉMENTATION : Pattern Strategy ===
|
||||
return GradingCalculator._calculate_score_with_strategy(grade_value, grading_type, max_points)
|
||||
else:
|
||||
# === ANCIENNE IMPLÉMENTATION : Logique conditionnelle ===
|
||||
return GradingCalculator._calculate_score_legacy(grade_value, grading_type, max_points)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_score_with_strategy(grade_value: str, grading_type: str, max_points: float) -> Optional[float]:
|
||||
"""
|
||||
Nouvelle implémentation utilisant le Pattern Strategy et l'injection de dépendances.
|
||||
"""
|
||||
from services.assessment_services import UnifiedGradingCalculator
|
||||
from providers.concrete_providers import ConfigManagerProvider
|
||||
|
||||
# Injection de dépendances pour éviter les imports circulaires
|
||||
config_provider = ConfigManagerProvider()
|
||||
unified_calculator = UnifiedGradingCalculator(config_provider)
|
||||
|
||||
return unified_calculator.calculate_score(grade_value, grading_type, max_points)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_score_legacy(grade_value: str, grading_type: str, max_points: float) -> Optional[float]:
|
||||
"""
|
||||
Ancienne implémentation avec logique conditionnelle (pour compatibilité).
|
||||
"""
|
||||
# Éviter les imports circulaires en important à l'utilisation
|
||||
from app_config import config_manager
|
||||
|
||||
# Valeurs spéciales en premier
|
||||
if config_manager.is_special_value(grade_value):
|
||||
special_config = config_manager.get_special_values()[grade_value]
|
||||
special_value = special_config['value']
|
||||
if special_value is None: # Dispensé
|
||||
return None
|
||||
return float(special_value) # 0 pour '.', 'a'
|
||||
|
||||
# Calcul selon type (logique conditionnelle legacy)
|
||||
try:
|
||||
if grading_type == 'notes':
|
||||
return float(grade_value)
|
||||
elif grading_type == 'score':
|
||||
# Score 0-3 converti en proportion du max_points
|
||||
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
|
||||
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def is_counted_in_total(grade_value: str, grading_type: str) -> bool:
|
||||
"""
|
||||
Détermine si une note doit être comptée dans le total.
|
||||
Utilise le feature flag USE_STRATEGY_PATTERN pour basculer vers les nouveaux services.
|
||||
|
||||
Returns:
|
||||
True si la note compte dans le total, False sinon (ex: dispensé)
|
||||
"""
|
||||
# Feature flag pour basculer vers le Pattern Strategy
|
||||
from config.feature_flags import is_feature_enabled, FeatureFlag
|
||||
|
||||
if is_feature_enabled(FeatureFlag.USE_STRATEGY_PATTERN):
|
||||
# === NOUVELLE IMPLÉMENTATION : Pattern Strategy ===
|
||||
return GradingCalculator._is_counted_in_total_with_strategy(grade_value)
|
||||
else:
|
||||
# === ANCIENNE IMPLÉMENTATION : Logique directe ===
|
||||
return GradingCalculator._is_counted_in_total_legacy(grade_value)
|
||||
|
||||
@staticmethod
|
||||
def _is_counted_in_total_with_strategy(grade_value: str) -> bool:
|
||||
"""
|
||||
Nouvelle implémentation utilisant l'injection de dépendances.
|
||||
"""
|
||||
from services.assessment_services import UnifiedGradingCalculator
|
||||
from providers.concrete_providers import ConfigManagerProvider
|
||||
|
||||
# Injection de dépendances pour éviter les imports circulaires
|
||||
config_provider = ConfigManagerProvider()
|
||||
unified_calculator = UnifiedGradingCalculator(config_provider)
|
||||
|
||||
return unified_calculator.is_counted_in_total(grade_value)
|
||||
|
||||
@staticmethod
|
||||
def _is_counted_in_total_legacy(grade_value: str) -> bool:
|
||||
"""
|
||||
Ancienne implémentation avec accès direct au config_manager.
|
||||
"""
|
||||
from app_config import config_manager
|
||||
|
||||
# Valeurs spéciales
|
||||
if config_manager.is_special_value(grade_value):
|
||||
special_config = config_manager.get_special_values()[grade_value]
|
||||
return special_config['counts']
|
||||
|
||||
# Toutes les autres valeurs comptent
|
||||
return True
|
||||
|
||||
|
||||
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):
|
||||
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) # Garder Float pour compatibilité
|
||||
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}>'
|
||||
|
||||
@property
|
||||
def grading_progress(self):
|
||||
"""
|
||||
Calcule le pourcentage de progression des notes saisies pour cette évaluation.
|
||||
Utilise le feature flag USE_REFACTORED_ASSESSMENT pour basculer entre
|
||||
l'ancienne logique et le nouveau AssessmentProgressService optimisé.
|
||||
|
||||
Returns:
|
||||
Dict avec les statistiques de progression
|
||||
"""
|
||||
# Feature flag pour migration progressive vers AssessmentProgressService
|
||||
from config.feature_flags import is_feature_enabled, FeatureFlag
|
||||
|
||||
if is_feature_enabled(FeatureFlag.USE_REFACTORED_ASSESSMENT):
|
||||
# === NOUVELLE IMPLÉMENTATION : AssessmentProgressService ===
|
||||
return self._grading_progress_with_service()
|
||||
else:
|
||||
# === ANCIENNE IMPLÉMENTATION : Logique dans le modèle ===
|
||||
return self._grading_progress_legacy()
|
||||
|
||||
def _grading_progress_with_service(self):
|
||||
"""
|
||||
Nouvelle implémentation utilisant AssessmentProgressService avec injection de dépendances.
|
||||
Optimise les requêtes pour éviter les problèmes N+1.
|
||||
"""
|
||||
from providers.concrete_providers import AssessmentServicesFactory
|
||||
|
||||
# Injection de dépendances pour éviter les imports circulaires
|
||||
services_facade = AssessmentServicesFactory.create_facade()
|
||||
progress_result = services_facade.get_grading_progress(self)
|
||||
|
||||
# Conversion du ProgressResult vers le format dict attendu
|
||||
return {
|
||||
'percentage': progress_result.percentage,
|
||||
'completed': progress_result.completed,
|
||||
'total': progress_result.total,
|
||||
'status': progress_result.status,
|
||||
'students_count': progress_result.students_count
|
||||
}
|
||||
|
||||
def _grading_progress_legacy(self):
|
||||
"""
|
||||
Ancienne implémentation avec requêtes multiples (pour compatibilité).
|
||||
"""
|
||||
# Obtenir tous les éléments de notation pour cette évaluation
|
||||
total_elements = 0
|
||||
completed_elements = 0
|
||||
total_students = len(self.class_group.students)
|
||||
|
||||
if total_students == 0:
|
||||
return {
|
||||
'percentage': 0,
|
||||
'completed': 0,
|
||||
'total': 0,
|
||||
'status': 'no_students',
|
||||
'students_count': 0
|
||||
}
|
||||
|
||||
# Parcourir tous les exercices et leurs éléments de notation
|
||||
for exercise in self.exercises:
|
||||
for grading_element in exercise.grading_elements:
|
||||
total_elements += total_students
|
||||
|
||||
# Compter les notes saisies (valeur non nulle et non vide, y compris '.')
|
||||
completed_for_element = db.session.query(Grade).filter(
|
||||
Grade.grading_element_id == grading_element.id,
|
||||
Grade.value.isnot(None),
|
||||
Grade.value != ''
|
||||
).count()
|
||||
|
||||
completed_elements += completed_for_element
|
||||
|
||||
if total_elements == 0:
|
||||
return {
|
||||
'percentage': 0,
|
||||
'completed': 0,
|
||||
'total': 0,
|
||||
'status': 'no_elements',
|
||||
'students_count': total_students
|
||||
}
|
||||
|
||||
percentage = round((completed_elements / total_elements) * 100)
|
||||
|
||||
# Déterminer le statut
|
||||
if percentage == 0:
|
||||
status = 'not_started'
|
||||
elif percentage == 100:
|
||||
status = 'completed'
|
||||
else:
|
||||
status = 'in_progress'
|
||||
|
||||
return {
|
||||
'percentage': percentage,
|
||||
'completed': completed_elements,
|
||||
'total': total_elements,
|
||||
'status': status,
|
||||
'students_count': total_students
|
||||
}
|
||||
|
||||
def calculate_student_scores(self):
|
||||
"""Calcule les scores de tous les élèves pour cette évaluation.
|
||||
Retourne un dictionnaire avec les scores par élève et par exercice.
|
||||
Logique de calcul simplifiée avec 2 types seulement."""
|
||||
# Feature flag pour migration progressive vers services optimisés
|
||||
from config.feature_flags import is_feature_enabled, FeatureFlag
|
||||
|
||||
if is_feature_enabled(FeatureFlag.USE_REFACTORED_ASSESSMENT):
|
||||
return self._calculate_student_scores_optimized()
|
||||
return self._calculate_student_scores_legacy()
|
||||
|
||||
def _calculate_student_scores_optimized(self):
|
||||
"""Version optimisée avec services découplés et requête unique."""
|
||||
from providers.concrete_providers import AssessmentServicesFactory
|
||||
|
||||
services = AssessmentServicesFactory.create_facade()
|
||||
students_scores_data, exercise_scores_data = services.score_calculator.calculate_student_scores(self)
|
||||
|
||||
# Conversion vers format legacy pour compatibilité
|
||||
students_scores = {}
|
||||
exercise_scores = {}
|
||||
|
||||
for student_id, score_data in students_scores_data.items():
|
||||
# Récupérer l'objet étudiant pour compatibilité
|
||||
student_obj = next(s for s in self.class_group.students if s.id == student_id)
|
||||
students_scores[student_id] = {
|
||||
'student': student_obj,
|
||||
'total_score': score_data.total_score,
|
||||
'total_max_points': score_data.total_max_points,
|
||||
'exercises': score_data.exercises
|
||||
}
|
||||
|
||||
for exercise_id, student_scores in exercise_scores_data.items():
|
||||
exercise_scores[exercise_id] = dict(student_scores)
|
||||
|
||||
return students_scores, exercise_scores
|
||||
|
||||
def _calculate_student_scores_legacy(self):
|
||||
"""Version legacy avec requêtes N+1 - à conserver temporairement."""
|
||||
from collections import defaultdict
|
||||
|
||||
students_scores = {}
|
||||
exercise_scores = defaultdict(lambda: defaultdict(float))
|
||||
|
||||
for student in self.class_group.students:
|
||||
total_score = 0
|
||||
total_max_points = 0
|
||||
student_exercises = {}
|
||||
|
||||
for exercise in self.exercises:
|
||||
exercise_score = 0
|
||||
exercise_max_points = 0
|
||||
|
||||
for element in exercise.grading_elements:
|
||||
grade = Grade.query.filter_by(
|
||||
student_id=student.id,
|
||||
grading_element_id=element.id
|
||||
).first()
|
||||
|
||||
# Si une note a été saisie pour cet élément (y compris valeurs spéciales)
|
||||
if grade and grade.value and grade.value != '':
|
||||
# Utiliser la nouvelle logique unifiée
|
||||
calculated_score = GradingCalculator.calculate_score(
|
||||
grade.value.strip(),
|
||||
element.grading_type,
|
||||
element.max_points
|
||||
)
|
||||
|
||||
# Vérifier si cette note compte dans le total
|
||||
if GradingCalculator.is_counted_in_total(grade.value.strip(), element.grading_type):
|
||||
if calculated_score is not None: # Pas dispensé
|
||||
exercise_score += calculated_score
|
||||
exercise_max_points += element.max_points
|
||||
# Si pas compté ou dispensé, on ignore complètement
|
||||
|
||||
student_exercises[exercise.id] = {
|
||||
'score': exercise_score,
|
||||
'max_points': exercise_max_points,
|
||||
'title': exercise.title
|
||||
}
|
||||
total_score += exercise_score
|
||||
total_max_points += exercise_max_points
|
||||
exercise_scores[exercise.id][student.id] = exercise_score
|
||||
|
||||
students_scores[student.id] = {
|
||||
'student': student,
|
||||
'total_score': round(total_score, 2),
|
||||
'total_max_points': total_max_points,
|
||||
'exercises': student_exercises
|
||||
}
|
||||
|
||||
return students_scores, dict(exercise_scores)
|
||||
|
||||
def get_assessment_statistics(self):
|
||||
"""
|
||||
Calcule les statistiques descriptives pour cette évaluation.
|
||||
|
||||
Utilise le feature flag USE_REFACTORED_ASSESSMENT pour basculer entre
|
||||
l'ancien système et les nouveaux services refactorisés.
|
||||
"""
|
||||
from config.feature_flags import FeatureFlag, is_feature_enabled
|
||||
|
||||
if is_feature_enabled(FeatureFlag.USE_REFACTORED_ASSESSMENT):
|
||||
from providers.concrete_providers import AssessmentServicesFactory
|
||||
services = AssessmentServicesFactory.create_facade()
|
||||
result = services.statistics_service.get_assessment_statistics(self)
|
||||
|
||||
# Conversion du StatisticsResult vers le format dict legacy
|
||||
return {
|
||||
'count': result.count,
|
||||
'mean': result.mean,
|
||||
'median': result.median,
|
||||
'min': result.min,
|
||||
'max': result.max,
|
||||
'std_dev': result.std_dev
|
||||
}
|
||||
|
||||
return self._get_assessment_statistics_legacy()
|
||||
|
||||
def _get_assessment_statistics_legacy(self):
|
||||
"""Version legacy des statistiques - À supprimer après migration complète."""
|
||||
students_scores, _ = self.calculate_student_scores()
|
||||
scores = [data['total_score'] for data in students_scores.values()]
|
||||
|
||||
if not scores:
|
||||
return {
|
||||
'count': 0,
|
||||
'mean': 0,
|
||||
'median': 0,
|
||||
'min': 0,
|
||||
'max': 0,
|
||||
'std_dev': 0
|
||||
}
|
||||
|
||||
import statistics
|
||||
import math
|
||||
|
||||
return {
|
||||
'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)
|
||||
}
|
||||
|
||||
def get_total_max_points(self):
|
||||
"""Calcule le total des points maximum pour cette évaluation."""
|
||||
total = 0
|
||||
for exercise in self.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
# Logique simplifiée avec 2 types : notes et score
|
||||
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) # Garder Float pour compatibilité
|
||||
# NOUVEAU : Types enum directement
|
||||
grading_type = db.Column(Enum('notes', 'score', name='grading_types'), nullable=False, default='notes')
|
||||
# Ajout du champ domain_id
|
||||
domain_id = db.Column(db.Integer, db.ForeignKey('domains.id'), nullable=True) # Optionnel
|
||||
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)) # Garder l'ancien format pour compatibilité
|
||||
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
|
||||
|
||||
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) # '0', '1', '2', '3', '.', 'd', etc.
|
||||
label = db.Column(db.String(100), nullable=False)
|
||||
color = db.Column(db.String(7), nullable=False) # Format #RRGGBB
|
||||
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) # Format #RRGGBB
|
||||
icon = db.Column(db.String(50), nullable=False)
|
||||
order_index = db.Column(db.Integer, default=0) # Pour l'ordre d'affichage
|
||||
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') # Format #RRGGBB
|
||||
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}>'
|
||||
@@ -1 +0,0 @@
|
||||
# Examples et guides de migration
|
||||
@@ -1,290 +0,0 @@
|
||||
"""
|
||||
Guide de migration vers la nouvelle architecture avec services découplés.
|
||||
|
||||
Ce fichier montre comment migrer progressivement du code existant
|
||||
vers la nouvelle architecture avec injection de dépendances.
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
|
||||
# =================== AVANT : Code couplé avec imports circulaires ===================
|
||||
|
||||
class OldRoute:
|
||||
"""Exemple de l'ancienne approche avec couplage fort."""
|
||||
|
||||
def assessment_detail_old(self, assessment_id: int):
|
||||
"""Ancienne version avec logique dans les modèles."""
|
||||
from models import Assessment # Import direct
|
||||
|
||||
assessment = Assessment.query.get_or_404(assessment_id)
|
||||
|
||||
# ❌ Problèmes :
|
||||
# 1. Logique métier dans le modèle (violation SRP)
|
||||
# 2. Import circulaire dans grading_progress
|
||||
# 3. Requêtes N+1 dans calculate_student_scores
|
||||
# 4. Pas de testabilité (dépendances hard-codées)
|
||||
|
||||
progress = assessment.grading_progress # Import circulaire caché
|
||||
scores, exercises = assessment.calculate_student_scores() # N+1 queries
|
||||
stats = assessment.get_assessment_statistics()
|
||||
|
||||
return {
|
||||
'assessment': assessment,
|
||||
'progress': progress,
|
||||
'scores': scores,
|
||||
'statistics': stats
|
||||
}
|
||||
|
||||
|
||||
# =================== APRÈS : Architecture découplée ===================
|
||||
|
||||
class NewRoute:
|
||||
"""Nouvelle approche avec injection de dépendances."""
|
||||
|
||||
def __init__(self, assessment_services_facade=None):
|
||||
"""Injection de dépendances pour testabilité."""
|
||||
if assessment_services_facade is None:
|
||||
from providers.concrete_providers import AssessmentServicesFactory
|
||||
assessment_services_facade = AssessmentServicesFactory.create_facade()
|
||||
|
||||
self.services = assessment_services_facade
|
||||
|
||||
def assessment_detail_new(self, assessment_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Nouvelle version avec services découplés.
|
||||
|
||||
✅ Avantages :
|
||||
1. Services dédiés (respect SRP)
|
||||
2. Plus d'imports circulaires
|
||||
3. Requêtes optimisées (plus de N+1)
|
||||
4. Testable avec mocks
|
||||
5. Extensible (pattern Strategy)
|
||||
"""
|
||||
from models_refactored import Assessment # Modèle allégé
|
||||
|
||||
assessment = Assessment.query.get_or_404(assessment_id)
|
||||
|
||||
# Appels optimisés aux services
|
||||
progress = self.services.get_grading_progress(assessment)
|
||||
scores, exercises = self.services.calculate_student_scores(assessment)
|
||||
stats = self.services.get_statistics(assessment)
|
||||
|
||||
return {
|
||||
'assessment': assessment,
|
||||
'progress': progress.__dict__, # Conversion DTO -> dict
|
||||
'scores': {k: v.__dict__ for k, v in scores.items()},
|
||||
'statistics': stats.__dict__
|
||||
}
|
||||
|
||||
|
||||
# =================== MIGRATION PROGRESSIVE ===================
|
||||
|
||||
class MigrationRoute:
|
||||
"""Exemple de migration progressive pour minimiser les risques."""
|
||||
|
||||
def __init__(self):
|
||||
# Feature flag pour basculer entre ancien et nouveau code
|
||||
self.use_new_services = self._get_feature_flag('USE_NEW_ASSESSMENT_SERVICES')
|
||||
|
||||
if self.use_new_services:
|
||||
from providers.concrete_providers import AssessmentServicesFactory
|
||||
self.services = AssessmentServicesFactory.create_facade()
|
||||
|
||||
def assessment_detail_hybrid(self, assessment_id: int):
|
||||
"""Version hybride permettant de tester graduellement."""
|
||||
from models import Assessment # Import de l'ancien modèle
|
||||
|
||||
assessment = Assessment.query.get_or_404(assessment_id)
|
||||
|
||||
if self.use_new_services:
|
||||
# Nouvelle implémentation
|
||||
progress = self.services.get_grading_progress(assessment)
|
||||
scores, exercises = self.services.calculate_student_scores(assessment)
|
||||
stats = self.services.get_statistics(assessment)
|
||||
|
||||
return {
|
||||
'assessment': assessment,
|
||||
'progress': progress.__dict__,
|
||||
'scores': scores,
|
||||
'statistics': stats.__dict__
|
||||
}
|
||||
else:
|
||||
# Ancienne implémentation (fallback)
|
||||
progress = assessment.grading_progress
|
||||
scores, exercises = assessment.calculate_student_scores()
|
||||
stats = assessment.get_assessment_statistics()
|
||||
|
||||
return {
|
||||
'assessment': assessment,
|
||||
'progress': progress,
|
||||
'scores': scores,
|
||||
'statistics': stats
|
||||
}
|
||||
|
||||
def _get_feature_flag(self, flag_name: str) -> bool:
|
||||
"""Récupère un feature flag depuis la configuration."""
|
||||
# Exemple d'implémentation
|
||||
import os
|
||||
return os.environ.get(flag_name, 'false').lower() == 'true'
|
||||
|
||||
|
||||
# =================== TESTS AVEC LA NOUVELLE ARCHITECTURE ===================
|
||||
|
||||
class TestableRoute:
|
||||
"""Exemple montrant la testabilité améliorée."""
|
||||
|
||||
def __init__(self, services_facade):
|
||||
self.services = services_facade
|
||||
|
||||
def get_assessment_summary(self, assessment_id: int):
|
||||
"""Méthode facilement testable avec mocks."""
|
||||
from models_refactored import Assessment
|
||||
|
||||
assessment = Assessment.query.get_or_404(assessment_id)
|
||||
progress = self.services.get_grading_progress(assessment)
|
||||
|
||||
return {
|
||||
'title': assessment.title,
|
||||
'progress_percentage': progress.percentage,
|
||||
'status': progress.status
|
||||
}
|
||||
|
||||
|
||||
def test_assessment_summary():
|
||||
"""Test unitaire simple grâce à l'injection de dépendances."""
|
||||
from unittest.mock import Mock
|
||||
from services.assessment_services import ProgressResult
|
||||
|
||||
# Création des mocks
|
||||
mock_services = Mock()
|
||||
mock_services.get_grading_progress.return_value = ProgressResult(
|
||||
percentage=75,
|
||||
completed=15,
|
||||
total=20,
|
||||
status='in_progress',
|
||||
students_count=25
|
||||
)
|
||||
|
||||
# Test de la route avec mock injecté
|
||||
route = TestableRoute(mock_services)
|
||||
|
||||
# Mock de l'assessment
|
||||
mock_assessment = Mock()
|
||||
mock_assessment.title = 'Test Assessment'
|
||||
|
||||
# Simulation du test (en vrai on moquerait aussi la DB)
|
||||
with patch('models_refactored.Assessment') as mock_model:
|
||||
mock_model.query.get_or_404.return_value = mock_assessment
|
||||
|
||||
result = route.get_assessment_summary(1)
|
||||
|
||||
assert result['title'] == 'Test Assessment'
|
||||
assert result['progress_percentage'] == 75
|
||||
assert result['status'] == 'in_progress'
|
||||
|
||||
|
||||
# =================== EXTENSIBILITÉ : Nouveaux types de notation ===================
|
||||
|
||||
class CustomGradingStrategy:
|
||||
"""Exemple d'extension pour un nouveau type de notation."""
|
||||
|
||||
def calculate_score(self, grade_value: str, max_points: float) -> float:
|
||||
"""Logique personnalisée (ex: notation par lettres A,B,C,D)."""
|
||||
letter_to_score = {
|
||||
'A': 1.0,
|
||||
'B': 0.75,
|
||||
'C': 0.5,
|
||||
'D': 0.25,
|
||||
'F': 0.0
|
||||
}
|
||||
|
||||
letter = grade_value.upper()
|
||||
ratio = letter_to_score.get(letter, 0.0)
|
||||
return ratio * max_points
|
||||
|
||||
def get_grading_type(self) -> str:
|
||||
return 'letters'
|
||||
|
||||
|
||||
def register_custom_grading():
|
||||
"""Exemple d'enregistrement d'un nouveau type de notation."""
|
||||
from services.assessment_services import GradingStrategyFactory
|
||||
|
||||
GradingStrategyFactory.register_strategy('letters', CustomGradingStrategy)
|
||||
|
||||
# Maintenant le système peut gérer le type 'letters' automatiquement
|
||||
strategy = GradingStrategyFactory.create('letters')
|
||||
score = strategy.calculate_score('B', 20.0) # = 15.0
|
||||
|
||||
|
||||
# =================== MONITORING ET MÉTRIQUES ===================
|
||||
|
||||
class MonitoredAssessmentService:
|
||||
"""Exemple d'ajout de monitoring sans modifier la logique métier."""
|
||||
|
||||
def __init__(self, services_facade):
|
||||
self.services = services_facade
|
||||
self.metrics_collector = self._init_metrics()
|
||||
|
||||
def get_grading_progress_with_metrics(self, assessment):
|
||||
"""Wrapper avec métriques autour du service."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
result = self.services.get_grading_progress(assessment)
|
||||
|
||||
# Métriques de succès
|
||||
self.metrics_collector.increment('assessment.progress.success')
|
||||
self.metrics_collector.histogram('assessment.progress.duration',
|
||||
time.time() - start_time)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Métriques d'erreur
|
||||
self.metrics_collector.increment('assessment.progress.error')
|
||||
self.metrics_collector.increment(f'assessment.progress.error.{type(e).__name__}')
|
||||
raise
|
||||
|
||||
def _init_metrics(self):
|
||||
"""Initialisation du collecteur de métriques."""
|
||||
# Exemple avec StatsD ou Prometheus
|
||||
return Mock() # Placeholder
|
||||
|
||||
|
||||
# =================== RÉSUMÉ DES BÉNÉFICES ===================
|
||||
|
||||
"""
|
||||
🎯 BÉNÉFICES DE LA REFACTORISATION :
|
||||
|
||||
1. **Respect des principes SOLID** :
|
||||
- Single Responsibility : Chaque service a UNE responsabilité
|
||||
- Open/Closed : Extensible via Strategy pattern (nouveaux types notation)
|
||||
- Liskov Substitution : Interfaces respectées
|
||||
- Interface Segregation : Interfaces spécialisées (ConfigProvider, DatabaseProvider)
|
||||
- Dependency Inversion : Injection de dépendances, plus d'imports circulaires
|
||||
|
||||
2. **Performance améliorée** :
|
||||
- Plus de requêtes N+1 (requêtes optimisées dans les providers)
|
||||
- Possibilité de cache au niveau des services
|
||||
- Calculs optimisés
|
||||
|
||||
3. **Testabilité** :
|
||||
- Services mockables indépendamment
|
||||
- Tests unitaires isolés
|
||||
- Tests d'intégration facilités
|
||||
|
||||
4. **Maintenabilité** :
|
||||
- Code plus lisible et organisé
|
||||
- Responsabilités clairement séparées
|
||||
- Evolution facilitée
|
||||
|
||||
5. **Extensibilité** :
|
||||
- Nouveaux types de notation via Strategy pattern
|
||||
- Nouveaux providers pour différents backends
|
||||
- Monitoring et logging ajoutables facilement
|
||||
|
||||
6. **Sécurité** :
|
||||
- Plus d'imports circulaires (réduction surface d'attaque)
|
||||
- Validation centralisée dans les services
|
||||
- Meilleur contrôle des dépendances
|
||||
"""
|
||||
@@ -1,556 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script de Finalisation Migration Progressive (JOUR 7 - Étape 4.1)
|
||||
|
||||
Ce script active définitivement tous les nouveaux services et finalise
|
||||
la migration selon le plan MIGRATION_PROGRESSIVE.md
|
||||
|
||||
Fonctionnalités:
|
||||
- Activation de tous les feature flags de migration
|
||||
- Validation du système en mode production
|
||||
- Tests complets de non-régression
|
||||
- Benchmark final de performance
|
||||
- Rapport de finalisation
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Configuration du logging pour le script de finalisation
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler('logs/migration_finalization.log', mode='w')
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def setup_flask_context():
|
||||
"""Configure le contexte Flask pour les tests finaux."""
|
||||
# Ajouter le répertoire racine au PYTHONPATH
|
||||
project_root = Path(__file__).parent
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
# Importer et configurer Flask
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
ctx = app.app_context()
|
||||
ctx.push()
|
||||
return app, ctx
|
||||
|
||||
def activate_all_migration_features():
|
||||
"""
|
||||
ÉTAPE 4.1: Active définitivement tous les feature flags de migration.
|
||||
"""
|
||||
logger.info("=== ÉTAPE 4.1: ACTIVATION DÉFINITIVE DES FEATURE FLAGS ===")
|
||||
|
||||
from config.feature_flags import feature_flags, FeatureFlag
|
||||
|
||||
# Liste des feature flags de migration à activer définitivement
|
||||
migration_flags = [
|
||||
FeatureFlag.USE_STRATEGY_PATTERN,
|
||||
FeatureFlag.USE_REFACTORED_ASSESSMENT,
|
||||
FeatureFlag.USE_NEW_STUDENT_SCORE_CALCULATOR,
|
||||
FeatureFlag.USE_NEW_ASSESSMENT_STATISTICS_SERVICE,
|
||||
]
|
||||
|
||||
logger.info(f"Activation de {len(migration_flags)} feature flags de migration...")
|
||||
|
||||
activation_results = {}
|
||||
for flag in migration_flags:
|
||||
success = feature_flags.enable(flag, reason="Finalisation migration JOUR 7 - Production ready")
|
||||
activation_results[flag.value] = success
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ {flag.value} activé avec succès")
|
||||
else:
|
||||
logger.error(f"❌ Erreur activation {flag.value}")
|
||||
|
||||
# Vérifier que tous les flags sont bien actifs
|
||||
logger.info("\n=== VÉRIFICATION ACTIVATION ===")
|
||||
all_active = True
|
||||
for flag in migration_flags:
|
||||
is_active = feature_flags.is_enabled(flag)
|
||||
status = "✅ ACTIF" if is_active else "❌ INACTIF"
|
||||
logger.info(f"{flag.value}: {status}")
|
||||
|
||||
if not is_active:
|
||||
all_active = False
|
||||
|
||||
# Résumé de l'état des feature flags
|
||||
status_summary = feature_flags.get_status_summary()
|
||||
logger.info(f"\n=== RÉSUMÉ FEATURE FLAGS ===")
|
||||
logger.info(f"Total flags actifs: {status_summary['total_enabled']}")
|
||||
logger.info(f"Migration Jour 3 prête: {status_summary['migration_status']['day_3_ready']}")
|
||||
logger.info(f"Migration Jour 4 prête: {status_summary['migration_status']['day_4_ready']}")
|
||||
logger.info(f"Migration Jour 5 prête: {status_summary['migration_status']['day_5_ready']}")
|
||||
logger.info(f"Migration Jour 6 prête: {status_summary['migration_status']['day_6_ready']}")
|
||||
|
||||
if not all_active:
|
||||
raise RuntimeError("Certains feature flags n'ont pas pu être activés !")
|
||||
|
||||
logger.info("✅ Tous les feature flags de migration sont maintenant ACTIFS")
|
||||
return activation_results
|
||||
|
||||
def validate_system_in_production_mode():
|
||||
"""
|
||||
ÉTAPE 4.1: Validation complète du système avec tous les nouveaux services actifs.
|
||||
"""
|
||||
logger.info("\n=== VALIDATION SYSTÈME EN MODE PRODUCTION ===")
|
||||
|
||||
from models import Assessment, ClassGroup, Student
|
||||
from services.assessment_services import (
|
||||
AssessmentProgressService,
|
||||
StudentScoreCalculator,
|
||||
AssessmentStatisticsService,
|
||||
UnifiedGradingCalculator
|
||||
)
|
||||
from providers.concrete_providers import (
|
||||
ConfigManagerProvider,
|
||||
SQLAlchemyDatabaseProvider
|
||||
)
|
||||
|
||||
# Vérifier qu'on a des données de test
|
||||
assessments = Assessment.query.limit(3).all()
|
||||
if not assessments:
|
||||
logger.warning("⚠️ Aucune évaluation trouvée pour les tests")
|
||||
return False
|
||||
|
||||
logger.info(f"Tests avec {len(assessments)} évaluations...")
|
||||
|
||||
# Test 1: AssessmentProgressService
|
||||
logger.info("Test 1: AssessmentProgressService...")
|
||||
try:
|
||||
service = AssessmentProgressService(SQLAlchemyDatabaseProvider())
|
||||
for assessment in assessments:
|
||||
progress = service.calculate_grading_progress(assessment)
|
||||
logger.info(f" Évaluation {assessment.id}: {progress.percentage}% complété")
|
||||
logger.info("✅ AssessmentProgressService OK")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AssessmentProgressService ERREUR: {str(e)}")
|
||||
return False
|
||||
|
||||
# Test 2: StudentScoreCalculator
|
||||
logger.info("Test 2: StudentScoreCalculator...")
|
||||
try:
|
||||
config_provider = ConfigManagerProvider()
|
||||
db_provider = SQLAlchemyDatabaseProvider()
|
||||
calculator = UnifiedGradingCalculator(config_provider)
|
||||
service = StudentScoreCalculator(calculator, db_provider)
|
||||
|
||||
for assessment in assessments:
|
||||
scores = service.calculate_student_scores(assessment)
|
||||
logger.info(f" Évaluation {assessment.id}: {len(scores)} scores calculés")
|
||||
logger.info("✅ StudentScoreCalculator OK")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ StudentScoreCalculator ERREUR: {str(e)}")
|
||||
return False
|
||||
|
||||
# Test 3: AssessmentStatisticsService
|
||||
logger.info("Test 3: AssessmentStatisticsService...")
|
||||
try:
|
||||
score_calculator = StudentScoreCalculator(calculator, db_provider)
|
||||
service = AssessmentStatisticsService(score_calculator)
|
||||
|
||||
for assessment in assessments:
|
||||
stats = service.get_assessment_statistics(assessment)
|
||||
logger.info(f" Évaluation {assessment.id}: moyenne {stats.mean if hasattr(stats, 'mean') else 'N/A'}")
|
||||
logger.info("✅ AssessmentStatisticsService OK")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AssessmentStatisticsService ERREUR: {str(e)}")
|
||||
return False
|
||||
|
||||
# Test 4: Pattern Strategy via UnifiedGradingCalculator
|
||||
logger.info("Test 4: Pattern Strategy...")
|
||||
try:
|
||||
calculator = UnifiedGradingCalculator(config_provider)
|
||||
|
||||
# Test différents types de notation
|
||||
test_cases = [
|
||||
("15.5", "notes", 20.0),
|
||||
("2", "score", 3.0),
|
||||
(".", "notes", 20.0),
|
||||
("d", "score", 3.0)
|
||||
]
|
||||
|
||||
for grade_value, grading_type, max_points in test_cases:
|
||||
score = calculator.calculate_score(grade_value, grading_type, max_points)
|
||||
logger.info(f" {grade_value} ({grading_type}/{max_points}) -> {score}")
|
||||
|
||||
logger.info("✅ Pattern Strategy OK")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Pattern Strategy ERREUR: {str(e)}")
|
||||
return False
|
||||
|
||||
logger.info("✅ VALIDATION SYSTÈME COMPLÈTE - SUCCÈS")
|
||||
return True
|
||||
|
||||
def run_comprehensive_tests():
|
||||
"""
|
||||
ÉTAPE 4.2: Exécute tous les tests pour s'assurer qu'aucune régression n'a été introduite.
|
||||
"""
|
||||
logger.info("\n=== ÉTAPE 4.2: TESTS FINAUX COMPLETS ===")
|
||||
|
||||
import subprocess
|
||||
|
||||
# 1. Tests unitaires standards
|
||||
logger.info("Exécution des tests unitaires...")
|
||||
result = subprocess.run([
|
||||
sys.executable, "-m", "pytest",
|
||||
"tests/", "-v", "--tb=short", "--disable-warnings"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("❌ Tests unitaires ÉCHOUÉS:")
|
||||
logger.error(result.stdout)
|
||||
logger.error(result.stderr)
|
||||
return False
|
||||
else:
|
||||
logger.info("✅ Tests unitaires RÉUSSIS")
|
||||
# Extraire le nombre de tests qui passent
|
||||
output_lines = result.stdout.split('\n')
|
||||
for line in output_lines:
|
||||
if "passed" in line and ("failed" in line or "error" in line or "test session starts" not in line):
|
||||
logger.info(f" {line.strip()}")
|
||||
break
|
||||
|
||||
# 2. Tests spécifiques de migration
|
||||
logger.info("\nExécution des tests de migration...")
|
||||
migration_test_files = [
|
||||
"tests/test_feature_flags.py",
|
||||
"tests/test_pattern_strategy_migration.py",
|
||||
"tests/test_assessment_progress_migration.py",
|
||||
"tests/test_student_score_calculator_migration.py",
|
||||
"tests/test_assessment_statistics_migration.py"
|
||||
]
|
||||
|
||||
for test_file in migration_test_files:
|
||||
if os.path.exists(test_file):
|
||||
logger.info(f" Tests {os.path.basename(test_file)}...")
|
||||
result = subprocess.run([
|
||||
sys.executable, "-m", "pytest",
|
||||
test_file, "-v", "--tb=short", "--disable-warnings"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"❌ {test_file} ÉCHOUÉ")
|
||||
logger.error(result.stdout[-500:]) # Dernières 500 chars
|
||||
return False
|
||||
else:
|
||||
logger.info(f"✅ {os.path.basename(test_file)} OK")
|
||||
|
||||
logger.info("✅ TOUS LES TESTS FINAUX RÉUSSIS")
|
||||
return True
|
||||
|
||||
def benchmark_final_performance():
|
||||
"""
|
||||
ÉTAPE 4.2: Benchmark final des performances vs baseline initiale.
|
||||
"""
|
||||
logger.info("\n=== ÉTAPE 4.2: BENCHMARK FINAL DE PERFORMANCE ===")
|
||||
|
||||
try:
|
||||
# Utiliser le script de benchmark existant s'il existe
|
||||
if os.path.exists("benchmark_final_migration.py"):
|
||||
logger.info("Exécution du benchmark final...")
|
||||
import subprocess
|
||||
result = subprocess.run([
|
||||
sys.executable, "benchmark_final_migration.py"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info("✅ Benchmark final exécuté avec succès:")
|
||||
logger.info(result.stdout)
|
||||
else:
|
||||
logger.error("❌ Erreur benchmark final:")
|
||||
logger.error(result.stderr)
|
||||
return False
|
||||
else:
|
||||
# Benchmark simple intégré
|
||||
logger.info("Benchmark intégré simple...")
|
||||
|
||||
from models import Assessment
|
||||
assessments = Assessment.query.limit(5).all()
|
||||
|
||||
if not assessments:
|
||||
logger.warning("⚠️ Pas d'évaluations pour le benchmark")
|
||||
return True
|
||||
|
||||
# Test de performance sur le calcul de progression
|
||||
start_time = time.time()
|
||||
for assessment in assessments:
|
||||
_ = assessment.grading_progress
|
||||
progression_time = time.time() - start_time
|
||||
|
||||
# Test de performance sur le calcul de scores
|
||||
start_time = time.time()
|
||||
for assessment in assessments:
|
||||
_ = assessment.calculate_student_scores()
|
||||
scores_time = time.time() - start_time
|
||||
|
||||
# Test de performance sur les statistiques
|
||||
start_time = time.time()
|
||||
for assessment in assessments:
|
||||
_ = assessment.get_assessment_statistics()
|
||||
stats_time = time.time() - start_time
|
||||
|
||||
logger.info(f"Performance avec nouveaux services (5 évaluations):")
|
||||
logger.info(f" - Calcul progression: {progression_time:.3f}s")
|
||||
logger.info(f" - Calcul scores: {scores_time:.3f}s")
|
||||
logger.info(f" - Calcul statistiques: {stats_time:.3f}s")
|
||||
logger.info(f" - Total: {progression_time + scores_time + stats_time:.3f}s")
|
||||
|
||||
logger.info("✅ BENCHMARK FINAL TERMINÉ")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Erreur benchmark final: {str(e)}")
|
||||
return False
|
||||
|
||||
def generate_migration_final_report():
|
||||
"""
|
||||
Génère le rapport final de migration avec toutes les métriques.
|
||||
"""
|
||||
logger.info("\n=== GÉNÉRATION RAPPORT FINAL DE MIGRATION ===")
|
||||
|
||||
from config.feature_flags import feature_flags
|
||||
|
||||
report_content = f"""
|
||||
# 🎯 RAPPORT FINAL - MIGRATION PROGRESSIVE NOTYTEX
|
||||
## JOUR 7 - Finalisation Complète
|
||||
|
||||
**Date de finalisation:** {datetime.now().strftime('%d/%m/%Y à %H:%M:%S')}
|
||||
**Version:** Architecture Refactorisée - Phase 2
|
||||
**État:** MIGRATION TERMINÉE AVEC SUCCÈS ✅
|
||||
|
||||
---
|
||||
|
||||
## 📊 RÉSUMÉ EXÉCUTIF
|
||||
|
||||
### ✅ OBJECTIFS ATTEINTS
|
||||
- **Architecture refactorisée** : Modèle Assessment découplé en 4 services spécialisés
|
||||
- **Pattern Strategy** : Système de notation extensible sans modification de code
|
||||
- **Injection de dépendances** : Élimination des imports circulaires
|
||||
- **Performance optimisée** : Requêtes N+1 éliminées
|
||||
- **Feature flags** : Migration progressive sécurisée avec rollback possible
|
||||
- **Tests complets** : 214+ tests passants, aucune régression
|
||||
|
||||
### 🎯 MÉTRIQUES CLÉS
|
||||
| Métrique | Avant | Après | Amélioration |
|
||||
|----------|-------|-------|--------------|
|
||||
| Taille modèle Assessment | 267 lignes | 80 lignes | -70% |
|
||||
| Responsabilités par classe | 4 | 1 | Respect SRP |
|
||||
| Imports circulaires | 3 | 0 | 100% éliminés |
|
||||
| Services découplés | 0 | 4 | Architecture moderne |
|
||||
| Tests passants | Variable | 214+ | Stabilité garantie |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ ARCHITECTURE FINALE
|
||||
|
||||
### Services Créés (560+ lignes nouvelles)
|
||||
1. **AssessmentProgressService** - Calcul de progression isolé et optimisé
|
||||
2. **StudentScoreCalculator** - Calculs de scores avec requêtes optimisées
|
||||
3. **AssessmentStatisticsService** - Analyses statistiques découplées
|
||||
4. **UnifiedGradingCalculator** - Logique de notation centralisée avec Pattern Strategy
|
||||
|
||||
### Pattern Strategy Opérationnel
|
||||
- **GradingStrategy** interface extensible
|
||||
- **NotesStrategy** et **ScoreStrategy** implémentées
|
||||
- **GradingStrategyFactory** pour gestion des types
|
||||
- Nouveaux types de notation ajoutables sans modification de code existant
|
||||
|
||||
### Injection de Dépendances
|
||||
- **ConfigProvider** et **DatabaseProvider** (interfaces)
|
||||
- **ConfigManagerProvider** et **SQLAlchemyDatabaseProvider** (implémentations)
|
||||
- Elimination complète des imports circulaires
|
||||
- Tests unitaires 100% mockables
|
||||
|
||||
---
|
||||
|
||||
## 🚀 FEATURE FLAGS - ÉTAT FINAL
|
||||
|
||||
{_get_feature_flags_summary()}
|
||||
|
||||
---
|
||||
|
||||
## ⚡ OPTIMISATIONS PERFORMANCE
|
||||
|
||||
### Élimination Problèmes N+1
|
||||
- **Avant** : 1 requête + N requêtes par élève/exercice
|
||||
- **Après** : Requêtes optimisées avec joinedload et batch loading
|
||||
- **Résultat** : Performance linéaire au lieu de quadratique
|
||||
|
||||
### Calculs Optimisés
|
||||
- Progression : Cache des requêtes fréquentes
|
||||
- Scores : Calcul en batch pour tous les élèves
|
||||
- Statistiques : Agrégations SQL au lieu de calculs Python
|
||||
|
||||
---
|
||||
|
||||
## 🧪 VALIDATION FINALE
|
||||
|
||||
### Tests de Non-Régression
|
||||
- ✅ Tous les tests existants passent
|
||||
- ✅ Tests spécifiques de migration passent
|
||||
- ✅ Validation des calculs identiques (ancien vs nouveau)
|
||||
- ✅ Performance égale ou améliorée
|
||||
|
||||
### Validation Système Production
|
||||
- ✅ Tous les services fonctionnels avec feature flags actifs
|
||||
- ✅ Pattern Strategy opérationnel sur tous types de notation
|
||||
- ✅ Injection de dépendances sans imports circulaires
|
||||
- ✅ Interface utilisateur inchangée (transparence utilisateur)
|
||||
|
||||
---
|
||||
|
||||
## 🎓 FORMATION & MAINTENANCE
|
||||
|
||||
### Nouveaux Patterns Disponibles
|
||||
- **Comment ajouter un type de notation** : Créer nouvelle GradingStrategy
|
||||
- **Comment modifier la logique de progression** : AssessmentProgressService
|
||||
- **Comment optimiser une requête** : DatabaseProvider avec eager loading
|
||||
|
||||
### Code Legacy
|
||||
- **Méthodes legacy** : Conservées temporairement pour sécurité
|
||||
- **Feature flags** : Permettent rollback instantané si nécessaire
|
||||
- **Documentation** : Migration guide complet fourni
|
||||
|
||||
---
|
||||
|
||||
## 📋 PROCHAINES ÉTAPES RECOMMANDÉES
|
||||
|
||||
### Phase 2 (Optionnelle - 2-4 semaines)
|
||||
1. **Nettoyage code legacy** une fois stabilisé en production (1-2 semaines)
|
||||
2. **Suppression feature flags** devenus permanents
|
||||
3. **Optimisations supplémentaires** : Cache Redis, pagination
|
||||
4. **Interface API REST** pour intégrations externes
|
||||
|
||||
### Maintenance Continue
|
||||
1. **Monitoring** : Surveiller performance en production
|
||||
2. **Tests** : Maintenir couverture >90%
|
||||
3. **Formation équipe** : Sessions sur nouvelle architecture
|
||||
4. **Documentation** : Tenir à jour selon évolutions
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CONCLUSION
|
||||
|
||||
La migration progressive de l'architecture Notytex est **TERMINÉE AVEC SUCCÈS**.
|
||||
|
||||
L'application bénéficie maintenant :
|
||||
- D'une **architecture moderne** respectant les principes SOLID
|
||||
- De **performances optimisées** avec élimination des anti-patterns
|
||||
- D'une **extensibilité facilitée** pour les futures évolutions
|
||||
- D'une **stabilité garantie** par 214+ tests passants
|
||||
- D'un **système de rollback** pour sécurité maximale
|
||||
|
||||
**L'équipe dispose désormais d'une base technique solide pour les développements futurs.** 🚀
|
||||
|
||||
---
|
||||
|
||||
*Rapport généré automatiquement le {datetime.now().strftime('%d/%m/%Y à %H:%M:%S')} par le script de finalisation de migration.*
|
||||
"""
|
||||
|
||||
# Écrire le rapport final
|
||||
report_path = "MIGRATION_FINAL_REPORT.md"
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(report_content)
|
||||
|
||||
logger.info(f"✅ Rapport final généré: {report_path}")
|
||||
return report_path
|
||||
|
||||
def _get_feature_flags_summary():
|
||||
"""Génère le résumé des feature flags pour le rapport."""
|
||||
from config.feature_flags import feature_flags
|
||||
|
||||
status_summary = feature_flags.get_status_summary()
|
||||
|
||||
summary = "| Feature Flag | État | Description |\n"
|
||||
summary += "|--------------|------|-------------|\n"
|
||||
|
||||
for flag_name, config in status_summary['flags'].items():
|
||||
status = "✅ ACTIF" if config['enabled'] else "❌ INACTIF"
|
||||
summary += f"| {flag_name} | {status} | {config['description']} |\n"
|
||||
|
||||
summary += f"\n**Total actifs:** {status_summary['total_enabled']} feature flags\n"
|
||||
summary += f"**Dernière mise à jour:** {status_summary['last_updated']}\n"
|
||||
|
||||
return summary
|
||||
|
||||
def main():
|
||||
"""
|
||||
Fonction principale de finalisation de migration.
|
||||
"""
|
||||
logger.info("🚀 DÉBUT FINALISATION MIGRATION PROGRESSIVE - JOUR 7")
|
||||
logger.info("=" * 60)
|
||||
|
||||
try:
|
||||
# Configuration Flask
|
||||
app, ctx = setup_flask_context()
|
||||
logger.info("✅ Contexte Flask configuré")
|
||||
|
||||
# Étape 4.1: Activation définitive des feature flags
|
||||
activation_results = activate_all_migration_features()
|
||||
logger.info("✅ ÉTAPE 4.1 TERMINÉE - Feature flags activés")
|
||||
|
||||
# Validation système en mode production
|
||||
system_valid = validate_system_in_production_mode()
|
||||
if not system_valid:
|
||||
raise RuntimeError("Validation système échouée")
|
||||
logger.info("✅ Système validé en mode production")
|
||||
|
||||
# Étape 4.2: Tests finaux complets
|
||||
tests_passed = run_comprehensive_tests()
|
||||
if not tests_passed:
|
||||
raise RuntimeError("Tests finaux échoués")
|
||||
logger.info("✅ ÉTAPE 4.2 TERMINÉE - Tests finaux réussis")
|
||||
|
||||
# Benchmark final
|
||||
benchmark_success = benchmark_final_performance()
|
||||
if not benchmark_success:
|
||||
logger.warning("⚠️ Benchmark final incomplet mais non bloquant")
|
||||
else:
|
||||
logger.info("✅ Benchmark final terminé")
|
||||
|
||||
# Génération rapport final
|
||||
report_path = generate_migration_final_report()
|
||||
logger.info(f"✅ Rapport final généré: {report_path}")
|
||||
|
||||
# Nettoyage contexte
|
||||
ctx.pop()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("🎉 MIGRATION PROGRESSIVE TERMINÉE AVEC SUCCÈS !")
|
||||
logger.info("=" * 60)
|
||||
logger.info("📋 Actions recommandées:")
|
||||
logger.info(" 1. Vérifier le rapport final: MIGRATION_FINAL_REPORT.md")
|
||||
logger.info(" 2. Déployer en production avec feature flags actifs")
|
||||
logger.info(" 3. Surveiller les performances pendant 1-2 semaines")
|
||||
logger.info(" 4. Nettoyer le code legacy si tout fonctionne bien")
|
||||
logger.info(" 5. Former l'équipe sur la nouvelle architecture")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ERREUR FATALE DURANT FINALISATION: {str(e)}")
|
||||
logger.exception("Détails de l'erreur:")
|
||||
|
||||
logger.error("=" * 60)
|
||||
logger.error("🚨 PROCÉDURE DE ROLLBACK RECOMMANDÉE:")
|
||||
logger.error(" 1. Désactiver tous les feature flags:")
|
||||
logger.error(" python -c \"from config.feature_flags import feature_flags, FeatureFlag; [feature_flags.disable(f) for f in FeatureFlag]\"")
|
||||
logger.error(" 2. Vérifier que l'application fonctionne avec l'ancien code")
|
||||
logger.error(" 3. Analyser l'erreur et corriger avant de réessayer")
|
||||
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,53 +0,0 @@
|
||||
🏆 RAPPORT FINAL DE MIGRATION - JOUR 7
|
||||
================================================================================
|
||||
Date: 2025-08-07 09:24:09
|
||||
Services testés: 4
|
||||
|
||||
📈 RÉSUMÉ EXÉCUTIF:
|
||||
Amélioration moyenne: -6.9%
|
||||
Meilleure amélioration: -0.9% (StudentScoreCalculator)
|
||||
Services améliorés: 0/4
|
||||
|
||||
📊 DÉTAIL PAR SERVICE:
|
||||
|
||||
🔹 AssessmentProgressService
|
||||
Ancien temps: 1.68ms ± 0.18ms
|
||||
Nouveau temps: 1.76ms ± 0.30ms
|
||||
Amélioration: -4.2%
|
||||
Itérations: 50
|
||||
Accélération: 0.96x
|
||||
|
||||
🔹 StudentScoreCalculator
|
||||
Ancien temps: 4.33ms ± 0.53ms
|
||||
Nouveau temps: 4.37ms ± 0.51ms
|
||||
Amélioration: -0.9%
|
||||
Itérations: 30
|
||||
Accélération: 0.99x
|
||||
|
||||
🔹 AssessmentStatisticsService
|
||||
Ancien temps: 4.44ms ± 0.63ms
|
||||
Nouveau temps: 4.53ms ± 0.82ms
|
||||
Amélioration: -2.1%
|
||||
Itérations: 30
|
||||
Accélération: 0.98x
|
||||
|
||||
🔹 UnifiedGradingCalculator
|
||||
Ancien temps: 0.05ms ± 0.01ms
|
||||
Nouveau temps: 0.06ms ± 0.03ms
|
||||
Amélioration: -20.2%
|
||||
Itérations: 200
|
||||
Accélération: 0.83x
|
||||
|
||||
🔧 ANALYSE TECHNIQUE:
|
||||
|
||||
⚠️ Services avec régression:
|
||||
• AssessmentProgressService: -4.2%
|
||||
• StudentScoreCalculator: -0.9%
|
||||
• AssessmentStatisticsService: -2.1%
|
||||
• UnifiedGradingCalculator: -20.2%
|
||||
|
||||
🎯 CONCLUSION:
|
||||
⚠️ Performance globale: -6.9%
|
||||
⚠️ Analyse des régressions nécessaire
|
||||
|
||||
🚀 Prêt pour la production avec la nouvelle architecture !
|
||||
@@ -1,267 +0,0 @@
|
||||
"""
|
||||
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,78 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-08-07T02:39:53.135159",
|
||||
"total_duration_ms": 12.613060003786813,
|
||||
"python_version": "3.13.5",
|
||||
"system_info": {
|
||||
"cpu_count": 8,
|
||||
"cpu_freq": {
|
||||
"current": 2249.1085000000003,
|
||||
"min": 400.0,
|
||||
"max": 4600.0
|
||||
},
|
||||
"memory_total_gb": 15.300716400146484,
|
||||
"python_version": "3.13.5 (main, Jun 21 2025, 09:35:00) [GCC 15.1.1 20250425]",
|
||||
"platform": "linux"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "database_query_assessments_with_relations",
|
||||
"execution_time_ms": 0.9407232035300694,
|
||||
"memory_usage_mb": 0.0234375,
|
||||
"iterations": 5,
|
||||
"min_time_ms": 0.322260006214492,
|
||||
"max_time_ms": 3.3645250005065463,
|
||||
"avg_time_ms": 0.9407232035300694,
|
||||
"std_dev_ms": 1.3550010965272643,
|
||||
"success": true,
|
||||
"error_message": null,
|
||||
"metadata": {
|
||||
"query_type": "assessments_with_joinedload"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "database_query_grades_complex_join",
|
||||
"execution_time_ms": 0.3953178005758673,
|
||||
"memory_usage_mb": 0.0078125,
|
||||
"iterations": 5,
|
||||
"min_time_ms": 0.1903810043586418,
|
||||
"max_time_ms": 1.1664140038192272,
|
||||
"avg_time_ms": 0.3953178005758673,
|
||||
"std_dev_ms": 0.43115645332458297,
|
||||
"success": true,
|
||||
"error_message": null,
|
||||
"metadata": {
|
||||
"query_type": "grades_with_complex_joins"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "config_get_competence_scale_values",
|
||||
"execution_time_ms": 0.30451139755314216,
|
||||
"memory_usage_mb": 0.0046875,
|
||||
"iterations": 5,
|
||||
"min_time_ms": 0.21855999511899427,
|
||||
"max_time_ms": 0.6202539952937514,
|
||||
"avg_time_ms": 0.30451139755314216,
|
||||
"std_dev_ms": 0.17659352127776015,
|
||||
"success": true,
|
||||
"error_message": null,
|
||||
"metadata": {
|
||||
"operation": "get_competence_scale_values"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "config_validate_grade_values",
|
||||
"execution_time_ms": 0.08327200193889439,
|
||||
"memory_usage_mb": 0.0,
|
||||
"iterations": 5,
|
||||
"min_time_ms": 0.055030999646987766,
|
||||
"max_time_ms": 0.18798900418914855,
|
||||
"avg_time_ms": 0.08327200193889439,
|
||||
"std_dev_ms": 0.05856681083962526,
|
||||
"success": true,
|
||||
"error_message": null,
|
||||
"metadata": {
|
||||
"operation": "validate_multiple_grade_values"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class AssessmentRepository(BaseRepository[Assessment]):
|
||||
self,
|
||||
trimester: Optional[int] = None,
|
||||
class_id: Optional[int] = None,
|
||||
correction_status: Optional[str] = None,
|
||||
sort_by: str = 'date_desc'
|
||||
) -> List[Assessment]:
|
||||
"""Trouve les évaluations selon les filtres avec eager loading des classes."""
|
||||
@@ -35,7 +36,14 @@ class AssessmentRepository(BaseRepository[Assessment]):
|
||||
# Application du tri
|
||||
query = self._apply_sorting(query, sort_by)
|
||||
|
||||
return query.all()
|
||||
# Récupérer les résultats
|
||||
assessments = query.all()
|
||||
|
||||
# Filtrer par statut de correction si nécessaire
|
||||
if correction_status:
|
||||
assessments = self._filter_by_correction_status(assessments, correction_status)
|
||||
|
||||
return assessments
|
||||
|
||||
def find_with_full_details(self, id: int) -> Optional[Assessment]:
|
||||
"""Trouve une évaluation avec tous ses détails."""
|
||||
@@ -98,4 +106,22 @@ class AssessmentRepository(BaseRepository[Assessment]):
|
||||
return query.order_by(Assessment.title.asc())
|
||||
elif sort_by == 'class':
|
||||
return query.join(ClassGroup).order_by(ClassGroup.name.asc())
|
||||
return query
|
||||
return query
|
||||
|
||||
def _filter_by_correction_status(self, assessments: List[Assessment], status: str) -> List[Assessment]:
|
||||
"""Filtre les évaluations par statut de correction."""
|
||||
filtered_assessments = []
|
||||
|
||||
for assessment in assessments:
|
||||
progress = assessment.grading_progress
|
||||
progress_status = progress.get('status', 'not_started')
|
||||
|
||||
# Mapper les statuts de progression aux filtres
|
||||
if status == 'complete' and progress_status == 'completed':
|
||||
filtered_assessments.append(assessment)
|
||||
elif status == 'incomplete' and progress_status in ['in_progress', 'not_started']:
|
||||
filtered_assessments.append(assessment)
|
||||
elif status == 'not_started' and progress_status == 'not_started':
|
||||
filtered_assessments.append(assessment)
|
||||
|
||||
return filtered_assessments
|
||||
@@ -15,12 +15,14 @@ def list():
|
||||
# Récupérer les paramètres de filtrage
|
||||
trimester_filter = request.args.get('trimester', '')
|
||||
class_filter = request.args.get('class', '')
|
||||
correction_filter = request.args.get('correction', '')
|
||||
sort_by = request.args.get('sort', 'date_desc')
|
||||
|
||||
# Utiliser le repository pour les filtres
|
||||
assessments = assessment_repo.find_by_filters(
|
||||
trimester=int(trimester_filter) if trimester_filter else None,
|
||||
class_id=int(class_filter) if class_filter else None,
|
||||
correction_status=correction_filter if correction_filter else None,
|
||||
sort_by=sort_by
|
||||
)
|
||||
|
||||
@@ -32,6 +34,7 @@ def list():
|
||||
classes=classes,
|
||||
current_trimester=trimester_filter,
|
||||
current_class=class_filter,
|
||||
current_correction=correction_filter,
|
||||
current_sort=sort_by)
|
||||
|
||||
# Route obsolète supprimée - utiliser new_unified à la place
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
|
||||
from models import db, Grade, GradingElement
|
||||
from repositories import AssessmentRepository, StudentRepository, GradeRepository
|
||||
from app_config import config_manager
|
||||
|
||||
80
run_tests.py
80
run_tests.py
@@ -1,80 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour exécuter les tests unitaires avec pytest
|
||||
Usage: uv run python run_tests.py [options]
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
def run_tests():
|
||||
"""Exécute les tests avec pytest et uv"""
|
||||
print("🧪 Exécution des tests unitaires avec pytest...")
|
||||
print("=" * 50)
|
||||
|
||||
# Commande de base pour exécuter les tests
|
||||
cmd = ["uv", "run", "pytest", "tests/", "-v", "--tb=short"]
|
||||
|
||||
# Ajouter la couverture de code si demandé
|
||||
if "--coverage" in sys.argv:
|
||||
cmd.extend(["--cov=.", "--cov-report=term-missing", "--cov-report=html:htmlcov"])
|
||||
print("📊 Génération du rapport de couverture activée")
|
||||
|
||||
# Mode quiet si demandé
|
||||
if "--quiet" in sys.argv:
|
||||
cmd = ["uv", "run", "pytest", "tests/", "-q"]
|
||||
|
||||
# Tests spécifiques si un pattern est fourni
|
||||
test_pattern = None
|
||||
for arg in sys.argv[1:]:
|
||||
if not arg.startswith("--"):
|
||||
test_pattern = arg
|
||||
cmd.append(f"tests/{test_pattern}")
|
||||
break
|
||||
|
||||
try:
|
||||
# Exécuter les tests
|
||||
result = subprocess.run(cmd, cwd=os.getcwd())
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
if result.returncode == 0:
|
||||
print("✅ Tous les tests sont passés avec succès!")
|
||||
else:
|
||||
print(f"❌ {result.returncode} test(s) ont échoué")
|
||||
|
||||
if "--coverage" in sys.argv:
|
||||
print("📈 Rapport de couverture généré dans htmlcov/index.html")
|
||||
|
||||
return result.returncode
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ Tests interrompus par l'utilisateur")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur lors de l'exécution des tests: {e}")
|
||||
return 1
|
||||
|
||||
def show_help():
|
||||
"""Affiche l'aide"""
|
||||
print("""
|
||||
Usage: uv run python run_tests.py [options] [pattern]
|
||||
|
||||
Options:
|
||||
--coverage Génère un rapport de couverture de code
|
||||
--quiet Mode silencieux (moins de détails)
|
||||
--help Affiche cette aide
|
||||
|
||||
Exemples:
|
||||
uv run python run_tests.py # Tous les tests
|
||||
uv run python run_tests.py --coverage # Avec couverture
|
||||
uv run python run_tests.py test_models.py # Tests d'un fichier spécifique
|
||||
uv run python run_tests.py --quiet # Mode silencieux
|
||||
""")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--help" in sys.argv:
|
||||
show_help()
|
||||
sys.exit(0)
|
||||
|
||||
sys.exit(run_tests())
|
||||
@@ -1,505 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script de Benchmark des Performances - Baseline (JOUR 1-2)
|
||||
|
||||
Ce script établit la baseline de performance de l'application avant la migration
|
||||
vers l'architecture refactorisée. Il mesure les métriques critiques :
|
||||
|
||||
1. Temps de réponse des opérations courantes
|
||||
2. Consommation mémoire des calculs
|
||||
3. Performance des requêtes de base de données
|
||||
4. Temps de rendu des templates
|
||||
|
||||
Utilisé pour valider que la migration n'introduit pas de régressions de performance.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import psutil
|
||||
import statistics
|
||||
from typing import Dict, List, Any, Callable, Optional
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Import Flask app pour tests
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
from app import create_app
|
||||
from models import db, Assessment, Student, ClassGroup, Exercise, GradingElement, Grade
|
||||
from app_config import config_manager
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
"""Résultat d'un benchmark individuel."""
|
||||
|
||||
name: str
|
||||
execution_time_ms: float
|
||||
memory_usage_mb: float
|
||||
iterations: int
|
||||
min_time_ms: float
|
||||
max_time_ms: float
|
||||
avg_time_ms: float
|
||||
std_dev_ms: float
|
||||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkSuite:
|
||||
"""Suite complète de benchmarks."""
|
||||
|
||||
timestamp: datetime
|
||||
total_duration_ms: float
|
||||
python_version: str
|
||||
system_info: Dict[str, Any]
|
||||
results: List[BenchmarkResult]
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convertit la suite en JSON pour persistance."""
|
||||
data = asdict(self)
|
||||
data['timestamp'] = self.timestamp.isoformat()
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> 'BenchmarkSuite':
|
||||
"""Charge une suite depuis JSON."""
|
||||
data = json.loads(json_str)
|
||||
data['timestamp'] = datetime.fromisoformat(data['timestamp'])
|
||||
data['results'] = [BenchmarkResult(**result) for result in data['results']]
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class PerformanceBenchmarker:
|
||||
"""
|
||||
Système de benchmark des performances.
|
||||
|
||||
Mesure les métriques critiques de l'application pour établir une baseline
|
||||
avant la migration vers l'architecture refactorisée.
|
||||
"""
|
||||
|
||||
def __init__(self, app=None, iterations: int = 10):
|
||||
self.app = app or create_app('testing')
|
||||
self.iterations = iterations
|
||||
self.results: List[BenchmarkResult] = []
|
||||
self.start_time: Optional[float] = None
|
||||
|
||||
@contextmanager
|
||||
def measure_performance(self, name: str, metadata: Dict[str, Any] = None):
|
||||
"""
|
||||
Context manager pour mesurer les performances d'une opération.
|
||||
|
||||
Usage:
|
||||
with benchmarker.measure_performance("operation_name"):
|
||||
# Code à mesurer
|
||||
result = expensive_operation()
|
||||
"""
|
||||
process = psutil.Process()
|
||||
memory_before = process.memory_info().rss / 1024 / 1024 # MB
|
||||
|
||||
start_time = time.perf_counter()
|
||||
error_message = None
|
||||
success = True
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
success = False
|
||||
error_message = str(e)
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
memory_after = process.memory_info().rss / 1024 / 1024 # MB
|
||||
|
||||
execution_time_ms = (end_time - start_time) * 1000
|
||||
memory_usage_mb = memory_after - memory_before
|
||||
|
||||
# Créer le résultat avec des valeurs temporaires
|
||||
# (sera mis à jour par run_benchmark pour les statistiques)
|
||||
result = BenchmarkResult(
|
||||
name=name,
|
||||
execution_time_ms=execution_time_ms,
|
||||
memory_usage_mb=memory_usage_mb,
|
||||
iterations=1,
|
||||
min_time_ms=execution_time_ms,
|
||||
max_time_ms=execution_time_ms,
|
||||
avg_time_ms=execution_time_ms,
|
||||
std_dev_ms=0.0,
|
||||
success=success,
|
||||
error_message=error_message,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
self.results.append(result)
|
||||
|
||||
def run_benchmark(self, name: str, operation: Callable, metadata: Dict[str, Any] = None) -> BenchmarkResult:
|
||||
"""
|
||||
Exécute un benchmark sur une opération donnée.
|
||||
|
||||
Args:
|
||||
name: Nom du benchmark
|
||||
operation: Fonction à benchmarker
|
||||
metadata: Métadonnées additionnelles
|
||||
|
||||
Returns:
|
||||
BenchmarkResult avec les statistiques détaillées
|
||||
"""
|
||||
times = []
|
||||
memory_usages = []
|
||||
success_count = 0
|
||||
last_error = None
|
||||
|
||||
print(f"🔄 Exécution benchmark '{name}' ({self.iterations} itérations)...")
|
||||
|
||||
for i in range(self.iterations):
|
||||
process = psutil.Process()
|
||||
memory_before = process.memory_info().rss / 1024 / 1024 # MB
|
||||
|
||||
start_time = time.perf_counter()
|
||||
|
||||
try:
|
||||
operation()
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
print(f" ⚠️ Erreur itération {i+1}: {e}")
|
||||
|
||||
end_time = time.perf_counter()
|
||||
memory_after = process.memory_info().rss / 1024 / 1024 # MB
|
||||
|
||||
execution_time_ms = (end_time - start_time) * 1000
|
||||
memory_usage_mb = memory_after - memory_before
|
||||
|
||||
times.append(execution_time_ms)
|
||||
memory_usages.append(memory_usage_mb)
|
||||
|
||||
# Calcul des statistiques
|
||||
success = success_count > 0
|
||||
avg_time_ms = statistics.mean(times) if times else 0
|
||||
min_time_ms = min(times) if times else 0
|
||||
max_time_ms = max(times) if times else 0
|
||||
std_dev_ms = statistics.stdev(times) if len(times) > 1 else 0
|
||||
avg_memory_mb = statistics.mean(memory_usages) if memory_usages else 0
|
||||
|
||||
result = BenchmarkResult(
|
||||
name=name,
|
||||
execution_time_ms=avg_time_ms,
|
||||
memory_usage_mb=avg_memory_mb,
|
||||
iterations=self.iterations,
|
||||
min_time_ms=min_time_ms,
|
||||
max_time_ms=max_time_ms,
|
||||
avg_time_ms=avg_time_ms,
|
||||
std_dev_ms=std_dev_ms,
|
||||
success=success,
|
||||
error_message=last_error if not success else None,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
self.results.append(result)
|
||||
|
||||
if success:
|
||||
print(f" ✅ Terminé - {avg_time_ms:.2f}ms ± {std_dev_ms:.2f}ms")
|
||||
else:
|
||||
print(f" ❌ Échec - {success_count}/{self.iterations} succès")
|
||||
|
||||
return result
|
||||
|
||||
def benchmark_grading_progress_calculation(self):
|
||||
"""Benchmark du calcul de progression de notation."""
|
||||
|
||||
with self.app.app_context():
|
||||
# Créer des données de test
|
||||
assessment = Assessment.query.first()
|
||||
if not assessment:
|
||||
print("⚠️ Pas d'évaluation trouvée, skip benchmark progression")
|
||||
return
|
||||
|
||||
def calculate_progress():
|
||||
# Test de l'ancienne implémentation
|
||||
progress = assessment.grading_progress
|
||||
return progress
|
||||
|
||||
self.run_benchmark(
|
||||
"grading_progress_calculation_legacy",
|
||||
calculate_progress,
|
||||
{"assessment_id": assessment.id, "method": "legacy_property"}
|
||||
)
|
||||
|
||||
def benchmark_student_scores_calculation(self):
|
||||
"""Benchmark du calcul des scores étudiants."""
|
||||
|
||||
with self.app.app_context():
|
||||
assessment = Assessment.query.first()
|
||||
if not assessment:
|
||||
print("⚠️ Pas d'évaluation trouvée, skip benchmark scores")
|
||||
return
|
||||
|
||||
def calculate_scores():
|
||||
# Test de l'ancienne implémentation
|
||||
scores = assessment.calculate_student_scores()
|
||||
return scores
|
||||
|
||||
self.run_benchmark(
|
||||
"student_scores_calculation_legacy",
|
||||
calculate_scores,
|
||||
{
|
||||
"assessment_id": assessment.id,
|
||||
"method": "legacy_method",
|
||||
"students_count": len(assessment.class_group.students)
|
||||
}
|
||||
)
|
||||
|
||||
def benchmark_assessment_statistics(self):
|
||||
"""Benchmark du calcul des statistiques d'évaluation."""
|
||||
|
||||
with self.app.app_context():
|
||||
assessment = Assessment.query.first()
|
||||
if not assessment:
|
||||
print("⚠️ Pas d'évaluation trouvée, skip benchmark statistiques")
|
||||
return
|
||||
|
||||
def calculate_statistics():
|
||||
# Test de l'ancienne implémentation
|
||||
stats = assessment.get_assessment_statistics()
|
||||
return stats
|
||||
|
||||
self.run_benchmark(
|
||||
"assessment_statistics_calculation_legacy",
|
||||
calculate_statistics,
|
||||
{
|
||||
"assessment_id": assessment.id,
|
||||
"method": "legacy_method",
|
||||
"exercises_count": len(assessment.exercises)
|
||||
}
|
||||
)
|
||||
|
||||
def benchmark_database_queries(self):
|
||||
"""Benchmark des requêtes de base de données critiques."""
|
||||
|
||||
with self.app.app_context():
|
||||
def query_assessments():
|
||||
# Requête typique : liste des évaluations avec relations
|
||||
assessments = Assessment.query.options(
|
||||
db.joinedload(Assessment.class_group),
|
||||
db.joinedload(Assessment.exercises)
|
||||
).all()
|
||||
return len(assessments)
|
||||
|
||||
self.run_benchmark(
|
||||
"database_query_assessments_with_relations",
|
||||
query_assessments,
|
||||
{"query_type": "assessments_with_joinedload"}
|
||||
)
|
||||
|
||||
def query_grades():
|
||||
# Requête typique : toutes les notes
|
||||
grades = Grade.query.join(GradingElement).join(Exercise).join(Assessment).all()
|
||||
return len(grades)
|
||||
|
||||
self.run_benchmark(
|
||||
"database_query_grades_complex_join",
|
||||
query_grades,
|
||||
{"query_type": "grades_with_complex_joins"}
|
||||
)
|
||||
|
||||
def benchmark_config_operations(self):
|
||||
"""Benchmark des opérations de configuration."""
|
||||
|
||||
with self.app.app_context():
|
||||
def get_scale_values():
|
||||
# Test des opérations de configuration fréquentes
|
||||
values = config_manager.get_competence_scale_values()
|
||||
return len(values)
|
||||
|
||||
self.run_benchmark(
|
||||
"config_get_competence_scale_values",
|
||||
get_scale_values,
|
||||
{"operation": "get_competence_scale_values"}
|
||||
)
|
||||
|
||||
def validate_grade_values():
|
||||
# Test de validation de notes
|
||||
test_values = ['15.5', '2', '.', 'd', 'invalid']
|
||||
results = []
|
||||
for value in test_values:
|
||||
results.append(config_manager.validate_grade_value(value, 'notes'))
|
||||
results.append(config_manager.validate_grade_value(value, 'score'))
|
||||
return len(results)
|
||||
|
||||
self.run_benchmark(
|
||||
"config_validate_grade_values",
|
||||
validate_grade_values,
|
||||
{"operation": "validate_multiple_grade_values"}
|
||||
)
|
||||
|
||||
def run_full_suite(self) -> BenchmarkSuite:
|
||||
"""Exécute la suite complète de benchmarks."""
|
||||
|
||||
print("🚀 Démarrage de la suite de benchmarks des performances")
|
||||
print(f"📊 Configuration: {self.iterations} itérations par test")
|
||||
print("=" * 60)
|
||||
|
||||
self.start_time = time.perf_counter()
|
||||
self.results = []
|
||||
|
||||
# Benchmarks des fonctionnalités core
|
||||
self.benchmark_grading_progress_calculation()
|
||||
self.benchmark_student_scores_calculation()
|
||||
self.benchmark_assessment_statistics()
|
||||
|
||||
# Benchmarks des requêtes de base de données
|
||||
self.benchmark_database_queries()
|
||||
|
||||
# Benchmarks des opérations de configuration
|
||||
self.benchmark_config_operations()
|
||||
|
||||
end_time = time.perf_counter()
|
||||
total_duration_ms = (end_time - self.start_time) * 1000
|
||||
|
||||
# Informations système
|
||||
system_info = {
|
||||
'cpu_count': psutil.cpu_count(),
|
||||
'cpu_freq': psutil.cpu_freq()._asdict() if psutil.cpu_freq() else None,
|
||||
'memory_total_gb': psutil.virtual_memory().total / 1024**3,
|
||||
'python_version': sys.version,
|
||||
'platform': sys.platform
|
||||
}
|
||||
|
||||
suite = BenchmarkSuite(
|
||||
timestamp=datetime.utcnow(),
|
||||
total_duration_ms=total_duration_ms,
|
||||
python_version=sys.version.split()[0],
|
||||
system_info=system_info,
|
||||
results=self.results
|
||||
)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("📈 RÉSUMÉ DES PERFORMANCES")
|
||||
print("=" * 60)
|
||||
|
||||
for result in self.results:
|
||||
status = "✅" if result.success else "❌"
|
||||
print(f"{status} {result.name:40} {result.avg_time_ms:8.2f}ms ± {result.std_dev_ms:6.2f}ms")
|
||||
|
||||
print(f"\n⏱️ Durée totale: {total_duration_ms:.2f}ms")
|
||||
print(f"📊 Tests réussis: {sum(1 for r in self.results if r.success)}/{len(self.results)}")
|
||||
|
||||
return suite
|
||||
|
||||
def save_baseline(self, filepath: str = "performance_baseline.json"):
|
||||
"""Sauvegarde la baseline de performance."""
|
||||
|
||||
suite = self.run_full_suite()
|
||||
|
||||
baseline_path = Path(filepath)
|
||||
baseline_path.write_text(suite.to_json())
|
||||
|
||||
print(f"\n💾 Baseline sauvegardée: {baseline_path.absolute()}")
|
||||
return suite
|
||||
|
||||
def compare_with_baseline(self, baseline_path: str = "performance_baseline.json") -> Dict[str, Any]:
|
||||
"""Compare les performances actuelles avec la baseline."""
|
||||
|
||||
baseline_file = Path(baseline_path)
|
||||
if not baseline_file.exists():
|
||||
raise FileNotFoundError(f"Baseline non trouvée: {baseline_path}")
|
||||
|
||||
baseline_suite = BenchmarkSuite.from_json(baseline_file.read_text())
|
||||
current_suite = self.run_full_suite()
|
||||
|
||||
comparison = {
|
||||
'baseline_date': baseline_suite.timestamp.isoformat(),
|
||||
'current_date': current_suite.timestamp.isoformat(),
|
||||
'comparisons': [],
|
||||
'summary': {
|
||||
'regressions': 0,
|
||||
'improvements': 0,
|
||||
'stable': 0
|
||||
}
|
||||
}
|
||||
|
||||
# Créer un dictionnaire de la baseline pour comparaison facile
|
||||
baseline_by_name = {r.name: r for r in baseline_suite.results}
|
||||
|
||||
for current_result in current_suite.results:
|
||||
name = current_result.name
|
||||
baseline_result = baseline_by_name.get(name)
|
||||
|
||||
if not baseline_result:
|
||||
continue
|
||||
|
||||
# Calcul du changement en pourcentage
|
||||
time_change_pct = ((current_result.avg_time_ms - baseline_result.avg_time_ms)
|
||||
/ baseline_result.avg_time_ms * 100)
|
||||
|
||||
# Détermination du statut (régression si > 10% plus lent)
|
||||
if time_change_pct > 10:
|
||||
status = 'regression'
|
||||
comparison['summary']['regressions'] += 1
|
||||
elif time_change_pct < -10:
|
||||
status = 'improvement'
|
||||
comparison['summary']['improvements'] += 1
|
||||
else:
|
||||
status = 'stable'
|
||||
comparison['summary']['stable'] += 1
|
||||
|
||||
comparison['comparisons'].append({
|
||||
'name': name,
|
||||
'baseline_time_ms': baseline_result.avg_time_ms,
|
||||
'current_time_ms': current_result.avg_time_ms,
|
||||
'time_change_pct': time_change_pct,
|
||||
'status': status
|
||||
})
|
||||
|
||||
# Affichage du résumé de comparaison
|
||||
print("\n" + "=" * 60)
|
||||
print("📊 COMPARAISON AVEC BASELINE")
|
||||
print("=" * 60)
|
||||
|
||||
for comp in comparison['comparisons']:
|
||||
status_icon = {'regression': '🔴', 'improvement': '🟢', 'stable': '🟡'}[comp['status']]
|
||||
print(f"{status_icon} {comp['name']:40} {comp['time_change_pct']:+7.1f}%")
|
||||
|
||||
summary = comparison['summary']
|
||||
print(f"\n📈 Régressions: {summary['regressions']}")
|
||||
print(f"📈 Améliorations: {summary['improvements']}")
|
||||
print(f"📈 Stable: {summary['stable']}")
|
||||
|
||||
return comparison
|
||||
|
||||
|
||||
def main():
|
||||
"""Point d'entrée principal du script."""
|
||||
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Benchmark des performances Notytex")
|
||||
parser.add_argument('--iterations', type=int, default=10,
|
||||
help='Nombre d\'itérations par test (défaut: 10)')
|
||||
parser.add_argument('--baseline', action='store_true',
|
||||
help='Créer une nouvelle baseline')
|
||||
parser.add_argument('--compare', type=str, metavar='BASELINE_FILE',
|
||||
help='Comparer avec une baseline existante')
|
||||
parser.add_argument('--output', type=str, default='performance_baseline.json',
|
||||
help='Fichier de sortie pour la baseline')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
benchmarker = PerformanceBenchmarker(iterations=args.iterations)
|
||||
|
||||
if args.baseline:
|
||||
benchmarker.save_baseline(args.output)
|
||||
elif args.compare:
|
||||
benchmarker.compare_with_baseline(args.compare)
|
||||
else:
|
||||
benchmarker.run_full_suite()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,566 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script de Validation de l'Architecture des Services (JOUR 1-2)
|
||||
|
||||
Ce script valide que l'architecture refactorisée est correctement préparée
|
||||
pour la migration progressive. Il vérifie :
|
||||
|
||||
1. Présence et structure des nouveaux services
|
||||
2. Compatibilité des interfaces publiques
|
||||
3. Tests de couverture des services
|
||||
4. Conformité aux principes SOLID
|
||||
5. Documentation et type hints
|
||||
|
||||
Utilisé avant de commencer la migration pour s'assurer que tout est prêt.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import inspect
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional, get_type_hints
|
||||
from dataclasses import dataclass
|
||||
import ast
|
||||
import subprocess
|
||||
|
||||
# Configuration du path pour imports
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
# Import Flask app early pour éviter les problèmes d'ordre d'import
|
||||
try:
|
||||
from app import create_app
|
||||
# Créer une instance d'app pour les imports qui en dépendent
|
||||
_app = create_app('testing')
|
||||
_app_context = _app.app_context()
|
||||
_app_context.push()
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not initialize Flask app context: {e}")
|
||||
_app_context = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Résultat d'une validation individuelle."""
|
||||
|
||||
name: str
|
||||
passed: bool
|
||||
message: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
severity: str = "ERROR" # ERROR, WARNING, INFO
|
||||
|
||||
|
||||
class ArchitectureValidator:
|
||||
"""
|
||||
Validateur de l'architecture des services refactorisés.
|
||||
|
||||
Vérifie que tous les composants nécessaires sont présents et correctement
|
||||
structurés pour la migration progressive.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.results: List[ValidationResult] = []
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
self.services_path = self.project_root / "services"
|
||||
|
||||
def add_result(self, name: str, passed: bool, message: str,
|
||||
details: Dict[str, Any] = None, severity: str = "ERROR"):
|
||||
"""Ajoute un résultat de validation."""
|
||||
result = ValidationResult(name, passed, message, details, severity)
|
||||
self.results.append(result)
|
||||
|
||||
# Affichage immédiat pour feedback
|
||||
status = "✅" if passed else ("⚠️" if severity == "WARNING" else "❌")
|
||||
print(f"{status} {name}: {message}")
|
||||
|
||||
def validate_services_module_structure(self):
|
||||
"""Valide la structure du module services."""
|
||||
|
||||
# Vérification de l'existence du dossier services
|
||||
if not self.services_path.exists():
|
||||
self.add_result(
|
||||
"services_directory_exists",
|
||||
False,
|
||||
"Le dossier 'services' n'existe pas"
|
||||
)
|
||||
return
|
||||
|
||||
self.add_result(
|
||||
"services_directory_exists",
|
||||
True,
|
||||
"Dossier services présent"
|
||||
)
|
||||
|
||||
# Vérification du __init__.py
|
||||
init_file = self.services_path / "__init__.py"
|
||||
if not init_file.exists():
|
||||
self.add_result(
|
||||
"services_init_file",
|
||||
False,
|
||||
"Fichier services/__init__.py manquant"
|
||||
)
|
||||
else:
|
||||
self.add_result(
|
||||
"services_init_file",
|
||||
True,
|
||||
"Fichier services/__init__.py présent"
|
||||
)
|
||||
|
||||
# Vérification des fichiers de services attendus
|
||||
expected_services = [
|
||||
"assessment_services.py"
|
||||
]
|
||||
|
||||
for service_file in expected_services:
|
||||
service_path = self.services_path / service_file
|
||||
if not service_path.exists():
|
||||
self.add_result(
|
||||
f"service_file_{service_file}",
|
||||
False,
|
||||
f"Fichier {service_file} manquant"
|
||||
)
|
||||
else:
|
||||
self.add_result(
|
||||
f"service_file_{service_file}",
|
||||
True,
|
||||
f"Fichier {service_file} présent"
|
||||
)
|
||||
|
||||
def validate_assessment_services_classes(self):
|
||||
"""Valide la présence des classes de services d'évaluation."""
|
||||
|
||||
try:
|
||||
from services.assessment_services import (
|
||||
GradingStrategy,
|
||||
NotesStrategy,
|
||||
ScoreStrategy,
|
||||
GradingStrategyFactory,
|
||||
UnifiedGradingCalculator,
|
||||
AssessmentProgressService,
|
||||
StudentScoreCalculator,
|
||||
AssessmentStatisticsService,
|
||||
AssessmentServicesFacade
|
||||
)
|
||||
|
||||
# Vérification des classes core (Pattern Strategy)
|
||||
expected_classes = [
|
||||
("GradingStrategy", GradingStrategy),
|
||||
("NotesStrategy", NotesStrategy),
|
||||
("ScoreStrategy", ScoreStrategy),
|
||||
("GradingStrategyFactory", GradingStrategyFactory),
|
||||
("UnifiedGradingCalculator", UnifiedGradingCalculator),
|
||||
("AssessmentProgressService", AssessmentProgressService),
|
||||
("StudentScoreCalculator", StudentScoreCalculator),
|
||||
("AssessmentStatisticsService", AssessmentStatisticsService),
|
||||
("AssessmentServicesFacade", AssessmentServicesFacade)
|
||||
]
|
||||
|
||||
for class_name, class_obj in expected_classes:
|
||||
self.add_result(
|
||||
f"service_class_{class_name}",
|
||||
True,
|
||||
f"Classe {class_name} définie correctement"
|
||||
)
|
||||
|
||||
# Vérification que c'est bien une classe
|
||||
if not inspect.isclass(class_obj):
|
||||
self.add_result(
|
||||
f"service_class_type_{class_name}",
|
||||
False,
|
||||
f"{class_name} n'est pas une classe"
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
self.add_result(
|
||||
"assessment_services_import",
|
||||
False,
|
||||
f"Impossible d'importer les services: {e}"
|
||||
)
|
||||
|
||||
def validate_service_interfaces(self):
|
||||
"""Valide les interfaces publiques des services."""
|
||||
|
||||
try:
|
||||
from services.assessment_services import (
|
||||
GradingStrategy,
|
||||
AssessmentProgressService,
|
||||
StudentScoreCalculator,
|
||||
AssessmentStatisticsService
|
||||
)
|
||||
|
||||
# Vérification GradingStrategy (ABC)
|
||||
if hasattr(GradingStrategy, '__abstractmethods__'):
|
||||
abstract_methods = GradingStrategy.__abstractmethods__
|
||||
expected_abstract = {'calculate_score'}
|
||||
|
||||
if expected_abstract.issubset(abstract_methods):
|
||||
self.add_result(
|
||||
"grading_strategy_abstract_methods",
|
||||
True,
|
||||
"GradingStrategy a les méthodes abstraites correctes"
|
||||
)
|
||||
else:
|
||||
self.add_result(
|
||||
"grading_strategy_abstract_methods",
|
||||
False,
|
||||
f"Méthodes abstraites manquantes: {expected_abstract - abstract_methods}"
|
||||
)
|
||||
|
||||
# Vérification des méthodes publiques des services
|
||||
service_methods = {
|
||||
AssessmentProgressService: ['calculate_grading_progress'],
|
||||
StudentScoreCalculator: ['calculate_student_scores'],
|
||||
AssessmentStatisticsService: ['get_assessment_statistics']
|
||||
}
|
||||
|
||||
for service_class, expected_methods in service_methods.items():
|
||||
for method_name in expected_methods:
|
||||
if hasattr(service_class, method_name):
|
||||
self.add_result(
|
||||
f"service_method_{service_class.__name__}_{method_name}",
|
||||
True,
|
||||
f"{service_class.__name__}.{method_name} présente"
|
||||
)
|
||||
else:
|
||||
self.add_result(
|
||||
f"service_method_{service_class.__name__}_{method_name}",
|
||||
False,
|
||||
f"Méthode {service_class.__name__}.{method_name} manquante"
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
self.add_result(
|
||||
"service_interfaces_validation",
|
||||
False,
|
||||
f"Impossible de valider les interfaces: {e}"
|
||||
)
|
||||
|
||||
def validate_type_hints(self):
|
||||
"""Valide la présence de type hints dans les services."""
|
||||
|
||||
services_file = self.services_path / "assessment_services.py"
|
||||
if not services_file.exists():
|
||||
self.add_result(
|
||||
"type_hints_validation",
|
||||
False,
|
||||
"Fichier assessment_services.py non trouvé pour validation type hints"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Parse le code pour analyser les type hints
|
||||
with open(services_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
tree = ast.parse(content)
|
||||
|
||||
# Compter les fonctions avec et sans type hints
|
||||
functions_with_hints = 0
|
||||
functions_without_hints = 0
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
# Ignorer les méthodes spéciales
|
||||
if node.name.startswith('__') and node.name.endswith('__'):
|
||||
continue
|
||||
|
||||
has_return_annotation = node.returns is not None
|
||||
has_arg_annotations = any(arg.annotation is not None for arg in node.args.args[1:]) # Skip self
|
||||
|
||||
if has_return_annotation or has_arg_annotations:
|
||||
functions_with_hints += 1
|
||||
else:
|
||||
functions_without_hints += 1
|
||||
|
||||
total_functions = functions_with_hints + functions_without_hints
|
||||
if total_functions > 0:
|
||||
hint_percentage = (functions_with_hints / total_functions) * 100
|
||||
|
||||
# Considérer comme bon si > 80% des fonctions ont des type hints
|
||||
passed = hint_percentage >= 80
|
||||
self.add_result(
|
||||
"type_hints_coverage",
|
||||
passed,
|
||||
f"Couverture type hints: {hint_percentage:.1f}% ({functions_with_hints}/{total_functions})",
|
||||
{"percentage": hint_percentage, "with_hints": functions_with_hints, "total": total_functions},
|
||||
severity="WARNING" if not passed else "INFO"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.add_result(
|
||||
"type_hints_validation",
|
||||
False,
|
||||
f"Erreur lors de l'analyse des type hints: {e}",
|
||||
severity="WARNING"
|
||||
)
|
||||
|
||||
def validate_test_coverage(self):
|
||||
"""Valide la couverture de tests des services."""
|
||||
|
||||
test_file = self.project_root / "tests" / "test_assessment_services.py"
|
||||
if not test_file.exists():
|
||||
self.add_result(
|
||||
"test_file_exists",
|
||||
False,
|
||||
"Fichier test_assessment_services.py manquant"
|
||||
)
|
||||
return
|
||||
|
||||
self.add_result(
|
||||
"test_file_exists",
|
||||
True,
|
||||
"Fichier de tests des services présent"
|
||||
)
|
||||
|
||||
# Analyser le contenu des tests
|
||||
try:
|
||||
with open(test_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Compter les classes de test et méthodes de test
|
||||
tree = ast.parse(content)
|
||||
test_classes = 0
|
||||
test_methods = 0
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name.startswith('Test'):
|
||||
test_classes += 1
|
||||
elif isinstance(node, ast.FunctionDef) and node.name.startswith('test_'):
|
||||
test_methods += 1
|
||||
|
||||
self.add_result(
|
||||
"test_coverage_analysis",
|
||||
test_methods >= 10, # Au moins 10 tests
|
||||
f"Tests trouvés: {test_classes} classes, {test_methods} méthodes",
|
||||
{"test_classes": test_classes, "test_methods": test_methods},
|
||||
severity="WARNING" if test_methods < 10 else "INFO"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.add_result(
|
||||
"test_coverage_analysis",
|
||||
False,
|
||||
f"Erreur lors de l'analyse des tests: {e}",
|
||||
severity="WARNING"
|
||||
)
|
||||
|
||||
def validate_solid_principles(self):
|
||||
"""Valide le respect des principes SOLID dans l'architecture."""
|
||||
|
||||
try:
|
||||
from services.assessment_services import (
|
||||
GradingStrategy,
|
||||
AssessmentProgressService,
|
||||
StudentScoreCalculator,
|
||||
AssessmentStatisticsService,
|
||||
AssessmentServicesFacade
|
||||
)
|
||||
|
||||
# Single Responsibility Principle: Chaque service a une responsabilité claire
|
||||
services_responsibilities = {
|
||||
"AssessmentProgressService": "Calcul de progression",
|
||||
"StudentScoreCalculator": "Calcul des scores",
|
||||
"AssessmentStatisticsService": "Calcul des statistiques",
|
||||
"AssessmentServicesFacade": "Orchestration des services"
|
||||
}
|
||||
|
||||
self.add_result(
|
||||
"solid_single_responsibility",
|
||||
True,
|
||||
f"Services avec responsabilité unique: {len(services_responsibilities)}",
|
||||
{"services": list(services_responsibilities.keys())},
|
||||
severity="INFO"
|
||||
)
|
||||
|
||||
# Open/Closed Principle: GradingStrategy est extensible
|
||||
if inspect.isabstract(GradingStrategy):
|
||||
self.add_result(
|
||||
"solid_open_closed",
|
||||
True,
|
||||
"Pattern Strategy permet l'extension sans modification",
|
||||
severity="INFO"
|
||||
)
|
||||
else:
|
||||
self.add_result(
|
||||
"solid_open_closed",
|
||||
False,
|
||||
"GradingStrategy devrait être une classe abstraite"
|
||||
)
|
||||
|
||||
# Dependency Inversion: Services dépendent d'abstractions
|
||||
facade_init = inspect.signature(AssessmentServicesFacade.__init__)
|
||||
params = list(facade_init.parameters.keys())
|
||||
|
||||
# Vérifier que le Facade accepte des services en injection
|
||||
injectable_params = [p for p in params if not p.startswith('_') and p != 'self']
|
||||
|
||||
self.add_result(
|
||||
"solid_dependency_inversion",
|
||||
len(injectable_params) > 0,
|
||||
f"Facade supporte l'injection de dépendances: {injectable_params}",
|
||||
{"injectable_parameters": injectable_params},
|
||||
severity="INFO"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.add_result(
|
||||
"solid_principles_validation",
|
||||
False,
|
||||
f"Erreur lors de la validation SOLID: {e}",
|
||||
severity="WARNING"
|
||||
)
|
||||
|
||||
def validate_compatibility_with_legacy(self):
|
||||
"""Valide la compatibilité avec le code existant."""
|
||||
|
||||
try:
|
||||
# Tester que les nouveaux services peuvent être utilisés
|
||||
# avec les modèles existants (contexte déjà initialisé)
|
||||
from models import Assessment
|
||||
from services.assessment_services import AssessmentServicesFacade
|
||||
|
||||
# Vérifier que les services acceptent les instances de modèles
|
||||
# Le Facade nécessite des providers - utilisons ceux par défaut
|
||||
from app_config import config_manager
|
||||
|
||||
class MockDBProvider:
|
||||
def get_db_session(self):
|
||||
from models import db
|
||||
return db.session
|
||||
|
||||
facade = AssessmentServicesFacade(
|
||||
config_provider=config_manager,
|
||||
db_provider=MockDBProvider()
|
||||
)
|
||||
|
||||
# Test avec None (pas de vrai Assessment en contexte de validation)
|
||||
try:
|
||||
# Ces appels devraient gérer gracieusement None ou lever des erreurs cohérentes
|
||||
facade.calculate_grading_progress(None)
|
||||
except Exception as e:
|
||||
# On s'attend à une erreur cohérente, pas un crash
|
||||
if "None" in str(e) or "NoneType" in str(e):
|
||||
self.add_result(
|
||||
"legacy_compatibility_error_handling",
|
||||
True,
|
||||
"Services gèrent correctement les entrées invalides",
|
||||
severity="INFO"
|
||||
)
|
||||
else:
|
||||
self.add_result(
|
||||
"legacy_compatibility_error_handling",
|
||||
False,
|
||||
f"Erreur inattendue: {e}",
|
||||
severity="WARNING"
|
||||
)
|
||||
|
||||
self.add_result(
|
||||
"legacy_compatibility_import",
|
||||
True,
|
||||
"Services importables avec modèles existants"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.add_result(
|
||||
"legacy_compatibility_import",
|
||||
False,
|
||||
f"Problème de compatibilité: {e}"
|
||||
)
|
||||
|
||||
def run_full_validation(self) -> Dict[str, Any]:
|
||||
"""Exécute la validation complète de l'architecture."""
|
||||
|
||||
print("🔍 Validation de l'Architecture des Services Refactorisés")
|
||||
print("=" * 60)
|
||||
|
||||
# Exécution des validations dans l'ordre logique
|
||||
self.validate_services_module_structure()
|
||||
self.validate_assessment_services_classes()
|
||||
self.validate_service_interfaces()
|
||||
self.validate_type_hints()
|
||||
self.validate_test_coverage()
|
||||
self.validate_solid_principles()
|
||||
self.validate_compatibility_with_legacy()
|
||||
|
||||
# Analyse des résultats
|
||||
total_tests = len(self.results)
|
||||
passed_tests = sum(1 for r in self.results if r.passed)
|
||||
failed_tests = total_tests - passed_tests
|
||||
|
||||
errors = [r for r in self.results if not r.passed and r.severity == "ERROR"]
|
||||
warnings = [r for r in self.results if not r.passed and r.severity == "WARNING"]
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("📊 RÉSUMÉ DE LA VALIDATION")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"✅ Tests réussis: {passed_tests}/{total_tests}")
|
||||
print(f"❌ Erreurs: {len(errors)}")
|
||||
print(f"⚠️ Avertissements: {len(warnings)}")
|
||||
|
||||
if errors:
|
||||
print("\n🔴 ERREURS À CORRIGER:")
|
||||
for error in errors:
|
||||
print(f" - {error.name}: {error.message}")
|
||||
|
||||
if warnings:
|
||||
print("\n🟡 AVERTISSEMENTS:")
|
||||
for warning in warnings:
|
||||
print(f" - {warning.name}: {warning.message}")
|
||||
|
||||
# Déterminer si l'architecture est prête pour la migration
|
||||
migration_ready = len(errors) == 0
|
||||
|
||||
print(f"\n🚀 État de préparation pour migration: {'✅ PRÊT' if migration_ready else '❌ NON PRÊT'}")
|
||||
|
||||
if migration_ready:
|
||||
print(" L'architecture est correctement préparée pour la migration progressive.")
|
||||
else:
|
||||
print(" Corriger les erreurs avant de commencer la migration.")
|
||||
|
||||
return {
|
||||
'total_tests': total_tests,
|
||||
'passed_tests': passed_tests,
|
||||
'failed_tests': failed_tests,
|
||||
'errors': [{'name': e.name, 'message': e.message} for e in errors],
|
||||
'warnings': [{'name': w.name, 'message': w.message} for w in warnings],
|
||||
'migration_ready': migration_ready,
|
||||
'results': self.results
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Point d'entrée principal du script."""
|
||||
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Validation de l'architecture des services")
|
||||
parser.add_argument('--json', action='store_true',
|
||||
help='Sortie au format JSON')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
validator = ArchitectureValidator()
|
||||
results = validator.run_full_validation()
|
||||
|
||||
if args.json:
|
||||
import json
|
||||
# Convertir les objets ValidationResult en dict pour JSON
|
||||
json_results = results.copy()
|
||||
json_results['results'] = [
|
||||
{
|
||||
'name': r.name,
|
||||
'passed': r.passed,
|
||||
'message': r.message,
|
||||
'details': r.details,
|
||||
'severity': r.severity
|
||||
}
|
||||
for r in results['results']
|
||||
]
|
||||
print(json.dumps(json_results, indent=2))
|
||||
|
||||
# Code de sortie approprié
|
||||
sys.exit(0 if results['migration_ready'] else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -160,15 +160,8 @@ const Notytex = {
|
||||
const url = new URL(window.location);
|
||||
url.search = params.toString();
|
||||
|
||||
// Navigation sans rechargement complet si possible
|
||||
if (history.pushState) {
|
||||
history.pushState(null, '', url.toString());
|
||||
// Déclenchement d'un événement personnalisé pour les composants qui écoutent
|
||||
window.dispatchEvent(new CustomEvent('filtersChanged', { detail: params }));
|
||||
} else {
|
||||
// Fallback pour les anciens navigateurs
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
// Rechargement de la page avec les nouveaux filtres
|
||||
window.location.href = url.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,16 @@
|
||||
'label': 'Classe',
|
||||
'options': class_options
|
||||
},
|
||||
{
|
||||
'id': 'correction-filter',
|
||||
'label': 'Correction',
|
||||
'options': [
|
||||
{'value': '', 'label': 'Toutes'},
|
||||
{'value': 'incomplete', 'label': 'Non terminées'},
|
||||
{'value': 'complete', 'label': 'Terminées'},
|
||||
{'value': 'not_started', 'label': 'Non commencées'}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 'sort-filter',
|
||||
'label': 'Tri',
|
||||
@@ -64,7 +74,7 @@
|
||||
}
|
||||
] %}
|
||||
|
||||
{% call filter_section(filters, {'trimester-filter': current_trimester, 'class-filter': current_class, 'sort-filter': current_sort}) %}
|
||||
{% call filter_section(filters, {'trimester-filter': current_trimester, 'class-filter': current_class, 'correction-filter': current_correction, 'sort-filter': current_sort}) %}
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-sm text-gray-500 font-medium">
|
||||
{{ assessments|length }} évaluation(s)
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
{% for filter in filters %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-sm font-medium text-gray-700">{{ filter.label }} :</label>
|
||||
<select data-filter="{{ filter.id.replace('-filter', '') }}" class="filter-control">
|
||||
<select data-filter="{{ filter.id.replace('-filter', '') }}" class="filter-control border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white shadow-sm hover:border-gray-400 transition-colors">
|
||||
{% for option in filter.options %}
|
||||
<option value="{{ option.value }}" {% if current_values.get(filter.id) == option.value %}selected{% endif %}>
|
||||
{{ option.label }}
|
||||
|
||||
Reference in New Issue
Block a user