feat: add temporal student gestion
This commit is contained in:
23
app.py
23
app.py
@@ -14,6 +14,7 @@ from routes.grading import bp as grading_bp
|
||||
from routes.config import bp as config_bp
|
||||
from routes.domains import bp as domains_bp
|
||||
from routes.classes import bp as classes_bp
|
||||
# from routes.student_movements import bp as student_movements_bp # Désactivé car page supprimée
|
||||
|
||||
def create_app(config_name=None):
|
||||
if config_name is None:
|
||||
@@ -45,6 +46,7 @@ def create_app(config_name=None):
|
||||
app.register_blueprint(config_bp)
|
||||
app.register_blueprint(domains_bp)
|
||||
app.register_blueprint(classes_bp)
|
||||
# app.register_blueprint(student_movements_bp) # Désactivé car page supprimée
|
||||
|
||||
# Register CLI commands
|
||||
app.cli.add_command(init_db)
|
||||
@@ -84,10 +86,23 @@ def create_app(config_name=None):
|
||||
@app.route('/students')
|
||||
def students():
|
||||
try:
|
||||
# Optimisation: utiliser joinedload pour éviter les requêtes N+1
|
||||
student_repo = StudentRepository()
|
||||
students = student_repo.find_all_with_class_ordered()
|
||||
return render_template('students.html', students=students)
|
||||
# Utilisation du repository temporel pour récupérer tous les élèves avec leur classe actuelle
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
from models import Student
|
||||
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
all_students = Student.query.order_by(Student.last_name, Student.first_name).all()
|
||||
|
||||
# Enrichir chaque élève avec sa classe actuelle
|
||||
students_with_classes = []
|
||||
for student in all_students:
|
||||
current_class = student.get_current_class()
|
||||
students_with_classes.append({
|
||||
'student': student,
|
||||
'current_class': current_class
|
||||
})
|
||||
|
||||
return render_template('students.html', students_with_classes=students_with_classes)
|
||||
except Exception as e:
|
||||
app.logger.error(f'Erreur lors du chargement des étudiants: {e}')
|
||||
return render_template('error.html', error="Erreur lors du chargement des étudiants"), 500
|
||||
|
||||
248
commands.py
248
commands.py
@@ -1,6 +1,7 @@
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
from models import db, ClassGroup, Student, Assessment, Exercise, GradingElement, Domain, CouncilAppreciation
|
||||
from models import db, ClassGroup, Student, StudentEnrollment, Assessment, Exercise, GradingElement, Domain, CouncilAppreciation
|
||||
from datetime import date, datetime
|
||||
|
||||
@click.command()
|
||||
@click.option('--mode', default='minimal', type=click.Choice(['minimal', 'midyear', 'demo']),
|
||||
@@ -34,7 +35,7 @@ def init_minimal_data():
|
||||
click.echo("Database initialized for start of school year - ready to create classes and students!")
|
||||
|
||||
def init_midyear_data():
|
||||
"""Initialize database with realistic mid-year data."""
|
||||
"""Initialize database with realistic mid-year data including temporal enrollments."""
|
||||
from app_config import config_manager
|
||||
from models import Grade
|
||||
import random
|
||||
@@ -44,6 +45,12 @@ def init_midyear_data():
|
||||
config_manager.set('context.school_name', 'Collège Jean Moulin')
|
||||
config_manager.save()
|
||||
|
||||
# Dates importantes pour les inscriptions temporelles
|
||||
school_year_start = date(2024, 9, 1) # Début d'année scolaire
|
||||
first_term_end = date(2024, 12, 20) # Fin du 1er trimestre
|
||||
second_term_start = date(2025, 1, 8) # Début du 2e trimestre
|
||||
today = date(2025, 2, 15) # Date actuelle (milieu 2e trimestre)
|
||||
|
||||
# Create 5 classes with realistic sizes
|
||||
classes_data = [
|
||||
("6ème A", "Classe de 6ème A", 28),
|
||||
@@ -71,52 +78,123 @@ def init_midyear_data():
|
||||
]
|
||||
|
||||
classes = []
|
||||
students_by_class = []
|
||||
all_students = []
|
||||
|
||||
# Global sets to ensure uniqueness across all classes
|
||||
used_names = set()
|
||||
used_emails = set()
|
||||
|
||||
# Create classes and students
|
||||
# Create classes first
|
||||
for class_name, description, nb_students in classes_data:
|
||||
classe = ClassGroup(name=class_name, description=description, year="2024-2025")
|
||||
db.session.add(classe)
|
||||
db.session.commit()
|
||||
classes.append(classe)
|
||||
|
||||
# Create students for this class
|
||||
class_students = []
|
||||
for i in range(nb_students):
|
||||
# Generate unique name combinations and emails
|
||||
while True:
|
||||
first_name = random.choice(first_names)
|
||||
last_name = random.choice(last_names)
|
||||
name_combo = f"{first_name}_{last_name}"
|
||||
base_email = f"{first_name.lower()}.{last_name.lower()}@college.edu"
|
||||
db.session.commit()
|
||||
|
||||
# Make email unique if needed
|
||||
email = base_email
|
||||
counter = 1
|
||||
while email in used_emails:
|
||||
email = f"{first_name.lower()}.{last_name.lower()}{counter}@college.edu"
|
||||
counter += 1
|
||||
# Create students without class assignment (temporal model)
|
||||
total_students = sum(nb for _, _, nb in classes_data)
|
||||
for i in range(total_students):
|
||||
# Generate unique name combinations and emails
|
||||
while True:
|
||||
first_name = random.choice(first_names)
|
||||
last_name = random.choice(last_names)
|
||||
name_combo = f"{first_name}_{last_name}"
|
||||
base_email = f"{first_name.lower()}.{last_name.lower()}@college.edu"
|
||||
|
||||
if name_combo not in used_names:
|
||||
used_names.add(name_combo)
|
||||
used_emails.add(email)
|
||||
break
|
||||
# Make email unique if needed
|
||||
email = base_email
|
||||
counter = 1
|
||||
while email in used_emails:
|
||||
email = f"{first_name.lower()}.{last_name.lower()}{counter}@college.edu"
|
||||
counter += 1
|
||||
|
||||
student = Student(
|
||||
last_name=last_name,
|
||||
first_name=first_name,
|
||||
email=email,
|
||||
class_group_id=classe.id
|
||||
)
|
||||
db.session.add(student)
|
||||
class_students.append(student)
|
||||
if name_combo not in used_names:
|
||||
used_names.add(name_combo)
|
||||
used_emails.add(email)
|
||||
break
|
||||
|
||||
students_by_class.append(class_students)
|
||||
db.session.commit()
|
||||
student = Student(
|
||||
last_name=last_name,
|
||||
first_name=first_name,
|
||||
email=email
|
||||
# Pas de class_group_id dans le nouveau modèle temporel
|
||||
)
|
||||
db.session.add(student)
|
||||
all_students.append(student)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Create temporal enrollments with realistic movements
|
||||
student_index = 0
|
||||
movements_log = []
|
||||
|
||||
for i, (class_name, description, nb_students) in enumerate(classes_data):
|
||||
classe = classes[i]
|
||||
|
||||
# Répartir les élèves pour cette classe
|
||||
class_students = all_students[student_index:student_index + nb_students]
|
||||
student_index += nb_students
|
||||
|
||||
for student in class_students:
|
||||
# 90% des élèves : inscrits depuis le début d'année, toujours présents
|
||||
if random.random() < 0.90:
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student.id,
|
||||
class_group_id=classe.id,
|
||||
enrollment_date=school_year_start,
|
||||
departure_date=None, # Toujours inscrit
|
||||
enrollment_reason="Inscription début d'année",
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
|
||||
# 7% des élèves : ont quitté pendant le 1er trimestre
|
||||
elif random.random() < 0.97: # 7% des 10% restants
|
||||
departure_date = date(2024, random.randint(10, 11), random.randint(1, 28))
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student.id,
|
||||
class_group_id=classe.id,
|
||||
enrollment_date=school_year_start,
|
||||
departure_date=departure_date,
|
||||
enrollment_reason="Inscription début d'année",
|
||||
departure_reason="Déménagement"
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
movements_log.append(f"Élève {student.full_name} : départ de {classe.name} le {departure_date}")
|
||||
|
||||
# Créer un nouvel élève pour remplacer (arrivé en janvier)
|
||||
new_student = Student(
|
||||
last_name=random.choice(last_names),
|
||||
first_name=random.choice(first_names),
|
||||
email=f"nouveau.{random.randint(1000,9999)}@college.edu"
|
||||
)
|
||||
db.session.add(new_student)
|
||||
db.session.commit() # Pour avoir l'ID
|
||||
|
||||
new_enrollment = StudentEnrollment(
|
||||
student_id=new_student.id,
|
||||
class_group_id=classe.id,
|
||||
enrollment_date=second_term_start,
|
||||
departure_date=None,
|
||||
enrollment_reason="Nouvel élève - arrivée en cours d'année"
|
||||
)
|
||||
db.session.add(new_enrollment)
|
||||
movements_log.append(f"Élève {new_student.full_name} : arrivée en {classe.name} le {second_term_start}")
|
||||
|
||||
# 3% des élèves : arrivés en cours d'année (janvier)
|
||||
else:
|
||||
arrival_date = date(2025, 1, random.randint(8, 31))
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student.id,
|
||||
class_group_id=classe.id,
|
||||
enrollment_date=arrival_date,
|
||||
departure_date=None,
|
||||
enrollment_reason="Arrivée en cours d'année"
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
movements_log.append(f"Élève {student.full_name} : arrivée en {classe.name} le {arrival_date}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Get domains for realistic grading elements
|
||||
domain_calcul = Domain.query.filter_by(name='Algèbre').first()
|
||||
@@ -290,15 +368,20 @@ def init_midyear_data():
|
||||
}
|
||||
]
|
||||
|
||||
# Create assessments for each class
|
||||
for i, classe in enumerate(classes):
|
||||
class_students = students_by_class[i]
|
||||
|
||||
# Create assessments for each class with temporal logic
|
||||
for classe in classes:
|
||||
for assessment_template in assessments_templates:
|
||||
# Create assessment
|
||||
# Create assessment with realistic date
|
||||
assessment_date = school_year_start
|
||||
if assessment_template["trimester"] == 1:
|
||||
assessment_date = date(2024, random.randint(10, 11), random.randint(1, 28))
|
||||
elif assessment_template["trimester"] == 2:
|
||||
assessment_date = date(2025, random.randint(1, 2), random.randint(1, 28))
|
||||
|
||||
assessment = Assessment(
|
||||
title=assessment_template["title"],
|
||||
description=assessment_template["description"],
|
||||
date=assessment_date,
|
||||
trimester=assessment_template["trimester"],
|
||||
class_group_id=classe.id,
|
||||
coefficient=assessment_template["coefficient"]
|
||||
@@ -338,8 +421,13 @@ def init_midyear_data():
|
||||
|
||||
# Add grades if assessment should be corrected
|
||||
if assessment_template["corrected"]:
|
||||
# Generate realistic grades for all students
|
||||
for student in class_students:
|
||||
# Get students eligible for this assessment using temporal logic
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
eligible_students = temporal_repo.find_eligible_for_assessment(assessment)
|
||||
|
||||
# Generate realistic grades only for eligible students
|
||||
for student in eligible_students:
|
||||
for element in grading_elements:
|
||||
# Generate realistic grade based on element type
|
||||
if element.grading_type == "notes":
|
||||
@@ -370,36 +458,54 @@ def init_midyear_data():
|
||||
|
||||
elif assessment_template.get("corrected") == False and random.random() < 0.4:
|
||||
# Partially grade some assessments (40% of non-corrected ones get partial grades)
|
||||
students_to_grade = random.sample(class_students, len(class_students) // 2)
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
eligible_students = temporal_repo.find_eligible_for_assessment(assessment)
|
||||
|
||||
for student in students_to_grade:
|
||||
for element in grading_elements:
|
||||
if random.random() < 0.7: # Grade 70% of elements for selected students
|
||||
if element.grading_type == "notes":
|
||||
base_ratio = random.uniform(0.6, 0.9)
|
||||
grade_value = round(base_ratio * element.max_points, 1)
|
||||
else:
|
||||
grade_value = random.choices([0, 1, 2, 3], weights=[0.1, 0.3, 0.5, 0.1])[0]
|
||||
if eligible_students:
|
||||
students_to_grade = random.sample(eligible_students, len(eligible_students) // 2)
|
||||
|
||||
grade = Grade(
|
||||
student_id=student.id,
|
||||
grading_element_id=element.id,
|
||||
value=str(grade_value)
|
||||
)
|
||||
db.session.add(grade)
|
||||
for student in students_to_grade:
|
||||
for element in grading_elements:
|
||||
if random.random() < 0.7: # Grade 70% of elements for selected students
|
||||
if element.grading_type == "notes":
|
||||
base_ratio = random.uniform(0.6, 0.9)
|
||||
grade_value = round(base_ratio * element.max_points, 1)
|
||||
else:
|
||||
grade_value = random.choices([0, 1, 2, 3], weights=[0.1, 0.3, 0.5, 0.1])[0]
|
||||
|
||||
grade = Grade(
|
||||
student_id=student.id,
|
||||
grading_element_id=element.id,
|
||||
value=str(grade_value)
|
||||
)
|
||||
db.session.add(grade)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
total_students = sum(nb for _, _, nb in classes_data)
|
||||
total_assessments = len(classes) * len(assessments_templates)
|
||||
click.echo(f"Database initialized for mid-year scenario:")
|
||||
click.echo(f" - {len(classes)} classes with {total_students} students total")
|
||||
click.echo(f" - {total_assessments} assessments created")
|
||||
|
||||
# Compter les inscriptions actuelles et mouvements
|
||||
total_current_students = StudentEnrollment.query.filter(StudentEnrollment.departure_date.is_(None)).count()
|
||||
total_movements = len(movements_log)
|
||||
|
||||
click.echo(f"Database initialized for mid-year scenario with temporal enrollments:")
|
||||
click.echo(f" - {len(classes)} classes with {total_students} students initially")
|
||||
click.echo(f" - {total_current_students} students currently enrolled")
|
||||
click.echo(f" - {total_movements} student movements simulated")
|
||||
click.echo(f" - {total_assessments} assessments created with temporal logic")
|
||||
click.echo(f" - 4 fully corrected assessments per class (Trimester 1)")
|
||||
click.echo(f" - 2 partially corrected assessments per class (Trimester 2)")
|
||||
click.echo(f"")
|
||||
click.echo(f"Student movements summary:")
|
||||
for movement in movements_log[:5]: # Montrer les 5 premiers mouvements
|
||||
click.echo(f" - {movement}")
|
||||
if len(movements_log) > 5:
|
||||
click.echo(f" ... and {len(movements_log) - 5} more movements")
|
||||
|
||||
def init_demo_data():
|
||||
"""Initialize database with simple demo data (original behavior)."""
|
||||
"""Initialize database with simple demo data using temporal enrollment model."""
|
||||
# Create sample class groups
|
||||
classe_6a = ClassGroup(name="6ème A", description="Classe de 6ème A", year="2024-2025")
|
||||
classe_5b = ClassGroup(name="5ème B", description="Classe de 5ème B", year="2024-2025")
|
||||
@@ -408,7 +514,7 @@ def init_demo_data():
|
||||
db.session.add(classe_5b)
|
||||
db.session.commit()
|
||||
|
||||
# Create sample students
|
||||
# Create sample students (without class_group_id)
|
||||
students_data = [
|
||||
("Dupont", "Marie", "marie.dupont@email.com", classe_6a.id),
|
||||
("Martin", "Pierre", "pierre.martin@email.com", classe_6a.id),
|
||||
@@ -417,14 +523,28 @@ def init_demo_data():
|
||||
("Bernard", "Emma", "emma.bernard@email.com", classe_5b.id),
|
||||
]
|
||||
|
||||
for last_name, first_name, email, class_group_id in students_data:
|
||||
school_start = date(2024, 9, 1)
|
||||
|
||||
for last_name, first_name, email, intended_class_id in students_data:
|
||||
# Create student without class assignment
|
||||
student = Student(
|
||||
last_name=last_name,
|
||||
first_name=first_name,
|
||||
email=email,
|
||||
class_group_id=class_group_id
|
||||
email=email
|
||||
# Pas de class_group_id dans le nouveau modèle temporel
|
||||
)
|
||||
db.session.add(student)
|
||||
db.session.commit() # Pour avoir l'ID du student
|
||||
|
||||
# Create temporal enrollment
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student.id,
|
||||
class_group_id=intended_class_id,
|
||||
enrollment_date=school_start,
|
||||
departure_date=None, # Toujours inscrit
|
||||
enrollment_reason="Inscription demo"
|
||||
)
|
||||
db.session.add(enrollment)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
175
models.py
175
models.py
@@ -1,6 +1,6 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
from sqlalchemy import CheckConstraint, Enum
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import CheckConstraint, Enum, Index
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
db = SQLAlchemy()
|
||||
@@ -57,8 +57,25 @@ class ClassGroup(db.Model):
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
description = db.Column(db.Text)
|
||||
year = db.Column(db.String(20), nullable=False)
|
||||
students = db.relationship('Student', backref='class_group', lazy=True)
|
||||
|
||||
# SUPPRESSION de la relation directe students (remplacée par logique temporelle)
|
||||
# students sera accessible via la relation StudentEnrollment
|
||||
|
||||
assessments = db.relationship('Assessment', backref='class_group', lazy=True)
|
||||
# enrollments déjà défini via backref dans StudentEnrollment
|
||||
|
||||
@property
|
||||
def students(self):
|
||||
"""Retourne les élèves actuellement inscrits dans cette classe."""
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
return temporal_repo.find_current_students_in_class(self.id)
|
||||
|
||||
def get_students_at_date(self, check_date: date):
|
||||
"""Retourne les élèves inscrits dans cette classe à une date donnée."""
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
return temporal_repo.find_enrolled_in_class_at_date(self.id, check_date)
|
||||
|
||||
def get_trimester_statistics(self, trimester=None):
|
||||
"""
|
||||
@@ -123,13 +140,72 @@ class ClassGroup(db.Model):
|
||||
def __repr__(self):
|
||||
return f'<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):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
last_name = db.Column(db.String(100), nullable=False)
|
||||
first_name = db.Column(db.String(100), nullable=False)
|
||||
email = db.Column(db.String(120), unique=True)
|
||||
class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False)
|
||||
|
||||
# SUPPRESSION de class_group_id (relation statique)
|
||||
# Remplacé par la relation temporelle via StudentEnrollment
|
||||
|
||||
grades = db.relationship('Grade', backref='student', lazy=True)
|
||||
# enrollments déjà défini via backref dans StudentEnrollment
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Student {self.first_name} {self.last_name}>'
|
||||
@@ -138,6 +214,42 @@ class Student(db.Model):
|
||||
def full_name(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
def get_current_class(self) -> Optional['ClassGroup']:
|
||||
"""Retourne la classe actuelle de l'élève (inscription active)."""
|
||||
active_enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=self.id,
|
||||
departure_date=None
|
||||
).first()
|
||||
return active_enrollment.class_group if active_enrollment else None
|
||||
|
||||
def get_current_enrollment(self) -> Optional['StudentEnrollment']:
|
||||
"""Retourne l'inscription actuelle de l'élève (inscription active)."""
|
||||
return StudentEnrollment.query.filter_by(
|
||||
student_id=self.id,
|
||||
departure_date=None
|
||||
).first()
|
||||
|
||||
def get_class_at_date(self, check_date: date) -> Optional['ClassGroup']:
|
||||
"""Retourne la classe de l'élève à une date donnée."""
|
||||
enrollment = StudentEnrollment.query.filter(
|
||||
StudentEnrollment.student_id == self.id,
|
||||
StudentEnrollment.enrollment_date <= check_date,
|
||||
db.or_(
|
||||
StudentEnrollment.departure_date.is_(None),
|
||||
StudentEnrollment.departure_date >= check_date
|
||||
)
|
||||
).first()
|
||||
return enrollment.class_group if enrollment else None
|
||||
|
||||
def is_eligible_for_assessment(self, assessment: 'Assessment') -> bool:
|
||||
"""Vérifie si l'élève peut être évalué sur cette évaluation."""
|
||||
if not assessment.date:
|
||||
return False
|
||||
|
||||
# L'élève doit être inscrit dans la classe de l'évaluation à la date de l'évaluation
|
||||
class_at_assessment_date = self.get_class_at_date(assessment.date)
|
||||
return class_at_assessment_date and class_at_assessment_date.id == assessment.class_group_id
|
||||
|
||||
class Assessment(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
@@ -159,52 +271,59 @@ class Assessment(db.Model):
|
||||
def grading_progress(self):
|
||||
"""
|
||||
Calcule le pourcentage de progression des notes saisies pour cette évaluation.
|
||||
Utilise AssessmentProgressService avec injection de dépendances.
|
||||
Utilise TemporalAssessmentProgressService avec injection de dépendances.
|
||||
|
||||
Returns:
|
||||
Dict avec les statistiques de progression
|
||||
Dict avec les statistiques de progression (version temporelle)
|
||||
"""
|
||||
from providers.concrete_providers import AssessmentServicesFactory
|
||||
from services.temporal_facade import TemporalAssessmentServicesFactory
|
||||
|
||||
# Injection de dépendances pour éviter les imports circulaires
|
||||
services_facade = AssessmentServicesFactory.create_facade()
|
||||
progress_result = services_facade.get_grading_progress(self)
|
||||
temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade()
|
||||
progress_result = temporal_facade.get_grading_progress(self)
|
||||
|
||||
# Conversion du ProgressResult vers le format dict attendu
|
||||
# Conversion du TemporalProgressResult vers le format dict attendu
|
||||
return {
|
||||
'percentage': progress_result.percentage,
|
||||
'completed': progress_result.completed,
|
||||
'total': progress_result.total,
|
||||
'status': progress_result.status,
|
||||
'students_count': progress_result.students_count
|
||||
'students_count': progress_result.students_count,
|
||||
'eligible_students_count': progress_result.eligible_students_count,
|
||||
'total_students_in_class': progress_result.total_students_in_class
|
||||
}
|
||||
|
||||
def calculate_student_scores(self, grade_repo=None):
|
||||
"""Calcule les scores de tous les élèves pour cette évaluation.
|
||||
"""Calcule les scores de tous les élèves éligibles pour cette évaluation.
|
||||
Retourne un dictionnaire avec les scores par élève et par exercice.
|
||||
Utilise StudentScoreCalculator avec injection de dépendances.
|
||||
Utilise TemporalStudentScoreCalculator avec injection de dépendances.
|
||||
|
||||
Args:
|
||||
grade_repo: Repository des notes (optionnel, maintenu pour compatibilité)
|
||||
"""
|
||||
from providers.concrete_providers import AssessmentServicesFactory
|
||||
from services.temporal_facade import TemporalAssessmentServicesFactory
|
||||
|
||||
services = AssessmentServicesFactory.create_facade()
|
||||
students_scores_data, exercise_scores_data = services.score_calculator.calculate_student_scores(self)
|
||||
temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade()
|
||||
students_scores_data, exercise_scores_data = temporal_facade.score_calculator.calculate_student_scores(self)
|
||||
|
||||
# Récupérer les élèves éligibles pour la conversion
|
||||
eligible_students = temporal_facade.get_eligible_students(self)
|
||||
eligible_students_dict = {s.id: s for s in eligible_students}
|
||||
|
||||
# Conversion vers format legacy pour compatibilité
|
||||
students_scores = {}
|
||||
exercise_scores = {}
|
||||
|
||||
for student_id, score_data in students_scores_data.items():
|
||||
# Récupérer l'objet étudiant pour compatibilité
|
||||
student_obj = next(s for s in self.class_group.students if s.id == student_id)
|
||||
students_scores[student_id] = {
|
||||
'student': student_obj,
|
||||
'total_score': score_data.total_score,
|
||||
'total_max_points': score_data.total_max_points,
|
||||
'exercises': score_data.exercises
|
||||
}
|
||||
# Utiliser les élèves éligibles au lieu de tous les élèves de la classe
|
||||
student_obj = eligible_students_dict.get(student_id)
|
||||
if student_obj:
|
||||
students_scores[student_id] = {
|
||||
'student': student_obj,
|
||||
'total_score': score_data.total_score,
|
||||
'total_max_points': score_data.total_max_points,
|
||||
'exercises': score_data.exercises
|
||||
}
|
||||
|
||||
for exercise_id, student_scores in exercise_scores_data.items():
|
||||
exercise_scores[exercise_id] = dict(student_scores)
|
||||
@@ -214,11 +333,11 @@ class Assessment(db.Model):
|
||||
def get_assessment_statistics(self):
|
||||
"""
|
||||
Calcule les statistiques descriptives pour cette évaluation.
|
||||
Utilise AssessmentStatisticsService avec injection de dépendances.
|
||||
Utilise TemporalAssessmentStatisticsService avec injection de dépendances.
|
||||
"""
|
||||
from providers.concrete_providers import AssessmentServicesFactory
|
||||
services = AssessmentServicesFactory.create_facade()
|
||||
result = services.statistics_service.get_assessment_statistics(self)
|
||||
from services.temporal_facade import TemporalAssessmentServicesFactory
|
||||
temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade()
|
||||
result = temporal_facade.get_assessment_statistics(self)
|
||||
|
||||
# Conversion du StatisticsResult vers le format dict legacy
|
||||
return {
|
||||
|
||||
@@ -71,10 +71,12 @@ class AppreciationRepository(BaseRepository[CouncilAppreciation]):
|
||||
|
||||
def get_completion_stats(self, class_group_id: int, trimester: int) -> dict:
|
||||
"""Statistiques de completion des appréciations pour une classe/trimestre."""
|
||||
from models import Student
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
|
||||
# Nombre total d'élèves dans la classe
|
||||
total_students = Student.query.filter_by(class_group_id=class_group_id).count()
|
||||
# Nombre total d'élèves actuellement dans la classe
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
current_students = temporal_repo.find_current_students_in_class(class_group_id)
|
||||
total_students = len(current_students)
|
||||
|
||||
# Nombre d'appréciations existantes
|
||||
total_appreciations = CouncilAppreciation.query.filter_by(
|
||||
|
||||
@@ -99,21 +99,29 @@ class ClassRepository(BaseRepository[ClassGroup]):
|
||||
Returns:
|
||||
Tuple[bool, Dict[str, int]]: (peut_être_supprimée, statistiques_dépendances)
|
||||
"""
|
||||
students_count = Student.query.filter_by(class_group_id=id).count()
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
from models import StudentEnrollment
|
||||
|
||||
# Compter les élèves actuellement inscrits ou ayant été inscrits
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
current_students = temporal_repo.find_current_students_in_class(id)
|
||||
enrollments_count = StudentEnrollment.query.filter_by(class_group_id=id).count()
|
||||
assessments_count = Assessment.query.filter_by(class_group_id=id).count()
|
||||
|
||||
dependencies = {
|
||||
'students': students_count,
|
||||
'students': len(current_students),
|
||||
'enrollments': enrollments_count,
|
||||
'assessments': assessments_count
|
||||
}
|
||||
|
||||
can_delete = students_count == 0 and assessments_count == 0
|
||||
# Une classe peut être supprimée s'il n'y a aucune inscription historique ni évaluation
|
||||
can_delete = enrollments_count == 0 and assessments_count == 0
|
||||
|
||||
return can_delete, dependencies
|
||||
|
||||
def find_with_students_ordered(self, id: int) -> Optional[ClassGroup]:
|
||||
"""
|
||||
Trouve une classe avec ses étudiants triés par nom.
|
||||
Trouve une classe avec ses étudiants actuels triés par nom.
|
||||
|
||||
Args:
|
||||
id: Identifiant de la classe
|
||||
@@ -125,10 +133,10 @@ class ClassRepository(BaseRepository[ClassGroup]):
|
||||
if not class_group:
|
||||
return None
|
||||
|
||||
# Charger les étudiants triés
|
||||
students = Student.query.filter_by(class_group_id=id).order_by(
|
||||
Student.last_name, Student.first_name
|
||||
).all()
|
||||
# Utiliser la logique temporelle pour récupérer les élèves actuels
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
students = temporal_repo.find_current_students_in_class(id)
|
||||
|
||||
# Assigner les étudiants triés à la classe
|
||||
class_group._students_ordered = students
|
||||
@@ -162,7 +170,7 @@ class ClassRepository(BaseRepository[ClassGroup]):
|
||||
|
||||
def find_with_full_details(self, id: int) -> Optional[ClassGroup]:
|
||||
"""
|
||||
Trouve une classe avec tous ses détails (étudiants et évaluations).
|
||||
Trouve une classe avec tous ses détails (étudiants actuels et évaluations).
|
||||
|
||||
Args:
|
||||
id: Identifiant de la classe
|
||||
@@ -170,22 +178,32 @@ class ClassRepository(BaseRepository[ClassGroup]):
|
||||
Returns:
|
||||
Optional[ClassGroup]: La classe avec tous ses détails ou None
|
||||
"""
|
||||
return ClassGroup.query.options(
|
||||
joinedload(ClassGroup.students),
|
||||
class_group = ClassGroup.query.options(
|
||||
joinedload(ClassGroup.assessments)
|
||||
).filter_by(id=id).first()
|
||||
|
||||
if class_group:
|
||||
# Charger manuellement les élèves actuels avec la logique temporelle
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
class_group._current_students = temporal_repo.find_current_students_in_class(id)
|
||||
|
||||
return class_group
|
||||
|
||||
def get_students_count(self, id: int) -> int:
|
||||
"""
|
||||
Compte le nombre d'étudiants dans une classe.
|
||||
Compte le nombre d'étudiants actuellement dans une classe.
|
||||
|
||||
Args:
|
||||
id: Identifiant de la classe
|
||||
|
||||
Returns:
|
||||
int: Nombre d'étudiants
|
||||
int: Nombre d'étudiants actuels
|
||||
"""
|
||||
return Student.query.filter_by(class_group_id=id).count()
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
current_students = temporal_repo.find_current_students_in_class(id)
|
||||
return len(current_students)
|
||||
|
||||
def get_assessments_count(self, id: int) -> int:
|
||||
"""
|
||||
@@ -212,8 +230,7 @@ class ClassRepository(BaseRepository[ClassGroup]):
|
||||
def find_with_statistics(self, class_id: int, trimester: Optional[int] = None) -> Optional[ClassGroup]:
|
||||
"""
|
||||
Récupère une classe avec toutes les données nécessaires pour les statistiques.
|
||||
Optimise les requêtes pour éviter les problèmes N+1 en chargeant toutes les relations
|
||||
nécessaires en une seule requête.
|
||||
Utilise la logique temporelle pour les étudiants et optimise les requêtes.
|
||||
|
||||
Args:
|
||||
class_id: Identifiant de la classe
|
||||
@@ -223,17 +240,21 @@ class ClassRepository(BaseRepository[ClassGroup]):
|
||||
Optional[ClassGroup]: La classe avec toutes ses données ou None
|
||||
"""
|
||||
try:
|
||||
# Construire la requête avec toutes les jointures optimisées
|
||||
# Construire la requête sans la relation students (supprimée du modèle temporel)
|
||||
query = ClassGroup.query.options(
|
||||
joinedload(ClassGroup.students),
|
||||
selectinload(ClassGroup.assessments).selectinload(Assessment.exercises)
|
||||
.selectinload(Exercise.grading_elements).selectinload(GradingElement.grades)
|
||||
).filter_by(id=class_id)
|
||||
|
||||
class_group = query.first()
|
||||
|
||||
# Filtrer les évaluations après récupération pour optimiser les calculs statistiques
|
||||
if class_group:
|
||||
# Charger les élèves actuels avec la logique temporelle
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
class_group._current_students = temporal_repo.find_current_students_in_class(class_id)
|
||||
|
||||
# Filtrer les évaluations après récupération pour optimiser les calculs statistiques
|
||||
if trimester is not None:
|
||||
class_group._filtered_assessments = [
|
||||
assessment for assessment in class_group.assessments
|
||||
@@ -303,9 +324,8 @@ class ClassRepository(BaseRepository[ClassGroup]):
|
||||
Optional[ClassGroup]: La classe avec ses évaluations optimisées ou None
|
||||
"""
|
||||
try:
|
||||
# Single-query avec toutes les relations nécessaires
|
||||
# Single-query avec toutes les relations nécessaires (sans students)
|
||||
base_query = ClassGroup.query.options(
|
||||
joinedload(ClassGroup.students),
|
||||
selectinload(ClassGroup.assessments).selectinload(Assessment.exercises)
|
||||
.selectinload(Exercise.grading_elements).selectinload(GradingElement.grades)
|
||||
)
|
||||
@@ -315,6 +335,11 @@ class ClassRepository(BaseRepository[ClassGroup]):
|
||||
if not class_group:
|
||||
return None
|
||||
|
||||
# Charger les élèves actuels avec la logique temporelle
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
class_group._current_students = temporal_repo.find_current_students_in_class(class_id)
|
||||
|
||||
# Pré-filtrer les évaluations par trimestre
|
||||
if trimester is not None:
|
||||
filtered_assessments = [
|
||||
|
||||
166
repositories/temporal_student_repository.py
Normal file
166
repositories/temporal_student_repository.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from typing import List, Optional, Tuple
|
||||
from datetime import date
|
||||
from sqlalchemy import and_, or_
|
||||
from models import db, Student, StudentEnrollment, Assessment
|
||||
from repositories.base_repository import BaseRepository
|
||||
|
||||
|
||||
class TemporalStudentRepository(BaseRepository[Student]):
|
||||
"""Repository pour gérer les étudiants avec logique temporelle."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Student)
|
||||
|
||||
def find_enrolled_in_class_at_date(self, class_group_id: int, check_date: date) -> List[Student]:
|
||||
"""Trouve les étudiants inscrits dans une classe à une date donnée."""
|
||||
from sqlalchemy import func
|
||||
return db.session.query(Student)\
|
||||
.join(StudentEnrollment)\
|
||||
.filter(
|
||||
StudentEnrollment.class_group_id == class_group_id,
|
||||
StudentEnrollment.enrollment_date <= check_date,
|
||||
or_(
|
||||
StudentEnrollment.departure_date.is_(None),
|
||||
StudentEnrollment.departure_date >= check_date
|
||||
)
|
||||
)\
|
||||
.order_by(func.lower(Student.last_name), func.lower(Student.first_name))\
|
||||
.all()
|
||||
|
||||
def find_eligible_for_assessment(self, assessment: Assessment) -> List[Student]:
|
||||
"""Trouve les étudiants éligibles pour une évaluation donnée."""
|
||||
if not assessment.date:
|
||||
return []
|
||||
|
||||
return self.find_enrolled_in_class_at_date(assessment.class_group_id, assessment.date)
|
||||
|
||||
def find_current_students_in_class(self, class_group_id: int) -> List[Student]:
|
||||
"""Trouve les étudiants actuellement inscrits dans une classe."""
|
||||
from sqlalchemy import func
|
||||
return db.session.query(Student)\
|
||||
.join(StudentEnrollment)\
|
||||
.filter(
|
||||
StudentEnrollment.class_group_id == class_group_id,
|
||||
StudentEnrollment.departure_date.is_(None)
|
||||
)\
|
||||
.order_by(func.lower(Student.last_name), func.lower(Student.first_name))\
|
||||
.all()
|
||||
|
||||
def get_enrollment_history(self, student_id: int) -> List[StudentEnrollment]:
|
||||
"""Récupère l'historique complet des inscriptions d'un élève."""
|
||||
return StudentEnrollment.query\
|
||||
.filter_by(student_id=student_id)\
|
||||
.order_by(StudentEnrollment.enrollment_date.desc())\
|
||||
.all()
|
||||
|
||||
def find_students_with_movements_in_period(self, start_date: date, end_date: date) -> List[Tuple[Student, List[StudentEnrollment]]]:
|
||||
"""Trouve les étudiants qui ont eu des mouvements (arrivée/départ) dans une période avec leurs inscriptions."""
|
||||
# Récupérer les étudiants qui ont eu des mouvements dans la période
|
||||
students_with_movements = db.session.query(Student)\
|
||||
.join(StudentEnrollment)\
|
||||
.filter(
|
||||
or_(
|
||||
# Arrivées dans la période
|
||||
and_(
|
||||
StudentEnrollment.enrollment_date >= start_date,
|
||||
StudentEnrollment.enrollment_date <= end_date
|
||||
),
|
||||
# Départs dans la période
|
||||
and_(
|
||||
StudentEnrollment.departure_date >= start_date,
|
||||
StudentEnrollment.departure_date <= end_date
|
||||
)
|
||||
)
|
||||
)\
|
||||
.distinct()\
|
||||
.order_by(Student.last_name, Student.first_name)\
|
||||
.all()
|
||||
|
||||
# Pour chaque étudiant, récupérer ses mouvements dans la période
|
||||
result = []
|
||||
for student in students_with_movements:
|
||||
movements = StudentEnrollment.query\
|
||||
.filter_by(student_id=student.id)\
|
||||
.filter(
|
||||
or_(
|
||||
# Arrivées dans la période
|
||||
and_(
|
||||
StudentEnrollment.enrollment_date >= start_date,
|
||||
StudentEnrollment.enrollment_date <= end_date
|
||||
),
|
||||
# Départs dans la période
|
||||
and_(
|
||||
StudentEnrollment.departure_date >= start_date,
|
||||
StudentEnrollment.departure_date <= end_date
|
||||
)
|
||||
)
|
||||
)\
|
||||
.order_by(StudentEnrollment.enrollment_date.desc())\
|
||||
.all()
|
||||
|
||||
if movements:
|
||||
result.append((student, movements))
|
||||
|
||||
return result
|
||||
|
||||
def create_enrollment(self, student_id: int, class_group_id: int,
|
||||
enrollment_date: date, enrollment_reason: str = None) -> StudentEnrollment:
|
||||
"""Crée une nouvelle inscription pour un élève."""
|
||||
# Vérifier s'il y a déjà une inscription active
|
||||
active_enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id,
|
||||
departure_date=None
|
||||
).first()
|
||||
|
||||
if active_enrollment:
|
||||
raise ValueError("L'élève a déjà une inscription active")
|
||||
|
||||
# Créer la nouvelle inscription
|
||||
enrollment = StudentEnrollment(
|
||||
student_id=student_id,
|
||||
class_group_id=class_group_id,
|
||||
enrollment_date=enrollment_date,
|
||||
enrollment_reason=enrollment_reason
|
||||
)
|
||||
|
||||
db.session.add(enrollment)
|
||||
return enrollment
|
||||
|
||||
def end_enrollment(self, student_id: int, departure_date: date,
|
||||
departure_reason: str = None) -> Optional[StudentEnrollment]:
|
||||
"""Termine l'inscription active d'un élève."""
|
||||
active_enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=student_id,
|
||||
departure_date=None
|
||||
).first()
|
||||
|
||||
if not active_enrollment:
|
||||
return None
|
||||
|
||||
active_enrollment.departure_date = departure_date
|
||||
active_enrollment.departure_reason = departure_reason
|
||||
|
||||
return active_enrollment
|
||||
|
||||
def transfer_student(self, student_id: int, new_class_group_id: int,
|
||||
transfer_date: date, transfer_reason: str = None) -> tuple[StudentEnrollment, StudentEnrollment]:
|
||||
"""Transfère un élève d'une classe à une autre."""
|
||||
# Terminer l'inscription actuelle
|
||||
old_enrollment = self.end_enrollment(
|
||||
student_id,
|
||||
transfer_date,
|
||||
f"Transfert: {transfer_reason}" if transfer_reason else "Transfert"
|
||||
)
|
||||
|
||||
if not old_enrollment:
|
||||
raise ValueError("Aucune inscription active trouvée pour cet élève")
|
||||
|
||||
# Créer la nouvelle inscription
|
||||
new_enrollment = self.create_enrollment(
|
||||
student_id,
|
||||
new_class_group_id,
|
||||
transfer_date,
|
||||
f"Transfert: {transfer_reason}" if transfer_reason else "Transfert"
|
||||
)
|
||||
|
||||
return old_enrollment, new_enrollment
|
||||
@@ -1,8 +1,9 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app, abort
|
||||
from models import db, ClassGroup, Student, Assessment
|
||||
from models import db, ClassGroup, Student, Assessment, StudentEnrollment
|
||||
from forms import ClassGroupForm
|
||||
from utils import handle_db_errors, ValidationError
|
||||
from repositories.class_repository import ClassRepository
|
||||
from datetime import date, datetime
|
||||
|
||||
bp = Blueprint('classes', __name__, url_prefix='/classes')
|
||||
|
||||
@@ -320,7 +321,8 @@ def save_appreciation_api(class_id, student_id):
|
||||
# Vérifier que l'élève appartient à cette classe
|
||||
from models import Student
|
||||
student = Student.query.get_or_404(student_id)
|
||||
if student.class_group_id != class_id:
|
||||
current_class = student.get_current_class()
|
||||
if not current_class or current_class.id != class_id:
|
||||
return jsonify({'success': False, 'error': 'Élève non trouvé dans cette classe'}), 403
|
||||
|
||||
# Préparer les données d'appréciation
|
||||
@@ -414,3 +416,362 @@ def council_data_api(class_id):
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Erreur API données conseil classe {class_id}: {e}')
|
||||
return jsonify({'error': 'Erreur lors de la récupération des données'}), 500
|
||||
|
||||
@bp.route('/<int:id>/students')
|
||||
@handle_db_errors
|
||||
def students(id):
|
||||
"""Page de gestion des élèves d'une classe."""
|
||||
class_repo = ClassRepository()
|
||||
class_group = class_repo.get_or_404(id)
|
||||
|
||||
try:
|
||||
# Repository temporel pour les élèves
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
# Élèves actuellement inscrits
|
||||
current_students = temporal_repo.find_current_students_in_class(id)
|
||||
|
||||
# Historique des mouvements (derniers 6 mois)
|
||||
from datetime import date, timedelta
|
||||
six_months_ago = date.today() - timedelta(days=180)
|
||||
movements = temporal_repo.find_students_with_movements_in_period(six_months_ago, date.today())
|
||||
|
||||
# Filtrer les mouvements de cette classe
|
||||
class_movements = []
|
||||
for student, enrollments in movements:
|
||||
class_enrollments = [e for e in enrollments if e.class_group_id == id]
|
||||
if class_enrollments:
|
||||
class_movements.append((student, class_enrollments))
|
||||
|
||||
# Statistiques d'effectifs
|
||||
total_current = len(current_students)
|
||||
|
||||
# Compter les arrivées et départs récents (30 derniers jours)
|
||||
thirty_days_ago = date.today() - timedelta(days=30)
|
||||
recent_arrivals = 0
|
||||
recent_departures = 0
|
||||
|
||||
for student, enrollments in class_movements:
|
||||
for enrollment in enrollments:
|
||||
if enrollment.enrollment_date and enrollment.enrollment_date >= thirty_days_ago:
|
||||
recent_arrivals += 1
|
||||
if enrollment.departure_date and enrollment.departure_date >= thirty_days_ago:
|
||||
recent_departures += 1
|
||||
|
||||
# Toutes les classes pour les transferts
|
||||
all_classes = ClassRepository().find_all_ordered('name')
|
||||
other_classes = [c for c in all_classes if c.id != id]
|
||||
|
||||
# Élèves non inscrits dans cette classe pour inscription
|
||||
from sqlalchemy import func
|
||||
all_students = Student.query.order_by(func.lower(Student.last_name), func.lower(Student.first_name)).all()
|
||||
available_students = []
|
||||
for student in all_students:
|
||||
current_class = student.get_current_class()
|
||||
if not current_class or current_class.id != id:
|
||||
available_students.append(student)
|
||||
|
||||
current_app.logger.debug(f'Page élèves classe {id} - {total_current} élèves actuels, {len(class_movements)} mouvements')
|
||||
|
||||
return render_template('class_students.html',
|
||||
class_group=class_group,
|
||||
current_students=current_students,
|
||||
class_movements=class_movements,
|
||||
other_classes=other_classes,
|
||||
available_students=available_students,
|
||||
stats={
|
||||
'total_current': total_current,
|
||||
'recent_arrivals': recent_arrivals,
|
||||
'recent_departures': recent_departures
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Erreur page élèves classe {id}: {e}')
|
||||
flash('Erreur lors du chargement des données des élèves.', 'error')
|
||||
return redirect(url_for('classes.dashboard', id=id))
|
||||
|
||||
@bp.route('/enroll', methods=['POST'])
|
||||
@handle_db_errors
|
||||
def enroll_student():
|
||||
"""Inscrire un élève dans une classe (existant ou nouveau)."""
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
class_group_id = int(data.get('class_id'))
|
||||
enrollment_date_str = data.get('enrollment_date', str(date.today()))
|
||||
enrollment_reason = data.get('enrollment_reason', '')
|
||||
mode = data.get('mode', 'existing')
|
||||
|
||||
# Validation de la date
|
||||
enrollment_date = datetime.strptime(enrollment_date_str, '%Y-%m-%d').date()
|
||||
|
||||
# Vérifier que la classe existe
|
||||
class_group = ClassGroup.query.get_or_404(class_group_id)
|
||||
|
||||
if mode == 'new':
|
||||
# Mode création d'un nouvel élève
|
||||
first_name = data.get('new_first_name', '').strip()
|
||||
last_name = data.get('new_last_name', '').strip()
|
||||
email = data.get('new_email', '').strip() or None
|
||||
|
||||
if not first_name or not last_name:
|
||||
raise ValueError("Le prénom et le nom sont obligatoires pour un nouvel élève")
|
||||
|
||||
# Vérifier que l'email n'est pas déjà utilisé si fourni
|
||||
if email:
|
||||
existing_email = Student.query.filter_by(email=email).first()
|
||||
if existing_email:
|
||||
raise ValueError("Un élève avec cet email existe déjà")
|
||||
|
||||
# Créer le nouvel élève
|
||||
student = Student(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=email
|
||||
)
|
||||
db.session.add(student)
|
||||
db.session.flush() # Pour obtenir l'ID du nouvel élève
|
||||
|
||||
current_app.logger.info(f'Nouvel élève créé: {student.full_name}')
|
||||
|
||||
else:
|
||||
# Mode élève existant
|
||||
student_id = data.get('student_id')
|
||||
if not student_id:
|
||||
raise ValueError("Veuillez sélectionner un élève")
|
||||
|
||||
student_id = int(student_id)
|
||||
student = Student.query.get_or_404(student_id)
|
||||
|
||||
# Créer l'inscription
|
||||
enrollment = temporal_repo.create_enrollment(
|
||||
student.id, class_group_id, enrollment_date, enrollment_reason
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Inscription créée: Élève {student.full_name} en {class_group.name}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Élève {student.full_name} inscrit en {class_group.name}',
|
||||
'enrollment_id': enrollment.id,
|
||||
'student_id': student.id,
|
||||
'is_new_student': mode == 'new'
|
||||
})
|
||||
else:
|
||||
if mode == 'new':
|
||||
flash(f'Nouvel élève {student.full_name} créé et inscrit en {class_group.name}', 'success')
|
||||
else:
|
||||
flash(f'Élève {student.full_name} inscrit en {class_group.name}', 'success')
|
||||
|
||||
# Pour une mise à jour immédiate de la liste, utiliser JavaScript pour recharger
|
||||
return redirect(url_for('classes.students', id=class_group_id) + '?reload=1')
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
current_app.logger.warning(f'Erreur inscription élève: {error_msg}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': error_msg}), 400
|
||||
else:
|
||||
flash(error_msg, 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Erreur inscription élève: {e}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': 'Erreur lors de l\'inscription'}), 500
|
||||
else:
|
||||
flash('Erreur lors de l\'inscription', 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
@bp.route('/transfer', methods=['POST'])
|
||||
@handle_db_errors
|
||||
def transfer_student():
|
||||
"""Transférer un élève d'une classe à une autre."""
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
student_id = int(data.get('student_id'))
|
||||
new_class_group_id = int(data.get('new_class_id'))
|
||||
transfer_date_str = data.get('transfer_date', str(date.today()))
|
||||
transfer_reason = data.get('transfer_reason', '')
|
||||
|
||||
# Validation de la date
|
||||
transfer_date = datetime.strptime(transfer_date_str, '%Y-%m-%d').date()
|
||||
|
||||
# Vérifier que l'élève et les classes existent
|
||||
student = Student.query.get_or_404(student_id)
|
||||
new_class_group = ClassGroup.query.get_or_404(new_class_group_id)
|
||||
|
||||
# Effectuer le transfert
|
||||
old_enrollment, new_enrollment = temporal_repo.transfer_student(
|
||||
student_id, new_class_group_id, transfer_date, transfer_reason
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Transfert effectué: Élève {student.full_name} vers {new_class_group.name}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Élève {student.full_name} transféré vers {new_class_group.name}',
|
||||
'old_enrollment_id': old_enrollment.id,
|
||||
'new_enrollment_id': new_enrollment.id
|
||||
})
|
||||
else:
|
||||
flash(f'Élève {student.full_name} transféré vers {new_class_group.name}', 'success')
|
||||
# Retourner à la page d'origine avec rechargement
|
||||
referrer = request.referrer
|
||||
if referrer and 'classes/' in referrer and '/students' in referrer:
|
||||
return redirect(referrer + ('&' if '?' in referrer else '?') + 'reload=1')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
current_app.logger.warning(f'Erreur transfert élève: {error_msg}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': error_msg}), 400
|
||||
else:
|
||||
flash(error_msg, 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Erreur transfert élève: {e}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': 'Erreur lors du transfert'}), 500
|
||||
else:
|
||||
flash('Erreur lors du transfert', 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
@bp.route('/departure', methods=['POST'])
|
||||
@handle_db_errors
|
||||
def student_departure():
|
||||
"""Enregistrer le départ d'un élève."""
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
student_id = int(data.get('student_id'))
|
||||
departure_date_str = data.get('departure_date', str(date.today()))
|
||||
departure_reason = data.get('departure_reason', '')
|
||||
|
||||
# Validation de la date
|
||||
departure_date = datetime.strptime(departure_date_str, '%Y-%m-%d').date()
|
||||
|
||||
# Vérifier que l'élève existe
|
||||
student = Student.query.get_or_404(student_id)
|
||||
|
||||
# Terminer l'inscription active
|
||||
enrollment = temporal_repo.end_enrollment(
|
||||
student_id, departure_date, departure_reason
|
||||
)
|
||||
|
||||
if not enrollment:
|
||||
raise ValueError("Aucune inscription active trouvée pour cet élève")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Départ enregistré: Élève {student.full_name}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Départ de {student.full_name} enregistré',
|
||||
'enrollment_id': enrollment.id
|
||||
})
|
||||
else:
|
||||
flash(f'Départ de {student.full_name} enregistré', 'success')
|
||||
# Retourner à la page d'origine avec rechargement
|
||||
referrer = request.referrer
|
||||
if referrer and 'classes/' in referrer and '/students' in referrer:
|
||||
return redirect(referrer + ('&' if '?' in referrer else '?') + 'reload=1')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
current_app.logger.warning(f'Erreur départ élève: {error_msg}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': error_msg}), 400
|
||||
else:
|
||||
flash(error_msg, 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Erreur départ élève: {e}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': 'Erreur lors de l\'enregistrement du départ'}), 500
|
||||
else:
|
||||
flash('Erreur lors de l\'enregistrement du départ', 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
@bp.route('/cancel-departure', methods=['POST'])
|
||||
@handle_db_errors
|
||||
def cancel_departure():
|
||||
"""Annuler le départ d'un élève (remettre inscription active)."""
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
enrollment_id = int(data.get('enrollment_id'))
|
||||
|
||||
# Récupérer l'inscription
|
||||
enrollment = StudentEnrollment.query.get_or_404(enrollment_id)
|
||||
|
||||
# Vérifier que l'inscription a bien une date de départ
|
||||
if not enrollment.departure_date:
|
||||
flash('Cette inscription n\'a pas de date de départ à annuler', 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
# Vérifier qu'il n'y a pas déjà une inscription active pour cet élève
|
||||
active_enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=enrollment.student_id,
|
||||
departure_date=None
|
||||
).first()
|
||||
|
||||
if active_enrollment:
|
||||
flash(f'L\'élève {enrollment.student.first_name} {enrollment.student.last_name} est déjà inscrit dans {active_enrollment.class_group.name}', 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
# Annuler le départ en supprimant la date de départ
|
||||
old_departure_date = enrollment.departure_date
|
||||
enrollment.departure_date = None
|
||||
enrollment.departure_reason = None
|
||||
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Départ annulé - Élève {enrollment.student_id} réintégré en {enrollment.class_group.name}')
|
||||
flash(f'Départ de {enrollment.student.first_name} {enrollment.student.last_name} annulé. Élève réintégré en {enrollment.class_group.name}', 'success')
|
||||
|
||||
# Rediriger vers la page d'origine avec rechargement
|
||||
referrer = request.referrer
|
||||
if referrer and 'classes/' in referrer and '/students' in referrer:
|
||||
return redirect(referrer + ('&' if '?' in referrer else '?') + 'reload=1')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
current_app.logger.error(f'Erreur données annulation départ: {e}')
|
||||
flash('Données d\'annulation invalides', 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Erreur annulation départ: {e}')
|
||||
flash('Erreur lors de l\'annulation du départ', 'error')
|
||||
return redirect(request.referrer or url_for('classes'))
|
||||
@@ -8,11 +8,14 @@ bp = Blueprint('grading', __name__)
|
||||
@bp.route('/assessments/<int:assessment_id>/grading')
|
||||
def assessment_grading(assessment_id):
|
||||
assessment_repo = AssessmentRepository()
|
||||
student_repo = StudentRepository()
|
||||
grade_repo = GradeRepository()
|
||||
|
||||
assessment = assessment_repo.get_or_404(assessment_id)
|
||||
students = student_repo.find_by_class_ordered(assessment.class_group_id)
|
||||
|
||||
# Utilisation de la logique temporelle pour récupérer les élèves éligibles
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_student_repo = TemporalStudentRepository()
|
||||
students = temporal_student_repo.find_eligible_for_assessment(assessment)
|
||||
|
||||
# Get all grading elements for this assessment
|
||||
grading_elements = []
|
||||
@@ -45,8 +48,9 @@ def assessment_grading(assessment_id):
|
||||
@bp.route('/assessments/<int:assessment_id>/grading/save', methods=['POST'])
|
||||
def save_grades(assessment_id):
|
||||
assessment_repo = AssessmentRepository()
|
||||
student_repo = StudentRepository()
|
||||
grade_repo = GradeRepository()
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_student_repo = TemporalStudentRepository()
|
||||
|
||||
assessment = assessment_repo.get_or_404(assessment_id)
|
||||
errors = []
|
||||
@@ -75,7 +79,7 @@ def save_grades(assessment_id):
|
||||
|
||||
# Vérifier que l'étudiant et l'élément existent avec protection
|
||||
try:
|
||||
student = student_repo.find_by_id(student_id)
|
||||
student = temporal_student_repo.find_by_id(student_id)
|
||||
grading_element = GradingElement.query.get(element_id)
|
||||
except Exception as e:
|
||||
errors.append(f'Erreur DB pour {key}: {str(e)}')
|
||||
@@ -222,8 +226,9 @@ def save_grades(assessment_id):
|
||||
def save_single_grade(assessment_id):
|
||||
"""Sauvegarde incrémentale d'une seule note"""
|
||||
assessment_repo = AssessmentRepository()
|
||||
student_repo = StudentRepository()
|
||||
grade_repo = GradeRepository()
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_student_repo = TemporalStudentRepository()
|
||||
|
||||
assessment = assessment_repo.get_or_404(assessment_id)
|
||||
|
||||
@@ -235,7 +240,7 @@ def save_single_grade(assessment_id):
|
||||
comment = data.get('comment', '').strip()
|
||||
|
||||
# Vérifications
|
||||
student = student_repo.find_by_id(student_id)
|
||||
student = temporal_student_repo.find_by_id(student_id)
|
||||
grading_element = GradingElement.query.get(element_id)
|
||||
|
||||
if not student or not grading_element:
|
||||
|
||||
336
routes/student_movements.py
Normal file
336
routes/student_movements.py
Normal file
@@ -0,0 +1,336 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app
|
||||
from datetime import date, datetime
|
||||
from models import db, Student, ClassGroup, StudentEnrollment
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
from utils import handle_db_errors
|
||||
|
||||
bp = Blueprint('student_movements', __name__, url_prefix='/movements')
|
||||
|
||||
@bp.route('/')
|
||||
@handle_db_errors
|
||||
def movements_index():
|
||||
"""Page principale de gestion des mouvements d'élèves."""
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
# Récupérer les mouvements récents (30 derniers jours)
|
||||
from datetime import date, timedelta
|
||||
recent_start = date.today() - timedelta(days=30)
|
||||
recent_end = date.today()
|
||||
|
||||
recent_movements = temporal_repo.find_students_with_movements_in_period(recent_start, recent_end)
|
||||
|
||||
# Récupérer toutes les classes pour les formulaires
|
||||
classes = ClassGroup.query.order_by(ClassGroup.name).all()
|
||||
|
||||
# Récupérer tous les élèves pour le formulaire d'inscription
|
||||
all_students = Student.query.order_by(Student.last_name, Student.first_name).all()
|
||||
|
||||
return render_template('student_movements.html',
|
||||
recent_movements=recent_movements,
|
||||
classes=classes,
|
||||
all_students=all_students)
|
||||
|
||||
@bp.route('/enroll', methods=['POST'])
|
||||
@handle_db_errors
|
||||
def enroll_student():
|
||||
"""Inscrire un élève dans une classe."""
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
student_id = int(data.get('student_id'))
|
||||
class_group_id = int(data.get('class_group_id'))
|
||||
enrollment_date_str = data.get('enrollment_date', str(date.today()))
|
||||
enrollment_reason = data.get('enrollment_reason', '')
|
||||
|
||||
# Validation de la date
|
||||
enrollment_date = datetime.strptime(enrollment_date_str, '%Y-%m-%d').date()
|
||||
|
||||
# Vérifier que l'élève et la classe existent
|
||||
student = Student.query.get_or_404(student_id)
|
||||
class_group = ClassGroup.query.get_or_404(class_group_id)
|
||||
|
||||
# Créer l'inscription
|
||||
enrollment = temporal_repo.create_enrollment(
|
||||
student_id, class_group_id, enrollment_date, enrollment_reason
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Inscription créée: Élève {student.full_name} en {class_group.name}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Élève {student.full_name} inscrit en {class_group.name}',
|
||||
'enrollment_id': enrollment.id
|
||||
})
|
||||
else:
|
||||
flash(f'Élève {student.full_name} inscrit en {class_group.name}', 'success')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
current_app.logger.warning(f'Erreur inscription élève: {error_msg}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': error_msg}), 400
|
||||
else:
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Erreur inscription élève: {e}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': 'Erreur lors de l\'inscription'}), 500
|
||||
else:
|
||||
flash('Erreur lors de l\'inscription', 'error')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
@bp.route('/departure', methods=['POST'])
|
||||
@handle_db_errors
|
||||
def student_departure():
|
||||
"""Enregistrer le départ d'un élève."""
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
student_id = int(data.get('student_id'))
|
||||
departure_date_str = data.get('departure_date', str(date.today()))
|
||||
departure_reason = data.get('departure_reason', '')
|
||||
|
||||
# Validation de la date
|
||||
departure_date = datetime.strptime(departure_date_str, '%Y-%m-%d').date()
|
||||
|
||||
# Vérifier que l'élève existe
|
||||
student = Student.query.get_or_404(student_id)
|
||||
|
||||
# Terminer l'inscription active
|
||||
enrollment = temporal_repo.end_enrollment(
|
||||
student_id, departure_date, departure_reason
|
||||
)
|
||||
|
||||
if not enrollment:
|
||||
raise ValueError("Aucune inscription active trouvée pour cet élève")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Départ enregistré: Élève {student.full_name}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Départ de {student.full_name} enregistré',
|
||||
'enrollment_id': enrollment.id
|
||||
})
|
||||
else:
|
||||
flash(f'Départ de {student.full_name} enregistré', 'success')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
current_app.logger.warning(f'Erreur départ élève: {error_msg}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': error_msg}), 400
|
||||
else:
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Erreur départ élève: {e}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': 'Erreur lors de l\'enregistrement du départ'}), 500
|
||||
else:
|
||||
flash('Erreur lors de l\'enregistrement du départ', 'error')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
@bp.route('/transfer', methods=['POST'])
|
||||
@handle_db_errors
|
||||
def transfer_student():
|
||||
"""Transférer un élève d'une classe à une autre."""
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
student_id = int(data.get('student_id'))
|
||||
new_class_group_id = int(data.get('new_class_group_id'))
|
||||
transfer_date_str = data.get('transfer_date', str(date.today()))
|
||||
transfer_reason = data.get('transfer_reason', '')
|
||||
|
||||
# Validation de la date
|
||||
transfer_date = datetime.strptime(transfer_date_str, '%Y-%m-%d').date()
|
||||
|
||||
# Vérifier que l'élève et les classes existent
|
||||
student = Student.query.get_or_404(student_id)
|
||||
new_class_group = ClassGroup.query.get_or_404(new_class_group_id)
|
||||
|
||||
# Effectuer le transfert
|
||||
old_enrollment, new_enrollment = temporal_repo.transfer_student(
|
||||
student_id, new_class_group_id, transfer_date, transfer_reason
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Transfert effectué: Élève {student.full_name} vers {new_class_group.name}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Élève {student.full_name} transféré vers {new_class_group.name}',
|
||||
'old_enrollment_id': old_enrollment.id,
|
||||
'new_enrollment_id': new_enrollment.id
|
||||
})
|
||||
else:
|
||||
flash(f'Élève {student.full_name} transféré vers {new_class_group.name}', 'success')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
current_app.logger.warning(f'Erreur transfert élève: {error_msg}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': error_msg}), 400
|
||||
else:
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Erreur transfert élève: {e}')
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': 'Erreur lors du transfert'}), 500
|
||||
else:
|
||||
flash('Erreur lors du transfert', 'error')
|
||||
return redirect(url_for('student_movements.movements_index'))
|
||||
|
||||
@bp.route('/history/<int:student_id>')
|
||||
@handle_db_errors
|
||||
def student_history(student_id):
|
||||
"""Afficher l'historique des inscriptions d'un élève."""
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
student = Student.query.get_or_404(student_id)
|
||||
enrollment_history = temporal_repo.get_enrollment_history(student_id)
|
||||
|
||||
if request.is_json:
|
||||
history_data = []
|
||||
for enrollment in enrollment_history:
|
||||
history_data.append({
|
||||
'id': enrollment.id,
|
||||
'class_name': enrollment.class_group.name,
|
||||
'enrollment_date': enrollment.enrollment_date.isoformat(),
|
||||
'departure_date': enrollment.departure_date.isoformat() if enrollment.departure_date else None,
|
||||
'enrollment_reason': enrollment.enrollment_reason,
|
||||
'departure_reason': enrollment.departure_reason,
|
||||
'is_active': enrollment.is_active
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'student': {
|
||||
'id': student.id,
|
||||
'name': student.full_name
|
||||
},
|
||||
'history': history_data
|
||||
})
|
||||
else:
|
||||
return render_template('student_history.html',
|
||||
student=student,
|
||||
enrollment_history=enrollment_history)
|
||||
|
||||
@bp.route('/api/eligible-students/<int:assessment_id>')
|
||||
@handle_db_errors
|
||||
def api_eligible_students(assessment_id):
|
||||
"""API pour récupérer les élèves éligibles pour une évaluation."""
|
||||
from models import Assessment
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
|
||||
assessment = Assessment.query.get_or_404(assessment_id)
|
||||
eligible_students = temporal_repo.find_eligible_for_assessment(assessment)
|
||||
current_students = temporal_repo.find_current_students_in_class(assessment.class_group_id)
|
||||
|
||||
eligible_data = []
|
||||
for student in eligible_students:
|
||||
eligible_data.append({
|
||||
'id': student.id,
|
||||
'name': student.full_name,
|
||||
'email': student.email,
|
||||
'eligible': True
|
||||
})
|
||||
|
||||
# Ajouter les élèves non-éligibles pour comparaison
|
||||
ineligible_students = [s for s in current_students if s not in eligible_students]
|
||||
for student in ineligible_students:
|
||||
eligible_data.append({
|
||||
'id': student.id,
|
||||
'name': student.full_name,
|
||||
'email': student.email,
|
||||
'eligible': False
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'assessment': {
|
||||
'id': assessment.id,
|
||||
'title': assessment.title,
|
||||
'date': assessment.date.isoformat() if assessment.date else None
|
||||
},
|
||||
'total_eligible': len(eligible_students),
|
||||
'total_current': len(current_students),
|
||||
'students': eligible_data
|
||||
})
|
||||
|
||||
@bp.route('/cancel-departure', methods=['POST'])
|
||||
@handle_db_errors
|
||||
def cancel_departure():
|
||||
"""Annuler le départ d'un élève (remettre inscription active)."""
|
||||
try:
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
enrollment_id = int(data.get('enrollment_id'))
|
||||
|
||||
# Récupérer l'inscription
|
||||
enrollment = StudentEnrollment.query.get_or_404(enrollment_id)
|
||||
|
||||
# Vérifier que l'inscription a bien une date de départ
|
||||
if not enrollment.departure_date:
|
||||
flash('Cette inscription n\'a pas de date de départ à annuler', 'error')
|
||||
return redirect(request.referrer or url_for('student_movements.movements_index'))
|
||||
|
||||
# Vérifier qu'il n'y a pas déjà une inscription active pour cet élève
|
||||
active_enrollment = StudentEnrollment.query.filter_by(
|
||||
student_id=enrollment.student_id,
|
||||
departure_date=None
|
||||
).first()
|
||||
|
||||
if active_enrollment:
|
||||
flash(f'L\'élève {enrollment.student.first_name} {enrollment.student.last_name} est déjà inscrit dans {active_enrollment.class_group.name}', 'error')
|
||||
return redirect(request.referrer or url_for('student_movements.movements_index'))
|
||||
|
||||
# Annuler le départ en supprimant la date de départ
|
||||
old_departure_date = enrollment.departure_date
|
||||
enrollment.departure_date = None
|
||||
enrollment.departure_reason = None
|
||||
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(f'Départ annulé - Élève {enrollment.student_id} réintégré en {enrollment.class_group.name}')
|
||||
flash(f'Départ de {enrollment.student.first_name} {enrollment.student.last_name} annulé. Élève réintégré en {enrollment.class_group.name}', 'success')
|
||||
|
||||
# Rediriger vers la page d'origine (page élèves de classe si disponible)
|
||||
return redirect(request.referrer or url_for('student_movements.movements_index'))
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
current_app.logger.error(f'Erreur données annulation départ: {e}')
|
||||
flash('Données d\'annulation invalides', 'error')
|
||||
return redirect(request.referrer or url_for('student_movements.movements_index'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f'Erreur annulation départ: {e}')
|
||||
flash('Erreur lors de l\'annulation du départ', 'error')
|
||||
return redirect(request.referrer or url_for('student_movements.movements_index'))
|
||||
@@ -51,9 +51,14 @@ class StudentEvaluationService:
|
||||
|
||||
def calculate_student_trimester_average(self, student_id: int, trimester: int) -> Optional[float]:
|
||||
"""Calcule la moyenne d'un élève pour un trimestre donné."""
|
||||
# Récupérer la classe actuelle de l'élève avec la logique temporelle
|
||||
student = Student.query.get(student_id)
|
||||
current_class = student.get_current_class()
|
||||
if not current_class:
|
||||
return None
|
||||
|
||||
assessments = self.assessment_repo.find_completed_by_class_trimester(
|
||||
# On récupère d'abord la classe de l'élève
|
||||
Student.query.get(student_id).class_group_id,
|
||||
current_class.id,
|
||||
trimester
|
||||
)
|
||||
|
||||
@@ -74,10 +79,20 @@ class StudentEvaluationService:
|
||||
def get_student_trimester_summary(self, student_id: int, trimester: int) -> StudentTrimesterSummary:
|
||||
"""Génère le résumé d'un élève pour un trimestre."""
|
||||
student = Student.query.get(student_id)
|
||||
current_class = student.get_current_class()
|
||||
if not current_class:
|
||||
return StudentTrimesterSummary(
|
||||
student=student,
|
||||
overall_average=None,
|
||||
assessment_count=0,
|
||||
grades_by_assessment={},
|
||||
appreciation=None,
|
||||
performance_status='no_data'
|
||||
)
|
||||
|
||||
# Récupérer les évaluations du trimestre
|
||||
assessments = self.assessment_repo.find_by_class_trimester_with_details(
|
||||
student.class_group_id, trimester
|
||||
current_class.id, trimester
|
||||
)
|
||||
|
||||
# Calculer les scores par évaluation
|
||||
@@ -96,7 +111,7 @@ class StudentEvaluationService:
|
||||
# Récupérer l'appréciation existante
|
||||
appreciation_repo = AppreciationRepository()
|
||||
appreciation = appreciation_repo.find_by_student_trimester(
|
||||
student_id, student.class_group_id, trimester
|
||||
student_id, current_class.id, trimester
|
||||
)
|
||||
|
||||
# Calculer les données de compétences et domaines
|
||||
@@ -121,9 +136,10 @@ class StudentEvaluationService:
|
||||
)
|
||||
|
||||
def get_students_summaries(self, class_group_id: int, trimester: int) -> List[StudentTrimesterSummary]:
|
||||
"""Génère les résumés de tous les élèves d'une classe pour un trimestre."""
|
||||
student_repo = StudentRepository()
|
||||
students = student_repo.find_by_class_group(class_group_id)
|
||||
"""Génère les résumés de tous les élèves actuellement dans une classe pour un trimestre."""
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
temporal_repo = TemporalStudentRepository()
|
||||
students = temporal_repo.find_current_students_in_class(class_group_id)
|
||||
|
||||
summaries = []
|
||||
for student in students:
|
||||
@@ -148,9 +164,13 @@ class StudentEvaluationService:
|
||||
if not student:
|
||||
return {'competences': [], 'domains': []}
|
||||
|
||||
# Récupérer toutes les évaluations du trimestre
|
||||
# Récupérer la classe actuelle et toutes les évaluations du trimestre
|
||||
current_class = student.get_current_class()
|
||||
if not current_class:
|
||||
return {'competences': [], 'domains': []}
|
||||
|
||||
assessments = self.assessment_repo.find_by_class_trimester_with_details(
|
||||
student.class_group_id, trimester
|
||||
current_class.id, trimester
|
||||
)
|
||||
|
||||
# Structures pour accumuler les données
|
||||
@@ -375,8 +395,18 @@ class StudentEvaluationService:
|
||||
global_details = self.grade_repo.get_special_values_details_by_student_trimester(student_id, trimester)
|
||||
|
||||
# 2. Comptes par évaluation
|
||||
student = Student.query.get(student_id)
|
||||
current_class = student.get_current_class()
|
||||
if not current_class:
|
||||
return {
|
||||
'global': {},
|
||||
'by_assessment': {},
|
||||
'total_special_values': 0,
|
||||
'has_special_values': False
|
||||
}
|
||||
|
||||
assessments = self.assessment_repo.find_by_class_trimester_with_details(
|
||||
Student.query.get(student_id).class_group_id, trimester
|
||||
current_class.id, trimester
|
||||
)
|
||||
|
||||
by_assessment = {}
|
||||
|
||||
207
services/temporal_assessment_services.py
Normal file
207
services/temporal_assessment_services.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Tuple
|
||||
from collections import defaultdict
|
||||
from models import db, Grade, GradingElement, Exercise
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
from services.assessment_services import AssessmentProgressService, StudentScoreCalculator, ProgressResult, StudentScore
|
||||
from providers.concrete_providers import SQLAlchemyDatabaseProvider
|
||||
from sqlalchemy import and_, func
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemporalProgressResult(ProgressResult):
|
||||
"""Extension avec information temporelle."""
|
||||
eligible_students_count: int
|
||||
total_students_in_class: int
|
||||
|
||||
|
||||
class TemporalAssessmentProgressService(AssessmentProgressService):
|
||||
"""
|
||||
Service de progression avec logique temporelle.
|
||||
Hérite du service existant et ajoute la logique temporelle.
|
||||
"""
|
||||
|
||||
def __init__(self, db_provider: SQLAlchemyDatabaseProvider, student_repo: TemporalStudentRepository):
|
||||
super().__init__(db_provider)
|
||||
self.student_repo = student_repo
|
||||
|
||||
def calculate_grading_progress(self, assessment) -> TemporalProgressResult:
|
||||
"""
|
||||
Calcule la progression en ne comptant que les élèves éligibles.
|
||||
"""
|
||||
# Étudiants éligibles à la date de l'évaluation
|
||||
eligible_students = self.student_repo.find_eligible_for_assessment(assessment)
|
||||
current_students = self.student_repo.find_current_students_in_class(assessment.class_group_id)
|
||||
|
||||
if not eligible_students:
|
||||
return TemporalProgressResult(
|
||||
percentage=0,
|
||||
completed=0,
|
||||
total=0,
|
||||
status='no_eligible_students',
|
||||
students_count=0,
|
||||
eligible_students_count=0,
|
||||
total_students_in_class=len(current_students)
|
||||
)
|
||||
|
||||
# Calcul basé uniquement sur les élèves éligibles
|
||||
eligible_student_ids = [s.id for s in eligible_students]
|
||||
|
||||
# Requête optimisée pour compter les notes des élèves éligibles uniquement
|
||||
grading_elements_data = self._get_grading_elements_for_eligible_students(
|
||||
assessment.id, eligible_student_ids
|
||||
)
|
||||
|
||||
total_elements = 0
|
||||
completed_elements = 0
|
||||
|
||||
for element_data in grading_elements_data:
|
||||
total_elements += len(eligible_students)
|
||||
completed_elements += element_data.completed_grades_count
|
||||
|
||||
if total_elements == 0:
|
||||
return TemporalProgressResult(
|
||||
percentage=0,
|
||||
completed=0,
|
||||
total=0,
|
||||
status='no_elements',
|
||||
students_count=len(eligible_students),
|
||||
eligible_students_count=len(eligible_students),
|
||||
total_students_in_class=len(current_students)
|
||||
)
|
||||
|
||||
percentage = round((completed_elements / total_elements) * 100)
|
||||
status = self._determine_status(percentage)
|
||||
|
||||
return TemporalProgressResult(
|
||||
percentage=percentage,
|
||||
completed=completed_elements,
|
||||
total=total_elements,
|
||||
status=status,
|
||||
students_count=len(eligible_students),
|
||||
eligible_students_count=len(eligible_students),
|
||||
total_students_in_class=len(current_students)
|
||||
)
|
||||
|
||||
def _get_grading_elements_for_eligible_students(self, assessment_id: int, eligible_student_ids: List[int]):
|
||||
"""Requête optimisée pour récupérer les notes des élèves éligibles."""
|
||||
|
||||
return db.session.query(
|
||||
GradingElement.id,
|
||||
func.count(Grade.id).label('completed_grades_count')
|
||||
).select_from(GradingElement)\
|
||||
.join(Exercise, GradingElement.exercise_id == Exercise.id)\
|
||||
.outerjoin(
|
||||
Grade,
|
||||
and_(
|
||||
Grade.grading_element_id == GradingElement.id,
|
||||
Grade.student_id.in_(eligible_student_ids),
|
||||
Grade.value.isnot(None),
|
||||
Grade.value != ''
|
||||
)
|
||||
)\
|
||||
.filter(Exercise.assessment_id == assessment_id)\
|
||||
.group_by(GradingElement.id)\
|
||||
.all()
|
||||
|
||||
|
||||
class TemporalStudentScoreCalculator(StudentScoreCalculator):
|
||||
"""Calculateur de scores avec logique temporelle."""
|
||||
|
||||
def __init__(self,
|
||||
grading_calculator,
|
||||
db_provider: SQLAlchemyDatabaseProvider,
|
||||
student_repo: TemporalStudentRepository):
|
||||
super().__init__(grading_calculator, db_provider)
|
||||
self.student_repo = student_repo
|
||||
|
||||
def calculate_student_scores(self, assessment) -> Tuple[Dict[int, StudentScore], Dict[int, Dict[int, float]]]:
|
||||
"""
|
||||
Calcule les scores uniquement pour les élèves éligibles.
|
||||
"""
|
||||
# Récupérer uniquement les élèves éligibles
|
||||
eligible_students = self.student_repo.find_eligible_for_assessment(assessment)
|
||||
|
||||
if not eligible_students:
|
||||
return {}, {}
|
||||
|
||||
# Requête optimisée pour les notes des élèves éligibles
|
||||
eligible_student_ids = [s.id for s in eligible_students]
|
||||
grades_data = self._get_grades_for_eligible_students(assessment.id, eligible_student_ids)
|
||||
|
||||
students_scores = {}
|
||||
exercise_scores = defaultdict(lambda: defaultdict(float))
|
||||
|
||||
# Calcul pour chaque élève éligible
|
||||
for student in eligible_students:
|
||||
student_score = self._calculate_single_student_score(
|
||||
student, assessment, grades_data
|
||||
)
|
||||
students_scores[student.id] = student_score
|
||||
|
||||
for exercise_id, exercise_data in student_score.exercises.items():
|
||||
exercise_scores[exercise_id][student.id] = exercise_data['score']
|
||||
|
||||
return students_scores, dict(exercise_scores)
|
||||
|
||||
def _get_grades_for_eligible_students(self, assessment_id: int, eligible_student_ids: List[int]):
|
||||
"""Requête optimisée pour les notes des élèves éligibles."""
|
||||
|
||||
grades = db.session.query(Grade)\
|
||||
.join(GradingElement, Grade.grading_element_id == GradingElement.id)\
|
||||
.join(Exercise, GradingElement.exercise_id == Exercise.id)\
|
||||
.filter(
|
||||
Exercise.assessment_id == assessment_id,
|
||||
Grade.student_id.in_(eligible_student_ids)
|
||||
).all()
|
||||
|
||||
# Convertir en format dict compatible avec le service parent
|
||||
return [
|
||||
{
|
||||
'student_id': grade.student_id,
|
||||
'grading_element_id': grade.grading_element_id,
|
||||
'value': grade.value,
|
||||
'grading_type': grade.grading_element.grading_type,
|
||||
'max_points': grade.grading_element.max_points
|
||||
}
|
||||
for grade in grades
|
||||
]
|
||||
|
||||
|
||||
class TemporalClassStatisticsService:
|
||||
"""Service pour calculer les statistiques de classe avec logique temporelle."""
|
||||
|
||||
def __init__(self, student_repo: TemporalStudentRepository):
|
||||
self.student_repo = student_repo
|
||||
|
||||
def get_class_enrollment_summary(self, class_group_id: int, trimester: int = None):
|
||||
"""Résumé des inscriptions pour une classe."""
|
||||
current_students = self.student_repo.find_current_students_in_class(class_group_id)
|
||||
|
||||
# Calcul des mouvements si on a un trimestre spécifique
|
||||
movements = {}
|
||||
if trimester:
|
||||
from datetime import date
|
||||
# Approximation des dates de trimestre
|
||||
trimester_dates = {
|
||||
1: (date(2024, 9, 1), date(2024, 12, 31)),
|
||||
2: (date(2025, 1, 1), date(2025, 3, 31)),
|
||||
3: (date(2025, 4, 1), date(2025, 6, 30))
|
||||
}
|
||||
|
||||
if trimester in trimester_dates:
|
||||
start_date, end_date = trimester_dates[trimester]
|
||||
students_with_movements = self.student_repo.find_students_with_movements_in_period(
|
||||
start_date, end_date
|
||||
)
|
||||
movements = {
|
||||
'arrivals': [],
|
||||
'departures': [],
|
||||
'total_movements': len(students_with_movements)
|
||||
}
|
||||
|
||||
return {
|
||||
'current_count': len(current_students),
|
||||
'current_students': current_students,
|
||||
'movements': movements
|
||||
}
|
||||
70
services/temporal_facade.py
Normal file
70
services/temporal_facade.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from typing import List
|
||||
from models import Student
|
||||
from repositories.temporal_student_repository import TemporalStudentRepository
|
||||
from services.temporal_assessment_services import (
|
||||
TemporalAssessmentProgressService,
|
||||
TemporalStudentScoreCalculator,
|
||||
TemporalClassStatisticsService,
|
||||
TemporalProgressResult
|
||||
)
|
||||
from services.assessment_services import AssessmentStatisticsService, UnifiedGradingCalculator
|
||||
from providers.concrete_providers import ConfigManagerProvider, SQLAlchemyDatabaseProvider
|
||||
|
||||
|
||||
class TemporalAssessmentServicesFacade:
|
||||
"""
|
||||
Facade temporelle qui remplace la facade standard.
|
||||
Intègre toute la logique temporelle de manière transparente.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
config_provider = None,
|
||||
db_provider = None):
|
||||
# Injection des repositories temporels
|
||||
self.student_repo = TemporalStudentRepository()
|
||||
|
||||
# Providers par défaut si non fournis
|
||||
if config_provider is None:
|
||||
config_provider = ConfigManagerProvider()
|
||||
if db_provider is None:
|
||||
db_provider = SQLAlchemyDatabaseProvider()
|
||||
|
||||
# Services temporels
|
||||
self.grading_calculator = UnifiedGradingCalculator(config_provider)
|
||||
self.progress_service = TemporalAssessmentProgressService(db_provider, self.student_repo)
|
||||
self.score_calculator = TemporalStudentScoreCalculator(
|
||||
self.grading_calculator, db_provider, self.student_repo
|
||||
)
|
||||
self.statistics_service = AssessmentStatisticsService(self.score_calculator)
|
||||
self.class_statistics_service = TemporalClassStatisticsService(self.student_repo)
|
||||
|
||||
def get_eligible_students(self, assessment) -> List[Student]:
|
||||
"""Nouvelle méthode pour récupérer les élèves éligibles."""
|
||||
return self.student_repo.find_eligible_for_assessment(assessment)
|
||||
|
||||
def get_grading_progress(self, assessment) -> TemporalProgressResult:
|
||||
"""Progression avec logique temporelle."""
|
||||
return self.progress_service.calculate_grading_progress(assessment)
|
||||
|
||||
def get_class_enrollment_summary(self, class_group_id: int, trimester: int = None):
|
||||
"""Résumé des inscriptions pour une classe."""
|
||||
return self.class_statistics_service.get_class_enrollment_summary(class_group_id, trimester)
|
||||
|
||||
# Compatibilité avec l'interface existante
|
||||
def get_assessment_statistics(self, assessment):
|
||||
"""Statistiques d'évaluation (héritée du service existant)."""
|
||||
return self.statistics_service.get_assessment_statistics(assessment)
|
||||
|
||||
|
||||
class TemporalAssessmentServicesFactory:
|
||||
"""Factory pour créer les services temporels."""
|
||||
|
||||
@staticmethod
|
||||
def create_temporal_facade(config_provider = None) -> TemporalAssessmentServicesFacade:
|
||||
"""Crée une facade temporelle."""
|
||||
return TemporalAssessmentServicesFacade(config_provider)
|
||||
|
||||
@staticmethod
|
||||
def create_temporal_student_repository() -> TemporalStudentRepository:
|
||||
"""Crée un repository d'élèves temporel."""
|
||||
return TemporalStudentRepository()
|
||||
@@ -92,7 +92,7 @@
|
||||
</a>
|
||||
|
||||
{# 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">
|
||||
<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">
|
||||
@@ -102,7 +102,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</a>
|
||||
@@ -410,7 +410,7 @@
|
||||
Effectif complet : {{ class_group.students|length }} élèves
|
||||
</span>
|
||||
</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]">
|
||||
<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"/>
|
||||
@@ -427,7 +427,7 @@
|
||||
</div>
|
||||
<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>
|
||||
<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]">
|
||||
<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"/>
|
||||
|
||||
608
templates/class_students.html
Normal file
608
templates/class_students.html
Normal file
@@ -0,0 +1,608 @@
|
||||
{% extends "base.html" %}
|
||||
{% from 'components/common/macros.html' import hero_section %}
|
||||
|
||||
{% block title %}Élèves de {{ class_group.name }} - Gestion Scolaire{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{# Hero Section avec Statistiques des Élèves #}
|
||||
{% set meta_info = [
|
||||
{
|
||||
'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/></svg>',
|
||||
'text': stats.total_current ~ ' élèves actuels'
|
||||
},
|
||||
{
|
||||
'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>',
|
||||
'text': stats.recent_arrivals ~ ' arrivées (30j)'
|
||||
},
|
||||
{
|
||||
'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>',
|
||||
'text': stats.recent_departures ~ ' départs (30j)'
|
||||
}
|
||||
] %}
|
||||
|
||||
{% set primary_action = {
|
||||
'url': 'javascript:openEnrollModal()',
|
||||
'text': 'Inscrire un élève',
|
||||
'icon': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>'
|
||||
} %}
|
||||
|
||||
{{ hero_section(
|
||||
title=class_group.name ~ " 👥",
|
||||
subtitle="Gestion des élèves",
|
||||
meta_info=meta_info,
|
||||
primary_action=primary_action,
|
||||
gradient_class="from-blue-500 to-blue-600"
|
||||
) }}
|
||||
|
||||
{# Breadcrumb Navigation #}
|
||||
<div class="flex items-center text-sm text-gray-600">
|
||||
<a href="{{ url_for('classes.dashboard', id=class_group.id) }}"
|
||||
class="hover:text-blue-600 transition-colors flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Retour au dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Statistiques d'effectifs #}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mr-4">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold">{{ stats.total_current }}</h3>
|
||||
<p class="text-sm opacity-90">Élèves actuels</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mr-4">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold">{{ stats.recent_arrivals }}</h3>
|
||||
<p class="text-sm opacity-90">Arrivées (30j)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-r from-orange-500 to-orange-600 text-white rounded-xl p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center mr-4">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold">{{ stats.recent_departures }}</h3>
|
||||
<p class="text-sm opacity-90">Départs (30j)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Liste des élèves actuels #}
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-6 h-6 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Élèves actuellement inscrits ({{ current_students|length }})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if current_students %}
|
||||
<div class="divide-y divide-gray-200">
|
||||
{% for student in current_students %}
|
||||
{% set enrollment = student.get_current_enrollment() %}
|
||||
<div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center mr-4">
|
||||
<span class="text-sm font-medium text-blue-600">{{ student.first_name[0] }}{{ student.last_name[0] }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-medium text-gray-900">{{ student.first_name }} {{ student.last_name }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{% if enrollment %}
|
||||
Inscrit depuis le {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }}
|
||||
{% if enrollment.enrollment_reason %}
|
||||
({{ enrollment.enrollment_reason }})
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<button onclick="transferStudent({{ student.id }}, '{{ student.first_name }} {{ student.last_name }}')"
|
||||
class="bg-blue-100 hover:bg-blue-200 text-blue-700 px-3 py-1 rounded text-sm transition-colors">
|
||||
Transférer
|
||||
</button>
|
||||
<button onclick="departureStudent({{ student.id }}, '{{ student.first_name }} {{ student.last_name }}')"
|
||||
class="bg-orange-100 hover:bg-orange-200 text-orange-700 px-3 py-1 rounded text-sm transition-colors">
|
||||
Départ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-6 py-8 text-center text-gray-500">
|
||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/>
|
||||
</svg>
|
||||
<p>Aucun élève inscrit dans cette classe</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Historique des mouvements #}
|
||||
{% if class_movements %}
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-6 h-6 mr-2 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Historique des mouvements (6 derniers mois)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-200">
|
||||
{% for student, enrollments in class_movements %}
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium text-gray-900">{{ student.first_name }} {{ student.last_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
{% for enrollment in enrollments %}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center">
|
||||
{% if enrollment.departure_date %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 mr-2">
|
||||
Départ
|
||||
</span>
|
||||
<span class="text-gray-600">
|
||||
Du {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }} au {{ enrollment.departure_date.strftime('%d/%m/%Y') }}
|
||||
{% if enrollment.departure_reason %}
|
||||
- {{ enrollment.departure_reason }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 mr-2">
|
||||
Arrivée
|
||||
</span>
|
||||
<span class="text-gray-600">
|
||||
Depuis le {{ enrollment.enrollment_date.strftime('%d/%m/%Y') }}
|
||||
{% if enrollment.enrollment_reason %}
|
||||
- {{ enrollment.enrollment_reason }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if enrollment.departure_date %}
|
||||
<button onclick="cancelDeparture({{ enrollment.id }}, '{{ student.first_name }} {{ student.last_name }}')"
|
||||
class="bg-green-100 hover:bg-green-200 text-green-700 px-2 py-1 rounded text-xs transition-colors">
|
||||
Annuler le départ
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Modal d'inscription d'élève #}
|
||||
<div id="enrollModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Inscrire un élève dans {{ class_group.name }}</h3>
|
||||
<button type="button" onclick="closeEnrollModal()"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Tabs pour choisir le mode #}
|
||||
<div class="flex mb-4 border-b border-gray-200">
|
||||
<button type="button" id="newStudentTab"
|
||||
class="px-4 py-2 text-sm font-medium text-blue-600 border-b-2 border-blue-600"
|
||||
onclick="switchEnrollMode('new')">
|
||||
Nouvel élève
|
||||
</button>
|
||||
<button type="button" id="existingStudentTab"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-500 border-b-2 border-transparent"
|
||||
onclick="switchEnrollMode('existing')">
|
||||
Élève existant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="enrollForm" method="post" action="{{ url_for('classes.enroll_student') }}">
|
||||
<input type="hidden" name="class_id" value="{{ class_group.id }}">
|
||||
<input type="hidden" name="mode" id="enrollMode" value="new">
|
||||
|
||||
{# Mode élève existant #}
|
||||
<div id="existingStudentFields" class="hidden">
|
||||
<div class="mb-4">
|
||||
<label for="student_id" class="block text-sm font-medium text-gray-700 mb-2">Élève</label>
|
||||
<select name="student_id" id="student_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Sélectionner un élève</option>
|
||||
{% for student in available_students %}
|
||||
<option value="{{ student.id }}">
|
||||
{{ student.first_name }} {{ student.last_name }}
|
||||
{% set current_class = student.get_current_class() %}
|
||||
{% if current_class %}
|
||||
(actuellement en {{ current_class.name }})
|
||||
{% else %}
|
||||
(non inscrit)
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Mode nouvel élève #}
|
||||
<div id="newStudentFields">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="new_first_name" class="block text-sm font-medium text-gray-700 mb-2">Prénom</label>
|
||||
<input type="text" name="new_first_name" id="new_first_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_last_name" class="block text-sm font-medium text-gray-700 mb-2">Nom</label>
|
||||
<input type="text" name="new_last_name" id="new_last_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="new_email" class="block text-sm font-medium text-gray-700 mb-2">Email (optionnel)</label>
|
||||
<input type="email" name="new_email" id="new_email"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="enrollment_date" class="block text-sm font-medium text-gray-700 mb-2">Date d'inscription</label>
|
||||
<input type="date" name="enrollment_date" id="enrollment_date" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="enrollment_reason" class="block text-sm font-medium text-gray-700 mb-2">Motif (optionnel)</label>
|
||||
<input type="text" name="enrollment_reason" id="enrollment_reason"
|
||||
placeholder="Ex: Nouvelle inscription, Transfert d'établissement..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="closeEnrollModal()"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-colors">
|
||||
Inscrire
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Modal de transfert #}
|
||||
<div id="transferModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Transférer <span id="transferStudentName"></span></h3>
|
||||
<button type="button" onclick="closeTransferModal()"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="transferForm" method="post" action="{{ url_for('classes.transfer_student') }}">
|
||||
<input type="hidden" name="student_id" id="transferStudentId">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="new_class_id" class="block text-sm font-medium text-gray-700 mb-2">Nouvelle classe</label>
|
||||
<select name="new_class_id" id="new_class_id" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Sélectionner une classe</option>
|
||||
{% for class in other_classes %}
|
||||
<option value="{{ class.id }}">{{ class.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="transfer_date" class="block text-sm font-medium text-gray-700 mb-2">Date de transfert</label>
|
||||
<input type="date" name="transfer_date" id="transfer_date" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="transfer_reason" class="block text-sm font-medium text-gray-700 mb-2">Motif (optionnel)</label>
|
||||
<input type="text" name="transfer_reason" id="transfer_reason"
|
||||
placeholder="Ex: Changement de niveau, Réorientation..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="closeTransferModal()"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">
|
||||
Transférer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Modal de départ #}
|
||||
<div id="departureModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Marquer le départ de <span id="departureStudentName"></span></h3>
|
||||
<button type="button" onclick="closeDepartureModal()"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="departureForm" method="post" action="{{ url_for('classes.student_departure') }}">
|
||||
<input type="hidden" name="student_id" id="departureStudentId">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="departure_date" class="block text-sm font-medium text-gray-700 mb-2">Date de départ</label>
|
||||
<input type="date" name="departure_date" id="departure_date" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="departure_reason" class="block text-sm font-medium text-gray-700 mb-2">Motif (optionnel)</label>
|
||||
<input type="text" name="departure_reason" id="departure_reason"
|
||||
placeholder="Ex: Fin de scolarité, Déménagement, Changement d'établissement..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="closeDepartureModal()"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-md transition-colors">
|
||||
Confirmer le départ
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Modal de confirmation d'annulation de départ #}
|
||||
<div id="cancelDepartureModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">Annuler le départ de <span id="cancelDepartureStudentName"></span></h3>
|
||||
<button type="button" onclick="closeCancelDepartureModal()"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-yellow-800">Attention</h4>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
Cette action va réintégrer l'élève dans la classe et supprimer la date de départ.
|
||||
L'élève redeviendra éligible pour les futures évaluations de cette classe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="cancelDepartureForm" method="post" action="{{ url_for('classes.cancel_departure') }}">
|
||||
<input type="hidden" name="enrollment_id" id="cancelDepartureEnrollmentId">
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="closeCancelDepartureModal()"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-colors">
|
||||
Confirmer l'annulation
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Initialiser la date d'aujourd'hui dans les champs de date
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('enrollment_date').value = today;
|
||||
document.getElementById('transfer_date').value = today;
|
||||
document.getElementById('departure_date').value = today;
|
||||
|
||||
// Vérifier s'il y a eu un rechargement après inscription
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('reload')) {
|
||||
// Supprimer le paramètre de l'URL pour éviter le rechargement en boucle
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, document.title, newUrl);
|
||||
|
||||
// Faire défiler vers le haut pour voir le message de succès
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Gestion des modals d'inscription
|
||||
function openEnrollModal() {
|
||||
document.getElementById('enrollModal').classList.remove('hidden');
|
||||
document.getElementById('enrollModal').classList.add('flex');
|
||||
// Réinitialiser au mode "nouvel élève" par défaut
|
||||
switchEnrollMode('new');
|
||||
}
|
||||
|
||||
function closeEnrollModal() {
|
||||
document.getElementById('enrollModal').classList.add('hidden');
|
||||
document.getElementById('enrollModal').classList.remove('flex');
|
||||
// Réinitialiser le formulaire
|
||||
document.getElementById('enrollForm').reset();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('enrollment_date').value = today;
|
||||
}
|
||||
|
||||
// Gestion du changement de mode d'inscription
|
||||
function switchEnrollMode(mode) {
|
||||
const existingTab = document.getElementById('existingStudentTab');
|
||||
const newTab = document.getElementById('newStudentTab');
|
||||
const existingFields = document.getElementById('existingStudentFields');
|
||||
const newFields = document.getElementById('newStudentFields');
|
||||
const modeInput = document.getElementById('enrollMode');
|
||||
|
||||
if (mode === 'existing') {
|
||||
// Activer l'onglet "Élève existant"
|
||||
existingTab.classList.add('text-blue-600', 'border-blue-600');
|
||||
existingTab.classList.remove('text-gray-500', 'border-transparent');
|
||||
newTab.classList.add('text-gray-500', 'border-transparent');
|
||||
newTab.classList.remove('text-blue-600', 'border-blue-600');
|
||||
|
||||
// Afficher les champs correspondants
|
||||
existingFields.classList.remove('hidden');
|
||||
newFields.classList.add('hidden');
|
||||
|
||||
// Mettre à jour le mode
|
||||
modeInput.value = 'existing';
|
||||
|
||||
// Rendre student_id requis
|
||||
document.getElementById('student_id').required = true;
|
||||
document.getElementById('new_first_name').required = false;
|
||||
document.getElementById('new_last_name').required = false;
|
||||
} else {
|
||||
// Activer l'onglet "Nouvel élève"
|
||||
newTab.classList.add('text-blue-600', 'border-blue-600');
|
||||
newTab.classList.remove('text-gray-500', 'border-transparent');
|
||||
existingTab.classList.add('text-gray-500', 'border-transparent');
|
||||
existingTab.classList.remove('text-blue-600', 'border-blue-600');
|
||||
|
||||
// Afficher les champs correspondants
|
||||
newFields.classList.remove('hidden');
|
||||
existingFields.classList.add('hidden');
|
||||
|
||||
// Mettre à jour le mode
|
||||
modeInput.value = 'new';
|
||||
|
||||
// Rendre les champs du nouvel élève requis
|
||||
document.getElementById('student_id').required = false;
|
||||
document.getElementById('new_first_name').required = true;
|
||||
document.getElementById('new_last_name').required = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion des modals de transfert
|
||||
function transferStudent(studentId, studentName) {
|
||||
document.getElementById('transferStudentId').value = studentId;
|
||||
document.getElementById('transferStudentName').textContent = studentName;
|
||||
document.getElementById('transferModal').classList.remove('hidden');
|
||||
document.getElementById('transferModal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeTransferModal() {
|
||||
document.getElementById('transferModal').classList.add('hidden');
|
||||
document.getElementById('transferModal').classList.remove('flex');
|
||||
}
|
||||
|
||||
// Gestion des modals de départ
|
||||
function departureStudent(studentId, studentName) {
|
||||
document.getElementById('departureStudentId').value = studentId;
|
||||
document.getElementById('departureStudentName').textContent = studentName;
|
||||
document.getElementById('departureModal').classList.remove('hidden');
|
||||
document.getElementById('departureModal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeDepartureModal() {
|
||||
document.getElementById('departureModal').classList.add('hidden');
|
||||
document.getElementById('departureModal').classList.remove('flex');
|
||||
}
|
||||
|
||||
// Gestion des modals d'annulation de départ
|
||||
function cancelDeparture(enrollmentId, studentName) {
|
||||
document.getElementById('cancelDepartureEnrollmentId').value = enrollmentId;
|
||||
document.getElementById('cancelDepartureStudentName').textContent = studentName;
|
||||
document.getElementById('cancelDepartureModal').classList.remove('hidden');
|
||||
document.getElementById('cancelDepartureModal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeCancelDepartureModal() {
|
||||
document.getElementById('cancelDepartureModal').classList.add('hidden');
|
||||
document.getElementById('cancelDepartureModal').classList.remove('flex');
|
||||
}
|
||||
|
||||
// Fermer les modals en cliquant à l'extérieur
|
||||
document.addEventListener('click', function(event) {
|
||||
const modals = ['enrollModal', 'transferModal', 'departureModal', 'cancelDepartureModal'];
|
||||
modals.forEach(modalId => {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (event.target === modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fermer les modals avec la touche Échap
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
const modals = ['enrollModal', 'transferModal', 'departureModal', 'cancelDepartureModal'];
|
||||
modals.forEach(modalId => {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal && !modal.classList.contains('hidden')) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="flex flex-col{% if not class.description %} mt-2{% endif %}">
|
||||
<!-- Actions principales -->
|
||||
<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">
|
||||
<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"/>
|
||||
|
||||
Reference in New Issue
Block a user