From 6549591f6302d67657c09d2503f3d90d3fc88257 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Sat, 16 Aug 2025 06:42:47 +0200 Subject: [PATCH] feat: add temporal student gestion --- app.py | 23 +- commands.py | 256 ++++++--- models.py | 175 +++++- repositories/appreciation_repository.py | 8 +- repositories/class_repository.py | 67 ++- repositories/temporal_student_repository.py | 166 ++++++ routes/classes.py | 367 +++++++++++- routes/grading.py | 17 +- routes/student_movements.py | 336 +++++++++++ services/council_services.py | 50 +- services/temporal_assessment_services.py | 207 +++++++ services/temporal_facade.py | 70 +++ templates/class_dashboard.html | 8 +- templates/class_students.html | 608 ++++++++++++++++++++ templates/components/class/class_card.html | 2 +- 15 files changed, 2212 insertions(+), 148 deletions(-) create mode 100644 repositories/temporal_student_repository.py create mode 100644 routes/student_movements.py create mode 100644 services/temporal_assessment_services.py create mode 100644 services/temporal_facade.py create mode 100644 templates/class_students.html diff --git a/app.py b/app.py index 04ae9a4..f1c9adc 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ from routes.grading import bp as grading_bp from routes.config import bp as config_bp from routes.domains import bp as domains_bp from routes.classes import bp as classes_bp +# from routes.student_movements import bp as student_movements_bp # Désactivé car page supprimée def create_app(config_name=None): if config_name is None: @@ -45,6 +46,7 @@ def create_app(config_name=None): app.register_blueprint(config_bp) app.register_blueprint(domains_bp) app.register_blueprint(classes_bp) + # app.register_blueprint(student_movements_bp) # Désactivé car page supprimée # Register CLI commands app.cli.add_command(init_db) @@ -84,10 +86,23 @@ def create_app(config_name=None): @app.route('/students') def students(): try: - # Optimisation: utiliser joinedload pour éviter les requêtes N+1 - student_repo = StudentRepository() - students = student_repo.find_all_with_class_ordered() - return render_template('students.html', students=students) + # Utilisation du repository temporel pour récupérer tous les élèves avec leur classe actuelle + from repositories.temporal_student_repository import TemporalStudentRepository + from models import Student + + temporal_repo = TemporalStudentRepository() + all_students = Student.query.order_by(Student.last_name, Student.first_name).all() + + # Enrichir chaque élève avec sa classe actuelle + students_with_classes = [] + for student in all_students: + current_class = student.get_current_class() + students_with_classes.append({ + 'student': student, + 'current_class': current_class + }) + + return render_template('students.html', students_with_classes=students_with_classes) except Exception as e: app.logger.error(f'Erreur lors du chargement des étudiants: {e}') return render_template('error.html', error="Erreur lors du chargement des étudiants"), 500 diff --git a/commands.py b/commands.py index e1a5d43..c8b075a 100644 --- a/commands.py +++ b/commands.py @@ -1,6 +1,7 @@ import click from flask.cli import with_appcontext -from models import db, ClassGroup, Student, Assessment, Exercise, GradingElement, Domain, CouncilAppreciation +from models import db, ClassGroup, Student, StudentEnrollment, Assessment, Exercise, GradingElement, Domain, CouncilAppreciation +from datetime import date, datetime @click.command() @click.option('--mode', default='minimal', type=click.Choice(['minimal', 'midyear', 'demo']), @@ -34,7 +35,7 @@ def init_minimal_data(): click.echo("Database initialized for start of school year - ready to create classes and students!") def init_midyear_data(): - """Initialize database with realistic mid-year data.""" + """Initialize database with realistic mid-year data including temporal enrollments.""" from app_config import config_manager from models import Grade import random @@ -44,6 +45,12 @@ def init_midyear_data(): config_manager.set('context.school_name', 'Collège Jean Moulin') config_manager.save() + # Dates importantes pour les inscriptions temporelles + school_year_start = date(2024, 9, 1) # Début d'année scolaire + first_term_end = date(2024, 12, 20) # Fin du 1er trimestre + second_term_start = date(2025, 1, 8) # Début du 2e trimestre + today = date(2025, 2, 15) # Date actuelle (milieu 2e trimestre) + # Create 5 classes with realistic sizes classes_data = [ ("6ème A", "Classe de 6ème A", 28), @@ -71,52 +78,123 @@ def init_midyear_data(): ] classes = [] - students_by_class = [] + all_students = [] # Global sets to ensure uniqueness across all classes used_names = set() used_emails = set() - # Create classes and students + # Create classes first for class_name, description, nb_students in classes_data: classe = ClassGroup(name=class_name, description=description, year="2024-2025") db.session.add(classe) - db.session.commit() classes.append(classe) - - # Create students for this class - class_students = [] - for i in range(nb_students): - # Generate unique name combinations and emails - while True: - first_name = random.choice(first_names) - last_name = random.choice(last_names) - name_combo = f"{first_name}_{last_name}" - base_email = f"{first_name.lower()}.{last_name.lower()}@college.edu" - - # Make email unique if needed - email = base_email - counter = 1 - while email in used_emails: - email = f"{first_name.lower()}.{last_name.lower()}{counter}@college.edu" - counter += 1 - - if name_combo not in used_names: - used_names.add(name_combo) - used_emails.add(email) - break + + db.session.commit() + + # Create students without class assignment (temporal model) + total_students = sum(nb for _, _, nb in classes_data) + for i in range(total_students): + # Generate unique name combinations and emails + while True: + first_name = random.choice(first_names) + last_name = random.choice(last_names) + name_combo = f"{first_name}_{last_name}" + base_email = f"{first_name.lower()}.{last_name.lower()}@college.edu" - student = Student( - last_name=last_name, - first_name=first_name, - email=email, - class_group_id=classe.id - ) - db.session.add(student) - class_students.append(student) + # Make email unique if needed + email = base_email + counter = 1 + while email in used_emails: + email = f"{first_name.lower()}.{last_name.lower()}{counter}@college.edu" + counter += 1 + + if name_combo not in used_names: + used_names.add(name_combo) + used_emails.add(email) + break - students_by_class.append(class_students) - db.session.commit() + student = Student( + last_name=last_name, + first_name=first_name, + email=email + # Pas de class_group_id dans le nouveau modèle temporel + ) + db.session.add(student) + all_students.append(student) + + db.session.commit() + + # Create temporal enrollments with realistic movements + student_index = 0 + movements_log = [] + + for i, (class_name, description, nb_students) in enumerate(classes_data): + classe = classes[i] + + # Répartir les élèves pour cette classe + class_students = all_students[student_index:student_index + nb_students] + student_index += nb_students + + for student in class_students: + # 90% des élèves : inscrits depuis le début d'année, toujours présents + if random.random() < 0.90: + enrollment = StudentEnrollment( + student_id=student.id, + class_group_id=classe.id, + enrollment_date=school_year_start, + departure_date=None, # Toujours inscrit + enrollment_reason="Inscription début d'année", + ) + db.session.add(enrollment) + + # 7% des élèves : ont quitté pendant le 1er trimestre + elif random.random() < 0.97: # 7% des 10% restants + departure_date = date(2024, random.randint(10, 11), random.randint(1, 28)) + enrollment = StudentEnrollment( + student_id=student.id, + class_group_id=classe.id, + enrollment_date=school_year_start, + departure_date=departure_date, + enrollment_reason="Inscription début d'année", + departure_reason="Déménagement" + ) + db.session.add(enrollment) + movements_log.append(f"Élève {student.full_name} : départ de {classe.name} le {departure_date}") + + # Créer un nouvel élève pour remplacer (arrivé en janvier) + new_student = Student( + last_name=random.choice(last_names), + first_name=random.choice(first_names), + email=f"nouveau.{random.randint(1000,9999)}@college.edu" + ) + db.session.add(new_student) + db.session.commit() # Pour avoir l'ID + + new_enrollment = StudentEnrollment( + student_id=new_student.id, + class_group_id=classe.id, + enrollment_date=second_term_start, + departure_date=None, + enrollment_reason="Nouvel élève - arrivée en cours d'année" + ) + db.session.add(new_enrollment) + movements_log.append(f"Élève {new_student.full_name} : arrivée en {classe.name} le {second_term_start}") + + # 3% des élèves : arrivés en cours d'année (janvier) + else: + arrival_date = date(2025, 1, random.randint(8, 31)) + enrollment = StudentEnrollment( + student_id=student.id, + class_group_id=classe.id, + enrollment_date=arrival_date, + departure_date=None, + enrollment_reason="Arrivée en cours d'année" + ) + db.session.add(enrollment) + movements_log.append(f"Élève {student.full_name} : arrivée en {classe.name} le {arrival_date}") + + db.session.commit() # Get domains for realistic grading elements domain_calcul = Domain.query.filter_by(name='Algèbre').first() @@ -290,15 +368,20 @@ def init_midyear_data(): } ] - # Create assessments for each class - for i, classe in enumerate(classes): - class_students = students_by_class[i] - + # Create assessments for each class with temporal logic + for classe in classes: for assessment_template in assessments_templates: - # Create assessment + # Create assessment with realistic date + assessment_date = school_year_start + if assessment_template["trimester"] == 1: + assessment_date = date(2024, random.randint(10, 11), random.randint(1, 28)) + elif assessment_template["trimester"] == 2: + assessment_date = date(2025, random.randint(1, 2), random.randint(1, 28)) + assessment = Assessment( title=assessment_template["title"], description=assessment_template["description"], + date=assessment_date, trimester=assessment_template["trimester"], class_group_id=classe.id, coefficient=assessment_template["coefficient"] @@ -338,8 +421,13 @@ def init_midyear_data(): # Add grades if assessment should be corrected if assessment_template["corrected"]: - # Generate realistic grades for all students - for student in class_students: + # Get students eligible for this assessment using temporal logic + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + eligible_students = temporal_repo.find_eligible_for_assessment(assessment) + + # Generate realistic grades only for eligible students + for student in eligible_students: for element in grading_elements: # Generate realistic grade based on element type if element.grading_type == "notes": @@ -370,36 +458,54 @@ def init_midyear_data(): elif assessment_template.get("corrected") == False and random.random() < 0.4: # Partially grade some assessments (40% of non-corrected ones get partial grades) - students_to_grade = random.sample(class_students, len(class_students) // 2) + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + eligible_students = temporal_repo.find_eligible_for_assessment(assessment) - for student in students_to_grade: - for element in grading_elements: - if random.random() < 0.7: # Grade 70% of elements for selected students - if element.grading_type == "notes": - base_ratio = random.uniform(0.6, 0.9) - grade_value = round(base_ratio * element.max_points, 1) - else: - grade_value = random.choices([0, 1, 2, 3], weights=[0.1, 0.3, 0.5, 0.1])[0] - - grade = Grade( - student_id=student.id, - grading_element_id=element.id, - value=str(grade_value) - ) - db.session.add(grade) + if eligible_students: + students_to_grade = random.sample(eligible_students, len(eligible_students) // 2) + + for student in students_to_grade: + for element in grading_elements: + if random.random() < 0.7: # Grade 70% of elements for selected students + if element.grading_type == "notes": + base_ratio = random.uniform(0.6, 0.9) + grade_value = round(base_ratio * element.max_points, 1) + else: + grade_value = random.choices([0, 1, 2, 3], weights=[0.1, 0.3, 0.5, 0.1])[0] + + grade = Grade( + student_id=student.id, + grading_element_id=element.id, + value=str(grade_value) + ) + db.session.add(grade) db.session.commit() total_students = sum(nb for _, _, nb in classes_data) total_assessments = len(classes) * len(assessments_templates) - click.echo(f"Database initialized for mid-year scenario:") - click.echo(f" - {len(classes)} classes with {total_students} students total") - click.echo(f" - {total_assessments} assessments created") + + # Compter les inscriptions actuelles et mouvements + total_current_students = StudentEnrollment.query.filter(StudentEnrollment.departure_date.is_(None)).count() + total_movements = len(movements_log) + + click.echo(f"Database initialized for mid-year scenario with temporal enrollments:") + click.echo(f" - {len(classes)} classes with {total_students} students initially") + click.echo(f" - {total_current_students} students currently enrolled") + click.echo(f" - {total_movements} student movements simulated") + click.echo(f" - {total_assessments} assessments created with temporal logic") click.echo(f" - 4 fully corrected assessments per class (Trimester 1)") click.echo(f" - 2 partially corrected assessments per class (Trimester 2)") + click.echo(f"") + click.echo(f"Student movements summary:") + for movement in movements_log[:5]: # Montrer les 5 premiers mouvements + click.echo(f" - {movement}") + if len(movements_log) > 5: + click.echo(f" ... and {len(movements_log) - 5} more movements") def init_demo_data(): - """Initialize database with simple demo data (original behavior).""" + """Initialize database with simple demo data using temporal enrollment model.""" # Create sample class groups classe_6a = ClassGroup(name="6ème A", description="Classe de 6ème A", year="2024-2025") classe_5b = ClassGroup(name="5ème B", description="Classe de 5ème B", year="2024-2025") @@ -408,7 +514,7 @@ def init_demo_data(): db.session.add(classe_5b) db.session.commit() - # Create sample students + # Create sample students (without class_group_id) students_data = [ ("Dupont", "Marie", "marie.dupont@email.com", classe_6a.id), ("Martin", "Pierre", "pierre.martin@email.com", classe_6a.id), @@ -417,14 +523,28 @@ def init_demo_data(): ("Bernard", "Emma", "emma.bernard@email.com", classe_5b.id), ] - for last_name, first_name, email, class_group_id in students_data: + school_start = date(2024, 9, 1) + + for last_name, first_name, email, intended_class_id in students_data: + # Create student without class assignment student = Student( last_name=last_name, first_name=first_name, - email=email, - class_group_id=class_group_id + email=email + # Pas de class_group_id dans le nouveau modèle temporel ) db.session.add(student) + db.session.commit() # Pour avoir l'ID du student + + # Create temporal enrollment + enrollment = StudentEnrollment( + student_id=student.id, + class_group_id=intended_class_id, + enrollment_date=school_start, + departure_date=None, # Toujours inscrit + enrollment_reason="Inscription demo" + ) + db.session.add(enrollment) db.session.commit() diff --git a/models.py b/models.py index 6dd02aa..8d4f30d 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,6 @@ from flask_sqlalchemy import SQLAlchemy -from datetime import datetime -from sqlalchemy import CheckConstraint, Enum +from datetime import datetime, date +from sqlalchemy import CheckConstraint, Enum, Index from typing import Optional, Dict, Any db = SQLAlchemy() @@ -57,8 +57,25 @@ class ClassGroup(db.Model): name = db.Column(db.String(100), nullable=False, unique=True) description = db.Column(db.Text) year = db.Column(db.String(20), nullable=False) - students = db.relationship('Student', backref='class_group', lazy=True) + + # SUPPRESSION de la relation directe students (remplacée par logique temporelle) + # students sera accessible via la relation StudentEnrollment + assessments = db.relationship('Assessment', backref='class_group', lazy=True) + # enrollments déjà défini via backref dans StudentEnrollment + + @property + def students(self): + """Retourne les élèves actuellement inscrits dans cette classe.""" + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + return temporal_repo.find_current_students_in_class(self.id) + + def get_students_at_date(self, check_date: date): + """Retourne les élèves inscrits dans cette classe à une date donnée.""" + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + return temporal_repo.find_enrolled_in_class_at_date(self.id, check_date) def get_trimester_statistics(self, trimester=None): """ @@ -123,13 +140,72 @@ class ClassGroup(db.Model): def __repr__(self): return f'' + +class StudentEnrollment(db.Model): + """ + Historique temporel des inscriptions élève-classe. + Pattern: Association temporelle avec validité temporelle. + """ + __tablename__ = 'student_enrollments' + + id = db.Column(db.Integer, primary_key=True) + student_id = db.Column(db.Integer, db.ForeignKey('student.id'), nullable=False) + class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False) + + # Période de validité + enrollment_date = db.Column(db.Date, nullable=False) # Date d'arrivée + departure_date = db.Column(db.Date, nullable=True) # Date de départ (NULL = toujours inscrit) + + # Métadonnées + enrollment_reason = db.Column(db.String(200)) # Motif d'arrivée + departure_reason = db.Column(db.String(200)) # Motif de départ + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relations + student = db.relationship('Student', backref='enrollments') + class_group = db.relationship('ClassGroup', backref='enrollments') + + # Contraintes d'intégrité temporelle + __table_args__ = ( + # Pas de chevauchement de périodes pour un même élève + CheckConstraint( + 'departure_date IS NULL OR departure_date >= enrollment_date', + name='check_valid_enrollment_period' + ), + # Index pour optimiser les requêtes temporelles + Index('idx_student_temporal', 'student_id', 'enrollment_date', 'departure_date'), + Index('idx_class_temporal', 'class_group_id', 'enrollment_date', 'departure_date'), + ) + + def __repr__(self): + return f'' + + @property + def is_active(self) -> bool: + """Vérifie si l'inscription est actuellement active.""" + return self.departure_date is None + + def is_valid_at_date(self, check_date: date) -> bool: + """Vérifie si l'inscription était valide à une date donnée.""" + if check_date < self.enrollment_date: + return False + if self.departure_date and check_date > self.departure_date: + return False + return True + + class Student(db.Model): id = db.Column(db.Integer, primary_key=True) last_name = db.Column(db.String(100), nullable=False) first_name = db.Column(db.String(100), nullable=False) email = db.Column(db.String(120), unique=True) - class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False) + + # SUPPRESSION de class_group_id (relation statique) + # Remplacé par la relation temporelle via StudentEnrollment + grades = db.relationship('Grade', backref='student', lazy=True) + # enrollments déjà défini via backref dans StudentEnrollment def __repr__(self): return f'' @@ -137,6 +213,42 @@ class Student(db.Model): @property def full_name(self): return f"{self.first_name} {self.last_name}" + + def get_current_class(self) -> Optional['ClassGroup']: + """Retourne la classe actuelle de l'élève (inscription active).""" + active_enrollment = StudentEnrollment.query.filter_by( + student_id=self.id, + departure_date=None + ).first() + return active_enrollment.class_group if active_enrollment else None + + def get_current_enrollment(self) -> Optional['StudentEnrollment']: + """Retourne l'inscription actuelle de l'élève (inscription active).""" + return StudentEnrollment.query.filter_by( + student_id=self.id, + departure_date=None + ).first() + + def get_class_at_date(self, check_date: date) -> Optional['ClassGroup']: + """Retourne la classe de l'élève à une date donnée.""" + enrollment = StudentEnrollment.query.filter( + StudentEnrollment.student_id == self.id, + StudentEnrollment.enrollment_date <= check_date, + db.or_( + StudentEnrollment.departure_date.is_(None), + StudentEnrollment.departure_date >= check_date + ) + ).first() + return enrollment.class_group if enrollment else None + + def is_eligible_for_assessment(self, assessment: 'Assessment') -> bool: + """Vérifie si l'élève peut être évalué sur cette évaluation.""" + if not assessment.date: + return False + + # L'élève doit être inscrit dans la classe de l'évaluation à la date de l'évaluation + class_at_assessment_date = self.get_class_at_date(assessment.date) + return class_at_assessment_date and class_at_assessment_date.id == assessment.class_group_id class Assessment(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -159,52 +271,59 @@ class Assessment(db.Model): def grading_progress(self): """ Calcule le pourcentage de progression des notes saisies pour cette évaluation. - Utilise AssessmentProgressService avec injection de dépendances. + Utilise TemporalAssessmentProgressService avec injection de dépendances. Returns: - Dict avec les statistiques de progression + Dict avec les statistiques de progression (version temporelle) """ - from providers.concrete_providers import AssessmentServicesFactory + from services.temporal_facade import TemporalAssessmentServicesFactory # Injection de dépendances pour éviter les imports circulaires - services_facade = AssessmentServicesFactory.create_facade() - progress_result = services_facade.get_grading_progress(self) + temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade() + progress_result = temporal_facade.get_grading_progress(self) - # Conversion du ProgressResult vers le format dict attendu + # Conversion du TemporalProgressResult vers le format dict attendu return { 'percentage': progress_result.percentage, 'completed': progress_result.completed, 'total': progress_result.total, 'status': progress_result.status, - 'students_count': progress_result.students_count + 'students_count': progress_result.students_count, + 'eligible_students_count': progress_result.eligible_students_count, + 'total_students_in_class': progress_result.total_students_in_class } def calculate_student_scores(self, grade_repo=None): - """Calcule les scores de tous les élèves pour cette évaluation. + """Calcule les scores de tous les élèves éligibles pour cette évaluation. Retourne un dictionnaire avec les scores par élève et par exercice. - Utilise StudentScoreCalculator avec injection de dépendances. + Utilise TemporalStudentScoreCalculator avec injection de dépendances. Args: grade_repo: Repository des notes (optionnel, maintenu pour compatibilité) """ - from providers.concrete_providers import AssessmentServicesFactory + from services.temporal_facade import TemporalAssessmentServicesFactory - services = AssessmentServicesFactory.create_facade() - students_scores_data, exercise_scores_data = services.score_calculator.calculate_student_scores(self) + temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade() + students_scores_data, exercise_scores_data = temporal_facade.score_calculator.calculate_student_scores(self) + + # Récupérer les élèves éligibles pour la conversion + eligible_students = temporal_facade.get_eligible_students(self) + eligible_students_dict = {s.id: s for s in eligible_students} # Conversion vers format legacy pour compatibilité students_scores = {} exercise_scores = {} for student_id, score_data in students_scores_data.items(): - # Récupérer l'objet étudiant pour compatibilité - student_obj = next(s for s in self.class_group.students if s.id == student_id) - students_scores[student_id] = { - 'student': student_obj, - 'total_score': score_data.total_score, - 'total_max_points': score_data.total_max_points, - 'exercises': score_data.exercises - } + # Utiliser les élèves éligibles au lieu de tous les élèves de la classe + student_obj = eligible_students_dict.get(student_id) + if student_obj: + students_scores[student_id] = { + 'student': student_obj, + 'total_score': score_data.total_score, + 'total_max_points': score_data.total_max_points, + 'exercises': score_data.exercises + } for exercise_id, student_scores in exercise_scores_data.items(): exercise_scores[exercise_id] = dict(student_scores) @@ -214,11 +333,11 @@ class Assessment(db.Model): def get_assessment_statistics(self): """ Calcule les statistiques descriptives pour cette évaluation. - Utilise AssessmentStatisticsService avec injection de dépendances. + Utilise TemporalAssessmentStatisticsService avec injection de dépendances. """ - from providers.concrete_providers import AssessmentServicesFactory - services = AssessmentServicesFactory.create_facade() - result = services.statistics_service.get_assessment_statistics(self) + from services.temporal_facade import TemporalAssessmentServicesFactory + temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade() + result = temporal_facade.get_assessment_statistics(self) # Conversion du StatisticsResult vers le format dict legacy return { diff --git a/repositories/appreciation_repository.py b/repositories/appreciation_repository.py index c2d7736..cce56bd 100644 --- a/repositories/appreciation_repository.py +++ b/repositories/appreciation_repository.py @@ -71,10 +71,12 @@ class AppreciationRepository(BaseRepository[CouncilAppreciation]): def get_completion_stats(self, class_group_id: int, trimester: int) -> dict: """Statistiques de completion des appréciations pour une classe/trimestre.""" - from models import Student + from repositories.temporal_student_repository import TemporalStudentRepository - # Nombre total d'élèves dans la classe - total_students = Student.query.filter_by(class_group_id=class_group_id).count() + # Nombre total d'élèves actuellement dans la classe + temporal_repo = TemporalStudentRepository() + current_students = temporal_repo.find_current_students_in_class(class_group_id) + total_students = len(current_students) # Nombre d'appréciations existantes total_appreciations = CouncilAppreciation.query.filter_by( diff --git a/repositories/class_repository.py b/repositories/class_repository.py index c720bfe..bc1f255 100644 --- a/repositories/class_repository.py +++ b/repositories/class_repository.py @@ -99,21 +99,29 @@ class ClassRepository(BaseRepository[ClassGroup]): Returns: Tuple[bool, Dict[str, int]]: (peut_être_supprimée, statistiques_dépendances) """ - students_count = Student.query.filter_by(class_group_id=id).count() + from repositories.temporal_student_repository import TemporalStudentRepository + from models import StudentEnrollment + + # Compter les élèves actuellement inscrits ou ayant été inscrits + temporal_repo = TemporalStudentRepository() + current_students = temporal_repo.find_current_students_in_class(id) + enrollments_count = StudentEnrollment.query.filter_by(class_group_id=id).count() assessments_count = Assessment.query.filter_by(class_group_id=id).count() dependencies = { - 'students': students_count, + 'students': len(current_students), + 'enrollments': enrollments_count, 'assessments': assessments_count } - can_delete = students_count == 0 and assessments_count == 0 + # Une classe peut être supprimée s'il n'y a aucune inscription historique ni évaluation + can_delete = enrollments_count == 0 and assessments_count == 0 return can_delete, dependencies def find_with_students_ordered(self, id: int) -> Optional[ClassGroup]: """ - Trouve une classe avec ses étudiants triés par nom. + Trouve une classe avec ses étudiants actuels triés par nom. Args: id: Identifiant de la classe @@ -125,10 +133,10 @@ class ClassRepository(BaseRepository[ClassGroup]): if not class_group: return None - # Charger les étudiants triés - students = Student.query.filter_by(class_group_id=id).order_by( - Student.last_name, Student.first_name - ).all() + # Utiliser la logique temporelle pour récupérer les élèves actuels + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + students = temporal_repo.find_current_students_in_class(id) # Assigner les étudiants triés à la classe class_group._students_ordered = students @@ -162,7 +170,7 @@ class ClassRepository(BaseRepository[ClassGroup]): def find_with_full_details(self, id: int) -> Optional[ClassGroup]: """ - Trouve une classe avec tous ses détails (étudiants et évaluations). + Trouve une classe avec tous ses détails (étudiants actuels et évaluations). Args: id: Identifiant de la classe @@ -170,22 +178,32 @@ class ClassRepository(BaseRepository[ClassGroup]): Returns: Optional[ClassGroup]: La classe avec tous ses détails ou None """ - return ClassGroup.query.options( - joinedload(ClassGroup.students), + class_group = ClassGroup.query.options( joinedload(ClassGroup.assessments) ).filter_by(id=id).first() + + if class_group: + # Charger manuellement les élèves actuels avec la logique temporelle + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + class_group._current_students = temporal_repo.find_current_students_in_class(id) + + return class_group def get_students_count(self, id: int) -> int: """ - Compte le nombre d'étudiants dans une classe. + Compte le nombre d'étudiants actuellement dans une classe. Args: id: Identifiant de la classe Returns: - int: Nombre d'étudiants + int: Nombre d'étudiants actuels """ - return Student.query.filter_by(class_group_id=id).count() + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + current_students = temporal_repo.find_current_students_in_class(id) + return len(current_students) def get_assessments_count(self, id: int) -> int: """ @@ -212,8 +230,7 @@ class ClassRepository(BaseRepository[ClassGroup]): def find_with_statistics(self, class_id: int, trimester: Optional[int] = None) -> Optional[ClassGroup]: """ Récupère une classe avec toutes les données nécessaires pour les statistiques. - Optimise les requêtes pour éviter les problèmes N+1 en chargeant toutes les relations - nécessaires en une seule requête. + Utilise la logique temporelle pour les étudiants et optimise les requêtes. Args: class_id: Identifiant de la classe @@ -223,17 +240,21 @@ class ClassRepository(BaseRepository[ClassGroup]): Optional[ClassGroup]: La classe avec toutes ses données ou None """ try: - # Construire la requête avec toutes les jointures optimisées + # Construire la requête sans la relation students (supprimée du modèle temporel) query = ClassGroup.query.options( - joinedload(ClassGroup.students), selectinload(ClassGroup.assessments).selectinload(Assessment.exercises) .selectinload(Exercise.grading_elements).selectinload(GradingElement.grades) ).filter_by(id=class_id) class_group = query.first() - # Filtrer les évaluations après récupération pour optimiser les calculs statistiques if class_group: + # Charger les élèves actuels avec la logique temporelle + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + class_group._current_students = temporal_repo.find_current_students_in_class(class_id) + + # Filtrer les évaluations après récupération pour optimiser les calculs statistiques if trimester is not None: class_group._filtered_assessments = [ assessment for assessment in class_group.assessments @@ -303,9 +324,8 @@ class ClassRepository(BaseRepository[ClassGroup]): Optional[ClassGroup]: La classe avec ses évaluations optimisées ou None """ try: - # Single-query avec toutes les relations nécessaires + # Single-query avec toutes les relations nécessaires (sans students) base_query = ClassGroup.query.options( - joinedload(ClassGroup.students), selectinload(ClassGroup.assessments).selectinload(Assessment.exercises) .selectinload(Exercise.grading_elements).selectinload(GradingElement.grades) ) @@ -315,6 +335,11 @@ class ClassRepository(BaseRepository[ClassGroup]): if not class_group: return None + # Charger les élèves actuels avec la logique temporelle + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + class_group._current_students = temporal_repo.find_current_students_in_class(class_id) + # Pré-filtrer les évaluations par trimestre if trimester is not None: filtered_assessments = [ diff --git a/repositories/temporal_student_repository.py b/repositories/temporal_student_repository.py new file mode 100644 index 0000000..6081606 --- /dev/null +++ b/repositories/temporal_student_repository.py @@ -0,0 +1,166 @@ +from typing import List, Optional, Tuple +from datetime import date +from sqlalchemy import and_, or_ +from models import db, Student, StudentEnrollment, Assessment +from repositories.base_repository import BaseRepository + + +class TemporalStudentRepository(BaseRepository[Student]): + """Repository pour gérer les étudiants avec logique temporelle.""" + + def __init__(self): + super().__init__(Student) + + def find_enrolled_in_class_at_date(self, class_group_id: int, check_date: date) -> List[Student]: + """Trouve les étudiants inscrits dans une classe à une date donnée.""" + from sqlalchemy import func + return db.session.query(Student)\ + .join(StudentEnrollment)\ + .filter( + StudentEnrollment.class_group_id == class_group_id, + StudentEnrollment.enrollment_date <= check_date, + or_( + StudentEnrollment.departure_date.is_(None), + StudentEnrollment.departure_date >= check_date + ) + )\ + .order_by(func.lower(Student.last_name), func.lower(Student.first_name))\ + .all() + + def find_eligible_for_assessment(self, assessment: Assessment) -> List[Student]: + """Trouve les étudiants éligibles pour une évaluation donnée.""" + if not assessment.date: + return [] + + return self.find_enrolled_in_class_at_date(assessment.class_group_id, assessment.date) + + def find_current_students_in_class(self, class_group_id: int) -> List[Student]: + """Trouve les étudiants actuellement inscrits dans une classe.""" + from sqlalchemy import func + return db.session.query(Student)\ + .join(StudentEnrollment)\ + .filter( + StudentEnrollment.class_group_id == class_group_id, + StudentEnrollment.departure_date.is_(None) + )\ + .order_by(func.lower(Student.last_name), func.lower(Student.first_name))\ + .all() + + def get_enrollment_history(self, student_id: int) -> List[StudentEnrollment]: + """Récupère l'historique complet des inscriptions d'un élève.""" + return StudentEnrollment.query\ + .filter_by(student_id=student_id)\ + .order_by(StudentEnrollment.enrollment_date.desc())\ + .all() + + def find_students_with_movements_in_period(self, start_date: date, end_date: date) -> List[Tuple[Student, List[StudentEnrollment]]]: + """Trouve les étudiants qui ont eu des mouvements (arrivée/départ) dans une période avec leurs inscriptions.""" + # Récupérer les étudiants qui ont eu des mouvements dans la période + students_with_movements = db.session.query(Student)\ + .join(StudentEnrollment)\ + .filter( + or_( + # Arrivées dans la période + and_( + StudentEnrollment.enrollment_date >= start_date, + StudentEnrollment.enrollment_date <= end_date + ), + # Départs dans la période + and_( + StudentEnrollment.departure_date >= start_date, + StudentEnrollment.departure_date <= end_date + ) + ) + )\ + .distinct()\ + .order_by(Student.last_name, Student.first_name)\ + .all() + + # Pour chaque étudiant, récupérer ses mouvements dans la période + result = [] + for student in students_with_movements: + movements = StudentEnrollment.query\ + .filter_by(student_id=student.id)\ + .filter( + or_( + # Arrivées dans la période + and_( + StudentEnrollment.enrollment_date >= start_date, + StudentEnrollment.enrollment_date <= end_date + ), + # Départs dans la période + and_( + StudentEnrollment.departure_date >= start_date, + StudentEnrollment.departure_date <= end_date + ) + ) + )\ + .order_by(StudentEnrollment.enrollment_date.desc())\ + .all() + + if movements: + result.append((student, movements)) + + return result + + def create_enrollment(self, student_id: int, class_group_id: int, + enrollment_date: date, enrollment_reason: str = None) -> StudentEnrollment: + """Crée une nouvelle inscription pour un élève.""" + # Vérifier s'il y a déjà une inscription active + active_enrollment = StudentEnrollment.query.filter_by( + student_id=student_id, + departure_date=None + ).first() + + if active_enrollment: + raise ValueError("L'élève a déjà une inscription active") + + # Créer la nouvelle inscription + enrollment = StudentEnrollment( + student_id=student_id, + class_group_id=class_group_id, + enrollment_date=enrollment_date, + enrollment_reason=enrollment_reason + ) + + db.session.add(enrollment) + return enrollment + + def end_enrollment(self, student_id: int, departure_date: date, + departure_reason: str = None) -> Optional[StudentEnrollment]: + """Termine l'inscription active d'un élève.""" + active_enrollment = StudentEnrollment.query.filter_by( + student_id=student_id, + departure_date=None + ).first() + + if not active_enrollment: + return None + + active_enrollment.departure_date = departure_date + active_enrollment.departure_reason = departure_reason + + return active_enrollment + + def transfer_student(self, student_id: int, new_class_group_id: int, + transfer_date: date, transfer_reason: str = None) -> tuple[StudentEnrollment, StudentEnrollment]: + """Transfère un élève d'une classe à une autre.""" + # Terminer l'inscription actuelle + old_enrollment = self.end_enrollment( + student_id, + transfer_date, + f"Transfert: {transfer_reason}" if transfer_reason else "Transfert" + ) + + if not old_enrollment: + raise ValueError("Aucune inscription active trouvée pour cet élève") + + # Créer la nouvelle inscription + new_enrollment = self.create_enrollment( + student_id, + new_class_group_id, + transfer_date, + f"Transfert: {transfer_reason}" if transfer_reason else "Transfert" + ) + + return old_enrollment, new_enrollment \ No newline at end of file diff --git a/routes/classes.py b/routes/classes.py index 793f28b..ec8fb53 100644 --- a/routes/classes.py +++ b/routes/classes.py @@ -1,8 +1,9 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app, abort -from models import db, ClassGroup, Student, Assessment +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') @@ -320,7 +321,8 @@ def save_appreciation_api(class_id, student_id): # Vérifier que l'élève appartient à cette classe from models import Student student = Student.query.get_or_404(student_id) - if student.class_group_id != class_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 @@ -413,4 +415,363 @@ def council_data_api(class_id): 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 \ No newline at end of file + 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')) \ No newline at end of file diff --git a/routes/grading.py b/routes/grading.py index 997b6a1..8c44917 100644 --- a/routes/grading.py +++ b/routes/grading.py @@ -8,11 +8,14 @@ bp = Blueprint('grading', __name__) @bp.route('/assessments//grading') def assessment_grading(assessment_id): assessment_repo = AssessmentRepository() - student_repo = StudentRepository() grade_repo = GradeRepository() assessment = assessment_repo.get_or_404(assessment_id) - students = student_repo.find_by_class_ordered(assessment.class_group_id) + + # Utilisation de la logique temporelle pour récupérer les élèves éligibles + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_student_repo = TemporalStudentRepository() + students = temporal_student_repo.find_eligible_for_assessment(assessment) # Get all grading elements for this assessment grading_elements = [] @@ -45,8 +48,9 @@ def assessment_grading(assessment_id): @bp.route('/assessments//grading/save', methods=['POST']) def save_grades(assessment_id): assessment_repo = AssessmentRepository() - student_repo = StudentRepository() grade_repo = GradeRepository() + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_student_repo = TemporalStudentRepository() assessment = assessment_repo.get_or_404(assessment_id) errors = [] @@ -75,7 +79,7 @@ def save_grades(assessment_id): # Vérifier que l'étudiant et l'élément existent avec protection try: - student = student_repo.find_by_id(student_id) + student = temporal_student_repo.find_by_id(student_id) grading_element = GradingElement.query.get(element_id) except Exception as e: errors.append(f'Erreur DB pour {key}: {str(e)}') @@ -222,8 +226,9 @@ def save_grades(assessment_id): def save_single_grade(assessment_id): """Sauvegarde incrémentale d'une seule note""" assessment_repo = AssessmentRepository() - student_repo = StudentRepository() grade_repo = GradeRepository() + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_student_repo = TemporalStudentRepository() assessment = assessment_repo.get_or_404(assessment_id) @@ -235,7 +240,7 @@ def save_single_grade(assessment_id): comment = data.get('comment', '').strip() # Vérifications - student = student_repo.find_by_id(student_id) + student = temporal_student_repo.find_by_id(student_id) grading_element = GradingElement.query.get(element_id) if not student or not grading_element: diff --git a/routes/student_movements.py b/routes/student_movements.py new file mode 100644 index 0000000..61d5b79 --- /dev/null +++ b/routes/student_movements.py @@ -0,0 +1,336 @@ +from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app +from datetime import date, datetime +from models import db, Student, ClassGroup, StudentEnrollment +from repositories.temporal_student_repository import TemporalStudentRepository +from utils import handle_db_errors + +bp = Blueprint('student_movements', __name__, url_prefix='/movements') + +@bp.route('/') +@handle_db_errors +def movements_index(): + """Page principale de gestion des mouvements d'élèves.""" + temporal_repo = TemporalStudentRepository() + + # Récupérer les mouvements récents (30 derniers jours) + from datetime import date, timedelta + recent_start = date.today() - timedelta(days=30) + recent_end = date.today() + + recent_movements = temporal_repo.find_students_with_movements_in_period(recent_start, recent_end) + + # Récupérer toutes les classes pour les formulaires + classes = ClassGroup.query.order_by(ClassGroup.name).all() + + # Récupérer tous les élèves pour le formulaire d'inscription + all_students = Student.query.order_by(Student.last_name, Student.first_name).all() + + return render_template('student_movements.html', + recent_movements=recent_movements, + classes=classes, + all_students=all_students) + +@bp.route('/enroll', methods=['POST']) +@handle_db_errors +def enroll_student(): + """Inscrire un élève dans une classe.""" + temporal_repo = TemporalStudentRepository() + + try: + data = request.get_json() if request.is_json else request.form + + student_id = int(data.get('student_id')) + class_group_id = int(data.get('class_group_id')) + enrollment_date_str = data.get('enrollment_date', str(date.today())) + enrollment_reason = data.get('enrollment_reason', '') + + # Validation de la date + enrollment_date = datetime.strptime(enrollment_date_str, '%Y-%m-%d').date() + + # Vérifier que l'élève et la classe existent + student = Student.query.get_or_404(student_id) + class_group = ClassGroup.query.get_or_404(class_group_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 + }) + else: + flash(f'Élève {student.full_name} inscrit en {class_group.name}', 'success') + return redirect(url_for('student_movements.movements_index')) + + 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(url_for('student_movements.movements_index')) + + 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(url_for('student_movements.movements_index')) + +@bp.route('/departure', methods=['POST']) +@handle_db_errors +def student_departure(): + """Enregistrer le départ d'un élève.""" + 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') + return redirect(url_for('student_movements.movements_index')) + + 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(url_for('student_movements.movements_index')) + + 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(url_for('student_movements.movements_index')) + +@bp.route('/transfer', methods=['POST']) +@handle_db_errors +def transfer_student(): + """Transférer un élève d'une classe à une autre.""" + 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_group_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') + return redirect(url_for('student_movements.movements_index')) + + 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(url_for('student_movements.movements_index')) + + 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(url_for('student_movements.movements_index')) + +@bp.route('/history/') +@handle_db_errors +def student_history(student_id): + """Afficher l'historique des inscriptions d'un élève.""" + temporal_repo = TemporalStudentRepository() + + student = Student.query.get_or_404(student_id) + enrollment_history = temporal_repo.get_enrollment_history(student_id) + + if request.is_json: + history_data = [] + for enrollment in enrollment_history: + history_data.append({ + 'id': enrollment.id, + 'class_name': enrollment.class_group.name, + 'enrollment_date': enrollment.enrollment_date.isoformat(), + 'departure_date': enrollment.departure_date.isoformat() if enrollment.departure_date else None, + 'enrollment_reason': enrollment.enrollment_reason, + 'departure_reason': enrollment.departure_reason, + 'is_active': enrollment.is_active + }) + + return jsonify({ + 'student': { + 'id': student.id, + 'name': student.full_name + }, + 'history': history_data + }) + else: + return render_template('student_history.html', + student=student, + enrollment_history=enrollment_history) + +@bp.route('/api/eligible-students/') +@handle_db_errors +def api_eligible_students(assessment_id): + """API pour récupérer les élèves éligibles pour une évaluation.""" + from models import Assessment + temporal_repo = TemporalStudentRepository() + + assessment = Assessment.query.get_or_404(assessment_id) + eligible_students = temporal_repo.find_eligible_for_assessment(assessment) + current_students = temporal_repo.find_current_students_in_class(assessment.class_group_id) + + eligible_data = [] + for student in eligible_students: + eligible_data.append({ + 'id': student.id, + 'name': student.full_name, + 'email': student.email, + 'eligible': True + }) + + # Ajouter les élèves non-éligibles pour comparaison + ineligible_students = [s for s in current_students if s not in eligible_students] + for student in ineligible_students: + eligible_data.append({ + 'id': student.id, + 'name': student.full_name, + 'email': student.email, + 'eligible': False + }) + + return jsonify({ + 'assessment': { + 'id': assessment.id, + 'title': assessment.title, + 'date': assessment.date.isoformat() if assessment.date else None + }, + 'total_eligible': len(eligible_students), + 'total_current': len(current_students), + 'students': eligible_data + }) + +@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('student_movements.movements_index')) + + # 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('student_movements.movements_index')) + + # 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 (page élèves de classe si disponible) + return redirect(request.referrer or url_for('student_movements.movements_index')) + + 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('student_movements.movements_index')) + 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('student_movements.movements_index')) \ No newline at end of file diff --git a/services/council_services.py b/services/council_services.py index efb1503..539353d 100644 --- a/services/council_services.py +++ b/services/council_services.py @@ -51,9 +51,14 @@ class StudentEvaluationService: def calculate_student_trimester_average(self, student_id: int, trimester: int) -> Optional[float]: """Calcule la moyenne d'un élève pour un trimestre donné.""" + # Récupérer la classe actuelle de l'élève avec la logique temporelle + student = Student.query.get(student_id) + current_class = student.get_current_class() + if not current_class: + return None + assessments = self.assessment_repo.find_completed_by_class_trimester( - # On récupère d'abord la classe de l'élève - Student.query.get(student_id).class_group_id, + current_class.id, trimester ) @@ -74,10 +79,20 @@ class StudentEvaluationService: def get_student_trimester_summary(self, student_id: int, trimester: int) -> StudentTrimesterSummary: """Génère le résumé d'un élève pour un trimestre.""" student = Student.query.get(student_id) + current_class = student.get_current_class() + if not current_class: + return StudentTrimesterSummary( + student=student, + overall_average=None, + assessment_count=0, + grades_by_assessment={}, + appreciation=None, + performance_status='no_data' + ) # Récupérer les évaluations du trimestre assessments = self.assessment_repo.find_by_class_trimester_with_details( - student.class_group_id, trimester + current_class.id, trimester ) # Calculer les scores par évaluation @@ -96,7 +111,7 @@ class StudentEvaluationService: # Récupérer l'appréciation existante appreciation_repo = AppreciationRepository() appreciation = appreciation_repo.find_by_student_trimester( - student_id, student.class_group_id, trimester + student_id, current_class.id, trimester ) # Calculer les données de compétences et domaines @@ -121,9 +136,10 @@ class StudentEvaluationService: ) def get_students_summaries(self, class_group_id: int, trimester: int) -> List[StudentTrimesterSummary]: - """Génère les résumés de tous les élèves d'une classe pour un trimestre.""" - student_repo = StudentRepository() - students = student_repo.find_by_class_group(class_group_id) + """Génère les résumés de tous les élèves actuellement dans une classe pour un trimestre.""" + from repositories.temporal_student_repository import TemporalStudentRepository + temporal_repo = TemporalStudentRepository() + students = temporal_repo.find_current_students_in_class(class_group_id) summaries = [] for student in students: @@ -148,9 +164,13 @@ class StudentEvaluationService: if not student: return {'competences': [], 'domains': []} - # Récupérer toutes les évaluations du trimestre + # Récupérer la classe actuelle et toutes les évaluations du trimestre + current_class = student.get_current_class() + if not current_class: + return {'competences': [], 'domains': []} + assessments = self.assessment_repo.find_by_class_trimester_with_details( - student.class_group_id, trimester + current_class.id, trimester ) # Structures pour accumuler les données @@ -375,8 +395,18 @@ class StudentEvaluationService: global_details = self.grade_repo.get_special_values_details_by_student_trimester(student_id, trimester) # 2. Comptes par évaluation + student = Student.query.get(student_id) + current_class = student.get_current_class() + if not current_class: + return { + 'global': {}, + 'by_assessment': {}, + 'total_special_values': 0, + 'has_special_values': False + } + assessments = self.assessment_repo.find_by_class_trimester_with_details( - Student.query.get(student_id).class_group_id, trimester + current_class.id, trimester ) by_assessment = {} diff --git a/services/temporal_assessment_services.py b/services/temporal_assessment_services.py new file mode 100644 index 0000000..b58e904 --- /dev/null +++ b/services/temporal_assessment_services.py @@ -0,0 +1,207 @@ +from dataclasses import dataclass +from typing import List, Dict, Tuple +from collections import defaultdict +from models import db, Grade, GradingElement, Exercise +from repositories.temporal_student_repository import TemporalStudentRepository +from services.assessment_services import AssessmentProgressService, StudentScoreCalculator, ProgressResult, StudentScore +from providers.concrete_providers import SQLAlchemyDatabaseProvider +from sqlalchemy import and_, func + + +@dataclass +class TemporalProgressResult(ProgressResult): + """Extension avec information temporelle.""" + eligible_students_count: int + total_students_in_class: int + + +class TemporalAssessmentProgressService(AssessmentProgressService): + """ + Service de progression avec logique temporelle. + Hérite du service existant et ajoute la logique temporelle. + """ + + def __init__(self, db_provider: SQLAlchemyDatabaseProvider, student_repo: TemporalStudentRepository): + super().__init__(db_provider) + self.student_repo = student_repo + + def calculate_grading_progress(self, assessment) -> TemporalProgressResult: + """ + Calcule la progression en ne comptant que les élèves éligibles. + """ + # Étudiants éligibles à la date de l'évaluation + eligible_students = self.student_repo.find_eligible_for_assessment(assessment) + current_students = self.student_repo.find_current_students_in_class(assessment.class_group_id) + + if not eligible_students: + return TemporalProgressResult( + percentage=0, + completed=0, + total=0, + status='no_eligible_students', + students_count=0, + eligible_students_count=0, + total_students_in_class=len(current_students) + ) + + # Calcul basé uniquement sur les élèves éligibles + eligible_student_ids = [s.id for s in eligible_students] + + # Requête optimisée pour compter les notes des élèves éligibles uniquement + grading_elements_data = self._get_grading_elements_for_eligible_students( + assessment.id, eligible_student_ids + ) + + total_elements = 0 + completed_elements = 0 + + for element_data in grading_elements_data: + total_elements += len(eligible_students) + completed_elements += element_data.completed_grades_count + + if total_elements == 0: + return TemporalProgressResult( + percentage=0, + completed=0, + total=0, + status='no_elements', + students_count=len(eligible_students), + eligible_students_count=len(eligible_students), + total_students_in_class=len(current_students) + ) + + percentage = round((completed_elements / total_elements) * 100) + status = self._determine_status(percentage) + + return TemporalProgressResult( + percentage=percentage, + completed=completed_elements, + total=total_elements, + status=status, + students_count=len(eligible_students), + eligible_students_count=len(eligible_students), + total_students_in_class=len(current_students) + ) + + def _get_grading_elements_for_eligible_students(self, assessment_id: int, eligible_student_ids: List[int]): + """Requête optimisée pour récupérer les notes des élèves éligibles.""" + + return db.session.query( + GradingElement.id, + func.count(Grade.id).label('completed_grades_count') + ).select_from(GradingElement)\ + .join(Exercise, GradingElement.exercise_id == Exercise.id)\ + .outerjoin( + Grade, + and_( + Grade.grading_element_id == GradingElement.id, + Grade.student_id.in_(eligible_student_ids), + Grade.value.isnot(None), + Grade.value != '' + ) + )\ + .filter(Exercise.assessment_id == assessment_id)\ + .group_by(GradingElement.id)\ + .all() + + +class TemporalStudentScoreCalculator(StudentScoreCalculator): + """Calculateur de scores avec logique temporelle.""" + + def __init__(self, + grading_calculator, + db_provider: SQLAlchemyDatabaseProvider, + student_repo: TemporalStudentRepository): + super().__init__(grading_calculator, db_provider) + self.student_repo = student_repo + + def calculate_student_scores(self, assessment) -> Tuple[Dict[int, StudentScore], Dict[int, Dict[int, float]]]: + """ + Calcule les scores uniquement pour les élèves éligibles. + """ + # Récupérer uniquement les élèves éligibles + eligible_students = self.student_repo.find_eligible_for_assessment(assessment) + + if not eligible_students: + return {}, {} + + # Requête optimisée pour les notes des élèves éligibles + eligible_student_ids = [s.id for s in eligible_students] + grades_data = self._get_grades_for_eligible_students(assessment.id, eligible_student_ids) + + students_scores = {} + exercise_scores = defaultdict(lambda: defaultdict(float)) + + # Calcul pour chaque élève éligible + for student in eligible_students: + student_score = self._calculate_single_student_score( + student, assessment, grades_data + ) + students_scores[student.id] = student_score + + for exercise_id, exercise_data in student_score.exercises.items(): + exercise_scores[exercise_id][student.id] = exercise_data['score'] + + return students_scores, dict(exercise_scores) + + def _get_grades_for_eligible_students(self, assessment_id: int, eligible_student_ids: List[int]): + """Requête optimisée pour les notes des élèves éligibles.""" + + grades = db.session.query(Grade)\ + .join(GradingElement, Grade.grading_element_id == GradingElement.id)\ + .join(Exercise, GradingElement.exercise_id == Exercise.id)\ + .filter( + Exercise.assessment_id == assessment_id, + Grade.student_id.in_(eligible_student_ids) + ).all() + + # Convertir en format dict compatible avec le service parent + return [ + { + 'student_id': grade.student_id, + 'grading_element_id': grade.grading_element_id, + 'value': grade.value, + 'grading_type': grade.grading_element.grading_type, + 'max_points': grade.grading_element.max_points + } + for grade in grades + ] + + +class TemporalClassStatisticsService: + """Service pour calculer les statistiques de classe avec logique temporelle.""" + + def __init__(self, student_repo: TemporalStudentRepository): + self.student_repo = student_repo + + def get_class_enrollment_summary(self, class_group_id: int, trimester: int = None): + """Résumé des inscriptions pour une classe.""" + current_students = self.student_repo.find_current_students_in_class(class_group_id) + + # Calcul des mouvements si on a un trimestre spécifique + movements = {} + if trimester: + from datetime import date + # Approximation des dates de trimestre + trimester_dates = { + 1: (date(2024, 9, 1), date(2024, 12, 31)), + 2: (date(2025, 1, 1), date(2025, 3, 31)), + 3: (date(2025, 4, 1), date(2025, 6, 30)) + } + + if trimester in trimester_dates: + start_date, end_date = trimester_dates[trimester] + students_with_movements = self.student_repo.find_students_with_movements_in_period( + start_date, end_date + ) + movements = { + 'arrivals': [], + 'departures': [], + 'total_movements': len(students_with_movements) + } + + return { + 'current_count': len(current_students), + 'current_students': current_students, + 'movements': movements + } \ No newline at end of file diff --git a/services/temporal_facade.py b/services/temporal_facade.py new file mode 100644 index 0000000..174b03c --- /dev/null +++ b/services/temporal_facade.py @@ -0,0 +1,70 @@ +from typing import List +from models import Student +from repositories.temporal_student_repository import TemporalStudentRepository +from services.temporal_assessment_services import ( + TemporalAssessmentProgressService, + TemporalStudentScoreCalculator, + TemporalClassStatisticsService, + TemporalProgressResult +) +from services.assessment_services import AssessmentStatisticsService, UnifiedGradingCalculator +from providers.concrete_providers import ConfigManagerProvider, SQLAlchemyDatabaseProvider + + +class TemporalAssessmentServicesFacade: + """ + Facade temporelle qui remplace la facade standard. + Intègre toute la logique temporelle de manière transparente. + """ + + def __init__(self, + config_provider = None, + db_provider = None): + # Injection des repositories temporels + self.student_repo = TemporalStudentRepository() + + # Providers par défaut si non fournis + if config_provider is None: + config_provider = ConfigManagerProvider() + if db_provider is None: + db_provider = SQLAlchemyDatabaseProvider() + + # Services temporels + self.grading_calculator = UnifiedGradingCalculator(config_provider) + self.progress_service = TemporalAssessmentProgressService(db_provider, self.student_repo) + self.score_calculator = TemporalStudentScoreCalculator( + self.grading_calculator, db_provider, self.student_repo + ) + self.statistics_service = AssessmentStatisticsService(self.score_calculator) + self.class_statistics_service = TemporalClassStatisticsService(self.student_repo) + + def get_eligible_students(self, assessment) -> List[Student]: + """Nouvelle méthode pour récupérer les élèves éligibles.""" + return self.student_repo.find_eligible_for_assessment(assessment) + + def get_grading_progress(self, assessment) -> TemporalProgressResult: + """Progression avec logique temporelle.""" + return self.progress_service.calculate_grading_progress(assessment) + + def get_class_enrollment_summary(self, class_group_id: int, trimester: int = None): + """Résumé des inscriptions pour une classe.""" + return self.class_statistics_service.get_class_enrollment_summary(class_group_id, trimester) + + # Compatibilité avec l'interface existante + def get_assessment_statistics(self, assessment): + """Statistiques d'évaluation (héritée du service existant).""" + return self.statistics_service.get_assessment_statistics(assessment) + + +class TemporalAssessmentServicesFactory: + """Factory pour créer les services temporels.""" + + @staticmethod + def create_temporal_facade(config_provider = None) -> TemporalAssessmentServicesFacade: + """Crée une facade temporelle.""" + return TemporalAssessmentServicesFacade(config_provider) + + @staticmethod + def create_temporal_student_repository() -> TemporalStudentRepository: + """Crée un repository d'élèves temporel.""" + return TemporalStudentRepository() \ No newline at end of file diff --git a/templates/class_dashboard.html b/templates/class_dashboard.html index 6bfe35e..af10ff4 100644 --- a/templates/class_dashboard.html +++ b/templates/class_dashboard.html @@ -92,7 +92,7 @@ {# Action BLEUE - Gérer les élèves #} -
@@ -102,7 +102,7 @@

