feat: add temporal student gestion
This commit is contained in:
		
							
								
								
									
										23
									
								
								app.py
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								app.py
									
									
									
									
									
								
							| @@ -14,6 +14,7 @@ from routes.grading import bp as grading_bp | |||||||
| from routes.config import bp as config_bp | from routes.config import bp as config_bp | ||||||
| from routes.domains import bp as domains_bp | from routes.domains import bp as domains_bp | ||||||
| from routes.classes import bp as classes_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): | def create_app(config_name=None): | ||||||
|     if config_name is None: |     if config_name is None: | ||||||
| @@ -45,6 +46,7 @@ def create_app(config_name=None): | |||||||
|     app.register_blueprint(config_bp) |     app.register_blueprint(config_bp) | ||||||
|     app.register_blueprint(domains_bp) |     app.register_blueprint(domains_bp) | ||||||
|     app.register_blueprint(classes_bp) |     app.register_blueprint(classes_bp) | ||||||
|  |     # app.register_blueprint(student_movements_bp)  # Désactivé car page supprimée | ||||||
|  |  | ||||||
|     # Register CLI commands |     # Register CLI commands | ||||||
|     app.cli.add_command(init_db) |     app.cli.add_command(init_db) | ||||||
| @@ -84,10 +86,23 @@ def create_app(config_name=None): | |||||||
|     @app.route('/students') |     @app.route('/students') | ||||||
|     def students(): |     def students(): | ||||||
|         try: |         try: | ||||||
|             # Optimisation: utiliser joinedload pour éviter les requêtes N+1 |             # Utilisation du repository temporel pour récupérer tous les élèves avec leur classe actuelle | ||||||
|             student_repo = StudentRepository() |             from repositories.temporal_student_repository import TemporalStudentRepository | ||||||
|             students = student_repo.find_all_with_class_ordered() |             from models import Student | ||||||
|             return render_template('students.html', students=students) |              | ||||||
|  |             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: |         except Exception as e: | ||||||
|             app.logger.error(f'Erreur lors du chargement des étudiants: {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 |             return render_template('error.html', error="Erreur lors du chargement des étudiants"), 500 | ||||||
|   | |||||||
							
								
								
									
										176
									
								
								commands.py
									
									
									
									
									
								
							
							
						
						
									
										176
									
								
								commands.py
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | |||||||
| import click | import click | ||||||
| from flask.cli import with_appcontext | 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.command() | ||||||
| @click.option('--mode', default='minimal', type=click.Choice(['minimal', 'midyear', 'demo']),  | @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!") |     click.echo("Database initialized for start of school year - ready to create classes and students!") | ||||||
|  |  | ||||||
| def init_midyear_data(): | 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 app_config import config_manager | ||||||
|     from models import Grade |     from models import Grade | ||||||
|     import random |     import random | ||||||
| @@ -44,6 +45,12 @@ def init_midyear_data(): | |||||||
|     config_manager.set('context.school_name', 'Collège Jean Moulin') |     config_manager.set('context.school_name', 'Collège Jean Moulin') | ||||||
|     config_manager.save() |     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 |     # Create 5 classes with realistic sizes | ||||||
|     classes_data = [ |     classes_data = [ | ||||||
|         ("6ème A", "Classe de 6ème A", 28), |         ("6ème A", "Classe de 6ème A", 28), | ||||||
| @@ -71,22 +78,23 @@ def init_midyear_data(): | |||||||
|     ] |     ] | ||||||
|      |      | ||||||
|     classes = [] |     classes = [] | ||||||
|     students_by_class = [] |     all_students = [] | ||||||
|      |      | ||||||
|     # Global sets to ensure uniqueness across all classes |     # Global sets to ensure uniqueness across all classes | ||||||
|     used_names = set() |     used_names = set() | ||||||
|     used_emails = set() |     used_emails = set() | ||||||
|      |      | ||||||
|     # Create classes and students |     # Create classes first | ||||||
|     for class_name, description, nb_students in classes_data: |     for class_name, description, nb_students in classes_data: | ||||||
|         classe = ClassGroup(name=class_name, description=description, year="2024-2025") |         classe = ClassGroup(name=class_name, description=description, year="2024-2025") | ||||||
|         db.session.add(classe) |         db.session.add(classe) | ||||||
|         db.session.commit() |  | ||||||
|         classes.append(classe) |         classes.append(classe) | ||||||
|      |      | ||||||
|         # Create students for this class |     db.session.commit() | ||||||
|         class_students = [] |      | ||||||
|         for i in range(nb_students): |     # 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 |         # Generate unique name combinations and emails | ||||||
|         while True: |         while True: | ||||||
|             first_name = random.choice(first_names) |             first_name = random.choice(first_names) | ||||||
| @@ -109,13 +117,83 @@ def init_midyear_data(): | |||||||
|         student = Student( |         student = Student( | ||||||
|             last_name=last_name, |             last_name=last_name, | ||||||
|             first_name=first_name, |             first_name=first_name, | ||||||
|                 email=email, |             email=email | ||||||
|                 class_group_id=classe.id |             # Pas de class_group_id dans le nouveau modèle temporel | ||||||
|         ) |         ) | ||||||
|         db.session.add(student) |         db.session.add(student) | ||||||
|             class_students.append(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}") | ||||||
|      |      | ||||||
|         students_by_class.append(class_students) |  | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|      |      | ||||||
|     # Get domains for realistic grading elements |     # Get domains for realistic grading elements | ||||||
| @@ -290,15 +368,20 @@ def init_midyear_data(): | |||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|      |      | ||||||
|     # Create assessments for each class |     # Create assessments for each class with temporal logic | ||||||
|     for i, classe in enumerate(classes): |     for classe in classes: | ||||||
|         class_students = students_by_class[i] |  | ||||||
|          |  | ||||||
|         for assessment_template in assessments_templates: |         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( |             assessment = Assessment( | ||||||
|                 title=assessment_template["title"], |                 title=assessment_template["title"], | ||||||
|                 description=assessment_template["description"], |                 description=assessment_template["description"], | ||||||
|  |                 date=assessment_date, | ||||||
|                 trimester=assessment_template["trimester"], |                 trimester=assessment_template["trimester"], | ||||||
|                 class_group_id=classe.id, |                 class_group_id=classe.id, | ||||||
|                 coefficient=assessment_template["coefficient"] |                 coefficient=assessment_template["coefficient"] | ||||||
| @@ -338,8 +421,13 @@ def init_midyear_data(): | |||||||
|                  |                  | ||||||
|                 # Add grades if assessment should be corrected |                 # Add grades if assessment should be corrected | ||||||
|                 if assessment_template["corrected"]: |                 if assessment_template["corrected"]: | ||||||
|                     # Generate realistic grades for all students |                     # Get students eligible for this assessment using temporal logic | ||||||
|                     for student in class_students: |                     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: |                         for element in grading_elements: | ||||||
|                             # Generate realistic grade based on element type |                             # Generate realistic grade based on element type | ||||||
|                             if element.grading_type == "notes": |                             if element.grading_type == "notes": | ||||||
| @@ -370,7 +458,12 @@ def init_midyear_data(): | |||||||
|                  |                  | ||||||
|                 elif assessment_template.get("corrected") == False and random.random() < 0.4: |                 elif assessment_template.get("corrected") == False and random.random() < 0.4: | ||||||
|                     # Partially grade some assessments (40% of non-corrected ones get partial grades) |                     # 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) | ||||||
|  |                      | ||||||
|  |                     if eligible_students: | ||||||
|  |                         students_to_grade = random.sample(eligible_students, len(eligible_students) // 2) | ||||||
|                          |                          | ||||||
|                         for student in students_to_grade: |                         for student in students_to_grade: | ||||||
|                             for element in grading_elements: |                             for element in grading_elements: | ||||||
| @@ -392,14 +485,27 @@ def init_midyear_data(): | |||||||
|      |      | ||||||
|     total_students = sum(nb for _, _, nb in classes_data) |     total_students = sum(nb for _, _, nb in classes_data) | ||||||
|     total_assessments = len(classes) * len(assessments_templates) |     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") |     # Compter les inscriptions actuelles et mouvements | ||||||
|     click.echo(f"  - {total_assessments} assessments created") |     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"  - 4 fully corrected assessments per class (Trimester 1)") | ||||||
|     click.echo(f"  - 2 partially corrected assessments per class (Trimester 2)") |     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(): | 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 |     # Create sample class groups | ||||||
|     classe_6a = ClassGroup(name="6ème A", description="Classe de 6ème A", year="2024-2025") |     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") |     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.add(classe_5b) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|      |      | ||||||
|     # Create sample students |     # Create sample students (without class_group_id) | ||||||
|     students_data = [ |     students_data = [ | ||||||
|         ("Dupont", "Marie", "marie.dupont@email.com", classe_6a.id), |         ("Dupont", "Marie", "marie.dupont@email.com", classe_6a.id), | ||||||
|         ("Martin", "Pierre", "pierre.martin@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), |         ("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( |         student = Student( | ||||||
|             last_name=last_name, |             last_name=last_name, | ||||||
|             first_name=first_name, |             first_name=first_name, | ||||||
|             email=email, |             email=email | ||||||
|             class_group_id=class_group_id |             # Pas de class_group_id dans le nouveau modèle temporel | ||||||
|         ) |         ) | ||||||
|         db.session.add(student) |         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() |     db.session.commit() | ||||||
|      |      | ||||||
|   | |||||||
							
								
								
									
										163
									
								
								models.py
									
									
									
									
									
								
							
							
						
						
									
										163
									
								
								models.py
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | |||||||
| from flask_sqlalchemy import SQLAlchemy | from flask_sqlalchemy import SQLAlchemy | ||||||
| from datetime import datetime | from datetime import datetime, date | ||||||
| from sqlalchemy import CheckConstraint, Enum | from sqlalchemy import CheckConstraint, Enum, Index | ||||||
| from typing import Optional, Dict, Any | from typing import Optional, Dict, Any | ||||||
|  |  | ||||||
| db = SQLAlchemy() | db = SQLAlchemy() | ||||||
| @@ -57,8 +57,25 @@ class ClassGroup(db.Model): | |||||||
|     name = db.Column(db.String(100), nullable=False, unique=True) |     name = db.Column(db.String(100), nullable=False, unique=True) | ||||||
|     description = db.Column(db.Text) |     description = db.Column(db.Text) | ||||||
|     year = db.Column(db.String(20), nullable=False) |     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) |     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): |     def get_trimester_statistics(self, trimester=None): | ||||||
|         """ |         """ | ||||||
| @@ -123,13 +140,72 @@ class ClassGroup(db.Model): | |||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return f'<ClassGroup {self.name}>' |         return f'<ClassGroup {self.name}>' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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'<StudentEnrollment {self.student_id} in {self.class_group_id} from {self.enrollment_date}>' | ||||||
|  |      | ||||||
|  |     @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): | class Student(db.Model): | ||||||
|     id = db.Column(db.Integer, primary_key=True) |     id = db.Column(db.Integer, primary_key=True) | ||||||
|     last_name = db.Column(db.String(100), nullable=False) |     last_name = db.Column(db.String(100), nullable=False) | ||||||
|     first_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) |     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) |     grades = db.relationship('Grade', backref='student', lazy=True) | ||||||
|  |     # enrollments déjà défini via backref dans StudentEnrollment | ||||||
|  |  | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return f'<Student {self.first_name} {self.last_name}>' |         return f'<Student {self.first_name} {self.last_name}>' | ||||||
| @@ -138,6 +214,42 @@ class Student(db.Model): | |||||||
|     def full_name(self): |     def full_name(self): | ||||||
|         return f"{self.first_name} {self.last_name}" |         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): | class Assessment(db.Model): | ||||||
|     id = db.Column(db.Integer, primary_key=True) |     id = db.Column(db.Integer, primary_key=True) | ||||||
|     title = db.Column(db.String(200), nullable=False) |     title = db.Column(db.String(200), nullable=False) | ||||||
| @@ -159,46 +271,53 @@ class Assessment(db.Model): | |||||||
|     def grading_progress(self): |     def grading_progress(self): | ||||||
|         """ |         """ | ||||||
|         Calcule le pourcentage de progression des notes saisies pour cette évaluation. |         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: |         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 |         # Injection de dépendances pour éviter les imports circulaires | ||||||
|         services_facade = AssessmentServicesFactory.create_facade() |         temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade() | ||||||
|         progress_result = services_facade.get_grading_progress(self) |         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 { |         return { | ||||||
|             'percentage': progress_result.percentage, |             'percentage': progress_result.percentage, | ||||||
|             'completed': progress_result.completed, |             'completed': progress_result.completed, | ||||||
|             'total': progress_result.total, |             'total': progress_result.total, | ||||||
|             'status': progress_result.status, |             '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): |     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. |         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: |         Args: | ||||||
|             grade_repo: Repository des notes (optionnel, maintenu pour compatibilité) |             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() |         temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade() | ||||||
|         students_scores_data, exercise_scores_data = services.score_calculator.calculate_student_scores(self) |         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é |         # Conversion vers format legacy pour compatibilité | ||||||
|         students_scores = {} |         students_scores = {} | ||||||
|         exercise_scores = {} |         exercise_scores = {} | ||||||
|          |          | ||||||
|         for student_id, score_data in students_scores_data.items(): |         for student_id, score_data in students_scores_data.items(): | ||||||
|             # Récupérer l'objet étudiant pour compatibilité |             # Utiliser les élèves éligibles au lieu de tous les élèves de la classe | ||||||
|             student_obj = next(s for s in self.class_group.students if s.id == student_id) |             student_obj = eligible_students_dict.get(student_id) | ||||||
|  |             if student_obj: | ||||||
|                 students_scores[student_id] = { |                 students_scores[student_id] = { | ||||||
|                     'student': student_obj, |                     'student': student_obj, | ||||||
|                     'total_score': score_data.total_score, |                     'total_score': score_data.total_score, | ||||||
| @@ -214,11 +333,11 @@ class Assessment(db.Model): | |||||||
|     def get_assessment_statistics(self): |     def get_assessment_statistics(self): | ||||||
|         """ |         """ | ||||||
|         Calcule les statistiques descriptives pour cette évaluation. |         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 |         from services.temporal_facade import TemporalAssessmentServicesFactory | ||||||
|         services = AssessmentServicesFactory.create_facade() |         temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade() | ||||||
|         result = services.statistics_service.get_assessment_statistics(self) |         result = temporal_facade.get_assessment_statistics(self) | ||||||
|          |          | ||||||
|         # Conversion du StatisticsResult vers le format dict legacy |         # Conversion du StatisticsResult vers le format dict legacy | ||||||
|         return { |         return { | ||||||
|   | |||||||
| @@ -71,10 +71,12 @@ class AppreciationRepository(BaseRepository[CouncilAppreciation]): | |||||||
|  |  | ||||||
|     def get_completion_stats(self, class_group_id: int, trimester: int) -> dict: |     def get_completion_stats(self, class_group_id: int, trimester: int) -> dict: | ||||||
|         """Statistiques de completion des appréciations pour une classe/trimestre.""" |         """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 |         # Nombre total d'élèves actuellement dans la classe | ||||||
|         total_students = Student.query.filter_by(class_group_id=class_group_id).count() |         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 |         # Nombre d'appréciations existantes | ||||||
|         total_appreciations = CouncilAppreciation.query.filter_by( |         total_appreciations = CouncilAppreciation.query.filter_by( | ||||||
|   | |||||||
| @@ -99,21 +99,29 @@ class ClassRepository(BaseRepository[ClassGroup]): | |||||||
|         Returns: |         Returns: | ||||||
|             Tuple[bool, Dict[str, int]]: (peut_être_supprimée, statistiques_dépendances) |             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() |         assessments_count = Assessment.query.filter_by(class_group_id=id).count() | ||||||
|          |          | ||||||
|         dependencies = { |         dependencies = { | ||||||
|             'students': students_count, |             'students': len(current_students), | ||||||
|  |             'enrollments': enrollments_count, | ||||||
|             'assessments': assessments_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 |         return can_delete, dependencies | ||||||
|      |      | ||||||
|     def find_with_students_ordered(self, id: int) -> Optional[ClassGroup]: |     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: |         Args: | ||||||
|             id: Identifiant de la classe |             id: Identifiant de la classe | ||||||
| @@ -125,10 +133,10 @@ class ClassRepository(BaseRepository[ClassGroup]): | |||||||
|         if not class_group: |         if not class_group: | ||||||
|             return None |             return None | ||||||
|          |          | ||||||
|         # Charger les étudiants triés |         # Utiliser la logique temporelle pour récupérer les élèves actuels | ||||||
|         students = Student.query.filter_by(class_group_id=id).order_by( |         from repositories.temporal_student_repository import TemporalStudentRepository | ||||||
|             Student.last_name, Student.first_name |         temporal_repo = TemporalStudentRepository() | ||||||
|         ).all() |         students = temporal_repo.find_current_students_in_class(id) | ||||||
|          |          | ||||||
|         # Assigner les étudiants triés à la classe |         # Assigner les étudiants triés à la classe | ||||||
|         class_group._students_ordered = students |         class_group._students_ordered = students | ||||||
| @@ -162,7 +170,7 @@ class ClassRepository(BaseRepository[ClassGroup]): | |||||||
|      |      | ||||||
|     def find_with_full_details(self, id: int) -> Optional[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: |         Args: | ||||||
|             id: Identifiant de la classe |             id: Identifiant de la classe | ||||||
| @@ -170,22 +178,32 @@ class ClassRepository(BaseRepository[ClassGroup]): | |||||||
|         Returns: |         Returns: | ||||||
|             Optional[ClassGroup]: La classe avec tous ses détails ou None |             Optional[ClassGroup]: La classe avec tous ses détails ou None | ||||||
|         """ |         """ | ||||||
|         return ClassGroup.query.options( |         class_group = ClassGroup.query.options( | ||||||
|             joinedload(ClassGroup.students), |  | ||||||
|             joinedload(ClassGroup.assessments) |             joinedload(ClassGroup.assessments) | ||||||
|         ).filter_by(id=id).first() |         ).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: |     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: |         Args: | ||||||
|             id: Identifiant de la classe |             id: Identifiant de la classe | ||||||
|              |              | ||||||
|         Returns: |         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: |     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]: |     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. |         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 |         Utilise la logique temporelle pour les étudiants et optimise les requêtes. | ||||||
|         nécessaires en une seule requête. |  | ||||||
|          |          | ||||||
|         Args: |         Args: | ||||||
|             class_id: Identifiant de la classe |             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 |             Optional[ClassGroup]: La classe avec toutes ses données ou None | ||||||
|         """ |         """ | ||||||
|         try: |         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( |             query = ClassGroup.query.options( | ||||||
|                 joinedload(ClassGroup.students), |  | ||||||
|                 selectinload(ClassGroup.assessments).selectinload(Assessment.exercises) |                 selectinload(ClassGroup.assessments).selectinload(Assessment.exercises) | ||||||
|                 .selectinload(Exercise.grading_elements).selectinload(GradingElement.grades) |                 .selectinload(Exercise.grading_elements).selectinload(GradingElement.grades) | ||||||
|             ).filter_by(id=class_id) |             ).filter_by(id=class_id) | ||||||
|              |              | ||||||
|             class_group = query.first() |             class_group = query.first() | ||||||
|              |              | ||||||
|             # Filtrer les évaluations après récupération pour optimiser les calculs statistiques |  | ||||||
|             if class_group: |             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: |                 if trimester is not None: | ||||||
|                     class_group._filtered_assessments = [ |                     class_group._filtered_assessments = [ | ||||||
|                         assessment for assessment in class_group.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 |             Optional[ClassGroup]: La classe avec ses évaluations optimisées ou None | ||||||
|         """ |         """ | ||||||
|         try: |         try: | ||||||
|             # Single-query avec toutes les relations nécessaires |             # Single-query avec toutes les relations nécessaires (sans students) | ||||||
|             base_query = ClassGroup.query.options( |             base_query = ClassGroup.query.options( | ||||||
|                 joinedload(ClassGroup.students), |  | ||||||
|                 selectinload(ClassGroup.assessments).selectinload(Assessment.exercises) |                 selectinload(ClassGroup.assessments).selectinload(Assessment.exercises) | ||||||
|                 .selectinload(Exercise.grading_elements).selectinload(GradingElement.grades) |                 .selectinload(Exercise.grading_elements).selectinload(GradingElement.grades) | ||||||
|             ) |             ) | ||||||
| @@ -315,6 +335,11 @@ class ClassRepository(BaseRepository[ClassGroup]): | |||||||
|             if not class_group: |             if not class_group: | ||||||
|                 return None |                 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 |             # Pré-filtrer les évaluations par trimestre | ||||||
|             if trimester is not None: |             if trimester is not None: | ||||||
|                 filtered_assessments = [ |                 filtered_assessments = [ | ||||||
|   | |||||||
							
								
								
									
										166
									
								
								repositories/temporal_student_repository.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								repositories/temporal_student_repository.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -1,8 +1,9 @@ | |||||||
| from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app, abort | 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 forms import ClassGroupForm | ||||||
| from utils import handle_db_errors, ValidationError | from utils import handle_db_errors, ValidationError | ||||||
| from repositories.class_repository import ClassRepository | from repositories.class_repository import ClassRepository | ||||||
|  | from datetime import date, datetime | ||||||
|  |  | ||||||
| bp = Blueprint('classes', __name__, url_prefix='/classes') | 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 |         # Vérifier que l'élève appartient à cette classe | ||||||
|         from models import Student |         from models import Student | ||||||
|         student = Student.query.get_or_404(student_id) |         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 |             return jsonify({'success': False, 'error': 'Élève non trouvé dans cette classe'}), 403 | ||||||
|          |          | ||||||
|         # Préparer les données d'appréciation |         # Préparer les données d'appréciation | ||||||
| @@ -414,3 +416,362 @@ def council_data_api(class_id): | |||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         current_app.logger.error(f'Erreur API données conseil classe {class_id}: {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 |         return jsonify({'error': 'Erreur lors de la récupération des données'}), 500 | ||||||
|  |  | ||||||
|  | @bp.route('/<int:id>/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')) | ||||||
| @@ -8,11 +8,14 @@ bp = Blueprint('grading', __name__) | |||||||
| @bp.route('/assessments/<int:assessment_id>/grading') | @bp.route('/assessments/<int:assessment_id>/grading') | ||||||
| def assessment_grading(assessment_id): | def assessment_grading(assessment_id): | ||||||
|     assessment_repo = AssessmentRepository() |     assessment_repo = AssessmentRepository() | ||||||
|     student_repo = StudentRepository() |  | ||||||
|     grade_repo = GradeRepository() |     grade_repo = GradeRepository() | ||||||
|      |      | ||||||
|     assessment = assessment_repo.get_or_404(assessment_id) |     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 |     # Get all grading elements for this assessment | ||||||
|     grading_elements = [] |     grading_elements = [] | ||||||
| @@ -45,8 +48,9 @@ def assessment_grading(assessment_id): | |||||||
| @bp.route('/assessments/<int:assessment_id>/grading/save', methods=['POST']) | @bp.route('/assessments/<int:assessment_id>/grading/save', methods=['POST']) | ||||||
| def save_grades(assessment_id): | def save_grades(assessment_id): | ||||||
|     assessment_repo = AssessmentRepository() |     assessment_repo = AssessmentRepository() | ||||||
|     student_repo = StudentRepository() |  | ||||||
|     grade_repo = GradeRepository() |     grade_repo = GradeRepository() | ||||||
|  |     from repositories.temporal_student_repository import TemporalStudentRepository | ||||||
|  |     temporal_student_repo = TemporalStudentRepository() | ||||||
|      |      | ||||||
|     assessment = assessment_repo.get_or_404(assessment_id) |     assessment = assessment_repo.get_or_404(assessment_id) | ||||||
|     errors = [] |     errors = [] | ||||||
| @@ -75,7 +79,7 @@ def save_grades(assessment_id): | |||||||
|                  |                  | ||||||
|                 # Vérifier que l'étudiant et l'élément existent avec protection |                 # Vérifier que l'étudiant et l'élément existent avec protection | ||||||
|                 try: |                 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) |                     grading_element = GradingElement.query.get(element_id) | ||||||
|                 except Exception as e: |                 except Exception as e: | ||||||
|                     errors.append(f'Erreur DB pour {key}: {str(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): | def save_single_grade(assessment_id): | ||||||
|     """Sauvegarde incrémentale d'une seule note""" |     """Sauvegarde incrémentale d'une seule note""" | ||||||
|     assessment_repo = AssessmentRepository() |     assessment_repo = AssessmentRepository() | ||||||
|     student_repo = StudentRepository() |  | ||||||
|     grade_repo = GradeRepository() |     grade_repo = GradeRepository() | ||||||
|  |     from repositories.temporal_student_repository import TemporalStudentRepository | ||||||
|  |     temporal_student_repo = TemporalStudentRepository() | ||||||
|      |      | ||||||
|     assessment = assessment_repo.get_or_404(assessment_id) |     assessment = assessment_repo.get_or_404(assessment_id) | ||||||
|      |      | ||||||
| @@ -235,7 +240,7 @@ def save_single_grade(assessment_id): | |||||||
|         comment = data.get('comment', '').strip() |         comment = data.get('comment', '').strip() | ||||||
|          |          | ||||||
|         # Vérifications |         # 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) |         grading_element = GradingElement.query.get(element_id) | ||||||
|          |          | ||||||
|         if not student or not grading_element: |         if not student or not grading_element: | ||||||
|   | |||||||
							
								
								
									
										336
									
								
								routes/student_movements.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										336
									
								
								routes/student_movements.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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/<int:student_id>') | ||||||
|  | @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/<int:assessment_id>') | ||||||
|  | @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')) | ||||||
| @@ -51,9 +51,14 @@ class StudentEvaluationService: | |||||||
|      |      | ||||||
|     def calculate_student_trimester_average(self, student_id: int, trimester: int) -> Optional[float]: |     def calculate_student_trimester_average(self, student_id: int, trimester: int) -> Optional[float]: | ||||||
|         """Calcule la moyenne d'un élève pour un trimestre donné.""" |         """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( |         assessments = self.assessment_repo.find_completed_by_class_trimester( | ||||||
|             # On récupère d'abord la classe de l'élève |             current_class.id,  | ||||||
|             Student.query.get(student_id).class_group_id,  |  | ||||||
|             trimester |             trimester | ||||||
|         ) |         ) | ||||||
|          |          | ||||||
| @@ -74,10 +79,20 @@ class StudentEvaluationService: | |||||||
|     def get_student_trimester_summary(self, student_id: int, trimester: int) -> StudentTrimesterSummary: |     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.""" |         """Génère le résumé d'un élève pour un trimestre.""" | ||||||
|         student = Student.query.get(student_id) |         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 |         # Récupérer les évaluations du trimestre | ||||||
|         assessments = self.assessment_repo.find_by_class_trimester_with_details( |         assessments = self.assessment_repo.find_by_class_trimester_with_details( | ||||||
|             student.class_group_id, trimester |             current_class.id, trimester | ||||||
|         ) |         ) | ||||||
|          |          | ||||||
|         # Calculer les scores par évaluation |         # Calculer les scores par évaluation | ||||||
| @@ -96,7 +111,7 @@ class StudentEvaluationService: | |||||||
|         # Récupérer l'appréciation existante |         # Récupérer l'appréciation existante | ||||||
|         appreciation_repo = AppreciationRepository() |         appreciation_repo = AppreciationRepository() | ||||||
|         appreciation = appreciation_repo.find_by_student_trimester( |         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 |         # 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]: |     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.""" |         """Génère les résumés de tous les élèves actuellement dans une classe pour un trimestre.""" | ||||||
|         student_repo = StudentRepository() |         from repositories.temporal_student_repository import TemporalStudentRepository | ||||||
|         students = student_repo.find_by_class_group(class_group_id) |         temporal_repo = TemporalStudentRepository() | ||||||
|  |         students = temporal_repo.find_current_students_in_class(class_group_id) | ||||||
|          |          | ||||||
|         summaries = [] |         summaries = [] | ||||||
|         for student in students: |         for student in students: | ||||||
| @@ -148,9 +164,13 @@ class StudentEvaluationService: | |||||||
|         if not student: |         if not student: | ||||||
|             return {'competences': [], 'domains': []} |             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( |         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 |         # 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) |         global_details = self.grade_repo.get_special_values_details_by_student_trimester(student_id, trimester) | ||||||
|          |          | ||||||
|         # 2. Comptes par évaluation |         # 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( |         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 = {} |         by_assessment = {} | ||||||
|   | |||||||
							
								
								
									
										207
									
								
								services/temporal_assessment_services.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								services/temporal_assessment_services.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  |         } | ||||||
							
								
								
									
										70
									
								
								services/temporal_facade.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								services/temporal_facade.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||||
| @@ -92,7 +92,7 @@ | |||||||
|         </a> |         </a> | ||||||
|  |  | ||||||
|         {# Action BLEUE - Gérer les élèves #} |         {# Action BLEUE - Gérer les élèves #} | ||||||
|         <a href="{{ url_for('students') }}?class_id={{ class_group.id }}"  |         <a href="{{ url_for('classes.students', id=class_group.id) }}"  | ||||||
|            class="group bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl p-6 hover:from-blue-600 hover:to-blue-700 transition-all duration-300 transform hover:scale-[1.02] shadow-lg hover:shadow-xl"> |            class="group bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl p-6 hover:from-blue-600 hover:to-blue-700 transition-all duration-300 transform hover:scale-[1.02] shadow-lg hover:shadow-xl"> | ||||||
|             <div class="flex items-center"> |             <div class="flex items-center"> | ||||||
|                 <div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mr-4 group-hover:bg-white/30 transition-colors"> |                 <div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mr-4 group-hover:bg-white/30 transition-colors"> | ||||||
| @@ -102,7 +102,7 @@ | |||||||
|                 </div> |                 </div> | ||||||
|                 <div> |                 <div> | ||||||
|                     <h3 class="text-lg font-bold mb-1">Gérer élèves</h3> |                     <h3 class="text-lg font-bold mb-1">Gérer élèves</h3> | ||||||
|                     <p class="text-sm opacity-90">{{ class_group.students|length }} élèves inscrits</p> |                     <p class="text-sm opacity-90">{% if class_group._current_students %}{{ class_group._current_students|length }}{% else %}{{ class_group.students|length }}{% endif %} élèves inscrits</p> | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
|         </a> |         </a> | ||||||
| @@ -410,7 +410,7 @@ | |||||||
|                             Effectif complet : {{ class_group.students|length }} élèves |                             Effectif complet : {{ class_group.students|length }} élèves | ||||||
|                         </span> |                         </span> | ||||||
|                     </div> |                     </div> | ||||||
|                     <a href="{{ url_for('students') }}?class_id={{ class_group.id }}"  |                     <a href="{{ url_for('classes.students', id=class_group.id) }}"  | ||||||
|                        class="inline-flex items-center text-sm bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-4 py-2 rounded-lg transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-[1.02]"> |                        class="inline-flex items-center text-sm bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-4 py-2 rounded-lg transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-[1.02]"> | ||||||
|                         <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"> |                         <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|                             <path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/> |                             <path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/> | ||||||
| @@ -427,7 +427,7 @@ | |||||||
|                     </div> |                     </div> | ||||||
|                     <h3 class="text-sm font-medium text-gray-900 mb-2">Aucun élève inscrit</h3> |                     <h3 class="text-sm font-medium text-gray-900 mb-2">Aucun élève inscrit</h3> | ||||||
|                     <p class="text-sm text-gray-500 mb-4">Ajoutez des élèves à cette classe pour commencer les évaluations</p> |                     <p class="text-sm text-gray-500 mb-4">Ajoutez des élèves à cette classe pour commencer les évaluations</p> | ||||||
|                     <a href="{{ url_for('students') }}?class_id={{ class_group.id }}"  |                     <a href="{{ url_for('classes.students', id=class_group.id) }}"  | ||||||
|                        class="inline-flex items-center text-sm bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-2 rounded-lg transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-[1.02]"> |                        class="inline-flex items-center text-sm bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white px-4 py-2 rounded-lg transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-[1.02]"> | ||||||
|                         <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"> |                         <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|                             <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/> |                             <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/> | ||||||
|   | |||||||
							
								
								
									
										608
									
								
								templates/class_students.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										608
									
								
								templates/class_students.html
									
									
									
									
									
										Normal file
									
								
							| @@ -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 %} | ||||||
|  | <div class="space-y-6"> | ||||||
|  |     {# Hero Section avec Statistiques des Élèves #} | ||||||
|  |     {% set meta_info = [ | ||||||
|  |         { | ||||||
|  |             'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/></svg>', | ||||||
|  |             'text': stats.total_current ~ ' élèves actuels' | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>', | ||||||
|  |             'text': stats.recent_arrivals ~ ' arrivées (30j)' | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>', | ||||||
|  |             'text': stats.recent_departures ~ ' départs (30j)' | ||||||
|  |         } | ||||||
|  |     ] %} | ||||||
|  |  | ||||||
|  |     {% set primary_action = { | ||||||
|  |         'url': 'javascript:openEnrollModal()', | ||||||
|  |         'text': 'Inscrire un élève', | ||||||
|  |         'icon': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>' | ||||||
|  |     } %} | ||||||
|  |  | ||||||
|  |     {{ 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 #} | ||||||
|  |     <div class="flex items-center text-sm text-gray-600"> | ||||||
|  |         <a href="{{ url_for('classes.dashboard', id=class_group.id) }}"  | ||||||
|  |            class="hover:text-blue-600 transition-colors flex items-center"> | ||||||
|  |             <svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                 <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/> | ||||||
|  |             </svg> | ||||||
|  |             Retour au dashboard | ||||||
|  |         </a> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     {# Statistiques d'effectifs #} | ||||||
|  |     <div class="grid grid-cols-1 md:grid-cols-3 gap-6"> | ||||||
|  |         <div class="bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl p-6"> | ||||||
|  |             <div class="flex items-center"> | ||||||
|  |                 <div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mr-4"> | ||||||
|  |                     <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                         <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> | ||||||
|  |                     </svg> | ||||||
|  |                 </div> | ||||||
|  |                 <div> | ||||||
|  |                     <h3 class="text-2xl font-bold">{{ stats.total_current }}</h3> | ||||||
|  |                     <p class="text-sm opacity-90">Élèves actuels</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <div class="bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl p-6"> | ||||||
|  |             <div class="flex items-center"> | ||||||
|  |                 <div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mr-4"> | ||||||
|  |                     <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                         <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/> | ||||||
|  |                     </svg> | ||||||
|  |                 </div> | ||||||
|  |                 <div> | ||||||
|  |                     <h3 class="text-2xl font-bold">{{ stats.recent_arrivals }}</h3> | ||||||
|  |                     <p class="text-sm opacity-90">Arrivées (30j)</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <div class="bg-gradient-to-r from-orange-500 to-orange-600 text-white rounded-xl p-6"> | ||||||
|  |             <div class="flex items-center"> | ||||||
|  |                 <div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mr-4"> | ||||||
|  |                     <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                         <path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/> | ||||||
|  |                     </svg> | ||||||
|  |                 </div> | ||||||
|  |                 <div> | ||||||
|  |                     <h3 class="text-2xl font-bold">{{ stats.recent_departures }}</h3> | ||||||
|  |                     <p class="text-sm opacity-90">Départs (30j)</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     {# Liste des élèves actuels #} | ||||||
|  |     <div class="bg-white shadow rounded-lg"> | ||||||
|  |         <div class="px-6 py-4 border-b border-gray-200"> | ||||||
|  |             <h2 class="text-xl font-semibold text-gray-900 flex items-center"> | ||||||
|  |                 <svg class="w-6 h-6 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                     <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> | ||||||
|  |                 </svg> | ||||||
|  |                 Élèves actuellement inscrits ({{ current_students|length }}) | ||||||
|  |             </h2> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         {% if current_students %} | ||||||
|  |             <div class="divide-y divide-gray-200"> | ||||||
|  |                 {% for student in current_students %} | ||||||
|  |                     {% set enrollment = student.get_current_enrollment() %} | ||||||
|  |                     <div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50"> | ||||||
|  |                         <div class="flex items-center"> | ||||||
|  |                             <div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center mr-4"> | ||||||
|  |                                 <span class="text-sm font-medium text-blue-600">{{ student.first_name[0] }}{{ student.last_name[0] }}</span> | ||||||
|  |                             </div> | ||||||
|  |                             <div> | ||||||
|  |                                 <div class="text-lg font-medium text-gray-900">{{ student.first_name }} {{ student.last_name }}</div> | ||||||
|  |                                 <div class="text-sm text-gray-500"> | ||||||
|  |                                     {% if enrollment %} | ||||||
|  |                                         Inscrit depuis le {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }} | ||||||
|  |                                         {% if enrollment.enrollment_reason %} | ||||||
|  |                                             ({{ enrollment.enrollment_reason }}) | ||||||
|  |                                         {% endif %} | ||||||
|  |                                     {% endif %} | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                          | ||||||
|  |                         <div class="flex space-x-2"> | ||||||
|  |                             <button onclick="transferStudent({{ student.id }}, '{{ student.first_name }} {{ student.last_name }}')"  | ||||||
|  |                                     class="bg-blue-100 hover:bg-blue-200 text-blue-700 px-3 py-1 rounded text-sm transition-colors"> | ||||||
|  |                                 Transférer | ||||||
|  |                             </button> | ||||||
|  |                             <button onclick="departureStudent({{ student.id }}, '{{ student.first_name }} {{ student.last_name }}')"  | ||||||
|  |                                     class="bg-orange-100 hover:bg-orange-200 text-orange-700 px-3 py-1 rounded text-sm transition-colors"> | ||||||
|  |                                 Départ | ||||||
|  |                             </button> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 {% endfor %} | ||||||
|  |             </div> | ||||||
|  |         {% else %} | ||||||
|  |             <div class="px-6 py-8 text-center text-gray-500"> | ||||||
|  |                 <svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                     <path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/> | ||||||
|  |                 </svg> | ||||||
|  |                 <p>Aucun élève inscrit dans cette classe</p> | ||||||
|  |             </div> | ||||||
|  |         {% endif %} | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     {# Historique des mouvements #} | ||||||
|  |     {% if class_movements %} | ||||||
|  |         <div class="bg-white shadow rounded-lg"> | ||||||
|  |             <div class="px-6 py-4 border-b border-gray-200"> | ||||||
|  |                 <h2 class="text-xl font-semibold text-gray-900 flex items-center"> | ||||||
|  |                     <svg class="w-6 h-6 mr-2 text-gray-600" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                         <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/> | ||||||
|  |                     </svg> | ||||||
|  |                     Historique des mouvements (6 derniers mois) | ||||||
|  |                 </h2> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="divide-y divide-gray-200"> | ||||||
|  |                 {% for student, enrollments in class_movements %} | ||||||
|  |                     <div class="px-6 py-4"> | ||||||
|  |                         <div class="flex items-center justify-between mb-2"> | ||||||
|  |                             <span class="font-medium text-gray-900">{{ student.first_name }} {{ student.last_name }}</span> | ||||||
|  |                         </div> | ||||||
|  |                          | ||||||
|  |                         <div class="space-y-1"> | ||||||
|  |                             {% for enrollment in enrollments %} | ||||||
|  |                                 <div class="flex items-center justify-between text-sm"> | ||||||
|  |                                     <div class="flex items-center"> | ||||||
|  |                                         {% if enrollment.departure_date %} | ||||||
|  |                                             <span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 mr-2"> | ||||||
|  |                                                 Départ | ||||||
|  |                                             </span> | ||||||
|  |                                             <span class="text-gray-600"> | ||||||
|  |                                                 Du {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }} au {{ enrollment.departure_date.strftime('%d/%m/%Y') }} | ||||||
|  |                                                 {% if enrollment.departure_reason %} | ||||||
|  |                                                     - {{ enrollment.departure_reason }} | ||||||
|  |                                                 {% endif %} | ||||||
|  |                                             </span> | ||||||
|  |                                         {% else %} | ||||||
|  |                                             <span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 mr-2"> | ||||||
|  |                                                 Arrivée | ||||||
|  |                                             </span> | ||||||
|  |                                             <span class="text-gray-600"> | ||||||
|  |                                                 Depuis le {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }} | ||||||
|  |                                                 {% if enrollment.enrollment_reason %} | ||||||
|  |                                                     - {{ enrollment.enrollment_reason }} | ||||||
|  |                                                 {% endif %} | ||||||
|  |                                             </span> | ||||||
|  |                                         {% endif %} | ||||||
|  |                                     </div> | ||||||
|  |                                      | ||||||
|  |                                     {% if enrollment.departure_date %} | ||||||
|  |                                         <button onclick="cancelDeparture({{ enrollment.id }}, '{{ student.first_name }} {{ student.last_name }}')" | ||||||
|  |                                                 class="bg-green-100 hover:bg-green-200 text-green-700 px-2 py-1 rounded text-xs transition-colors"> | ||||||
|  |                                             Annuler le départ | ||||||
|  |                                         </button> | ||||||
|  |                                     {% endif %} | ||||||
|  |                                 </div> | ||||||
|  |                             {% endfor %} | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 {% endfor %} | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     {% endif %} | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {# Modal d'inscription d'élève #} | ||||||
|  | <div id="enrollModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50"> | ||||||
|  |     <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4"> | ||||||
|  |         <div class="flex items-center justify-between mb-4"> | ||||||
|  |             <h3 class="text-lg font-semibold">Inscrire un élève dans {{ class_group.name }}</h3> | ||||||
|  |             <button type="button" onclick="closeEnrollModal()"  | ||||||
|  |                     class="text-gray-400 hover:text-gray-600 transition-colors"> | ||||||
|  |                 <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                     <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> | ||||||
|  |                 </svg> | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         {# Tabs pour choisir le mode #} | ||||||
|  |         <div class="flex mb-4 border-b border-gray-200"> | ||||||
|  |             <button type="button" id="newStudentTab"  | ||||||
|  |                     class="px-4 py-2 text-sm font-medium text-blue-600 border-b-2 border-blue-600" | ||||||
|  |                     onclick="switchEnrollMode('new')"> | ||||||
|  |                 Nouvel élève | ||||||
|  |             </button> | ||||||
|  |             <button type="button" id="existingStudentTab"  | ||||||
|  |                     class="px-4 py-2 text-sm font-medium text-gray-500 border-b-2 border-transparent" | ||||||
|  |                     onclick="switchEnrollMode('existing')"> | ||||||
|  |                 Élève existant | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <form id="enrollForm" method="post" action="{{ url_for('classes.enroll_student') }}"> | ||||||
|  |             <input type="hidden" name="class_id" value="{{ class_group.id }}"> | ||||||
|  |             <input type="hidden" name="mode" id="enrollMode" value="new"> | ||||||
|  |              | ||||||
|  |             {# Mode élève existant #} | ||||||
|  |             <div id="existingStudentFields" class="hidden"> | ||||||
|  |                 <div class="mb-4"> | ||||||
|  |                     <label for="student_id" class="block text-sm font-medium text-gray-700 mb-2">Élève</label> | ||||||
|  |                     <select name="student_id" id="student_id" | ||||||
|  |                             class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |                         <option value="">Sélectionner un élève</option> | ||||||
|  |                         {% for student in available_students %} | ||||||
|  |                             <option value="{{ student.id }}"> | ||||||
|  |                                 {{ student.first_name }} {{ student.last_name }} | ||||||
|  |                                 {% set current_class = student.get_current_class() %} | ||||||
|  |                                 {% if current_class %} | ||||||
|  |                                     (actuellement en {{ current_class.name }}) | ||||||
|  |                                 {% else %} | ||||||
|  |                                     (non inscrit) | ||||||
|  |                                 {% endif %} | ||||||
|  |                             </option> | ||||||
|  |                         {% endfor %} | ||||||
|  |                     </select> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             {# Mode nouvel élève #} | ||||||
|  |             <div id="newStudentFields"> | ||||||
|  |                 <div class="grid grid-cols-2 gap-4 mb-4"> | ||||||
|  |                     <div> | ||||||
|  |                         <label for="new_first_name" class="block text-sm font-medium text-gray-700 mb-2">Prénom</label> | ||||||
|  |                         <input type="text" name="new_first_name" id="new_first_name"  | ||||||
|  |                                class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |                     </div> | ||||||
|  |                     <div> | ||||||
|  |                         <label for="new_last_name" class="block text-sm font-medium text-gray-700 mb-2">Nom</label> | ||||||
|  |                         <input type="text" name="new_last_name" id="new_last_name"  | ||||||
|  |                                class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="mb-4"> | ||||||
|  |                     <label for="new_email" class="block text-sm font-medium text-gray-700 mb-2">Email (optionnel)</label> | ||||||
|  |                     <input type="email" name="new_email" id="new_email"  | ||||||
|  |                            class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="mb-4"> | ||||||
|  |                 <label for="enrollment_date" class="block text-sm font-medium text-gray-700 mb-2">Date d'inscription</label> | ||||||
|  |                 <input type="date" name="enrollment_date" id="enrollment_date" required | ||||||
|  |                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="mb-6"> | ||||||
|  |                 <label for="enrollment_reason" class="block text-sm font-medium text-gray-700 mb-2">Motif (optionnel)</label> | ||||||
|  |                 <input type="text" name="enrollment_reason" id="enrollment_reason"  | ||||||
|  |                        placeholder="Ex: Nouvelle inscription, Transfert d'établissement..." | ||||||
|  |                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="flex justify-end space-x-3"> | ||||||
|  |                 <button type="button" onclick="closeEnrollModal()"  | ||||||
|  |                         class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"> | ||||||
|  |                     Annuler | ||||||
|  |                 </button> | ||||||
|  |                 <button type="submit"  | ||||||
|  |                         class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-colors"> | ||||||
|  |                     Inscrire | ||||||
|  |                 </button> | ||||||
|  |             </div> | ||||||
|  |         </form> | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {# Modal de transfert #} | ||||||
|  | <div id="transferModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50"> | ||||||
|  |     <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4"> | ||||||
|  |         <div class="flex items-center justify-between mb-4"> | ||||||
|  |             <h3 class="text-lg font-semibold">Transférer <span id="transferStudentName"></span></h3> | ||||||
|  |             <button type="button" onclick="closeTransferModal()"  | ||||||
|  |                     class="text-gray-400 hover:text-gray-600 transition-colors"> | ||||||
|  |                 <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                     <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> | ||||||
|  |                 </svg> | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <form id="transferForm" method="post" action="{{ url_for('classes.transfer_student') }}"> | ||||||
|  |             <input type="hidden" name="student_id" id="transferStudentId"> | ||||||
|  |              | ||||||
|  |             <div class="mb-4"> | ||||||
|  |                 <label for="new_class_id" class="block text-sm font-medium text-gray-700 mb-2">Nouvelle classe</label> | ||||||
|  |                 <select name="new_class_id" id="new_class_id" required  | ||||||
|  |                         class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |                     <option value="">Sélectionner une classe</option> | ||||||
|  |                     {% for class in other_classes %} | ||||||
|  |                         <option value="{{ class.id }}">{{ class.name }}</option> | ||||||
|  |                     {% endfor %} | ||||||
|  |                 </select> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="mb-4"> | ||||||
|  |                 <label for="transfer_date" class="block text-sm font-medium text-gray-700 mb-2">Date de transfert</label> | ||||||
|  |                 <input type="date" name="transfer_date" id="transfer_date" required | ||||||
|  |                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="mb-6"> | ||||||
|  |                 <label for="transfer_reason" class="block text-sm font-medium text-gray-700 mb-2">Motif (optionnel)</label> | ||||||
|  |                 <input type="text" name="transfer_reason" id="transfer_reason"  | ||||||
|  |                        placeholder="Ex: Changement de niveau, Réorientation..." | ||||||
|  |                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="flex justify-end space-x-3"> | ||||||
|  |                 <button type="button" onclick="closeTransferModal()"  | ||||||
|  |                         class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"> | ||||||
|  |                     Annuler | ||||||
|  |                 </button> | ||||||
|  |                 <button type="submit"  | ||||||
|  |                         class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors"> | ||||||
|  |                     Transférer | ||||||
|  |                 </button> | ||||||
|  |             </div> | ||||||
|  |         </form> | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {# Modal de départ #} | ||||||
|  | <div id="departureModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50"> | ||||||
|  |     <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4"> | ||||||
|  |         <div class="flex items-center justify-between mb-4"> | ||||||
|  |             <h3 class="text-lg font-semibold">Marquer le départ de <span id="departureStudentName"></span></h3> | ||||||
|  |             <button type="button" onclick="closeDepartureModal()"  | ||||||
|  |                     class="text-gray-400 hover:text-gray-600 transition-colors"> | ||||||
|  |                 <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                     <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> | ||||||
|  |                 </svg> | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <form id="departureForm" method="post" action="{{ url_for('classes.student_departure') }}"> | ||||||
|  |             <input type="hidden" name="student_id" id="departureStudentId"> | ||||||
|  |              | ||||||
|  |             <div class="mb-4"> | ||||||
|  |                 <label for="departure_date" class="block text-sm font-medium text-gray-700 mb-2">Date de départ</label> | ||||||
|  |                 <input type="date" name="departure_date" id="departure_date" required | ||||||
|  |                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="mb-6"> | ||||||
|  |                 <label for="departure_reason" class="block text-sm font-medium text-gray-700 mb-2">Motif (optionnel)</label> | ||||||
|  |                 <input type="text" name="departure_reason" id="departure_reason"  | ||||||
|  |                        placeholder="Ex: Fin de scolarité, Déménagement, Changement d'établissement..." | ||||||
|  |                        class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="flex justify-end space-x-3"> | ||||||
|  |                 <button type="button" onclick="closeDepartureModal()"  | ||||||
|  |                         class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"> | ||||||
|  |                     Annuler | ||||||
|  |                 </button> | ||||||
|  |                 <button type="submit"  | ||||||
|  |                         class="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-md transition-colors"> | ||||||
|  |                     Confirmer le départ | ||||||
|  |                 </button> | ||||||
|  |             </div> | ||||||
|  |         </form> | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {# Modal de confirmation d'annulation de départ #} | ||||||
|  | <div id="cancelDepartureModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50"> | ||||||
|  |     <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4"> | ||||||
|  |         <div class="flex items-center justify-between mb-4"> | ||||||
|  |             <h3 class="text-lg font-semibold">Annuler le départ de <span id="cancelDepartureStudentName"></span></h3> | ||||||
|  |             <button type="button" onclick="closeCancelDepartureModal()"  | ||||||
|  |                     class="text-gray-400 hover:text-gray-600 transition-colors"> | ||||||
|  |                 <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                     <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/> | ||||||
|  |                 </svg> | ||||||
|  |             </button> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <div class="mb-6"> | ||||||
|  |             <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4"> | ||||||
|  |                 <div class="flex"> | ||||||
|  |                     <div class="flex-shrink-0"> | ||||||
|  |                         <svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                             <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/> | ||||||
|  |                         </svg> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="ml-3"> | ||||||
|  |                         <h4 class="text-sm font-medium text-yellow-800">Attention</h4> | ||||||
|  |                         <p class="text-sm text-yellow-700 mt-1"> | ||||||
|  |                             Cette action va réintégrer l'élève dans la classe et supprimer la date de départ.  | ||||||
|  |                             L'élève redeviendra éligible pour les futures évaluations de cette classe. | ||||||
|  |                         </p> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <form id="cancelDepartureForm" method="post" action="{{ url_for('classes.cancel_departure') }}"> | ||||||
|  |             <input type="hidden" name="enrollment_id" id="cancelDepartureEnrollmentId"> | ||||||
|  |              | ||||||
|  |             <div class="flex justify-end space-x-3"> | ||||||
|  |                 <button type="button" onclick="closeCancelDepartureModal()"  | ||||||
|  |                         class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors"> | ||||||
|  |                     Annuler | ||||||
|  |                 </button> | ||||||
|  |                 <button type="submit"  | ||||||
|  |                         class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-colors"> | ||||||
|  |                     Confirmer l'annulation | ||||||
|  |                 </button> | ||||||
|  |             </div> | ||||||
|  |         </form> | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <script> | ||||||
|  | // Initialiser la date d'aujourd'hui dans les champs de date | ||||||
|  | document.addEventListener('DOMContentLoaded', function() { | ||||||
|  |     const today = new Date().toISOString().split('T')[0]; | ||||||
|  |     document.getElementById('enrollment_date').value = today; | ||||||
|  |     document.getElementById('transfer_date').value = today; | ||||||
|  |     document.getElementById('departure_date').value = today; | ||||||
|  |      | ||||||
|  |     // Vérifier s'il y a eu un rechargement après inscription | ||||||
|  |     const urlParams = new URLSearchParams(window.location.search); | ||||||
|  |     if (urlParams.has('reload')) { | ||||||
|  |         // Supprimer le paramètre de l'URL pour éviter le rechargement en boucle | ||||||
|  |         const newUrl = window.location.pathname; | ||||||
|  |         window.history.replaceState({}, document.title, newUrl); | ||||||
|  |          | ||||||
|  |         // Faire défiler vers le haut pour voir le message de succès | ||||||
|  |         window.scrollTo(0, 0); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Gestion des modals d'inscription | ||||||
|  | function openEnrollModal() { | ||||||
|  |     document.getElementById('enrollModal').classList.remove('hidden'); | ||||||
|  |     document.getElementById('enrollModal').classList.add('flex'); | ||||||
|  |     // Réinitialiser au mode "nouvel élève" par défaut | ||||||
|  |     switchEnrollMode('new'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function closeEnrollModal() { | ||||||
|  |     document.getElementById('enrollModal').classList.add('hidden'); | ||||||
|  |     document.getElementById('enrollModal').classList.remove('flex'); | ||||||
|  |     // Réinitialiser le formulaire | ||||||
|  |     document.getElementById('enrollForm').reset(); | ||||||
|  |     const today = new Date().toISOString().split('T')[0]; | ||||||
|  |     document.getElementById('enrollment_date').value = today; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Gestion du changement de mode d'inscription | ||||||
|  | function switchEnrollMode(mode) { | ||||||
|  |     const existingTab = document.getElementById('existingStudentTab'); | ||||||
|  |     const newTab = document.getElementById('newStudentTab'); | ||||||
|  |     const existingFields = document.getElementById('existingStudentFields'); | ||||||
|  |     const newFields = document.getElementById('newStudentFields'); | ||||||
|  |     const modeInput = document.getElementById('enrollMode'); | ||||||
|  |      | ||||||
|  |     if (mode === 'existing') { | ||||||
|  |         // Activer l'onglet "Élève existant" | ||||||
|  |         existingTab.classList.add('text-blue-600', 'border-blue-600'); | ||||||
|  |         existingTab.classList.remove('text-gray-500', 'border-transparent'); | ||||||
|  |         newTab.classList.add('text-gray-500', 'border-transparent'); | ||||||
|  |         newTab.classList.remove('text-blue-600', 'border-blue-600'); | ||||||
|  |          | ||||||
|  |         // Afficher les champs correspondants | ||||||
|  |         existingFields.classList.remove('hidden'); | ||||||
|  |         newFields.classList.add('hidden'); | ||||||
|  |          | ||||||
|  |         // Mettre à jour le mode | ||||||
|  |         modeInput.value = 'existing'; | ||||||
|  |          | ||||||
|  |         // Rendre student_id requis | ||||||
|  |         document.getElementById('student_id').required = true; | ||||||
|  |         document.getElementById('new_first_name').required = false; | ||||||
|  |         document.getElementById('new_last_name').required = false; | ||||||
|  |     } else { | ||||||
|  |         // Activer l'onglet "Nouvel élève" | ||||||
|  |         newTab.classList.add('text-blue-600', 'border-blue-600'); | ||||||
|  |         newTab.classList.remove('text-gray-500', 'border-transparent'); | ||||||
|  |         existingTab.classList.add('text-gray-500', 'border-transparent'); | ||||||
|  |         existingTab.classList.remove('text-blue-600', 'border-blue-600'); | ||||||
|  |          | ||||||
|  |         // Afficher les champs correspondants | ||||||
|  |         newFields.classList.remove('hidden'); | ||||||
|  |         existingFields.classList.add('hidden'); | ||||||
|  |          | ||||||
|  |         // Mettre à jour le mode | ||||||
|  |         modeInput.value = 'new'; | ||||||
|  |          | ||||||
|  |         // Rendre les champs du nouvel élève requis | ||||||
|  |         document.getElementById('student_id').required = false; | ||||||
|  |         document.getElementById('new_first_name').required = true; | ||||||
|  |         document.getElementById('new_last_name').required = true; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Gestion des modals de transfert | ||||||
|  | function transferStudent(studentId, studentName) { | ||||||
|  |     document.getElementById('transferStudentId').value = studentId; | ||||||
|  |     document.getElementById('transferStudentName').textContent = studentName; | ||||||
|  |     document.getElementById('transferModal').classList.remove('hidden'); | ||||||
|  |     document.getElementById('transferModal').classList.add('flex'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function closeTransferModal() { | ||||||
|  |     document.getElementById('transferModal').classList.add('hidden'); | ||||||
|  |     document.getElementById('transferModal').classList.remove('flex'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Gestion des modals de départ | ||||||
|  | function departureStudent(studentId, studentName) { | ||||||
|  |     document.getElementById('departureStudentId').value = studentId; | ||||||
|  |     document.getElementById('departureStudentName').textContent = studentName; | ||||||
|  |     document.getElementById('departureModal').classList.remove('hidden'); | ||||||
|  |     document.getElementById('departureModal').classList.add('flex'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function closeDepartureModal() { | ||||||
|  |     document.getElementById('departureModal').classList.add('hidden'); | ||||||
|  |     document.getElementById('departureModal').classList.remove('flex'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Gestion des modals d'annulation de départ | ||||||
|  | function cancelDeparture(enrollmentId, studentName) { | ||||||
|  |     document.getElementById('cancelDepartureEnrollmentId').value = enrollmentId; | ||||||
|  |     document.getElementById('cancelDepartureStudentName').textContent = studentName; | ||||||
|  |     document.getElementById('cancelDepartureModal').classList.remove('hidden'); | ||||||
|  |     document.getElementById('cancelDepartureModal').classList.add('flex'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function closeCancelDepartureModal() { | ||||||
|  |     document.getElementById('cancelDepartureModal').classList.add('hidden'); | ||||||
|  |     document.getElementById('cancelDepartureModal').classList.remove('flex'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Fermer les modals en cliquant à l'extérieur | ||||||
|  | document.addEventListener('click', function(event) { | ||||||
|  |     const modals = ['enrollModal', 'transferModal', 'departureModal', 'cancelDepartureModal']; | ||||||
|  |     modals.forEach(modalId => { | ||||||
|  |         const modal = document.getElementById(modalId); | ||||||
|  |         if (event.target === modal) { | ||||||
|  |             modal.classList.add('hidden'); | ||||||
|  |             modal.classList.remove('flex'); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Fermer les modals avec la touche Échap | ||||||
|  | document.addEventListener('keydown', function(event) { | ||||||
|  |     if (event.key === 'Escape') { | ||||||
|  |         const modals = ['enrollModal', 'transferModal', 'departureModal', 'cancelDepartureModal']; | ||||||
|  |         modals.forEach(modalId => { | ||||||
|  |             const modal = document.getElementById(modalId); | ||||||
|  |             if (modal && !modal.classList.contains('hidden')) { | ||||||
|  |                 modal.classList.add('hidden'); | ||||||
|  |                 modal.classList.remove('flex'); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  | {% endblock %} | ||||||
| @@ -39,7 +39,7 @@ | |||||||
|         <div class="flex flex-col{% if not class.description %} mt-2{% endif %}"> |         <div class="flex flex-col{% if not class.description %} mt-2{% endif %}"> | ||||||
|             <!-- Actions principales --> |             <!-- Actions principales --> | ||||||
|             <div class="grid grid-cols-2 gap-2 mb-3"> |             <div class="grid grid-cols-2 gap-2 mb-3"> | ||||||
|                 <a href="{{ url_for('students') }}?class_id={{ class.id }}"  |                 <a href="{{ url_for('classes.students', id=class.id) }}"  | ||||||
|                    class="bg-{{ year_config.accent }}-50 hover:bg-{{ year_config.accent }}-100 text-{{ year_config.accent }}-700 hover:text-{{ year_config.accent }}-900 px-3 py-2.5 rounded-lg text-xs font-medium transition-colors flex items-center justify-center space-x-2"> |                    class="bg-{{ year_config.accent }}-50 hover:bg-{{ year_config.accent }}-100 text-{{ year_config.accent }}-700 hover:text-{{ year_config.accent }}-900 px-3 py-2.5 rounded-lg text-xs font-medium transition-colors flex items-center justify-center space-x-2"> | ||||||
|                     <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> |                     <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|                         <path d="M9 6a3 3 0 11-6 0 3 3 0 616 0zM17 6a3 3 0 11-6 0 3 3 0 616 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 515 5v1H1v-1a5 5 0 515-5z"/> |                         <path d="M9 6a3 3 0 11-6 0 3 3 0 616 0zM17 6a3 3 0 11-6 0 3 3 0 616 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 515 5v1H1v-1a5 5 0 515-5z"/> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user