from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app, abort from models import db, ClassGroup, Student, Assessment, StudentEnrollment from forms import ClassGroupForm from utils import handle_db_errors, ValidationError from repositories.class_repository import ClassRepository from datetime import date, datetime bp = Blueprint('classes', __name__, url_prefix='/classes') @bp.route('/new') @handle_db_errors def new(): """Formulaire de création d'une nouvelle classe.""" form = ClassGroupForm() return render_template('class_form.html', form=form, title="Créer une nouvelle classe", is_edit=False) @bp.route('/', methods=['POST']) @handle_db_errors def create(): """Traitement de la création d'une classe.""" form = ClassGroupForm() class_repo = ClassRepository() if form.validate_on_submit(): try: # Vérification d'unicité du nom de classe if class_repo.exists_by_name(form.name.data): flash('Une classe avec ce nom existe déjà.', 'error') return render_template('class_form.html', form=form, title="Créer une nouvelle classe", is_edit=False) # Création de la nouvelle classe class_group = ClassGroup( name=form.name.data, description=form.description.data, year=form.year.data ) db.session.add(class_group) db.session.commit() current_app.logger.info(f'Nouvelle classe créée: {class_group.name}') flash(f'Classe "{class_group.name}" créée avec succès !', 'success') return redirect(url_for('classes')) except Exception as e: db.session.rollback() current_app.logger.error(f'Erreur lors de la création de la classe: {e}') flash('Erreur lors de la création de la classe.', 'error') return render_template('class_form.html', form=form, title="Créer une nouvelle classe", is_edit=False) @bp.route('//edit') @handle_db_errors def edit(id): """Formulaire de modification d'une classe.""" class_repo = ClassRepository() class_group = class_repo.get_or_404(id) form = ClassGroupForm(obj=class_group) return render_template('class_form.html', form=form, class_group=class_group, title=f"Modifier la classe {class_group.name}", is_edit=True) @bp.route('/', methods=['POST']) @handle_db_errors def update(id): """Traitement de la modification d'une classe.""" class_repo = ClassRepository() class_group = class_repo.get_or_404(id) form = ClassGroupForm() if form.validate_on_submit(): try: # Vérification d'unicité du nom (sauf si c'est le même nom) if class_repo.exists_by_name(form.name.data, exclude_id=id): flash('Une autre classe avec ce nom existe déjà.', 'error') return render_template('class_form.html', form=form, class_group=class_group, title=f"Modifier la classe {class_group.name}", is_edit=True) # Mise à jour des données class_group.name = form.name.data class_group.description = form.description.data class_group.year = form.year.data db.session.commit() current_app.logger.info(f'Classe modifiée: {class_group.name}') flash(f'Classe "{class_group.name}" modifiée avec succès !', 'success') return redirect(url_for('classes')) except Exception as e: db.session.rollback() current_app.logger.error(f'Erreur lors de la modification de la classe: {e}') flash('Erreur lors de la modification de la classe.', 'error') return render_template('class_form.html', form=form, class_group=class_group, title=f"Modifier la classe {class_group.name}", is_edit=True) @bp.route('//delete', methods=['POST']) @handle_db_errors def delete(id): """Suppression d'une classe avec vérifications.""" class_repo = ClassRepository() class_group = class_repo.get_or_404(id) try: # Vérifier s'il y a des étudiants ou des évaluations liés can_delete, dependencies = class_repo.can_be_deleted(id) if not can_delete: students_count = dependencies['students'] assessments_count = dependencies['assessments'] flash( f'Impossible de supprimer la classe "{class_group.name}". ' f'Elle contient {students_count} élève(s) et {assessments_count} évaluation(s). ' f'Supprimez d\'abord ces éléments.', 'error' ) return redirect(url_for('classes')) # Suppression de la classe db.session.delete(class_group) db.session.commit() current_app.logger.info(f'Classe supprimée: {class_group.name}') flash(f'Classe "{class_group.name}" supprimée avec succès.', 'success') except Exception as e: db.session.rollback() current_app.logger.error(f'Erreur lors de la suppression de la classe: {e}') flash('Erreur lors de la suppression de la classe.', 'error') return redirect(url_for('classes')) @bp.route('/', methods=['GET']) @handle_db_errors def details(id): """Redirection transparente vers le dashboard de classe.""" return redirect(url_for('classes.dashboard', id=id)) @bp.route('//dashboard') @handle_db_errors def dashboard(id): """Page de présentation de classe avec statistiques.""" # Récupération paramètre trimestre trimester = request.args.get('trimestre', type=int) if trimester and trimester not in [1, 2, 3]: trimester = None # Repository optimisé class_repo = ClassRepository() class_group = class_repo.find_with_statistics(id, trimester) if not class_group: abort(404) current_app.logger.debug(f'Dashboard classe {id} affiché pour trimestre {trimester}') return render_template('class_dashboard.html', class_group=class_group, selected_trimester=trimester) @bp.route('//stats') @handle_db_errors def get_stats_api(id): """API JSON pour statistiques dynamiques (AJAX).""" # Récupération paramètre trimestre trimester = request.args.get('trimestre', type=int) if trimester and trimester not in [1, 2, 3]: trimester = None # Repository optimisé class_repo = ClassRepository() class_group = class_repo.find_with_statistics(id, trimester) if not class_group: abort(404) try: # Construction de la réponse JSON avec les nouvelles méthodes du modèle quantity_stats = class_group.get_trimester_statistics(trimester) domain_analysis = class_group.get_domain_analysis(trimester) competence_analysis = class_group.get_competence_analysis(trimester) class_results = class_group.get_class_results(trimester) # Compter le nombre d'évaluations selon le trimestre if hasattr(class_group, '_filtered_assessments'): assessments_count = len(class_group._filtered_assessments) current_app.logger.debug(f'Assessments count from _filtered_assessments: {assessments_count}') else: # Fallback si _filtered_assessments n'existe pas if trimester: assessments_count = len([a for a in class_group.assessments if a.trimester == trimester]) else: assessments_count = len(class_group.assessments) current_app.logger.debug(f'Assessments count from fallback: {assessments_count}') current_app.logger.debug(f'Final assessments_count value: {assessments_count}, type: {type(assessments_count)}') stats = { "quantity": { "total": quantity_stats["total"], "completed": quantity_stats["completed"], "in_progress": quantity_stats["in_progress"], "not_started": quantity_stats["not_started"] }, "domains": domain_analysis["domains"], # Extraire directement le tableau "competences": competence_analysis["competences"], # Extraire directement le tableau "results": { "average": class_results["overall_statistics"]["mean"], "min": class_results["overall_statistics"]["min"], "max": class_results["overall_statistics"]["max"], "median": class_results["overall_statistics"]["median"], "std_dev": class_results["overall_statistics"]["std_dev"], "assessments_count": assessments_count, "student_averages": class_results["student_averages"], "student_averages_distribution": class_results["student_averages_distribution"] } } current_app.logger.debug(f'Statistiques API générées pour classe {id}, trimestre {trimester}') return jsonify(stats) except Exception as e: current_app.logger.error(f'Erreur génération statistiques API classe {id}: {e}') return jsonify({"error": "Erreur lors de la génération des statistiques"}), 500 @bp.route('//details') @handle_db_errors def details_legacy(id): """Page de détail d'une classe avec ses étudiants et évaluations (legacy).""" class_repo = ClassRepository() class_group = class_repo.find_with_full_details(id) if not class_group: abort(404) # Trier les étudiants par nom (optimisé en Python car déjà chargés) students = sorted(class_group.students, key=lambda s: (s.last_name, s.first_name)) # Prendre les 5 évaluations les plus récentes (optimisé en Python car déjà chargées) recent_assessments = sorted(class_group.assessments, key=lambda a: a.date, reverse=True)[:5] return render_template('class_details.html', class_group=class_group, students=students, recent_assessments=recent_assessments) @bp.route('//council') @handle_db_errors def council_preparation(id): """Page de préparation du conseil de classe.""" # Le trimestre est obligatoire pour la préparation du conseil trimester = request.args.get('trimestre', type=int) if not trimester or trimester not in [1, 2, 3]: flash('Veuillez sélectionner un trimestre pour préparer le conseil de classe.', 'error') return redirect(url_for('classes.dashboard', id=id)) # Vérifier que la classe existe class_repo = ClassRepository() class_group = class_repo.get_or_404(id) try: # Injection de dépendances via factory from services.council_services import CouncilServiceFactory council_service = CouncilServiceFactory.create_council_preparation_service() # Préparer toutes les données du conseil council_data = council_service.prepare_council_data(id, trimester) current_app.logger.info(f'Préparation conseil classe {id}, trimestre {trimester} - {len(council_data.student_summaries)} élèves') return render_template('class_council_preparation.html', class_group=class_group, trimester=trimester, council_data=council_data, student_summaries=council_data.student_summaries, class_statistics=council_data.class_statistics, appreciation_stats=council_data.appreciation_stats) except Exception as e: current_app.logger.error(f'Erreur préparation conseil classe {id}: {e}') flash('Erreur lors de la préparation des données du conseil de classe.', 'error') return redirect(url_for('classes.dashboard', id=id)) @bp.route('//council/appreciation/', methods=['POST']) @handle_db_errors def save_appreciation_api(class_id, student_id): """API pour sauvegarde d'appréciations (AJAX).""" try: # Vérifications de base class_repo = ClassRepository() class_group = class_repo.get_or_404(class_id) data = request.get_json() if not data: return jsonify({'success': False, 'error': 'Données manquantes'}), 400 # Validation du trimestre trimester = data.get('trimester') if not trimester or trimester not in [1, 2, 3]: return jsonify({'success': False, 'error': 'Trimestre invalide'}), 400 # Vérifier que l'élève appartient à cette classe from models import Student student = Student.query.get_or_404(student_id) current_class = student.get_current_class() if not current_class or current_class.id != class_id: return jsonify({'success': False, 'error': 'Élève non trouvé dans cette classe'}), 403 # Préparer les données d'appréciation appreciation_data = { 'student_id': student_id, 'class_group_id': class_id, 'trimester': trimester, 'general_appreciation': data.get('appreciation', '').strip() or None, 'strengths': data.get('strengths', '').strip() or None, 'areas_for_improvement': data.get('areas_for_improvement', '').strip() or None, 'status': data.get('status', 'draft') } # Sauvegarder via le service from services.council_services import CouncilServiceFactory appreciation_service = CouncilServiceFactory.create_appreciation_service() result = appreciation_service.save_appreciation(appreciation_data) current_app.logger.info(f'Appréciation sauvegardée - Élève {student_id}, Classe {class_id}, T{trimester}') return jsonify({ 'success': True, 'appreciation_id': result.id, 'last_modified': result.last_modified.isoformat(), 'status': result.status, 'has_content': result.has_content }) except Exception as e: current_app.logger.error(f'Erreur sauvegarde appréciation élève {student_id}: {e}') return jsonify({ 'success': False, 'error': 'Erreur lors de la sauvegarde' }), 500 @bp.route('//council/api') @handle_db_errors def council_data_api(class_id): """API JSON pour récupérer les données d'un trimestre (AJAX).""" try: # Vérifications trimester = request.args.get('trimestre', type=int) if not trimester or trimester not in [1, 2, 3]: return jsonify({'error': 'Trimestre invalide'}), 400 class_repo = ClassRepository() class_group = class_repo.get_or_404(class_id) # Récupérer les données via le service from services.council_services import CouncilServiceFactory council_service = CouncilServiceFactory.create_council_preparation_service() council_data = council_service.prepare_council_data(class_id, trimester) # Formatter pour JSON response_data = { 'trimester': trimester, 'class_id': class_id, 'total_students': council_data.total_students, 'completed_appreciations': council_data.completed_appreciations, 'class_statistics': council_data.class_statistics, 'appreciation_stats': council_data.appreciation_stats, 'students': [] } # Ajouter les données des élèves for summary in council_data.student_summaries: student_data = { 'id': summary.student.id, 'name': summary.student.full_name, 'last_name': summary.student.last_name, 'first_name': summary.student.first_name, 'average': summary.overall_average, 'assessment_count': summary.assessment_count, 'performance_status': summary.performance_status, 'has_appreciation': summary.has_appreciation, 'assessments': {} } # Ajouter les détails des évaluations for assessment_id, assessment_data in summary.grades_by_assessment.items(): student_data['assessments'][assessment_id] = { 'score': assessment_data['score'], 'max': assessment_data['max'], 'title': assessment_data['title'] } response_data['students'].append(student_data) return jsonify(response_data) except Exception as e: current_app.logger.error(f'Erreur API données conseil classe {class_id}: {e}') return jsonify({'error': 'Erreur lors de la récupération des données'}), 500 @bp.route('//students') @handle_db_errors def students(id): """Page de gestion des élèves d'une classe.""" class_repo = ClassRepository() class_group = class_repo.get_or_404(id) try: # Repository temporel pour les élèves from repositories.temporal_student_repository import TemporalStudentRepository temporal_repo = TemporalStudentRepository() # Élèves actuellement inscrits current_students = temporal_repo.find_current_students_in_class(id) # Historique des mouvements (derniers 6 mois) from datetime import date, timedelta six_months_ago = date.today() - timedelta(days=180) movements = temporal_repo.find_students_with_movements_in_period(six_months_ago, date.today()) # Filtrer les mouvements de cette classe class_movements = [] for student, enrollments in movements: class_enrollments = [e for e in enrollments if e.class_group_id == id] if class_enrollments: class_movements.append((student, class_enrollments)) # Statistiques d'effectifs total_current = len(current_students) # Compter les arrivées et départs récents (30 derniers jours) thirty_days_ago = date.today() - timedelta(days=30) recent_arrivals = 0 recent_departures = 0 for student, enrollments in class_movements: for enrollment in enrollments: if enrollment.enrollment_date and enrollment.enrollment_date >= thirty_days_ago: recent_arrivals += 1 if enrollment.departure_date and enrollment.departure_date >= thirty_days_ago: recent_departures += 1 # Toutes les classes pour les transferts all_classes = ClassRepository().find_all_ordered('name') other_classes = [c for c in all_classes if c.id != id] # Élèves non inscrits dans cette classe pour inscription from sqlalchemy import func all_students = Student.query.order_by(func.lower(Student.last_name), func.lower(Student.first_name)).all() available_students = [] for student in all_students: current_class = student.get_current_class() if not current_class or current_class.id != id: available_students.append(student) current_app.logger.debug(f'Page élèves classe {id} - {total_current} élèves actuels, {len(class_movements)} mouvements') return render_template('class_students.html', class_group=class_group, current_students=current_students, class_movements=class_movements, other_classes=other_classes, available_students=available_students, stats={ 'total_current': total_current, 'recent_arrivals': recent_arrivals, 'recent_departures': recent_departures }) except Exception as e: current_app.logger.error(f'Erreur page élèves classe {id}: {e}') flash('Erreur lors du chargement des données des élèves.', 'error') return redirect(url_for('classes.dashboard', id=id)) @bp.route('/enroll', methods=['POST']) @handle_db_errors def enroll_student(): """Inscrire un élève dans une classe (existant ou nouveau).""" from repositories.temporal_student_repository import TemporalStudentRepository temporal_repo = TemporalStudentRepository() try: data = request.get_json() if request.is_json else request.form class_group_id = int(data.get('class_id')) enrollment_date_str = data.get('enrollment_date', str(date.today())) enrollment_reason = data.get('enrollment_reason', '') mode = data.get('mode', 'existing') # Validation de la date enrollment_date = datetime.strptime(enrollment_date_str, '%Y-%m-%d').date() # Vérifier que la classe existe class_group = ClassGroup.query.get_or_404(class_group_id) if mode == 'new': # Mode création d'un nouvel élève first_name = data.get('new_first_name', '').strip() last_name = data.get('new_last_name', '').strip() email = data.get('new_email', '').strip() or None if not first_name or not last_name: raise ValueError("Le prénom et le nom sont obligatoires pour un nouvel élève") # Vérifier que l'email n'est pas déjà utilisé si fourni if email: existing_email = Student.query.filter_by(email=email).first() if existing_email: raise ValueError("Un élève avec cet email existe déjà") # Créer le nouvel élève student = Student( first_name=first_name, last_name=last_name, email=email ) db.session.add(student) db.session.flush() # Pour obtenir l'ID du nouvel élève current_app.logger.info(f'Nouvel élève créé: {student.full_name}') else: # Mode élève existant student_id = data.get('student_id') if not student_id: raise ValueError("Veuillez sélectionner un élève") student_id = int(student_id) student = Student.query.get_or_404(student_id) # Créer l'inscription enrollment = temporal_repo.create_enrollment( student.id, class_group_id, enrollment_date, enrollment_reason ) db.session.commit() current_app.logger.info(f'Inscription créée: Élève {student.full_name} en {class_group.name}') if request.is_json: return jsonify({ 'success': True, 'message': f'Élève {student.full_name} inscrit en {class_group.name}', 'enrollment_id': enrollment.id, 'student_id': student.id, 'is_new_student': mode == 'new' }) else: if mode == 'new': flash(f'Nouvel élève {student.full_name} créé et inscrit en {class_group.name}', 'success') else: flash(f'Élève {student.full_name} inscrit en {class_group.name}', 'success') # Pour une mise à jour immédiate de la liste, utiliser JavaScript pour recharger return redirect(url_for('classes.students', id=class_group_id) + '?reload=1') except ValueError as e: error_msg = str(e) current_app.logger.warning(f'Erreur inscription élève: {error_msg}') if request.is_json: return jsonify({'success': False, 'error': error_msg}), 400 else: flash(error_msg, 'error') return redirect(request.referrer or url_for('classes')) except Exception as e: db.session.rollback() current_app.logger.error(f'Erreur inscription élève: {e}') if request.is_json: return jsonify({'success': False, 'error': 'Erreur lors de l\'inscription'}), 500 else: flash('Erreur lors de l\'inscription', 'error') return redirect(request.referrer or url_for('classes')) @bp.route('/transfer', methods=['POST']) @handle_db_errors def transfer_student(): """Transférer un élève d'une classe à une autre.""" from repositories.temporal_student_repository import TemporalStudentRepository temporal_repo = TemporalStudentRepository() try: data = request.get_json() if request.is_json else request.form student_id = int(data.get('student_id')) new_class_group_id = int(data.get('new_class_id')) transfer_date_str = data.get('transfer_date', str(date.today())) transfer_reason = data.get('transfer_reason', '') # Validation de la date transfer_date = datetime.strptime(transfer_date_str, '%Y-%m-%d').date() # Vérifier que l'élève et les classes existent student = Student.query.get_or_404(student_id) new_class_group = ClassGroup.query.get_or_404(new_class_group_id) # Effectuer le transfert old_enrollment, new_enrollment = temporal_repo.transfer_student( student_id, new_class_group_id, transfer_date, transfer_reason ) db.session.commit() current_app.logger.info(f'Transfert effectué: Élève {student.full_name} vers {new_class_group.name}') if request.is_json: return jsonify({ 'success': True, 'message': f'Élève {student.full_name} transféré vers {new_class_group.name}', 'old_enrollment_id': old_enrollment.id, 'new_enrollment_id': new_enrollment.id }) else: flash(f'Élève {student.full_name} transféré vers {new_class_group.name}', 'success') # Retourner à la page d'origine avec rechargement referrer = request.referrer if referrer and 'classes/' in referrer and '/students' in referrer: return redirect(referrer + ('&' if '?' in referrer else '?') + 'reload=1') return redirect(request.referrer or url_for('classes')) except ValueError as e: error_msg = str(e) current_app.logger.warning(f'Erreur transfert élève: {error_msg}') if request.is_json: return jsonify({'success': False, 'error': error_msg}), 400 else: flash(error_msg, 'error') return redirect(request.referrer or url_for('classes')) except Exception as e: db.session.rollback() current_app.logger.error(f'Erreur transfert élève: {e}') if request.is_json: return jsonify({'success': False, 'error': 'Erreur lors du transfert'}), 500 else: flash('Erreur lors du transfert', 'error') return redirect(request.referrer or url_for('classes')) @bp.route('/departure', methods=['POST']) @handle_db_errors def student_departure(): """Enregistrer le départ d'un élève.""" from repositories.temporal_student_repository import TemporalStudentRepository temporal_repo = TemporalStudentRepository() try: data = request.get_json() if request.is_json else request.form student_id = int(data.get('student_id')) departure_date_str = data.get('departure_date', str(date.today())) departure_reason = data.get('departure_reason', '') # Validation de la date departure_date = datetime.strptime(departure_date_str, '%Y-%m-%d').date() # Vérifier que l'élève existe student = Student.query.get_or_404(student_id) # Terminer l'inscription active enrollment = temporal_repo.end_enrollment( student_id, departure_date, departure_reason ) if not enrollment: raise ValueError("Aucune inscription active trouvée pour cet élève") db.session.commit() current_app.logger.info(f'Départ enregistré: Élève {student.full_name}') if request.is_json: return jsonify({ 'success': True, 'message': f'Départ de {student.full_name} enregistré', 'enrollment_id': enrollment.id }) else: flash(f'Départ de {student.full_name} enregistré', 'success') # Retourner à la page d'origine avec rechargement referrer = request.referrer if referrer and 'classes/' in referrer and '/students' in referrer: return redirect(referrer + ('&' if '?' in referrer else '?') + 'reload=1') return redirect(request.referrer or url_for('classes')) except ValueError as e: error_msg = str(e) current_app.logger.warning(f'Erreur départ élève: {error_msg}') if request.is_json: return jsonify({'success': False, 'error': error_msg}), 400 else: flash(error_msg, 'error') return redirect(request.referrer or url_for('classes')) except Exception as e: db.session.rollback() current_app.logger.error(f'Erreur départ élève: {e}') if request.is_json: return jsonify({'success': False, 'error': 'Erreur lors de l\'enregistrement du départ'}), 500 else: flash('Erreur lors de l\'enregistrement du départ', 'error') return redirect(request.referrer or url_for('classes')) @bp.route('/cancel-departure', methods=['POST']) @handle_db_errors def cancel_departure(): """Annuler le départ d'un élève (remettre inscription active).""" try: data = request.get_json() if request.is_json else request.form enrollment_id = int(data.get('enrollment_id')) # Récupérer l'inscription enrollment = StudentEnrollment.query.get_or_404(enrollment_id) # Vérifier que l'inscription a bien une date de départ if not enrollment.departure_date: flash('Cette inscription n\'a pas de date de départ à annuler', 'error') return redirect(request.referrer or url_for('classes')) # Vérifier qu'il n'y a pas déjà une inscription active pour cet élève active_enrollment = StudentEnrollment.query.filter_by( student_id=enrollment.student_id, departure_date=None ).first() if active_enrollment: flash(f'L\'élève {enrollment.student.first_name} {enrollment.student.last_name} est déjà inscrit dans {active_enrollment.class_group.name}', 'error') return redirect(request.referrer or url_for('classes')) # Annuler le départ en supprimant la date de départ old_departure_date = enrollment.departure_date enrollment.departure_date = None enrollment.departure_reason = None db.session.commit() current_app.logger.info(f'Départ annulé - Élève {enrollment.student_id} réintégré en {enrollment.class_group.name}') flash(f'Départ de {enrollment.student.first_name} {enrollment.student.last_name} annulé. Élève réintégré en {enrollment.class_group.name}', 'success') # Rediriger vers la page d'origine avec rechargement referrer = request.referrer if referrer and 'classes/' in referrer and '/students' in referrer: return redirect(referrer + ('&' if '?' in referrer else '?') + 'reload=1') return redirect(request.referrer or url_for('classes')) except (ValueError, TypeError) as e: current_app.logger.error(f'Erreur données annulation départ: {e}') flash('Données d\'annulation invalides', 'error') return redirect(request.referrer or url_for('classes')) except Exception as e: db.session.rollback() current_app.logger.error(f'Erreur annulation départ: {e}') flash('Erreur lors de l\'annulation du départ', 'error') return redirect(request.referrer or url_for('classes'))