feat: add assessment_* pages
This commit is contained in:
		
							
								
								
									
										111
									
								
								models.py
									
									
									
									
									
								
							
							
						
						
									
										111
									
								
								models.py
									
									
									
									
									
								
							| @@ -72,12 +72,11 @@ class Assessment(db.Model): | ||||
|             for grading_element in exercise.grading_elements: | ||||
|                 total_elements += total_students | ||||
|                  | ||||
|                 # Compter les notes saisies (valeur non nulle et non vide) | ||||
|                 # 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 != '', | ||||
|                     Grade.value != '.' | ||||
|                     Grade.value != '' | ||||
|                 ).count() | ||||
|                  | ||||
|                 completed_elements += completed_for_element | ||||
| @@ -107,6 +106,112 @@ class Assessment(db.Model): | ||||
|             '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.""" | ||||
|         from collections import defaultdict | ||||
|         import statistics | ||||
|          | ||||
|         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 '.') | ||||
|                     if grade and grade.value and grade.value != '': | ||||
|                         if element.grading_type == 'points': | ||||
|                             if grade.value == '.': | ||||
|                                 # '.' signifie non répondu = 0 point mais on compte le max | ||||
|                                 exercise_score += 0 | ||||
|                                 exercise_max_points += element.max_points | ||||
|                             else: | ||||
|                                 try: | ||||
|                                     exercise_score += float(grade.value) | ||||
|                                     exercise_max_points += element.max_points | ||||
|                                 except ValueError: | ||||
|                                     pass | ||||
|                         else:  # compétences | ||||
|                             if grade.value == '.': | ||||
|                                 # '.' signifie non évalué = 0 point mais on compte le max | ||||
|                                 exercise_score += 0 | ||||
|                                 exercise_max_points += (1/3) * 3 * element.max_points  # Score max de 3 | ||||
|                             else: | ||||
|                                 try: | ||||
|                                     score_value = float(grade.value) | ||||
|                                     exercise_score += (1/3) * score_value * element.max_points | ||||
|                                     exercise_max_points += (1/3) * 3 * element.max_points  # Score max de 3 | ||||
|                                 except ValueError: | ||||
|                                     pass | ||||
|                  | ||||
|                 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.""" | ||||
|         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: | ||||
|                 if element.grading_type == 'points': | ||||
|                     total += element.max_points | ||||
|                 else:  # compétences | ||||
|                     total += (1/3) * 3 * element.max_points  # Score max de 3 | ||||
|         return total | ||||
|  | ||||
| class Exercise(db.Model): | ||||
|     id = db.Column(db.Integer, primary_key=True) | ||||
|   | ||||
| @@ -178,6 +178,38 @@ def new(): | ||||
|      | ||||
|     return render_template('assessment_form_unified.html', form=form, title='Nouvelle évaluation complète') | ||||
|  | ||||
| @bp.route('/<int:id>/results') | ||||
| @handle_db_errors | ||||
| def results(id): | ||||
|     from sqlalchemy.orm import joinedload | ||||
|     from models import Exercise, GradingElement | ||||
|      | ||||
|     assessment = Assessment.query.options( | ||||
|         joinedload(Assessment.class_group), | ||||
|         joinedload(Assessment.exercises).joinedload(Exercise.grading_elements) | ||||
|     ).get_or_404(id) | ||||
|      | ||||
|     # Calculer les scores des élèves | ||||
|     students_scores, exercise_scores = assessment.calculate_student_scores() | ||||
|      | ||||
|     # Trier les élèves par ordre alphabétique | ||||
|     sorted_students = sorted(students_scores.values(),  | ||||
|                            key=lambda x: (x['student'].last_name.lower(), x['student'].first_name.lower())) | ||||
|      | ||||
|     # Calculer les statistiques | ||||
|     statistics = assessment.get_assessment_statistics() | ||||
|     total_max_points = assessment.get_total_max_points() | ||||
|      | ||||
|     # Préparer les données pour l'histogramme | ||||
|     scores = [data['total_score'] for data in students_scores.values()] | ||||
|      | ||||
|     return render_template('assessment_results.html',  | ||||
|                          assessment=assessment, | ||||
|                          students_scores=sorted_students, | ||||
|                          statistics=statistics, | ||||
|                          total_max_points=total_max_points, | ||||
|                          scores_json=scores) | ||||
|  | ||||
| @bp.route('/<int:id>/delete', methods=['POST']) | ||||
| @handle_db_errors | ||||
| def delete(id): | ||||
|   | ||||
| @@ -180,12 +180,12 @@ | ||||
|                     </svg> | ||||
|                     Saisir les notes | ||||
|                 </a> | ||||
|                 <button class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"> | ||||
|                 <a href="{{ url_for('assessments.results', id=assessment.id) }}" class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"> | ||||
|                     <svg class="w-5 h-5 mr-2 text-purple-600" fill="currentColor" viewBox="0 0 20 20"> | ||||
|                         <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 011 1v1a1 1 0 01-1 1H4a1 1 0 01-1-1v-1zM3 4a1 1 0 011-1h12a1 1 0 011 1v1a1 1 0 01-1 1H4a1 1 0 01-1-1V4z" clip-rule="evenodd"/> | ||||
|                     </svg> | ||||
|                     Voir les résultats | ||||
|                 </button> | ||||
|                 </a> | ||||
|                 <button class="flex items-center justify-center px-4 py-3 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"> | ||||
|                     <svg class="w-5 h-5 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> | ||||
|                         <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 011 1v1a1 1 0 01-1 1H4a1 1 0 01-1-1v-1zM3 4a1 1 0 011-1h12a1 1 0 011 1v1a1 1 0 01-1 1H4a1 1 0 01-1-1V4z" clip-rule="evenodd"/> | ||||
|   | ||||
							
								
								
									
										195
									
								
								templates/assessment_results.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								templates/assessment_results.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,195 @@ | ||||
| {% extends "base.html" %} | ||||
|  | ||||
| {% block title %}Résultats - {{ assessment.title }} - Gestion Scolaire{% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="space-y-6"> | ||||
|     <div class="flex justify-between items-center"> | ||||
|         <div> | ||||
|             <a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium mb-2 inline-block"> | ||||
|                 ← Retour à l'évaluation | ||||
|             </a> | ||||
|             <h1 class="text-2xl font-bold text-gray-900">Résultats - {{ assessment.title }}</h1> | ||||
|             <p class="text-gray-600">{{ assessment.class_group.name }} - {{ assessment.date.strftime('%d/%m/%Y') }} - Trimestre {{ assessment.trimester }}</p> | ||||
|         </div> | ||||
|     </div> | ||||
|      | ||||
|     <!-- Statistiques générales --> | ||||
|     <div class="bg-white shadow rounded-lg"> | ||||
|         <div class="px-6 py-4 border-b border-gray-200"> | ||||
|             <h2 class="text-lg font-medium text-gray-900">Statistiques de l'évaluation</h2> | ||||
|         </div> | ||||
|         <div class="px-6 py-4"> | ||||
|             <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> | ||||
|                 <div class="text-center"> | ||||
|                     <div class="text-2xl font-bold text-blue-600">{{ statistics.count }}</div> | ||||
|                     <div class="text-sm text-gray-500">Élèves</div> | ||||
|                 </div> | ||||
|                 <div class="text-center"> | ||||
|                     <div class="text-2xl font-bold text-green-600">{{ statistics.mean }}</div> | ||||
|                     <div class="text-sm text-gray-500">Moyenne</div> | ||||
|                 </div> | ||||
|                 <div class="text-center"> | ||||
|                     <div class="text-2xl font-bold text-purple-600">{{ statistics.median }}</div> | ||||
|                     <div class="text-sm text-gray-500">Médiane</div> | ||||
|                 </div> | ||||
|                 <div class="text-center"> | ||||
|                     <div class="text-2xl font-bold text-red-600">{{ statistics.min }}</div> | ||||
|                     <div class="text-sm text-gray-500">Minimum</div> | ||||
|                 </div> | ||||
|                 <div class="text-center"> | ||||
|                     <div class="text-2xl font-bold text-green-600">{{ statistics.max }}</div> | ||||
|                     <div class="text-sm text-gray-500">Maximum</div> | ||||
|                 </div> | ||||
|                 <div class="text-center"> | ||||
|                     <div class="text-2xl font-bold text-orange-600">{{ statistics.std_dev }}</div> | ||||
|                     <div class="text-sm text-gray-500">Écart-type</div> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="mt-4 text-sm text-gray-600 text-center"> | ||||
|                 Total des points de l'évaluation : {{ "%.1f"|format(total_max_points) }} | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|      | ||||
|     <!-- Histogramme des notes --> | ||||
|     <div class="bg-white shadow rounded-lg"> | ||||
|         <div class="px-6 py-4 border-b border-gray-200"> | ||||
|             <h2 class="text-lg font-medium text-gray-900">Distribution des notes</h2> | ||||
|         </div> | ||||
|         <div class="px-6 py-4"> | ||||
|             <canvas id="scoresChart" width="400" height="200"></canvas> | ||||
|         </div> | ||||
|     </div> | ||||
|      | ||||
|     <!-- Tableau des résultats --> | ||||
|     <div class="bg-white shadow rounded-lg"> | ||||
|         <div class="px-6 py-4 border-b border-gray-200"> | ||||
|             <h2 class="text-lg font-medium text-gray-900">Résultats par élève</h2> | ||||
|         </div> | ||||
|         <div class="overflow-x-auto"> | ||||
|             <table class="min-w-full divide-y divide-gray-200"> | ||||
|                 <thead class="bg-gray-50"> | ||||
|                     <tr> | ||||
|                         <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> | ||||
|                             Élève | ||||
|                         </th> | ||||
|                         <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider"> | ||||
|                             Note totale | ||||
|                         </th> | ||||
|                         {% for exercise in assessment.exercises|sort(attribute='order') %} | ||||
|                         <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider"> | ||||
|                             {{ exercise.title }} | ||||
|                         </th> | ||||
|                         {% endfor %} | ||||
|                     </tr> | ||||
|                 </thead> | ||||
|                 <tbody class="bg-white divide-y divide-gray-200"> | ||||
|                     {% for student_data in students_scores %} | ||||
|                     <tr class="hover:bg-gray-50"> | ||||
|                         <td class="px-6 py-4 whitespace-nowrap"> | ||||
|                             <div class="text-sm font-medium text-gray-900"> | ||||
|                                 {{ student_data.student.last_name }} {{ student_data.student.first_name }} | ||||
|                             </div> | ||||
|                         </td> | ||||
|                         <td class="px-6 py-4 whitespace-nowrap text-center"> | ||||
|                             <div class="text-sm font-bold text-gray-900"> | ||||
|                                 {{ "%.1f"|format(student_data.total_score) }} / {{ "%.1f"|format(student_data.total_max_points) }} | ||||
|                             </div> | ||||
|                         </td> | ||||
|                         {% for exercise in assessment.exercises|sort(attribute='order') %} | ||||
|                         <td class="px-6 py-4 whitespace-nowrap text-center"> | ||||
|                             <div class="text-sm text-gray-900"> | ||||
|                                 {% if exercise.id in student_data.exercises %} | ||||
|                                     {{ "%.1f"|format(student_data.exercises[exercise.id].score) }} / {{ "%.1f"|format(student_data.exercises[exercise.id].max_points) }} | ||||
|                                 {% else %} | ||||
|                                     - | ||||
|                                 {% endif %} | ||||
|                             </div> | ||||
|                         </td> | ||||
|                         {% endfor %} | ||||
|                     </tr> | ||||
|                     {% endfor %} | ||||
|                 </tbody> | ||||
|             </table> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <!-- Chart.js --> | ||||
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | ||||
| <script> | ||||
| document.addEventListener('DOMContentLoaded', function() { | ||||
|     const ctx = document.getElementById('scoresChart').getContext('2d'); | ||||
|     const scores = {{ scores_json | tojson }}; | ||||
|      | ||||
|     // Créer les intervalles pour l'histogramme | ||||
|     const totalMaxPoints = {{ total_max_points }}; | ||||
|     const minScore = 0; | ||||
|     const maxScore = totalMaxPoints; | ||||
|     const binWidth = 1; // Groupes de 1 point | ||||
|     const numBins = Math.ceil(maxScore - minScore); // Nombre de bins basé sur le total des points | ||||
|      | ||||
|     const bins = []; | ||||
|     const labels = []; | ||||
|      | ||||
|     for (let i = 0; i < numBins; i++) { | ||||
|         const binStart = minScore + i * binWidth; | ||||
|         const binEnd = minScore + (i + 1) * binWidth; | ||||
|         bins.push(0); | ||||
|         labels.push(`${binStart.toFixed(0)} - ${binEnd.toFixed(0)}`); | ||||
|     } | ||||
|      | ||||
|     // Compter les scores dans chaque intervalle | ||||
|     scores.forEach(score => { | ||||
|         let binIndex = Math.floor((score - minScore) / binWidth); | ||||
|         if (binIndex >= numBins) binIndex = numBins - 1; // Pour le score maximum | ||||
|         if (binIndex >= 0) bins[binIndex]++; | ||||
|     }); | ||||
|      | ||||
|     new Chart(ctx, { | ||||
|         type: 'bar', | ||||
|         data: { | ||||
|             labels: labels, | ||||
|             datasets: [{ | ||||
|                 label: 'Nombre d\'élèves', | ||||
|                 data: bins, | ||||
|                 backgroundColor: 'rgba(59, 130, 246, 0.5)', | ||||
|                 borderColor: 'rgba(59, 130, 246, 1)', | ||||
|                 borderWidth: 1 | ||||
|             }] | ||||
|         }, | ||||
|         options: { | ||||
|             responsive: true, | ||||
|             plugins: { | ||||
|                 title: { | ||||
|                     display: true, | ||||
|                     text: 'Distribution des notes' | ||||
|                 }, | ||||
|                 legend: { | ||||
|                     display: false | ||||
|                 } | ||||
|             }, | ||||
|             scales: { | ||||
|                 y: { | ||||
|                     beginAtZero: true, | ||||
|                     ticks: { | ||||
|                         stepSize: 1 | ||||
|                     }, | ||||
|                     title: { | ||||
|                         display: true, | ||||
|                         text: 'Nombre d\'élèves' | ||||
|                     } | ||||
|                 }, | ||||
|                 x: { | ||||
|                     title: { | ||||
|                         display: true, | ||||
|                         text: 'Intervalles de notes' | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
| }); | ||||
| </script> | ||||
| {% endblock %} | ||||
		Reference in New Issue
	
	Block a user