Files
notytex/routes/assessments.py

607 lines
25 KiB
Python

from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app
from models import db
from forms import AssessmentForm
from services import AssessmentService
from repositories import AssessmentRepository, ClassRepository
from utils import handle_db_errors, ValidationError
bp = Blueprint('assessments', __name__, url_prefix='/assessments')
@bp.route('/')
@handle_db_errors
def list():
assessment_repo = AssessmentRepository()
class_repo = ClassRepository()
# Récupérer les paramètres de filtrage
trimester_filter = request.args.get('trimester', '')
class_filter = request.args.get('class', '')
correction_filter = request.args.get('correction', '')
sort_by = request.args.get('sort', 'date_desc')
# Utiliser le repository pour les filtres
assessments = assessment_repo.find_by_filters(
trimester=int(trimester_filter) if trimester_filter else None,
class_id=int(class_filter) if class_filter else None,
correction_status=correction_filter if correction_filter else None,
sort_by=sort_by
)
# Récupérer le total non filtré pour le compteur
total_assessments = assessment_repo.find_by_filters()
# Récupérer toutes les classes pour le filtre
classes = class_repo.find_for_form_choices()
return render_template('assessments.html',
assessments=assessments,
classes=classes,
total_assessments_count=len(total_assessments),
current_trimester=trimester_filter,
current_class=class_filter,
current_correction=correction_filter,
current_sort=sort_by)
# Route obsolète supprimée - utiliser new_unified à la place
@bp.route('/<int:id>')
@handle_db_errors
def detail(id):
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_with_full_details_or_404(id)
return render_template('assessment_detail.html', assessment=assessment)
def _handle_unified_assessment_request(form, assessment=None, is_edit=False):
"""Fonction helper pour traiter les requêtes JSON d'évaluation unifiée"""
# Ne traiter que les requêtes POST
if request.method != 'POST':
return None
if request.is_json:
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Aucune donnée fournie'}), 400
# Peupler le formulaire pour validation CSRF
form.csrf_token.data = data.get('csrf_token')
# Traitement via le service
if is_edit and assessment:
processed_assessment = AssessmentService.process_assessment_with_exercises(
data, is_edit=True, existing_assessment=assessment
)
else:
processed_assessment = AssessmentService.process_assessment_with_exercises(data)
db.session.commit()
flash('Évaluation ' + ('modifiée' if is_edit else 'créée') + ' avec succès !', 'success')
return jsonify({'success': True, 'assessment_id': processed_assessment.id})
except ValidationError as e:
current_app.logger.warning(f'Erreur de validation: {e}')
return jsonify({'success': False, 'error': str(e)}), 400
except ValueError as e:
current_app.logger.warning(f'Erreur de données: {e}')
return jsonify({'success': False, 'error': str(e)}), 400
else: # request.method == 'POST' and not request.is_json
# Traitement classique du formulaire (fallback)
if form.validate_on_submit():
if is_edit and assessment:
AssessmentService.update_assessment_basic_info(assessment, {
'title': form.title.data,
'description': form.description.data,
'date': form.date.data,
'trimester': form.trimester.data,
'class_group_id': form.class_group_id.data,
'coefficient': form.coefficient.data
})
else:
assessment = AssessmentService.create_assessment({
'title': form.title.data,
'description': form.description.data,
'date': form.date.data,
'trimester': form.trimester.data,
'class_group_id': form.class_group_id.data,
'coefficient': form.coefficient.data
})
db.session.commit()
flash('Évaluation ' + ('modifiée' if is_edit else 'créée') + ' avec succès !', 'success')
return redirect(url_for('assessments.detail', id=assessment.id))
return None
@bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@handle_db_errors
def edit(id):
assessment_repo = AssessmentRepository()
class_repo = ClassRepository()
assessment = assessment_repo.get_with_full_details_or_404(id)
form = AssessmentForm(obj=assessment)
form.populate_class_choices(class_repo)
result = _handle_unified_assessment_request(form, assessment, is_edit=True)
if result:
return result
# Préparer les exercices pour la sérialisation JSON
exercises_data = []
for exercise in assessment.exercises:
exercise_data = {
'id': exercise.id,
'title': exercise.title,
'description': exercise.description or '',
'order': exercise.order,
'grading_elements': []
}
for element in exercise.grading_elements:
element_data = {
'id': element.id,
'label': element.label,
'description': element.description or '',
'skill': element.skill or '',
'max_points': float(element.max_points),
'grading_type': element.grading_type,
'domain_id': element.domain_id
}
exercise_data['grading_elements'].append(element_data)
exercises_data.append(exercise_data)
# Récupérer les compétences et domaines configurées
from app_config import config_manager
competences = config_manager.get_competences_list()
domains = config_manager.get_domains_list()
return render_template('assessment_form.html',
form=form,
title='Modifier l\'évaluation',
assessment=assessment,
exercises_json=exercises_data,
is_edit=True,
competences=competences,
domains=domains)
@bp.route('/new', methods=['GET', 'POST'])
@handle_db_errors
def new():
from app_config import config_manager
class_repo = ClassRepository()
form = AssessmentForm()
form.populate_class_choices(class_repo)
result = _handle_unified_assessment_request(form, is_edit=False)
if result:
return result
# Récupérer les compétences et domaines configurées
competences = config_manager.get_competences_list()
domains = config_manager.get_domains_list()
return render_template('assessment_form.html',
form=form,
title='Nouvelle évaluation',
competences=competences,
domains=domains)
@bp.route('/<int:id>/results')
@handle_db_errors
def results(id):
from models import Competence, Domain
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_with_full_details_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()]
# === NOUVEAUX : Préparer les données pour les heatmaps ===
# Récupérer toutes les compétences et domaines avec leurs couleurs depuis la BD
all_competences = {comp.name: comp.color for comp in Competence.query.all()}
all_domains = {domain.id: {'name': domain.name, 'color': domain.color} for domain in Domain.query.all()}
# Collecter toutes les compétences et domaines présents dans cette évaluation
competences_in_eval = set()
domains_in_eval = set()
for exercise in assessment.exercises:
for element in exercise.grading_elements:
if element.skill:
competences_in_eval.add(element.skill)
if element.domain_id:
domains_in_eval.add(element.domain_id)
# Préparer les données heatmap compétences
students_list = [f"{s['student'].last_name} {s['student'].first_name}" for s in sorted_students]
competences_list = sorted(competences_in_eval)
# Calculer les scores par élève/compétence
competences_scores_matrix = []
for student_data in sorted_students:
student_scores_by_competence = {}
student_totals_by_competence = {}
for exercise_id, exercise_data in student_data['exercises'].items():
# Récupérer l'exercice pour accéder aux grading_elements
exercise = next(ex for ex in assessment.exercises if ex.id == exercise_id)
for element in exercise.grading_elements:
if element.skill and element.skill in competences_in_eval:
# Trouver la note correspondante
grade = None
for g in element.grades:
if g.student_id == student_data['student'].id:
grade = g
break
if grade and grade.value:
from models import GradingCalculator
score = GradingCalculator.calculate_score(grade.value, element.grading_type, element.max_points)
if score is not None: # Exclure les dispensés
if element.skill not in student_scores_by_competence:
student_scores_by_competence[element.skill] = 0
student_totals_by_competence[element.skill] = 0
student_scores_by_competence[element.skill] += score
student_totals_by_competence[element.skill] += element.max_points
# Calculer les pourcentages par compétence pour cet élève
student_row = []
for comp in competences_list:
if comp in student_scores_by_competence and student_totals_by_competence[comp] > 0:
percentage = (student_scores_by_competence[comp] / student_totals_by_competence[comp]) * 100
student_row.append(round(percentage, 1))
else:
student_row.append(None) # Pas de données pour cette compétence
competences_scores_matrix.append(student_row)
# Préparer les données heatmap domaines
domains_list = []
domains_colors = {}
# Trier les domain_id (entiers) puis récupérer les noms
sorted_domain_ids = sorted(domains_in_eval)
for domain_id in sorted_domain_ids:
if domain_id in all_domains:
domain_name = all_domains[domain_id]['name']
domains_list.append(domain_name)
domains_colors[domain_name] = all_domains[domain_id]['color']
# Calculer les scores par élève/domaine
domains_scores_matrix = []
for student_data in sorted_students:
student_scores_by_domain = {}
student_totals_by_domain = {}
for exercise_id, exercise_data in student_data['exercises'].items():
# Récupérer l'exercice pour accéder aux grading_elements
exercise = next(ex for ex in assessment.exercises if ex.id == exercise_id)
for element in exercise.grading_elements:
if element.domain_id and element.domain_id in domains_in_eval:
domain_name = all_domains[element.domain_id]['name']
# Trouver la note correspondante
grade = None
for g in element.grades:
if g.student_id == student_data['student'].id:
grade = g
break
if grade and grade.value:
from models import GradingCalculator
score = GradingCalculator.calculate_score(grade.value, element.grading_type, element.max_points)
if score is not None: # Exclure les dispensés
if domain_name not in student_scores_by_domain:
student_scores_by_domain[domain_name] = 0
student_totals_by_domain[domain_name] = 0
student_scores_by_domain[domain_name] += score
student_totals_by_domain[domain_name] += element.max_points
# Calculer les pourcentages par domaine pour cet élève
student_row = []
for domain in domains_list:
if domain in student_scores_by_domain and student_totals_by_domain[domain] > 0:
percentage = (student_scores_by_domain[domain] / student_totals_by_domain[domain]) * 100
student_row.append(round(percentage, 1))
else:
student_row.append(None) # Pas de données pour ce domaine
domains_scores_matrix.append(student_row)
# Préparer les couleurs des compétences pour celles présentes dans l'évaluation
competences_colors = {comp: all_competences.get(comp, '#6b7280') for comp in competences_list}
heatmap_competences = {
'students': students_list,
'competences': competences_list,
'scores': competences_scores_matrix,
'colors': competences_colors
} if competences_list else None
heatmap_domains = {
'students': students_list,
'domains': domains_list,
'scores': domains_scores_matrix,
'colors': domains_colors
} if domains_list else None
# === NOUVEAU : Préparer les données heatmap éléments de notation ===
# Collecter tous les éléments de notation de l'évaluation
grading_elements_list = []
grading_elements_info = {}
for exercise in sorted(assessment.exercises, key=lambda x: x.order):
for element in exercise.grading_elements:
element_key = f"{exercise.title} - {element.label}"
grading_elements_list.append(element_key)
grading_elements_info[element_key] = {
'element': element,
'exercise': exercise
}
# Collecter les données détaillées par élève/élément avec valeurs originales
grading_elements_detailed_matrix = []
for student_data in sorted_students:
student_row = []
for element_key in grading_elements_list:
element_info = grading_elements_info[element_key]
element = element_info['element']
# Trouver la note correspondante
grade = None
for g in element.grades:
if g.student_id == student_data['student'].id:
grade = g
break
if grade and grade.value:
# Stocker les données complètes pour le rendu avec couleurs spécifiques
student_row.append({
'value': grade.value,
'grading_type': element.grading_type,
'max_points': element.max_points
})
else:
student_row.append(None) # Pas de note
grading_elements_detailed_matrix.append(student_row)
# Préparer les couleurs des éléments basées sur les exercices pour une meilleure distinction
grading_elements_colors = {}
exercise_colors = [
'#3b82f6', # Bleu
'#10b981', # Vert
'#f59e0b', # Orange
'#8b5cf6', # Violet
'#ef4444', # Rouge
'#06b6d4', # Cyan
'#84cc16', # Vert clair
'#f97316', # Orange foncé
]
exercises_seen = {}
color_index = 0
for element_key in grading_elements_list:
element_info = grading_elements_info[element_key]
exercise = element_info['exercise']
# Assigner une couleur unique par exercice
if exercise.id not in exercises_seen:
exercises_seen[exercise.id] = exercise_colors[color_index % len(exercise_colors)]
color_index += 1
grading_elements_colors[element_key] = exercises_seen[exercise.id]
# Récupérer la configuration des couleurs depuis la base de données
from app_config import config_manager
scale_colors = config_manager.get_competence_scale_values()
heatmap_grading_elements = {
'students': students_list,
'elements': grading_elements_list,
'detailed_scores': grading_elements_detailed_matrix,
'colors': grading_elements_colors,
'scale_colors': scale_colors
} if grading_elements_list else None
return render_template('assessment_results.html',
assessment=assessment,
students_scores=sorted_students,
statistics=statistics,
total_max_points=total_max_points,
scores_json=scores,
heatmap_competences=heatmap_competences,
heatmap_domains=heatmap_domains,
heatmap_grading_elements=heatmap_grading_elements)
@bp.route('/<int:id>/delete', methods=['POST'])
@handle_db_errors
def delete(id):
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_or_404(id)
title = assessment.title # Conserver pour le log
db.session.delete(assessment)
db.session.commit()
current_app.logger.info(f'Évaluation supprimée: {title} (ID: {id})')
flash('Évaluation supprimée avec succès !', 'success')
return redirect(url_for('assessments.list'))
@bp.route('/<int:id>/preview-report/<int:student_id>')
@handle_db_errors
def preview_report(id, student_id):
"""Prévisualise le bilan d'un élève dans le navigateur."""
from services.student_report_service import StudentReportService
from models import Student
# Récupérer l'évaluation
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_with_full_details_or_404(id)
# Récupérer l'élève
student = Student.query.get_or_404(student_id)
# Générer le rapport
report_service = StudentReportService()
report_data = report_service.generate_student_report(assessment, student)
# Afficher le template email directement
return render_template('email/student_report.html', report=report_data)
@bp.route('/<int:id>/send-reports', methods=['POST'])
@handle_db_errors
def send_reports(id):
"""Envoie les bilans d'évaluation par email."""
try:
# Récupération des données du formulaire
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Aucune donnée fournie'}), 400
student_ids = data.get('student_ids', [])
custom_message = data.get('custom_message', '').strip()
if not student_ids:
return jsonify({'success': False, 'error': 'Aucun élève sélectionné'}), 400
# Récupération de l'évaluation
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_with_full_details_or_404(id)
# Vérification de la configuration email
from services.email_service import EmailService
from services.student_report_service import StudentReportService
from flask import render_template
email_service = EmailService()
if not email_service.is_configured():
return jsonify({
'success': False,
'error': 'Configuration email incomplète. Rendez-vous dans Configuration > Email.'
}), 400
report_service = StudentReportService()
# Génération des rapports
reports_data = report_service.generate_multiple_reports(assessment, student_ids)
if reports_data['error_count'] > 0:
current_app.logger.warning(f"Erreurs lors de la génération de rapports: {reports_data['errors']}")
# Envoi des emails
sent_count = 0
errors = []
for student_id, report_data in reports_data['reports'].items():
try:
student = report_data['student']
# Vérification de l'email de l'élève
if not student['email']:
errors.append(f"{student['full_name']}: Aucune adresse email")
continue
# Validation de l'email
validation = email_service.validate_email_addresses([student['email']])
if validation['invalid_count'] > 0:
errors.append(f"{student['full_name']}: Adresse email invalide ({student['email']})")
continue
# Génération du HTML de l'email
html_content = render_template('email/student_report.html',
report=report_data,
custom_message=custom_message)
# Sujet de l'email
subject = f"Bilan d'évaluation - {assessment.title} - {student['full_name']}"
# Envoi de l'email
result = email_service.send_email([student['email']], subject, html_content)
if result['success']:
sent_count += 1
current_app.logger.info(f"Bilan envoyé à {student['full_name']} ({student['email']})")
else:
errors.append(f"{student['full_name']}: {result['error']}")
current_app.logger.error(f"Erreur envoi bilan à {student['full_name']}: {result['error']}")
except Exception as e:
error_msg = f"{report_data.get('student', {}).get('full_name', 'Élève inconnu')}: Erreur inattendue - {str(e)}"
errors.append(error_msg)
current_app.logger.error(f"Erreur envoi bilan: {e}")
# Préparer la réponse
response_data = {
'success': sent_count > 0,
'sent_count': sent_count,
'total_requested': len(student_ids),
'error_count': len(errors),
'errors': errors
}
if sent_count > 0:
if len(errors) == 0:
response_data['message'] = f"{sent_count} bilan(s) envoyé(s) avec succès !"
else:
response_data['message'] = f"{sent_count} bilan(s) envoyé(s), {len(errors)} erreur(s)"
else:
response_data['message'] = f"❌ Aucun bilan envoyé - {len(errors)} erreur(s)"
return jsonify(response_data)
except Exception as e:
current_app.logger.error(f"Erreur lors de l'envoi de bilans: {e}")
return jsonify({
'success': False,
'error': f'Erreur inattendue: {str(e)}'
}), 500
@bp.route('/<int:id>/eligible-students')
@handle_db_errors
def get_eligible_students(id):
"""Récupère la liste des élèves éligibles avec leurs emails pour l'envoi de bilans."""
try:
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_with_full_details_or_404(id)
# Récupérer les élèves éligibles (ceux qui étaient dans la classe à la date de l'évaluation)
eligible_students = []
for student in assessment.class_group.get_students_at_date(assessment.date):
eligible_students.append({
'id': student.id,
'first_name': student.first_name,
'last_name': student.last_name,
'full_name': student.full_name,
'email': student.email or '',
'has_email': bool(student.email)
})
# Trier par nom de famille puis prénom
eligible_students.sort(key=lambda x: (x['last_name'].lower(), x['first_name'].lower()))
return jsonify({
'success': True,
'students': eligible_students,
'total_count': len(eligible_students),
'with_email_count': len([s for s in eligible_students if s['has_email']])
})
except Exception as e:
current_app.logger.error(f"Erreur récupération élèves éligibles: {e}")
return jsonify({
'success': False,
'error': f'Erreur: {str(e)}'
}), 500