607 lines
25 KiB
Python
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 |