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: |             for grading_element in exercise.grading_elements: | ||||||
|                 total_elements += total_students |                 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( |                 completed_for_element = db.session.query(Grade).filter( | ||||||
|                     Grade.grading_element_id == grading_element.id, |                     Grade.grading_element_id == grading_element.id, | ||||||
|                     Grade.value.isnot(None), |                     Grade.value.isnot(None), | ||||||
|                     Grade.value != '', |                     Grade.value != '' | ||||||
|                     Grade.value != '.' |  | ||||||
|                 ).count() |                 ).count() | ||||||
|                  |                  | ||||||
|                 completed_elements += completed_for_element |                 completed_elements += completed_for_element | ||||||
| @@ -107,6 +106,112 @@ class Assessment(db.Model): | |||||||
|             'status': status, |             'status': status, | ||||||
|             'students_count': total_students |             '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): | class Exercise(db.Model): | ||||||
|     id = db.Column(db.Integer, primary_key=True) |     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') |     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']) | @bp.route('/<int:id>/delete', methods=['POST']) | ||||||
| @handle_db_errors | @handle_db_errors | ||||||
| def delete(id): | def delete(id): | ||||||
|   | |||||||
| @@ -180,12 +180,12 @@ | |||||||
|                     </svg> |                     </svg> | ||||||
|                     Saisir les notes |                     Saisir les notes | ||||||
|                 </a> |                 </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"> |                     <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"/> |                         <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> |                     </svg> | ||||||
|                     Voir les résultats |                     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"> |                 <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"> |                     <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"/> |                         <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