feat: add temporal student gestion

This commit is contained in:
2025-08-16 06:42:47 +02:00
parent f438082c4c
commit 6549591f63
15 changed files with 2212 additions and 148 deletions

23
app.py
View File

@@ -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

View File

@@ -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,52 +78,123 @@ 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)
# Generate unique name combinations and emails total_students = sum(nb for _, _, nb in classes_data)
while True: for i in range(total_students):
first_name = random.choice(first_names) # Generate unique name combinations and emails
last_name = random.choice(last_names) while True:
name_combo = f"{first_name}_{last_name}" first_name = random.choice(first_names)
base_email = f"{first_name.lower()}.{last_name.lower()}@college.edu" last_name = random.choice(last_names)
name_combo = f"{first_name}_{last_name}"
# Make email unique if needed base_email = f"{first_name.lower()}.{last_name.lower()}@college.edu"
email = base_email
counter = 1
while email in used_emails:
email = f"{first_name.lower()}.{last_name.lower()}{counter}@college.edu"
counter += 1
if name_combo not in used_names:
used_names.add(name_combo)
used_emails.add(email)
break
student = Student( # Make email unique if needed
last_name=last_name, email = base_email
first_name=first_name, counter = 1
email=email, while email in used_emails:
class_group_id=classe.id email = f"{first_name.lower()}.{last_name.lower()}{counter}@college.edu"
) counter += 1
db.session.add(student)
class_students.append(student) if name_combo not in used_names:
used_names.add(name_combo)
used_emails.add(email)
break
students_by_class.append(class_students) student = Student(
db.session.commit() last_name=last_name,
first_name=first_name,
email=email
# Pas de class_group_id dans le nouveau modèle temporel
)
db.session.add(student)
all_students.append(student)
db.session.commit()
# Create temporal enrollments with realistic movements
student_index = 0
movements_log = []
for i, (class_name, description, nb_students) in enumerate(classes_data):
classe = classes[i]
# Répartir les élèves pour cette classe
class_students = all_students[student_index:student_index + nb_students]
student_index += nb_students
for student in class_students:
# 90% des élèves : inscrits depuis le début d'année, toujours présents
if random.random() < 0.90:
enrollment = StudentEnrollment(
student_id=student.id,
class_group_id=classe.id,
enrollment_date=school_year_start,
departure_date=None, # Toujours inscrit
enrollment_reason="Inscription début d'année",
)
db.session.add(enrollment)
# 7% des élèves : ont quitté pendant le 1er trimestre
elif random.random() < 0.97: # 7% des 10% restants
departure_date = date(2024, random.randint(10, 11), random.randint(1, 28))
enrollment = StudentEnrollment(
student_id=student.id,
class_group_id=classe.id,
enrollment_date=school_year_start,
departure_date=departure_date,
enrollment_reason="Inscription début d'année",
departure_reason="Déménagement"
)
db.session.add(enrollment)
movements_log.append(f"Élève {student.full_name} : départ de {classe.name} le {departure_date}")
# Créer un nouvel élève pour remplacer (arrivé en janvier)
new_student = Student(
last_name=random.choice(last_names),
first_name=random.choice(first_names),
email=f"nouveau.{random.randint(1000,9999)}@college.edu"
)
db.session.add(new_student)
db.session.commit() # Pour avoir l'ID
new_enrollment = StudentEnrollment(
student_id=new_student.id,
class_group_id=classe.id,
enrollment_date=second_term_start,
departure_date=None,
enrollment_reason="Nouvel élève - arrivée en cours d'année"
)
db.session.add(new_enrollment)
movements_log.append(f"Élève {new_student.full_name} : arrivée en {classe.name} le {second_term_start}")
# 3% des élèves : arrivés en cours d'année (janvier)
else:
arrival_date = date(2025, 1, random.randint(8, 31))
enrollment = StudentEnrollment(
student_id=student.id,
class_group_id=classe.id,
enrollment_date=arrival_date,
departure_date=None,
enrollment_reason="Arrivée en cours d'année"
)
db.session.add(enrollment)
movements_log.append(f"Élève {student.full_name} : arrivée en {classe.name} le {arrival_date}")
db.session.commit()
# Get domains for realistic grading elements # Get domains for realistic grading elements
domain_calcul = Domain.query.filter_by(name='Algèbre').first() domain_calcul = Domain.query.filter_by(name='Algèbre').first()
@@ -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,36 +458,54 @@ 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)
for student in students_to_grade: if eligible_students:
for element in grading_elements: students_to_grade = random.sample(eligible_students, len(eligible_students) // 2)
if random.random() < 0.7: # Grade 70% of elements for selected students
if element.grading_type == "notes": for student in students_to_grade:
base_ratio = random.uniform(0.6, 0.9) for element in grading_elements:
grade_value = round(base_ratio * element.max_points, 1) if random.random() < 0.7: # Grade 70% of elements for selected students
else: if element.grading_type == "notes":
grade_value = random.choices([0, 1, 2, 3], weights=[0.1, 0.3, 0.5, 0.1])[0] base_ratio = random.uniform(0.6, 0.9)
grade_value = round(base_ratio * element.max_points, 1)
grade = Grade( else:
student_id=student.id, grade_value = random.choices([0, 1, 2, 3], weights=[0.1, 0.3, 0.5, 0.1])[0]
grading_element_id=element.id,
value=str(grade_value) grade = Grade(
) student_id=student.id,
db.session.add(grade) grading_element_id=element.id,
value=str(grade_value)
)
db.session.add(grade)
db.session.commit() db.session.commit()
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()

175
models.py
View File

@@ -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}>'
@@ -137,6 +213,42 @@ class Student(db.Model):
@property @property
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)
@@ -159,52 +271,59 @@ 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)
students_scores[student_id] = { if student_obj:
'student': student_obj, students_scores[student_id] = {
'total_score': score_data.total_score, 'student': student_obj,
'total_max_points': score_data.total_max_points, 'total_score': score_data.total_score,
'exercises': score_data.exercises 'total_max_points': score_data.total_max_points,
} 'exercises': score_data.exercises
}
for exercise_id, student_scores in exercise_scores_data.items(): for exercise_id, student_scores in exercise_scores_data.items():
exercise_scores[exercise_id] = dict(student_scores) exercise_scores[exercise_id] = dict(student_scores)
@@ -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 {

View File

@@ -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(

View File

@@ -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 = [

View 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

View File

@@ -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
@@ -413,4 +415,363 @@ 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'))

View File

@@ -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
View 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'))

View File

@@ -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 = {}

View 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
}

View 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()

View File

@@ -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"/>

View 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 %}

View File

@@ -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"/>