649 lines
24 KiB
Python
649 lines
24 KiB
Python
from flask_sqlalchemy import SQLAlchemy
|
|
from datetime import datetime, date
|
|
from sqlalchemy import CheckConstraint, Enum, Index
|
|
from typing import Optional
|
|
|
|
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
|
|
|
|
def get_domains_distribution(self):
|
|
"""
|
|
Retourne la distribution des points par domaine pour cette évaluation.
|
|
|
|
Returns:
|
|
List[Dict]: Liste des domaines avec leurs points et couleurs
|
|
[{'name': str, 'points': float, 'color': str}, ...]
|
|
"""
|
|
domains_points = {}
|
|
|
|
for exercise in self.exercises:
|
|
for element in exercise.grading_elements:
|
|
if element.domain:
|
|
domain_name = element.domain.name
|
|
domain_color = element.domain.color
|
|
if domain_name not in domains_points:
|
|
domains_points[domain_name] = {
|
|
"points": 0,
|
|
"color": domain_color,
|
|
}
|
|
domains_points[domain_name]["points"] += element.max_points
|
|
|
|
# Convertir en liste triée par nombre de points (descendant)
|
|
domains_list = []
|
|
for domain_name, data in domains_points.items():
|
|
domains_list.append(
|
|
{"name": domain_name, "points": data["points"], "color": data["color"]}
|
|
)
|
|
|
|
# Trier par nombre de points décroissant
|
|
domains_list.sort(key=lambda x: x["points"], reverse=True)
|
|
return domains_list
|
|
|
|
def get_skills_distribution(self):
|
|
"""
|
|
Retourne la distribution des points par compétence pour cette évaluation.
|
|
|
|
Returns:
|
|
List[Dict]: Liste des compétences avec leurs points et couleurs
|
|
[{'name': str, 'points': float, 'color': str}, ...]
|
|
"""
|
|
skills_points = {}
|
|
|
|
for exercise in self.exercises:
|
|
for element in exercise.grading_elements:
|
|
if element.skill:
|
|
skill_name = element.skill
|
|
if skill_name not in skills_points:
|
|
skills_points[skill_name] = 0
|
|
skills_points[skill_name] += element.max_points
|
|
|
|
# Récupérer les couleurs des compétences depuis la base de données
|
|
competences_colors = {}
|
|
competences = Competence.query.all()
|
|
for comp in competences:
|
|
competences_colors[comp.name] = comp.color
|
|
|
|
# Convertir en liste triée par nombre de points (descendant)
|
|
skills_list = []
|
|
for skill_name, points in skills_points.items():
|
|
skills_list.append(
|
|
{
|
|
"name": skill_name,
|
|
"points": points,
|
|
"color": competences_colors.get(
|
|
skill_name, "#6B7280"
|
|
), # Couleur par défaut si non trouvée
|
|
}
|
|
)
|
|
|
|
# Trier par nombre de points décroissant
|
|
skills_list.sort(key=lambda x: x["points"], reverse=True)
|
|
return skills_list
|
|
|
|
|
|
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())
|
|
)
|
|
|