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('/') @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('//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_unified.html', form=form, title='Modifier l\'évaluation complète', 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_unified.html', form=form, title='Nouvelle évaluation complète', competences=competences, domains=domains) @bp.route('//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('//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('//preview-report/') @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('//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('//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