Files
notytex/backend/infrastructure/database/models.py
Bertrand Benjamin a0ab7224e1 refactor: extract duplicated patterns into shared helpers
Backend: create api/helpers.py with eligible_enrollment_filter,
count_eligible_students, get_active_enrollment, ensure_unique_name,
upsert_app_configs, and build_heatmap. Add full_name properties to
Student model. Apply across all route files (-481/+184 lines).

Frontend: create stores/helpers.js with withLoading composable,
apply to assessments and classes Pinia stores.

96/96 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:05:10 +01:00

387 lines
13 KiB
Python

"""
Modèles SQLAlchemy pour Notytex v2.
IMPORTANT: Ce fichier contient UNIQUEMENT les définitions de tables.
La logique métier est dans domain/services/.
Ces modèles sont identiques au schéma de la v1 pour assurer la compatibilité
de la base de données partagée.
"""
from datetime import datetime, date
from typing import Optional, List
from sqlalchemy import (
Column,
Integer,
String,
Float,
Date,
DateTime,
Text,
Boolean,
ForeignKey,
CheckConstraint,
Enum,
Index,
UniqueConstraint,
)
from sqlalchemy.orm import relationship, DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""Base class for all models."""
pass
class ClassGroup(Base):
"""Groupe de classe (6ème A, 5ème B, etc.)"""
__tablename__ = "class_group"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
description: Mapped[Optional[str]] = mapped_column(Text)
year: Mapped[str] = mapped_column(String(20), nullable=False)
# Relations
assessments: Mapped[List["Assessment"]] = relationship(
"Assessment", back_populates="class_group", lazy="selectin"
)
enrollments: Mapped[List["StudentEnrollment"]] = relationship(
"StudentEnrollment", back_populates="class_group", lazy="selectin"
)
council_appreciations: Mapped[List["CouncilAppreciation"]] = relationship(
"CouncilAppreciation", back_populates="class_group", lazy="selectin"
)
def __repr__(self):
return f"<ClassGroup {self.name}>"
class StudentEnrollment(Base):
"""
Historique temporel des inscriptions élève-classe.
Pattern: Association temporelle avec validité temporelle.
"""
__tablename__ = "student_enrollments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
student_id: Mapped[int] = mapped_column(Integer, ForeignKey("student.id"), nullable=False)
class_group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("class_group.id"), nullable=False
)
# Période de validité
enrollment_date: Mapped[date] = mapped_column(Date, nullable=False)
departure_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
# Métadonnées
enrollment_reason: Mapped[Optional[str]] = mapped_column(String(200))
departure_reason: Mapped[Optional[str]] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relations
student: Mapped["Student"] = relationship("Student", back_populates="enrollments")
class_group: Mapped["ClassGroup"] = relationship("ClassGroup", back_populates="enrollments")
__table_args__ = (
CheckConstraint(
"departure_date IS NULL OR departure_date >= enrollment_date",
name="check_valid_enrollment_period",
),
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}>"
class Student(Base):
"""Élève"""
__tablename__ = "student"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(120), unique=True)
# Relations
grades: Mapped[List["Grade"]] = relationship(
"Grade", back_populates="student", lazy="selectin"
)
enrollments: Mapped[List["StudentEnrollment"]] = relationship(
"StudentEnrollment", back_populates="student", lazy="selectin"
)
council_appreciations: Mapped[List["CouncilAppreciation"]] = relationship(
"CouncilAppreciation", back_populates="student", lazy="selectin"
)
@property
def full_name(self) -> str:
"""Prénom Nom"""
return f"{self.first_name} {self.last_name}"
@property
def full_name_reversed(self) -> str:
"""Nom Prénom"""
return f"{self.last_name} {self.first_name}"
def __repr__(self):
return f"<Student {self.first_name} {self.last_name}>"
class Assessment(Base):
"""Évaluation"""
__tablename__ = "assessment"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
date: Mapped[date] = mapped_column(Date, nullable=False, default=datetime.utcnow)
trimester: Mapped[int] = mapped_column(Integer, nullable=False)
class_group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("class_group.id"), nullable=False
)
coefficient: Mapped[float] = mapped_column(Float, default=1.0)
# Relations
class_group: Mapped["ClassGroup"] = relationship(
"ClassGroup", back_populates="assessments"
)
exercises: Mapped[List["Exercise"]] = relationship(
"Exercise",
back_populates="assessment",
lazy="selectin",
cascade="all, delete-orphan",
)
__table_args__ = (
CheckConstraint("trimester IN (1, 2, 3)", name="check_trimester_valid"),
)
def __repr__(self):
return f"<Assessment {self.title}>"
class Exercise(Base):
"""Exercice d'une évaluation"""
__tablename__ = "exercise"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
assessment_id: Mapped[int] = mapped_column(
Integer, ForeignKey("assessment.id"), nullable=False
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
order: Mapped[int] = mapped_column(Integer, default=1)
# Relations
assessment: Mapped["Assessment"] = relationship(
"Assessment", back_populates="exercises"
)
grading_elements: Mapped[List["GradingElement"]] = relationship(
"GradingElement",
back_populates="exercise",
lazy="selectin",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<Exercise {self.title}>"
class GradingElement(Base):
"""Élément de notation (question, critère, etc.)"""
__tablename__ = "grading_element"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
exercise_id: Mapped[int] = mapped_column(
Integer, ForeignKey("exercise.id"), nullable=False
)
label: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
skill: Mapped[Optional[str]] = mapped_column(String(200))
max_points: Mapped[float] = mapped_column(Float, nullable=False)
grading_type: Mapped[str] = mapped_column(
Enum("notes", "score", name="grading_types"), nullable=False, default="notes"
)
domain_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("domains.id"), nullable=True
)
# Relations
exercise: Mapped["Exercise"] = relationship(
"Exercise", back_populates="grading_elements"
)
domain: Mapped[Optional["Domain"]] = relationship(
"Domain", back_populates="grading_elements"
)
grades: Mapped[List["Grade"]] = relationship(
"Grade",
back_populates="grading_element",
lazy="selectin",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<GradingElement {self.label}>"
class Grade(Base):
"""Note attribuée à un élève pour un élément de notation"""
__tablename__ = "grade"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
student_id: Mapped[int] = mapped_column(
Integer, ForeignKey("student.id"), nullable=False
)
grading_element_id: Mapped[int] = mapped_column(
Integer, ForeignKey("grading_element.id"), nullable=False
)
value: Mapped[Optional[str]] = mapped_column(String(10))
comment: Mapped[Optional[str]] = mapped_column(Text)
# Relations
student: Mapped["Student"] = relationship("Student", back_populates="grades")
grading_element: Mapped["GradingElement"] = relationship(
"GradingElement", back_populates="grades"
)
def __repr__(self):
return f"<Grade {self.value}>"
# Configuration tables
class AppConfig(Base):
"""Configuration simple de l'application (clé-valeur)."""
__tablename__ = "app_config"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
def __repr__(self):
return f"<AppConfig {self.key}={self.value}>"
class CompetenceScaleValue(Base):
"""Valeurs de l'échelle des compétences (0, 1, 2, 3, ., d, etc.)."""
__tablename__ = "competence_scale_values"
value: Mapped[str] = mapped_column(String(10), primary_key=True)
label: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(7), nullable=False)
included_in_total: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
def __repr__(self):
return f"<CompetenceScaleValue {self.value}: {self.label}>"
class Competence(Base):
"""Liste des compétences (Calculer, Raisonner, etc.)."""
__tablename__ = "competences"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
color: Mapped[str] = mapped_column(String(7), nullable=False)
icon: Mapped[str] = mapped_column(String(50), nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
def __repr__(self):
return f"<Competence {self.name}>"
class Domain(Base):
"""Domaines/tags pour les éléments de notation."""
__tablename__ = "domains"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6B7280")
description: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relation inverse
grading_elements: Mapped[List["GradingElement"]] = relationship(
"GradingElement", back_populates="domain", lazy="selectin"
)
def __repr__(self):
return f"<Domain {self.name}>"
class CouncilAppreciation(Base):
"""Appréciations saisies lors de la préparation du conseil de classe."""
__tablename__ = "council_appreciations"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
student_id: Mapped[int] = mapped_column(
Integer, ForeignKey("student.id"), nullable=False
)
class_group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("class_group.id"), nullable=False
)
trimester: Mapped[int] = mapped_column(Integer, nullable=False)
# Appréciations structurées
general_appreciation: Mapped[Optional[str]] = mapped_column(Text)
strengths: Mapped[Optional[str]] = mapped_column(Text)
areas_for_improvement: Mapped[Optional[str]] = mapped_column(Text)
# Statut et métadonnées
status: Mapped[str] = mapped_column(
Enum("draft", "finalized", name="appreciation_status"),
nullable=False,
default="draft",
)
last_modified: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relations
student: Mapped["Student"] = relationship(
"Student", back_populates="council_appreciations"
)
class_group: Mapped["ClassGroup"] = relationship(
"ClassGroup", back_populates="council_appreciations"
)
__table_args__ = (
CheckConstraint(
"trimester IN (1, 2, 3)", name="check_appreciation_trimester_valid"
),
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}>"