refact: clean code and update doc

This commit is contained in:
2025-08-09 21:49:09 +02:00
parent ac2762218e
commit 4f8ab0925b
18 changed files with 4050 additions and 3275 deletions

View File

@@ -405,6 +405,485 @@ class AssessmentServicesFacade:
return self.statistics_service.get_assessment_statistics(assessment)
# =================== SERVICES pour ClassGroup ===================
class ClassStatisticsService:
"""
Service dédié aux statistiques de classe (get_trimester_statistics, get_class_results).
Single Responsibility: calculs statistiques au niveau classe.
"""
def __init__(self, db_provider: DatabaseProvider):
self.db_provider = db_provider
def get_trimester_statistics(self, class_group, trimester=None) -> Dict[str, Any]:
"""
Retourne les statistiques globales pour un trimestre ou toutes les évaluations.
Args:
class_group: Instance de ClassGroup
trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations
Returns:
Dict avec nombre total, répartition par statut (terminées/en cours/non commencées)
"""
try:
# Utiliser les évaluations filtrées si disponibles depuis le repository
if hasattr(class_group, '_filtered_assessments'):
assessments = class_group._filtered_assessments
else:
# Import ici pour éviter la dépendance circulaire
from models import Assessment, db
# Construire la requête de base avec jointures optimisées
query = Assessment.query.filter(Assessment.class_group_id == class_group.id)
# Filtrage par trimestre si spécifié
if trimester is not None:
query = query.filter(Assessment.trimester == trimester)
# Récupérer toutes les évaluations avec leurs exercices et éléments
assessments = query.options(
db.joinedload(Assessment.exercises).joinedload('grading_elements')
).all()
# Compter le nombre d'élèves dans la classe
students_count = len(class_group.students)
# Initialiser les compteurs
total_assessments = len(assessments)
completed_count = 0
in_progress_count = 0
not_started_count = 0
# Analyser le statut de chaque évaluation
for assessment in assessments:
# Utiliser la propriété grading_progress existante
progress = assessment.grading_progress
status = progress['status']
if status == 'completed':
completed_count += 1
elif status in ['in_progress']:
in_progress_count += 1
else: # not_started, no_students, no_elements
not_started_count += 1
return {
'total': total_assessments,
'completed': completed_count,
'in_progress': in_progress_count,
'not_started': not_started_count,
'students_count': students_count,
'trimester': trimester
}
except Exception as e:
from flask import current_app
current_app.logger.error(f"Erreur dans get_trimester_statistics: {e}", exc_info=True)
return {
'total': 0,
'completed': 0,
'in_progress': 0,
'not_started': 0,
'students_count': 0,
'trimester': trimester
}
def get_class_results(self, class_group, trimester=None) -> Dict[str, Any]:
"""
Statistiques de résultats pour la classe sur un trimestre.
Args:
class_group: Instance de ClassGroup
trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations
Returns:
Dict avec moyennes, distribution des notes et métriques statistiques
"""
try:
# Utiliser les évaluations filtrées si disponibles
if hasattr(class_group, '_filtered_assessments'):
assessments = class_group._filtered_assessments
else:
# Import ici pour éviter la dépendance circulaire
from models import Assessment
# Construire la requête des évaluations avec filtres
assessments_query = Assessment.query.filter(Assessment.class_group_id == class_group.id)
if trimester is not None:
assessments_query = assessments_query.filter(Assessment.trimester == trimester)
assessments = assessments_query.all()
if not assessments:
return self._empty_class_results(class_group, trimester)
# Calculer les moyennes par évaluation et par élève
class_averages = []
all_individual_scores = [] # Toutes les notes individuelles pour statistiques globales
student_averages = {} # Moyennes par élève {student_id: [scores]}
for assessment in assessments:
# Utiliser la méthode existante calculate_student_scores
students_scores, _ = assessment.calculate_student_scores()
# Extraire les scores individuels
individual_scores = []
for student_id, student_data in students_scores.items():
score = student_data['total_score']
max_points = student_data['total_max_points']
if max_points > 0: # Éviter la division par zéro
# Normaliser sur 20 pour comparaison
normalized_score = (score / max_points) * 20
individual_scores.append(normalized_score)
all_individual_scores.append(normalized_score)
# Ajouter à la moyenne de l'élève
if student_id not in student_averages:
student_averages[student_id] = []
student_averages[student_id].append(normalized_score)
# Calculer la moyenne de classe pour cette évaluation
if individual_scores:
import statistics
class_average = statistics.mean(individual_scores)
class_averages.append({
'assessment_id': assessment.id,
'assessment_title': assessment.title,
'date': assessment.date.isoformat() if assessment.date else None,
'class_average': round(class_average, 2),
'students_evaluated': len(individual_scores),
'max_possible': 20 # Normalisé sur 20
})
# Calculer les moyennes finales des élèves
student_final_averages = []
for student_id, scores in student_averages.items():
if scores:
import statistics
avg = statistics.mean(scores)
student_final_averages.append(round(avg, 2))
# Statistiques globales et distributions
overall_stats, distribution, student_averages_distribution = self._calculate_statistics_and_distribution(
student_final_averages
)
return {
'trimester': trimester,
'assessments_count': len(assessments),
'students_count': len(class_group.students),
'class_averages': class_averages,
'student_averages': student_final_averages,
'overall_statistics': overall_stats,
'distribution': distribution,
'student_averages_distribution': student_averages_distribution
}
except Exception as e:
from flask import current_app
current_app.logger.error(f"Erreur dans get_class_results: {e}", exc_info=True)
return self._empty_class_results(class_group, trimester)
def _empty_class_results(self, class_group, trimester) -> Dict[str, Any]:
"""Retourne un résultat vide pour get_class_results."""
return {
'trimester': trimester,
'assessments_count': 0,
'students_count': len(class_group.students),
'class_averages': [],
'student_averages': [],
'overall_statistics': {
'count': 0,
'mean': 0,
'median': 0,
'min': 0,
'max': 0,
'std_dev': 0
},
'distribution': [],
'student_averages_distribution': []
}
def _calculate_statistics_and_distribution(self, student_final_averages) -> Tuple[Dict[str, Any], List[Dict], List[Dict]]:
"""Calcule les statistiques et la distribution des moyennes."""
overall_stats = {
'count': 0,
'mean': 0,
'median': 0,
'min': 0,
'max': 0,
'std_dev': 0
}
distribution = []
student_averages_distribution = []
# Utiliser les moyennes des élèves pour les statistiques (cohérent avec l'histogramme)
if student_final_averages:
import statistics
overall_stats = {
'count': len(student_final_averages),
'mean': round(statistics.mean(student_final_averages), 2),
'median': round(statistics.median(student_final_averages), 2),
'min': round(min(student_final_averages), 2),
'max': round(max(student_final_averages), 2),
'std_dev': round(statistics.stdev(student_final_averages) if len(student_final_averages) > 1 else 0, 2)
}
# Créer l'histogramme des moyennes des élèves (distribution principale)
if student_final_averages:
# Bins pour les moyennes des élèves (de 0 à 20)
avg_bins = list(range(0, 22))
avg_bin_counts = [0] * (len(avg_bins) - 1)
for avg in student_final_averages:
# Trouver le bon bin
bin_index = min(int(avg), len(avg_bin_counts) - 1)
avg_bin_counts[bin_index] += 1
# Formatage pour Chart.js
for i in range(len(avg_bin_counts)):
if i == len(avg_bin_counts) - 1:
label = f"{avg_bins[i]}+"
else:
label = f"{avg_bins[i]}-{avg_bins[i+1]}"
bin_data = {
'range': label,
'count': avg_bin_counts[i]
}
student_averages_distribution.append(bin_data)
# Maintenir la compatibilité avec distribution (même données maintenant)
distribution.append(bin_data.copy())
return overall_stats, distribution, student_averages_distribution
class ClassAnalysisService:
"""
Service dédié aux analyses de classe (get_domain_analysis, get_competence_analysis).
Single Responsibility: analyses métier des domaines et compétences.
"""
def __init__(self, db_provider: DatabaseProvider):
self.db_provider = db_provider
def get_domain_analysis(self, class_group, trimester=None) -> Dict[str, Any]:
"""
Analyse les domaines couverts dans les évaluations d'un trimestre.
Args:
class_group: Instance de ClassGroup
trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations
Returns:
Dict avec liste des domaines, points totaux et nombre d'éléments par domaine
"""
try:
# Import ici pour éviter la dépendance circulaire
from models import db, GradingElement, Exercise, Assessment, Domain
# Utiliser les évaluations filtrées si disponibles
if hasattr(class_group, '_filtered_assessments'):
assessment_ids = [a.id for a in class_group._filtered_assessments]
if not assessment_ids:
return {'domains': [], 'trimester': trimester}
query = db.session.query(
GradingElement.domain_id,
Domain.name.label('domain_name'),
Domain.color.label('domain_color'),
db.func.sum(GradingElement.max_points).label('total_points'),
db.func.count(GradingElement.id).label('elements_count')
).select_from(GradingElement)\
.join(Exercise, GradingElement.exercise_id == Exercise.id)\
.outerjoin(Domain, GradingElement.domain_id == Domain.id)\
.filter(Exercise.assessment_id.in_(assessment_ids))
else:
# Requête originale avec toutes les jointures nécessaires
query = db.session.query(
GradingElement.domain_id,
Domain.name.label('domain_name'),
Domain.color.label('domain_color'),
db.func.sum(GradingElement.max_points).label('total_points'),
db.func.count(GradingElement.id).label('elements_count')
).select_from(GradingElement)\
.join(Exercise, GradingElement.exercise_id == Exercise.id)\
.join(Assessment, Exercise.assessment_id == Assessment.id)\
.outerjoin(Domain, GradingElement.domain_id == Domain.id)\
.filter(Assessment.class_group_id == class_group.id)
# Filtrage par trimestre si spécifié
if trimester is not None:
query = query.filter(Assessment.trimester == trimester)
# Grouper par domaine (y compris les éléments sans domaine)
query = query.group_by(
GradingElement.domain_id,
Domain.name,
Domain.color
)
results = query.all()
domains = []
for result in results:
if result.domain_id is not None:
# Domaine défini
domains.append({
'id': result.domain_id,
'name': result.domain_name,
'color': result.domain_color,
'total_points': float(result.total_points) if result.total_points else 0.0,
'elements_count': result.elements_count
})
else:
# Éléments sans domaine assigné
domains.append({
'id': None,
'name': 'Sans domaine',
'color': '#6B7280', # Gris neutre
'total_points': float(result.total_points) if result.total_points else 0.0,
'elements_count': result.elements_count
})
# Trier par ordre alphabétique, avec "Sans domaine" en dernier
domains.sort(key=lambda x: (x['name'] == 'Sans domaine', x['name'].lower()))
return {
'domains': domains,
'trimester': trimester
}
except Exception as e:
from flask import current_app
current_app.logger.error(f"Erreur dans get_domain_analysis: {e}", exc_info=True)
return {
'domains': [],
'trimester': trimester
}
def get_competence_analysis(self, class_group, trimester=None) -> Dict[str, Any]:
"""
Analyse les compétences évaluées dans un trimestre.
Args:
class_group: Instance de ClassGroup
trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations
Returns:
Dict avec liste des compétences, points totaux et nombre d'éléments par compétence
"""
try:
# Import ici pour éviter la dépendance circulaire
from models import db, GradingElement, Exercise, Assessment
# Utiliser les évaluations filtrées si disponibles
if hasattr(class_group, '_filtered_assessments'):
assessment_ids = [a.id for a in class_group._filtered_assessments]
if not assessment_ids:
return {'competences': [], 'trimester': trimester}
query = db.session.query(
GradingElement.skill.label('skill_name'),
db.func.sum(GradingElement.max_points).label('total_points'),
db.func.count(GradingElement.id).label('elements_count')
).select_from(GradingElement)\
.join(Exercise, GradingElement.exercise_id == Exercise.id)\
.filter(Exercise.assessment_id.in_(assessment_ids))\
.filter(GradingElement.skill.isnot(None))\
.filter(GradingElement.skill != '')
else:
# Requête optimisée pour analyser les compétences
query = db.session.query(
GradingElement.skill.label('skill_name'),
db.func.sum(GradingElement.max_points).label('total_points'),
db.func.count(GradingElement.id).label('elements_count')
).select_from(GradingElement)\
.join(Exercise, GradingElement.exercise_id == Exercise.id)\
.join(Assessment, Exercise.assessment_id == Assessment.id)\
.filter(Assessment.class_group_id == class_group.id)\
.filter(GradingElement.skill.isnot(None))\
.filter(GradingElement.skill != '')
# Filtrage par trimestre si spécifié
if trimester is not None:
query = query.filter(Assessment.trimester == trimester)
# Grouper par compétence
query = query.group_by(GradingElement.skill)
results = query.all()
# Récupérer la configuration des compétences pour les couleurs
from app_config import config_manager
competences_config = {comp['name']: comp for comp in config_manager.get_competences_list()}
competences = []
for result in results:
skill_name = result.skill_name
# Récupérer la couleur depuis la configuration ou utiliser une couleur par défaut
config = competences_config.get(skill_name, {})
color = config.get('color', '#6B7280') # Gris neutre par défaut
competences.append({
'name': skill_name,
'color': color,
'total_points': float(result.total_points) if result.total_points else 0.0,
'elements_count': result.elements_count
})
# Trier par ordre alphabétique
competences.sort(key=lambda x: x['name'].lower())
return {
'competences': competences,
'trimester': trimester
}
except Exception as e:
from flask import current_app
current_app.logger.error(f"Erreur dans get_competence_analysis: {e}", exc_info=True)
return {
'competences': [],
'trimester': trimester
}
# =================== FACADE ÉTENDUE ===================
class ClassServicesFacade:
"""
Facade qui regroupe tous les services pour les classes.
Point d'entrée unique pour les méthodes de ClassGroup.
"""
def __init__(self, db_provider: DatabaseProvider):
self.statistics_service = ClassStatisticsService(db_provider)
self.analysis_service = ClassAnalysisService(db_provider)
def get_trimester_statistics(self, class_group, trimester=None) -> Dict[str, Any]:
"""Point d'entrée pour les statistiques trimestrielles."""
return self.statistics_service.get_trimester_statistics(class_group, trimester)
def get_class_results(self, class_group, trimester=None) -> Dict[str, Any]:
"""Point d'entrée pour les résultats de classe."""
return self.statistics_service.get_class_results(class_group, trimester)
def get_domain_analysis(self, class_group, trimester=None) -> Dict[str, Any]:
"""Point d'entrée pour l'analyse des domaines."""
return self.analysis_service.get_domain_analysis(class_group, trimester)
def get_competence_analysis(self, class_group, trimester=None) -> Dict[str, Any]:
"""Point d'entrée pour l'analyse des compétences."""
return self.analysis_service.get_competence_analysis(class_group, trimester)
# =================== FACTORY FUNCTION ===================
def create_assessment_services() -> AssessmentServicesFacade: