Files
notytex/services/council_services.py

658 lines
30 KiB
Python

"""
Services pour la préparation du conseil de classe.
Comprend CouncilPreparationService, StudentEvaluationService, AppreciationService.
"""
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple, Any
from datetime import datetime
from repositories.appreciation_repository import AppreciationRepository
from repositories.grade_repository import GradeRepository
from repositories.assessment_repository import AssessmentRepository
from repositories.student_repository import StudentRepository
from models import Student, Assessment, CouncilAppreciation, GradingCalculator
@dataclass
class StudentTrimesterSummary:
"""Résumé d'un élève pour un trimestre."""
student: Student
overall_average: Optional[float]
assessment_count: int
grades_by_assessment: Dict[int, Dict] # assessment_id -> {'score': float, 'max': float, 'title': str}
appreciation: Optional[CouncilAppreciation]
performance_status: str # 'excellent', 'good', 'average', 'struggling'
competence_domain_breakdown: Optional[Dict] = None # Données des compétences et domaines
special_values_summary: Optional[Dict] = None # Résumé des valeurs spéciales
@property
def has_appreciation(self) -> bool:
"""Vérifie si l'élève a une appréciation avec contenu."""
return self.appreciation and self.appreciation.has_content
@dataclass
class CouncilPreparationData:
"""Données complètes pour la préparation du conseil de classe."""
class_group_id: int
trimester: int
student_summaries: List[StudentTrimesterSummary]
class_statistics: Dict
appreciation_stats: Dict
total_students: int
completed_appreciations: int
class StudentEvaluationService:
"""Service spécialisé dans l'évaluation des performances étudiantes."""
def __init__(self, grade_repo: GradeRepository, assessment_repo: AssessmentRepository):
self.grade_repo = grade_repo
self.assessment_repo = assessment_repo
def calculate_student_trimester_average(self, student_id: int, trimester: int) -> Optional[float]:
"""Calcule la moyenne d'un élève pour un trimestre donné."""
assessments = self.assessment_repo.find_completed_by_class_trimester(
# On récupère d'abord la classe de l'élève
Student.query.get(student_id).class_group_id,
trimester
)
if not assessments:
return None
weighted_sum = 0.0
total_coefficient = 0.0
for assessment in assessments:
student_score = self._calculate_assessment_score_for_student(assessment, student_id)
if student_score is not None:
weighted_sum += student_score * assessment.coefficient
total_coefficient += assessment.coefficient
return round(weighted_sum / total_coefficient, 2) if total_coefficient > 0 else None
def get_student_trimester_summary(self, student_id: int, trimester: int) -> StudentTrimesterSummary:
"""Génère le résumé d'un élève pour un trimestre."""
student = Student.query.get(student_id)
# Récupérer les évaluations du trimestre
assessments = self.assessment_repo.find_by_class_trimester_with_details(
student.class_group_id, trimester
)
# Calculer les scores par évaluation
grades_by_assessment = {}
for assessment in assessments:
score_data = self._get_student_assessment_data(student_id, assessment)
if score_data:
grades_by_assessment[assessment.id] = score_data
# Calculer la moyenne générale
overall_average = self.calculate_student_trimester_average(student_id, trimester)
# Déterminer le statut de performance
performance_status = self._determine_performance_status(overall_average, grades_by_assessment)
# Récupérer l'appréciation existante
appreciation_repo = AppreciationRepository()
appreciation = appreciation_repo.find_by_student_trimester(
student_id, student.class_group_id, trimester
)
# Calculer les données de compétences et domaines
competence_domain_breakdown = self.get_student_competence_domain_breakdown(student_id, trimester)
# Calculer le résumé des valeurs spéciales
special_values_summary = self.get_student_special_values_summary(student_id, trimester)
# Ajouter tous les commentaires organisés par évaluations
all_comments_data = self.grade_repo.get_all_comments_by_student_trimester(student_id, trimester)
special_values_summary['comments_by_assessments'] = all_comments_data
return StudentTrimesterSummary(
student=student,
overall_average=overall_average,
assessment_count=len([a for a in assessments if self._has_grades(student_id, a)]),
grades_by_assessment=grades_by_assessment,
appreciation=appreciation,
performance_status=performance_status,
competence_domain_breakdown=competence_domain_breakdown,
special_values_summary=special_values_summary
)
def get_students_summaries(self, class_group_id: int, trimester: int) -> List[StudentTrimesterSummary]:
"""Génère les résumés de tous les élèves d'une classe pour un trimestre."""
student_repo = StudentRepository()
students = student_repo.find_by_class_group(class_group_id)
summaries = []
for student in students:
summary = self.get_student_trimester_summary(student.id, trimester)
summaries.append(summary)
# Trier par nom de famille puis prénom
summaries.sort(key=lambda s: (s.student.last_name, s.student.first_name))
return summaries
def get_student_competence_domain_breakdown(self, student_id: int, trimester: int) -> Dict[str, List[Dict]]:
"""
Calcule la répartition des points par compétence et domaine pour un élève.
Returns:
Dict avec 'competences' et 'domains', chacun contenant les points accumulés par évaluation
"""
from models import Student, db, GradingCalculator
student = Student.query.get(student_id)
if not student:
return {'competences': [], 'domains': []}
# Récupérer toutes les évaluations du trimestre
assessments = self.assessment_repo.find_by_class_trimester_with_details(
student.class_group_id, trimester
)
# Structures pour accumuler les données
competences_data = {}
domains_data = {}
# Parcourir chaque évaluation
for assessment in assessments:
grades = self.grade_repo.get_student_grades_by_assessment(student_id, assessment.id)
for grade in grades:
element = grade.grading_element
# Calculer le score pour cet élément
if grade.value and GradingCalculator.is_counted_in_total(grade.value, element.grading_type):
score = GradingCalculator.calculate_score(
grade.value, element.grading_type, element.max_points
)
if score is not None:
# Traiter les compétences
if element.skill:
if element.skill not in competences_data:
competences_data[element.skill] = {
'name': element.skill,
'earned_points': 0.0,
'total_points': 0.0,
'assessments': {}
}
# Accumuler les points
competences_data[element.skill]['earned_points'] += score
competences_data[element.skill]['total_points'] += element.max_points
# Grouper par évaluation
if assessment.id not in competences_data[element.skill]['assessments']:
competences_data[element.skill]['assessments'][assessment.id] = {
'id': assessment.id,
'title': assessment.title,
'earned': 0.0,
'max': 0.0,
'date': assessment.date
}
competences_data[element.skill]['assessments'][assessment.id]['earned'] += score
competences_data[element.skill]['assessments'][assessment.id]['max'] += element.max_points
# Traiter les domaines
if element.domain_id:
domain = element.domain
domain_name = domain.name if domain else f"Domaine {element.domain_id}"
if domain_name not in domains_data:
domains_data[domain_name] = {
'id': element.domain_id,
'name': domain_name,
'color': domain.color if domain else '#6B7280',
'earned_points': 0.0,
'total_points': 0.0,
'assessments': {}
}
# Accumuler les points
domains_data[domain_name]['earned_points'] += score
domains_data[domain_name]['total_points'] += element.max_points
# Grouper par évaluation
if assessment.id not in domains_data[domain_name]['assessments']:
domains_data[domain_name]['assessments'][assessment.id] = {
'id': assessment.id,
'title': assessment.title,
'earned': 0.0,
'max': 0.0,
'date': assessment.date
}
domains_data[domain_name]['assessments'][assessment.id]['earned'] += score
domains_data[domain_name]['assessments'][assessment.id]['max'] += element.max_points
# 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()}
# Finaliser les données des compétences
competences = []
for comp_name, comp_data in competences_data.items():
config = competences_config.get(comp_name, {})
# Calculer le pourcentage
percentage = (comp_data['earned_points'] / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0
# Convertir les assessments en liste avec calculs d'accumulation
assessments_list = []
# Palette de couleurs moderne et contrastée
colors_palette = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
# D'abord, trier par date pour avoir l'ordre chronologique
sorted_assessments = sorted(comp_data['assessments'].values(),
key=lambda x: x['date'] if x['date'] else datetime.min)
# Calculer l'accumulation progressive
cumulative_earned = 0
cumulative_max = 0
for i, assessment_data in enumerate(sorted_assessments):
# Ajouter les points de cette évaluation à l'accumulation
cumulative_earned += assessment_data['earned']
cumulative_max += assessment_data['max']
# Calculer les pourcentages
cumulative_percentage = (cumulative_earned / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0
assessment_performance = (assessment_data['earned'] / assessment_data['max'] * 100) if assessment_data['max'] > 0 else 0
# Calculer le pourcentage de contribution de cette évaluation par rapport au total final
contribution_percentage = (assessment_data['earned'] / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0
# Couleur cohérente basée sur l'ID de l'évaluation pour garantir la cohérence entre compétences/domaines
assessment_color = colors_palette[assessment_data['id'] % len(colors_palette)]
assessments_list.append({
'id': assessment_data['id'],
'title': assessment_data['title'],
'earned_this': round(assessment_data['earned'], 2), # Points de cette évaluation
'max_this': round(assessment_data['max'], 2), # Max de cette évaluation
'earned_cumulative': round(cumulative_earned, 2), # Total cumulé jusqu'à cette éval
'max_cumulative': round(cumulative_max, 2), # Max cumulé jusqu'à cette éval
'date': assessment_data['date'],
'percentage_cumulative': round(cumulative_percentage, 1), # % cumulé par rapport au total
'percentage_contribution': round(contribution_percentage, 1), # % de contribution individuelle
'performance': round(assessment_performance, 1), # % de réussite de cette évaluation seule
'color': assessment_color # Couleur cohérente basée sur l'ID
})
competences.append({
'name': comp_name,
'color': config.get('color', '#6B7280'),
'earned_points': round(comp_data['earned_points'], 2),
'total_points': round(comp_data['total_points'], 2),
'percentage': round(percentage, 1),
'assessments': assessments_list
})
# Finaliser les données des domaines
domains = []
for domain_data in domains_data.values():
# Calculer le pourcentage
percentage = (domain_data['earned_points'] / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0
# Convertir les assessments en liste avec calculs d'accumulation
assessments_list = []
# Utiliser la même palette que les compétences pour la cohérence
colors_palette = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
# D'abord, trier par date pour avoir l'ordre chronologique
sorted_assessments = sorted(domain_data['assessments'].values(),
key=lambda x: x['date'] if x['date'] else datetime.min)
# Calculer l'accumulation progressive
cumulative_earned = 0
cumulative_max = 0
for i, assessment_data in enumerate(sorted_assessments):
# Ajouter les points de cette évaluation à l'accumulation
cumulative_earned += assessment_data['earned']
cumulative_max += assessment_data['max']
# Calculer les pourcentages
cumulative_percentage = (cumulative_earned / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0
assessment_performance = (assessment_data['earned'] / assessment_data['max'] * 100) if assessment_data['max'] > 0 else 0
# Calculer le pourcentage de contribution de cette évaluation par rapport au total final
contribution_percentage = (assessment_data['earned'] / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0
# Couleur cohérente basée sur l'ID de l'évaluation (même logique que les compétences)
assessment_color = colors_palette[assessment_data['id'] % len(colors_palette)]
assessments_list.append({
'id': assessment_data['id'],
'title': assessment_data['title'],
'earned_this': round(assessment_data['earned'], 2), # Points de cette évaluation
'max_this': round(assessment_data['max'], 2), # Max de cette évaluation
'earned_cumulative': round(cumulative_earned, 2), # Total cumulé jusqu'à cette éval
'max_cumulative': round(cumulative_max, 2), # Max cumulé jusqu'à cette éval
'date': assessment_data['date'],
'percentage_cumulative': round(cumulative_percentage, 1), # % cumulé par rapport au total
'percentage_contribution': round(contribution_percentage, 1), # % de contribution individuelle
'performance': round(assessment_performance, 1), # % de réussite de cette évaluation seule
'color': assessment_color # Couleur cohérente basée sur l'ID
})
domains.append({
'id': domain_data['id'],
'name': domain_data['name'],
'color': domain_data['color'],
'earned_points': round(domain_data['earned_points'], 2),
'total_points': round(domain_data['total_points'], 2),
'percentage': round(percentage, 1),
'assessments': assessments_list
})
# Trier par nom
competences.sort(key=lambda x: x['name'].lower())
domains.sort(key=lambda x: x['name'].lower())
return {
'competences': competences,
'domains': domains
}
def get_student_special_values_summary(self, student_id: int, trimester: int) -> Dict[str, Any]:
"""
Calcule le résumé des valeurs spéciales pour un élève sur un trimestre.
Returns:
Dict avec 'global' (total par trimestre) et 'by_assessment' (détail par évaluation)
"""
from app_config import config_manager
# Récupérer les valeurs spéciales configurées
special_values_config = config_manager.get_special_values()
# 1. Comptes globaux par trimestre
global_counts = self.grade_repo.get_special_values_counts_by_student_trimester(student_id, trimester)
global_details = self.grade_repo.get_special_values_details_by_student_trimester(student_id, trimester)
# 2. Comptes par évaluation
assessments = self.assessment_repo.find_by_class_trimester_with_details(
Student.query.get(student_id).class_group_id, trimester
)
by_assessment = {}
for assessment in assessments:
assessment_counts = self.grade_repo.get_special_values_counts_by_student_assessment(
student_id, assessment.id
)
assessment_details = self.grade_repo.get_special_values_details_by_student_assessment(
student_id, assessment.id
)
# Ajouter seulement si l'élève a des valeurs spéciales dans cette évaluation
if any(count > 0 for count in assessment_counts.values()):
by_assessment[assessment.id] = {
'title': assessment.title,
'date': assessment.date,
'counts': assessment_counts,
'details': assessment_details
}
# 3. Enrichir avec les métadonnées de configuration
enriched_global = {}
enriched_by_assessment = {}
for special_value, config in special_values_config.items():
enriched_global[special_value] = {
'count': global_counts.get(special_value, 0),
'label': config['label'],
'color': config['color'],
'counts_in_total': config['counts'],
'details': global_details.get(special_value, [])
}
for assessment_id, assessment_data in by_assessment.items():
enriched_by_assessment[assessment_id] = {
'title': assessment_data['title'],
'date': assessment_data['date'],
'special_values': {}
}
for special_value, config in special_values_config.items():
enriched_by_assessment[assessment_id]['special_values'][special_value] = {
'count': assessment_data['counts'].get(special_value, 0),
'label': config['label'],
'color': config['color'],
'details': assessment_data['details'].get(special_value, [])
}
return {
'global': enriched_global,
'by_assessment': enriched_by_assessment,
'total_special_values': sum(global_counts.values()),
'has_special_values': sum(global_counts.values()) > 0
}
def _calculate_assessment_score_for_student(self, assessment: Assessment, student_id: int) -> Optional[float]:
"""Calcule le score d'un élève pour une évaluation."""
grades = self.grade_repo.get_student_grades_by_assessment(student_id, assessment.id)
if not grades:
return None
total_score = 0.0
total_max_points = 0.0
for grade in grades:
element = grade.grading_element
if grade.value:
score = GradingCalculator.calculate_score(
grade.value, element.grading_type, element.max_points
)
if score is not None and GradingCalculator.is_counted_in_total(grade.value, element.grading_type):
total_score += score
total_max_points += element.max_points
return round(total_score / total_max_points * 20, 2) if total_max_points > 0 else None
def _get_student_assessment_data(self, student_id: int, assessment: Assessment) -> Optional[Dict]:
"""Récupère les données d'évaluation d'un élève pour une évaluation."""
score = self._calculate_assessment_score_for_student(assessment, student_id)
if score is None:
return None
return {
'score': score,
'max': 20.0, # Score ramené sur 20
'title': assessment.title,
'date': assessment.date,
'coefficient': assessment.coefficient
}
def _has_grades(self, student_id: int, assessment: Assessment) -> bool:
"""Vérifie si un élève a des notes pour une évaluation."""
grades = self.grade_repo.get_student_grades_by_assessment(student_id, assessment.id)
return any(grade.value for grade in grades)
def _determine_performance_status(self, average: Optional[float], grades_by_assessment: Dict) -> str:
"""Détermine le statut de performance d'un élève."""
if not average:
return 'no_data'
if average >= 16:
return 'excellent'
elif average >= 14:
return 'good'
elif average >= 10:
return 'average'
else:
return 'struggling'
class AppreciationService:
"""Service pour la gestion des appréciations du conseil de classe."""
def __init__(self, appreciation_repo: AppreciationRepository):
self.appreciation_repo = appreciation_repo
def save_appreciation(self, data: Dict) -> CouncilAppreciation:
"""Sauvegarde ou met à jour une appréciation."""
return self.appreciation_repo.create_or_update(
student_id=data['student_id'],
class_group_id=data['class_group_id'],
trimester=data['trimester'],
data={
'general_appreciation': data.get('general_appreciation'),
'strengths': data.get('strengths'),
'areas_for_improvement': data.get('areas_for_improvement'),
'status': data.get('status', 'draft')
}
)
def auto_save_appreciation(self, data: Dict) -> CouncilAppreciation:
"""Sauvegarde automatique en mode brouillon."""
data['status'] = 'draft'
return self.save_appreciation(data)
def finalize_appreciation(self, student_id: int, class_group_id: int, trimester: int) -> CouncilAppreciation:
"""Finalise une appréciation (change le statut à 'finalized')."""
appreciation = self.appreciation_repo.find_by_student_trimester(
student_id, class_group_id, trimester
)
if not appreciation:
raise ValueError("Aucune appréciation trouvée pour finalisation")
appreciation.status = 'finalized'
return self.appreciation_repo.update(appreciation)
def get_class_appreciations(self, class_group_id: int, trimester: int) -> List[CouncilAppreciation]:
"""Récupère toutes les appréciations d'une classe pour un trimestre."""
return self.appreciation_repo.find_by_class_trimester(class_group_id, trimester)
def get_completion_stats(self, class_group_id: int, trimester: int) -> Dict:
"""Statistiques de completion des appréciations."""
return self.appreciation_repo.get_completion_stats(class_group_id, trimester)
class CouncilPreparationService:
"""Service principal pour la préparation du conseil de classe."""
def __init__(self,
student_evaluation_service: StudentEvaluationService,
appreciation_service: AppreciationService,
assessment_repo: AssessmentRepository):
self.student_evaluation = student_evaluation_service
self.appreciation = appreciation_service
self.assessment_repo = assessment_repo
def prepare_council_data(self, class_group_id: int, trimester: int) -> CouncilPreparationData:
"""Prépare toutes les données nécessaires au conseil de classe."""
# 1. Résumés par élève
student_summaries = self.student_evaluation.get_students_summaries(class_group_id, trimester)
# 2. Statistiques générales de la classe
class_statistics = self._calculate_class_statistics(student_summaries)
# 3. Statistiques des appréciations
appreciation_stats = self.appreciation.get_completion_stats(class_group_id, trimester)
return CouncilPreparationData(
class_group_id=class_group_id,
trimester=trimester,
student_summaries=student_summaries,
class_statistics=class_statistics,
appreciation_stats=appreciation_stats,
total_students=len(student_summaries),
completed_appreciations=appreciation_stats['completed_appreciations']
)
def _calculate_class_statistics(self, student_summaries: List[StudentTrimesterSummary]) -> Dict:
"""Calcule les statistiques de la classe."""
averages = [s.overall_average for s in student_summaries if s.overall_average is not None]
if not averages:
return {
'mean': None,
'median': None,
'min': None,
'max': None,
'std_dev': None,
'performance_distribution': {
'excellent': 0,
'good': 0,
'average': 0,
'struggling': 0,
'no_data': len(student_summaries)
}
}
# Calculs statistiques
mean = round(sum(averages) / len(averages), 2)
sorted_averages = sorted(averages)
n = len(sorted_averages)
if n % 2 == 0:
median = (sorted_averages[n//2 - 1] + sorted_averages[n//2]) / 2
else:
median = sorted_averages[n//2]
median = round(median, 2)
min_avg = min(averages)
max_avg = max(averages)
# Écart-type
variance = sum((x - mean) ** 2 for x in averages) / len(averages)
std_dev = round(variance ** 0.5, 2)
# Distribution des performances
performance_distribution = {
'excellent': 0,
'good': 0,
'average': 0,
'struggling': 0,
'no_data': 0
}
for summary in student_summaries:
performance_distribution[summary.performance_status] += 1
return {
'mean': mean,
'median': median,
'min': min_avg,
'max': max_avg,
'std_dev': std_dev,
'performance_distribution': performance_distribution,
'student_count_with_data': len(averages),
'total_students': len(student_summaries)
}
# Factory pour créer les services avec injection de dépendances
class CouncilServiceFactory:
"""Factory pour créer les services du conseil de classe."""
@staticmethod
def create_council_preparation_service() -> CouncilPreparationService:
"""Crée le service principal avec toutes ses dépendances."""
# Repositories
grade_repo = GradeRepository()
assessment_repo = AssessmentRepository()
appreciation_repo = AppreciationRepository()
# Services
student_evaluation_service = StudentEvaluationService(grade_repo, assessment_repo)
appreciation_service = AppreciationService(appreciation_repo)
return CouncilPreparationService(
student_evaluation_service,
appreciation_service,
assessment_repo
)
@staticmethod
def create_appreciation_service() -> AppreciationService:
"""Crée le service d'appréciations."""
appreciation_repo = AppreciationRepository()
return AppreciationService(appreciation_repo)