Files
notytex/models.py

502 lines
22 KiB
Python

from flask_sqlalchemy import SQLAlchemy
from datetime import datetime, date
from sqlalchemy import CheckConstraint, Enum, Index
from typing import Optional, Dict, Any
db = SQLAlchemy()
class GradingCalculator:
"""
Calculateur unifié pour tous types de notation utilisant le Pattern Strategy.
Version simplifiée après suppression des feature flags.
"""
@staticmethod
def calculate_score(grade_value: str, grading_type: str, max_points: float) -> Optional[float]:
"""
Point d'entrée unifié pour tous les calculs de score.
Args:
grade_value: Valeur de la note (ex: '15.5', '2', '.', 'd')
grading_type: Type de notation ('notes' ou 'score')
max_points: Points maximum de l'élément de notation
Returns:
Score calculé ou None pour les valeurs dispensées
"""
from services.assessment_services import UnifiedGradingCalculator
from providers.concrete_providers import ConfigManagerProvider
# Injection de dépendances pour éviter les imports circulaires
config_provider = ConfigManagerProvider()
unified_calculator = UnifiedGradingCalculator(config_provider)
return unified_calculator.calculate_score(grade_value, grading_type, max_points)
@staticmethod
def is_counted_in_total(grade_value: str, grading_type: str) -> bool:
"""
Détermine si une note doit être comptée dans le total.
Returns:
True si la note compte dans le total, False sinon (ex: dispensé)
"""
from services.assessment_services import UnifiedGradingCalculator
from providers.concrete_providers import ConfigManagerProvider
# Injection de dépendances pour éviter les imports circulaires
config_provider = ConfigManagerProvider()
unified_calculator = UnifiedGradingCalculator(config_provider)
return unified_calculator.is_counted_in_total(grade_value)
class ClassGroup(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
description = db.Column(db.Text)
year = db.Column(db.String(20), nullable=False)
# 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):
"""
Adapter vers ClassStatisticsService pour maintenir la compatibilité API.
Args:
trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations
Returns:
Dict avec nombre total, répartition par statut (terminées/en cours/non commencées)
"""
from providers.concrete_providers import AssessmentServicesFactory
class_services = AssessmentServicesFactory.create_class_services_facade()
return class_services.get_trimester_statistics(self, trimester)
def get_domain_analysis(self, trimester=None):
"""
Adapter vers ClassAnalysisService pour maintenir la compatibilité API.
Args:
trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations
Returns:
Dict avec liste des domaines, points totaux et nombre d'éléments par domaine
"""
from providers.concrete_providers import AssessmentServicesFactory
class_services = AssessmentServicesFactory.create_class_services_facade()
return class_services.get_domain_analysis(self, trimester)
def get_competence_analysis(self, trimester=None):
"""
Adapter vers ClassAnalysisService pour maintenir la compatibilité API.
Args:
trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations
Returns:
Dict avec liste des compétences, points totaux et nombre d'éléments par compétence
"""
from providers.concrete_providers import AssessmentServicesFactory
class_services = AssessmentServicesFactory.create_class_services_facade()
return class_services.get_competence_analysis(self, trimester)
def get_class_results(self, trimester=None):
"""
Adapter vers ClassStatisticsService pour maintenir la compatibilité API.
Args:
trimester: Trimestre à filtrer (1, 2, 3) ou None pour toutes les évaluations
Returns:
Dict avec moyennes, distribution des notes et métriques statistiques
"""
from providers.concrete_providers import AssessmentServicesFactory
class_services = AssessmentServicesFactory.create_class_services_facade()
return class_services.get_class_results(self, trimester)
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)
# 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}>'
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
def get_current_class(self) -> Optional['ClassGroup']:
"""Retourne la classe actuelle de l'élève (inscription active)."""
active_enrollment = StudentEnrollment.query.filter_by(
student_id=self.id,
departure_date=None
).first()
return active_enrollment.class_group if active_enrollment else None
def get_current_enrollment(self) -> Optional['StudentEnrollment']:
"""Retourne l'inscription actuelle de l'élève (inscription active)."""
return StudentEnrollment.query.filter_by(
student_id=self.id,
departure_date=None
).first()
def get_class_at_date(self, check_date: date) -> Optional['ClassGroup']:
"""Retourne la classe de l'élève à une date donnée."""
enrollment = StudentEnrollment.query.filter(
StudentEnrollment.student_id == self.id,
StudentEnrollment.enrollment_date <= check_date,
db.or_(
StudentEnrollment.departure_date.is_(None),
StudentEnrollment.departure_date >= check_date
)
).first()
return enrollment.class_group if enrollment else None
def is_eligible_for_assessment(self, assessment: 'Assessment') -> bool:
"""Vérifie si l'élève peut être évalué sur cette évaluation."""
if not assessment.date:
return False
# L'élève doit être inscrit dans la classe de l'évaluation à la date de l'évaluation
class_at_assessment_date = self.get_class_at_date(assessment.date)
return class_at_assessment_date and class_at_assessment_date.id == assessment.class_group_id
class Assessment(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
trimester = db.Column(db.Integer, nullable=False) # 1, 2, ou 3
class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False)
coefficient = db.Column(db.Float, default=1.0) # Garder Float pour compatibilité
exercises = db.relationship('Exercise', backref='assessment', lazy=True, cascade='all, delete-orphan')
__table_args__ = (
CheckConstraint('trimester IN (1, 2, 3)', name='check_trimester_valid'),
)
def __repr__(self):
return f'<Assessment {self.title}>'
@property
def grading_progress(self):
"""
Calcule le pourcentage de progression des notes saisies pour cette évaluation.
Utilise TemporalAssessmentProgressService avec injection de dépendances.
Returns:
Dict avec les statistiques de progression (version temporelle)
"""
from services.temporal_facade import TemporalAssessmentServicesFactory
# Injection de dépendances pour éviter les imports circulaires
temporal_facade = TemporalAssessmentServicesFactory.create_temporal_facade()
progress_result = temporal_facade.get_grading_progress(self)
# 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,
'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 éligibles pour cette évaluation.
Retourne un dictionnaire avec les scores par élève et par exercice.
Utilise TemporalStudentScoreCalculator avec injection de dépendances.
Args:
grade_repo: Repository des notes (optionnel, maintenu pour compatibilité)
"""
from services.temporal_facade import TemporalAssessmentServicesFactory
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():
# 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)
return students_scores, exercise_scores
def get_assessment_statistics(self):
"""
Calcule les statistiques descriptives pour cette évaluation.
Utilise TemporalAssessmentStatisticsService avec injection de dépendances.
"""
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 {
'count': result.count,
'mean': result.mean,
'median': result.median,
'min': result.min,
'max': result.max,
'std_dev': result.std_dev
}
def get_total_max_points(self):
"""Calcule le total des points maximum pour cette évaluation."""
total = 0
for exercise in self.exercises:
for element in exercise.grading_elements:
# Logique simplifiée avec 2 types : notes et score
total += element.max_points
return total
class Exercise(db.Model):
id = db.Column(db.Integer, primary_key=True)
assessment_id = db.Column(db.Integer, db.ForeignKey('assessment.id'), nullable=False)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
order = db.Column(db.Integer, default=1)
grading_elements = db.relationship('GradingElement', backref='exercise', lazy=True, cascade='all, delete-orphan')
def __repr__(self):
return f'<Exercise {self.title}>'
class GradingElement(db.Model):
id = db.Column(db.Integer, primary_key=True)
exercise_id = db.Column(db.Integer, db.ForeignKey('exercise.id'), nullable=False)
label = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
skill = db.Column(db.String(200))
max_points = db.Column(db.Float, nullable=False) # Garder Float pour compatibilité
# NOUVEAU : Types enum directement
grading_type = db.Column(Enum('notes', 'score', name='grading_types'), nullable=False, default='notes')
# Ajout du champ domain_id
domain_id = db.Column(db.Integer, db.ForeignKey('domains.id'), nullable=True) # Optionnel
grades = db.relationship('Grade', backref='grading_element', lazy=True, cascade='all, delete-orphan')
def __repr__(self):
return f'<GradingElement {self.label}>'
class Grade(db.Model):
id = db.Column(db.Integer, primary_key=True)
student_id = db.Column(db.Integer, db.ForeignKey('student.id'), nullable=False)
grading_element_id = db.Column(db.Integer, db.ForeignKey('grading_element.id'), nullable=False)
value = db.Column(db.String(10)) # Garder l'ancien format pour compatibilité
comment = db.Column(db.Text)
def __repr__(self):
return f'<Grade {self.value} for {self.student.first_name if self.student else "Unknown"}>'
# Configuration tables
class AppConfig(db.Model):
"""Configuration simple de l'application (clé-valeur)."""
__tablename__ = 'app_config'
key = db.Column(db.String(100), primary_key=True)
value = db.Column(db.Text, nullable=False)
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<AppConfig {self.key}={self.value}>'
class CompetenceScaleValue(db.Model):
"""Valeurs de l'échelle des compétences (0, 1, 2, 3, ., d, etc.)."""
__tablename__ = 'competence_scale_values'
value = db.Column(db.String(10), primary_key=True) # '0', '1', '2', '3', '.', 'd', etc.
label = db.Column(db.String(100), nullable=False)
color = db.Column(db.String(7), nullable=False) # Format #RRGGBB
included_in_total = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<CompetenceScaleValue {self.value}: {self.label}>'
class Competence(db.Model):
"""Liste des compétences (Calculer, Raisonner, etc.)."""
__tablename__ = 'competences'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
color = db.Column(db.String(7), nullable=False) # Format #RRGGBB
icon = db.Column(db.String(50), nullable=False)
order_index = db.Column(db.Integer, default=0) # Pour l'ordre d'affichage
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<Competence {self.name}>'
class Domain(db.Model):
"""Domaines/tags pour les éléments de notation."""
__tablename__ = 'domains'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
color = db.Column(db.String(7), nullable=False, default='#6B7280') # Format #RRGGBB
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relation inverse
grading_elements = db.relationship('GradingElement', backref='domain', lazy=True)
def __repr__(self):
return f'<Domain {self.name}>'
class CouncilAppreciation(db.Model):
"""Appréciations saisies lors de la préparation du conseil de classe."""
__tablename__ = 'council_appreciations'
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)
trimester = db.Column(db.Integer, nullable=False) # 1, 2, ou 3
# Appréciations structurées
general_appreciation = db.Column(db.Text) # Appréciation générale
strengths = db.Column(db.Text) # Points forts
areas_for_improvement = db.Column(db.Text) # Axes d'amélioration
# Statut et métadonnées
status = db.Column(Enum('draft', 'finalized', name='appreciation_status'), nullable=False, default='draft')
last_modified = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relations
student = db.relationship('Student', backref='council_appreciations')
class_group = db.relationship('ClassGroup', backref='council_appreciations')
__table_args__ = (
CheckConstraint('trimester IN (1, 2, 3)', name='check_appreciation_trimester_valid'),
# Contrainte d'unicité : une seule appréciation par élève/classe/trimestre
db.UniqueConstraint('student_id', 'class_group_id', 'trimester',
name='uq_student_class_trimester_appreciation')
)
def __repr__(self):
return f'<CouncilAppreciation Student:{self.student_id} Class:{self.class_group_id} T{self.trimester}>'
@property
def has_content(self):
"""Vérifie si l'appréciation a du contenu."""
return bool(
(self.general_appreciation and self.general_appreciation.strip()) or
(self.strengths and self.strengths.strip()) or
(self.areas_for_improvement and self.areas_for_improvement.strip())
)