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>
387 lines
13 KiB
Python
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}>"
|