Gérer élèves

-

{{ class_group.students|length }} élèves inscrits

+

{% if class_group._current_students %}{{ class_group._current_students|length }}{% else %}{{ class_group.students|length }}{% endif %} élèves inscrits

@@ -410,7 +410,7 @@ Effectif complet : {{ class_group.students|length }} élèves - @@ -427,7 +427,7 @@

Aucun élève inscrit

Ajoutez des élèves à cette classe pour commencer les évaluations

-
diff --git a/templates/class_students.html b/templates/class_students.html new file mode 100644 index 0000000..47d3656 --- /dev/null +++ b/templates/class_students.html @@ -0,0 +1,608 @@ +{% extends "base.html" %} +{% from 'components/common/macros.html' import hero_section %} + +{% block title %}Élèves de {{ class_group.name }} - Gestion Scolaire{% endblock %} + +{% block content %} +
+ {# Hero Section avec Statistiques des Élèves #} + {% set meta_info = [ + { + 'icon': '', + 'text': stats.total_current ~ ' élèves actuels' + }, + { + 'icon': '', + 'text': stats.recent_arrivals ~ ' arrivées (30j)' + }, + { + 'icon': '', + 'text': stats.recent_departures ~ ' départs (30j)' + } + ] %} + + {% set primary_action = { + 'url': 'javascript:openEnrollModal()', + 'text': 'Inscrire un élève', + 'icon': '' + } %} + + {{ hero_section( + title=class_group.name ~ " 👥", + subtitle="Gestion des élèves", + meta_info=meta_info, + primary_action=primary_action, + gradient_class="from-blue-500 to-blue-600" + ) }} + + {# Breadcrumb Navigation #} + + + {# Statistiques d'effectifs #} +
+
+
+
+ + + +
+
+

{{ stats.total_current }}

+

Élèves actuels

+
+
+
+ +
+
+
+ + + +
+
+

{{ stats.recent_arrivals }}

+

Arrivées (30j)

+
+
+
+ +
+
+
+ + + +
+
+

{{ stats.recent_departures }}

+

Départs (30j)

+
+
+
+
+ + {# Liste des élèves actuels #} +
+
+

+ + + + Élèves actuellement inscrits ({{ current_students|length }}) +

+
+ + {% if current_students %} +
+ {% for student in current_students %} + {% set enrollment = student.get_current_enrollment() %} +
+
+
+ {{ student.first_name[0] }}{{ student.last_name[0] }} +
+
+
{{ student.first_name }} {{ student.last_name }}
+
+ {% if enrollment %} + Inscrit depuis le {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }} + {% if enrollment.enrollment_reason %} + ({{ enrollment.enrollment_reason }}) + {% endif %} + {% endif %} +
+
+
+ +
+ + +
+
+ {% endfor %} +
+ {% else %} +
+ + + +

Aucun élève inscrit dans cette classe

+
+ {% endif %} +
+ + {# Historique des mouvements #} + {% if class_movements %} +
+
+

+ + + + Historique des mouvements (6 derniers mois) +

+
+ +
+ {% for student, enrollments in class_movements %} +
+
+ {{ student.first_name }} {{ student.last_name }} +
+ +
+ {% for enrollment in enrollments %} +
+
+ {% if enrollment.departure_date %} + + Départ + + + Du {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }} au {{ enrollment.departure_date.strftime('%d/%m/%Y') }} + {% if enrollment.departure_reason %} + - {{ enrollment.departure_reason }} + {% endif %} + + {% else %} + + Arrivée + + + Depuis le {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }} + {% if enrollment.enrollment_reason %} + - {{ enrollment.enrollment_reason }} + {% endif %} + + {% endif %} +
+ + {% if enrollment.departure_date %} + + {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+
+ {% endif %} +
+ +{# Modal d'inscription d'élève #} + + +{# Modal de transfert #} + + +{# Modal de départ #} + + +{# Modal de confirmation d'annulation de départ #} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/components/class/class_card.html b/templates/components/class/class_card.html index 08e7465..b92e7f1 100644 --- a/templates/components/class/class_card.html +++ b/templates/components/class/class_card.html @@ -39,7 +39,7 @@
-