refact: clean code and update doc
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user