✨ Changements majeurs: - Suppression complète du code Flask legacy - Migration backend FastAPI vers racine /backend - Migration frontend Vue.js vers racine /frontend - Suppression de notytex-v2/ (code monté à la racine) ✅ Validations: - Backend démarre correctement (port 8000) - API /api/v2/health répond healthy - 99/99 tests unitaires passent - Frontend configuré avec proxy Vite 📝 Documentation: - README.md réécrit pour v2 - Instructions de démarrage mises à jour - .gitignore adapté pour backend/frontend/ 🎯 Architecture finale: notytex/ ├── backend/ # FastAPI + SQLAlchemy + Pydantic ├── frontend/ # Vue 3 + Vite + TailwindCSS ├── docs/ # Documentation └── school_management.db # Base de données (inchangée) Jalon 6 complété: Application v2 prête pour utilisation!
596 lines
22 KiB
Python
596 lines
22 KiB
Python
"""
|
|
Service de génération de bilans d'évaluation pour les élèves.
|
|
Génère les rapports individualisés à envoyer par email.
|
|
"""
|
|
|
|
from typing import Dict, List, Any, Optional
|
|
from dataclasses import dataclass
|
|
|
|
from domain.services.grading_calculator import GradingCalculator
|
|
from domain.services.config_service import ConfigService
|
|
from domain.services.score_calculator import StudentScoreCalculator
|
|
from domain.services.statistics_service import StatisticsService
|
|
|
|
|
|
@dataclass
|
|
class StudentReportData:
|
|
"""Données d'un rapport d'élève."""
|
|
assessment: Dict[str, Any]
|
|
student: Dict[str, Any]
|
|
results: Dict[str, Any]
|
|
exercises: List[Dict[str, Any]]
|
|
competences: List[Dict[str, Any]]
|
|
domains: List[Dict[str, Any]]
|
|
class_statistics: Dict[str, Any]
|
|
|
|
|
|
class StudentReportService:
|
|
"""Service de génération des bilans d'évaluation individuels."""
|
|
|
|
def __init__(self, config_service: Optional[ConfigService] = None):
|
|
"""
|
|
Initialise le service avec le service de configuration.
|
|
|
|
Args:
|
|
config_service: Service de configuration (utilise les valeurs par défaut si non fourni)
|
|
"""
|
|
self.config_service = config_service or ConfigService()
|
|
# Convertir les valeurs spéciales du ConfigService en format attendu par GradingCalculator
|
|
special_values_dict = self.config_service.get_special_values_dict()
|
|
self.grading_calculator = GradingCalculator(special_values_dict)
|
|
self.stats_service = StatisticsService()
|
|
|
|
def generate_student_report(
|
|
self,
|
|
assessment_data: Dict[str, Any],
|
|
student_data: Dict[str, Any],
|
|
all_students_grades: Dict[int, List[Dict[str, Any]]],
|
|
exercises_data: List[Dict[str, Any]]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Génère le rapport individuel d'un élève pour une évaluation.
|
|
|
|
Args:
|
|
assessment_data: Données de l'évaluation
|
|
student_data: Données de l'élève
|
|
all_students_grades: Notes de tous les élèves {student_id: [grades]}
|
|
exercises_data: Données des exercices avec leurs éléments
|
|
|
|
Returns:
|
|
Dict contenant toutes les données du rapport
|
|
"""
|
|
student_id = student_data['id']
|
|
score_meanings = self.config_service.get_score_meanings()
|
|
|
|
# Calculer les scores de tous les élèves
|
|
all_student_scores = self._calculate_all_student_scores(
|
|
all_students_grades, exercises_data
|
|
)
|
|
|
|
# Vérifier que l'élève est dans les résultats
|
|
if student_id not in all_student_scores:
|
|
raise ValueError(f"L'élève {student_data.get('full_name', student_id)} n'a pas de notes pour cette évaluation")
|
|
|
|
student_scores = all_student_scores[student_id]
|
|
|
|
# Calculer les statistiques de classe
|
|
all_totals = [s['total_score'] for s in all_student_scores.values()]
|
|
statistics = self.stats_service.calculate_statistics(all_totals)
|
|
|
|
# Calculer la position de l'élève dans la classe
|
|
all_scores_sorted = sorted(all_totals, reverse=True)
|
|
student_position = all_scores_sorted.index(student_scores['total_score']) + 1
|
|
total_students = len(all_totals)
|
|
|
|
# Préparer les détails par exercice
|
|
exercises_details = self._prepare_exercises_details(
|
|
exercises_data,
|
|
student_scores,
|
|
all_students_grades.get(student_id, []),
|
|
score_meanings
|
|
)
|
|
|
|
# Calculer les performances par compétence
|
|
competences_performance = self._calculate_competences_performance(
|
|
exercises_data,
|
|
all_students_grades.get(student_id, [])
|
|
)
|
|
|
|
# Calculer les performances par domaine
|
|
domains_performance = self._calculate_domains_performance(
|
|
exercises_data,
|
|
all_students_grades.get(student_id, [])
|
|
)
|
|
|
|
return {
|
|
'assessment': {
|
|
'title': assessment_data['title'],
|
|
'description': assessment_data.get('description', ''),
|
|
'date': assessment_data['date'],
|
|
'trimester': assessment_data['trimester'],
|
|
'class_name': assessment_data['class_name'],
|
|
'coefficient': assessment_data.get('coefficient', 1.0)
|
|
},
|
|
'student': {
|
|
'full_name': f"{student_data['first_name']} {student_data['last_name']}",
|
|
'first_name': student_data['first_name'],
|
|
'last_name': student_data['last_name'],
|
|
'email': student_data.get('email')
|
|
},
|
|
'results': {
|
|
'total_score': student_scores['total_score'],
|
|
'total_max_points': student_scores['total_max_points'],
|
|
'percentage': round(
|
|
(student_scores['total_score'] / student_scores['total_max_points']) * 100, 1
|
|
) if student_scores['total_max_points'] > 0 else 0,
|
|
'position': student_position,
|
|
'total_students': total_students
|
|
},
|
|
'exercises': exercises_details,
|
|
'competences': competences_performance,
|
|
'domains': domains_performance,
|
|
'class_statistics': {
|
|
'count': statistics.count,
|
|
'mean': statistics.mean,
|
|
'median': statistics.median,
|
|
'min': statistics.min,
|
|
'max': statistics.max,
|
|
'std_dev': statistics.std_dev
|
|
}
|
|
}
|
|
|
|
def _calculate_all_student_scores(
|
|
self,
|
|
all_students_grades: Dict[int, List[Dict[str, Any]]],
|
|
exercises_data: List[Dict[str, Any]]
|
|
) -> Dict[int, Dict[str, Any]]:
|
|
"""Calcule les scores de tous les élèves."""
|
|
results = {}
|
|
|
|
# Créer un mapping element_id -> element_data
|
|
elements_map = {}
|
|
for exercise in exercises_data:
|
|
for element in exercise.get('elements', []):
|
|
elements_map[element['id']] = element
|
|
|
|
for student_id, grades in all_students_grades.items():
|
|
total_score = 0.0
|
|
total_max = 0.0
|
|
exercises_scores = {}
|
|
|
|
# Grouper les notes par exercice
|
|
for grade in grades:
|
|
element_id = grade['element_id']
|
|
element = elements_map.get(element_id)
|
|
if not element:
|
|
continue
|
|
|
|
exercise_id = element['exercise_id']
|
|
value = grade['value']
|
|
|
|
# Calculer le score
|
|
calculated = self.grading_calculator.calculate_score(
|
|
value, element['grading_type'], element['max_points']
|
|
)
|
|
|
|
if calculated is not None:
|
|
if exercise_id not in exercises_scores:
|
|
exercises_scores[exercise_id] = {'score': 0, 'max_points': 0}
|
|
|
|
exercises_scores[exercise_id]['score'] += calculated
|
|
exercises_scores[exercise_id]['max_points'] += element['max_points']
|
|
total_score += calculated
|
|
total_max += element['max_points']
|
|
|
|
results[student_id] = {
|
|
'total_score': round(total_score, 2),
|
|
'total_max_points': round(total_max, 2),
|
|
'exercises': exercises_scores
|
|
}
|
|
|
|
return results
|
|
|
|
def _prepare_exercises_details(
|
|
self,
|
|
exercises_data: List[Dict[str, Any]],
|
|
student_scores: Dict[str, Any],
|
|
student_grades: List[Dict[str, Any]],
|
|
score_meanings: Dict[int, Any]
|
|
) -> List[Dict[str, Any]]:
|
|
"""Prépare les détails par exercice pour le rapport."""
|
|
# Créer un mapping grade par element_id
|
|
grades_map = {g['element_id']: g for g in student_grades}
|
|
|
|
exercises_details = []
|
|
|
|
for exercise in sorted(exercises_data, key=lambda x: x.get('order', 0)):
|
|
exercise_id = exercise['id']
|
|
exercise_score = student_scores.get('exercises', {}).get(exercise_id, {'score': 0, 'max_points': 0})
|
|
|
|
# Détails des éléments de notation
|
|
elements_details = []
|
|
for element in exercise.get('elements', []):
|
|
grade = grades_map.get(element['id'])
|
|
|
|
if grade and grade.get('value'):
|
|
value = grade['value']
|
|
calculated = self.grading_calculator.calculate_score(
|
|
value, element['grading_type'], element['max_points']
|
|
)
|
|
|
|
# Récupérer le label de compétence si c'est un score
|
|
score_label = ''
|
|
if element['grading_type'] == 'score' and value.isdigit():
|
|
score_val = int(value)
|
|
meaning = score_meanings.get(score_val)
|
|
if meaning:
|
|
score_label = meaning.label
|
|
|
|
elements_details.append({
|
|
'label': element['label'],
|
|
'description': element.get('description', ''),
|
|
'skill': element.get('skill', ''),
|
|
'domain': element.get('domain_name', ''),
|
|
'raw_value': value,
|
|
'calculated_score': calculated,
|
|
'max_points': element['max_points'],
|
|
'grading_type': element['grading_type'],
|
|
'score_label': score_label,
|
|
'comment': grade.get('comment', '')
|
|
})
|
|
else:
|
|
elements_details.append({
|
|
'label': element['label'],
|
|
'description': element.get('description', ''),
|
|
'skill': element.get('skill', ''),
|
|
'domain': element.get('domain_name', ''),
|
|
'raw_value': None,
|
|
'calculated_score': None,
|
|
'max_points': element['max_points'],
|
|
'grading_type': element['grading_type'],
|
|
'score_label': '',
|
|
'comment': ''
|
|
})
|
|
|
|
max_pts = exercise_score['max_points']
|
|
exercises_details.append({
|
|
'title': exercise['title'],
|
|
'description': exercise.get('description', ''),
|
|
'score': exercise_score['score'],
|
|
'max_points': max_pts,
|
|
'percentage': round((exercise_score['score'] / max_pts) * 100, 1) if max_pts > 0 else 0,
|
|
'elements': elements_details
|
|
})
|
|
|
|
return exercises_details
|
|
|
|
def _calculate_competences_performance(
|
|
self,
|
|
exercises_data: List[Dict[str, Any]],
|
|
student_grades: List[Dict[str, Any]]
|
|
) -> List[Dict[str, Any]]:
|
|
"""Calcule les performances de l'élève par compétence."""
|
|
competences_data = {}
|
|
grades_map = {g['element_id']: g for g in student_grades}
|
|
|
|
for exercise in exercises_data:
|
|
for element in exercise.get('elements', []):
|
|
skill = element.get('skill')
|
|
if not skill:
|
|
continue
|
|
|
|
grade = grades_map.get(element['id'])
|
|
if grade and grade.get('value'):
|
|
calculated = self.grading_calculator.calculate_score(
|
|
grade['value'], element['grading_type'], element['max_points']
|
|
)
|
|
|
|
if calculated is not None:
|
|
if skill not in competences_data:
|
|
competences_data[skill] = {
|
|
'total_score': 0,
|
|
'total_max_points': 0,
|
|
'elements_count': 0
|
|
}
|
|
|
|
competences_data[skill]['total_score'] += calculated
|
|
competences_data[skill]['total_max_points'] += element['max_points']
|
|
competences_data[skill]['elements_count'] += 1
|
|
|
|
# Convertir en liste avec pourcentages
|
|
competences_performance = []
|
|
for competence, data in competences_data.items():
|
|
percentage = round(
|
|
(data['total_score'] / data['total_max_points']) * 100, 1
|
|
) if data['total_max_points'] > 0 else 0
|
|
|
|
competences_performance.append({
|
|
'name': competence,
|
|
'score': data['total_score'],
|
|
'max_points': data['total_max_points'],
|
|
'percentage': percentage,
|
|
'elements_count': data['elements_count']
|
|
})
|
|
|
|
return sorted(competences_performance, key=lambda x: x['name'])
|
|
|
|
def _calculate_domains_performance(
|
|
self,
|
|
exercises_data: List[Dict[str, Any]],
|
|
student_grades: List[Dict[str, Any]]
|
|
) -> List[Dict[str, Any]]:
|
|
"""Calcule les performances de l'élève par domaine."""
|
|
domains_data = {}
|
|
grades_map = {g['element_id']: g for g in student_grades}
|
|
|
|
for exercise in exercises_data:
|
|
for element in exercise.get('elements', []):
|
|
domain_name = element.get('domain_name')
|
|
if not domain_name:
|
|
continue
|
|
|
|
grade = grades_map.get(element['id'])
|
|
if grade and grade.get('value'):
|
|
calculated = self.grading_calculator.calculate_score(
|
|
grade['value'], element['grading_type'], element['max_points']
|
|
)
|
|
|
|
if calculated is not None:
|
|
if domain_name not in domains_data:
|
|
domains_data[domain_name] = {
|
|
'total_score': 0,
|
|
'total_max_points': 0,
|
|
'elements_count': 0,
|
|
'color': element.get('domain_color', '#6b7280')
|
|
}
|
|
|
|
domains_data[domain_name]['total_score'] += calculated
|
|
domains_data[domain_name]['total_max_points'] += element['max_points']
|
|
domains_data[domain_name]['elements_count'] += 1
|
|
|
|
# Convertir en liste avec pourcentages
|
|
domains_performance = []
|
|
for domain, data in domains_data.items():
|
|
percentage = round(
|
|
(data['total_score'] / data['total_max_points']) * 100, 1
|
|
) if data['total_max_points'] > 0 else 0
|
|
|
|
domains_performance.append({
|
|
'name': domain,
|
|
'score': data['total_score'],
|
|
'max_points': data['total_max_points'],
|
|
'percentage': percentage,
|
|
'elements_count': data['elements_count'],
|
|
'color': data['color']
|
|
})
|
|
|
|
return sorted(domains_performance, key=lambda x: x['name'])
|
|
|
|
def get_assessment_summary(
|
|
self,
|
|
assessment_data: Dict[str, Any],
|
|
exercises_data: List[Dict[str, Any]],
|
|
statistics: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Génère un résumé de l'évaluation pour les emails.
|
|
|
|
Args:
|
|
assessment_data: Données de l'évaluation
|
|
exercises_data: Données des exercices
|
|
statistics: Statistiques de l'évaluation
|
|
|
|
Returns:
|
|
Dict avec le résumé de l'évaluation
|
|
"""
|
|
# Calculer le total des points
|
|
total_max_points = 0
|
|
elements_count = 0
|
|
competences = set()
|
|
domains = set()
|
|
|
|
for exercise in exercises_data:
|
|
for element in exercise.get('elements', []):
|
|
total_max_points += element['max_points']
|
|
elements_count += 1
|
|
if element.get('skill'):
|
|
competences.add(element['skill'])
|
|
if element.get('domain_name'):
|
|
domains.add(element['domain_name'])
|
|
|
|
return {
|
|
'assessment': {
|
|
'title': assessment_data['title'],
|
|
'description': assessment_data.get('description', ''),
|
|
'date': assessment_data['date'],
|
|
'trimester': assessment_data['trimester'],
|
|
'class_name': assessment_data['class_name'],
|
|
'coefficient': assessment_data.get('coefficient', 1.0),
|
|
'total_max_points': total_max_points,
|
|
'exercises_count': len(exercises_data),
|
|
'elements_count': elements_count
|
|
},
|
|
'statistics': statistics,
|
|
'competences_evaluated': sorted(list(competences)),
|
|
'domains_evaluated': sorted(list(domains))
|
|
}
|
|
|
|
|
|
def generate_report_html(report_data: Dict[str, Any], message: str = "") -> str:
|
|
"""
|
|
Génère le HTML du rapport d'élève.
|
|
|
|
Args:
|
|
report_data: Données du rapport
|
|
message: Message personnalisé du professeur
|
|
|
|
Returns:
|
|
HTML du rapport
|
|
"""
|
|
student = report_data['student']
|
|
assessment = report_data['assessment']
|
|
results = report_data['results']
|
|
stats = report_data['class_statistics']
|
|
|
|
# Couleur basée sur le pourcentage
|
|
percentage = results['percentage']
|
|
if percentage >= 80:
|
|
color = "#22c55e" # vert
|
|
elif percentage >= 60:
|
|
color = "#f6d32d" # jaune
|
|
elif percentage >= 40:
|
|
color = "#f97316" # orange
|
|
else:
|
|
color = "#ef4444" # rouge
|
|
|
|
html = f"""
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body {{
|
|
font-family: Arial, sans-serif;
|
|
padding: 20px;
|
|
color: #333;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
}}
|
|
.header {{
|
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
|
color: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
}}
|
|
.score-box {{
|
|
background: {color};
|
|
color: white;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
margin: 20px 0;
|
|
}}
|
|
.stats-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 10px;
|
|
margin: 20px 0;
|
|
}}
|
|
.stat-item {{
|
|
background: #f3f4f6;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
text-align: center;
|
|
}}
|
|
.stat-label {{
|
|
font-size: 12px;
|
|
color: #6b7280;
|
|
}}
|
|
.stat-value {{
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
}}
|
|
.exercise {{
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
margin: 10px 0;
|
|
}}
|
|
.exercise-header {{
|
|
font-weight: bold;
|
|
margin-bottom: 10px;
|
|
}}
|
|
.element {{
|
|
padding: 5px 0;
|
|
border-bottom: 1px solid #f3f4f6;
|
|
}}
|
|
.footer {{
|
|
margin-top: 30px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e5e7eb;
|
|
font-size: 12px;
|
|
color: #6b7280;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>{assessment['title']}</h1>
|
|
<p>{assessment['class_name']} - Trimestre {assessment['trimester']}</p>
|
|
<p>Date: {assessment['date']}</p>
|
|
</div>
|
|
|
|
<h2>Bonjour {student['first_name']},</h2>
|
|
|
|
<p>Voici votre bilan pour l'évaluation <strong>{assessment['title']}</strong>.</p>
|
|
|
|
<div class="score-box">
|
|
{results['total_score']}/{results['total_max_points']} ({results['percentage']}%)
|
|
</div>
|
|
|
|
<p style="text-align: center; color: #6b7280;">
|
|
Position: {results['position']}/{results['total_students']} élèves
|
|
</p>
|
|
|
|
<h3>Statistiques de la classe</h3>
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<div class="stat-label">Moyenne</div>
|
|
<div class="stat-value">{stats['mean']}</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">Médiane</div>
|
|
<div class="stat-value">{stats['median']}</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">Écart-type</div>
|
|
<div class="stat-value">{stats['std_dev']}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h3>Détail par exercice</h3>
|
|
"""
|
|
|
|
# Ajouter les exercices
|
|
for exercise in report_data['exercises']:
|
|
html += f"""
|
|
<div class="exercise">
|
|
<div class="exercise-header">
|
|
{exercise['title']} - {exercise['score']}/{exercise['max_points']} ({exercise['percentage']}%)
|
|
</div>
|
|
"""
|
|
|
|
for element in exercise['elements']:
|
|
value_display = element.get('raw_value', '-') or '-'
|
|
if element.get('score_label'):
|
|
value_display = f"{value_display} ({element['score_label']})"
|
|
|
|
html += f"""
|
|
<div class="element">
|
|
<strong>{element['label']}</strong>: {value_display}
|
|
</div>
|
|
"""
|
|
|
|
html += "</div>"
|
|
|
|
# Message personnalisé
|
|
if message:
|
|
html += f"""
|
|
<div style="background: #dbeafe; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
|
<strong>Message du professeur:</strong><br>
|
|
{message}
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
<div class="footer">
|
|
<p>Email généré automatiquement par Notytex - Système de gestion scolaire</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html
|