ScoDoc-Lille/app/models/formations.py

239 lines
9.0 KiB
Python

"""ScoDoc 9 models : Formations
"""
import flask_sqlalchemy
import app
from app import db
from app.comp import df_cache
from app.models import SHORT_STR_LEN
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
)
from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns
from app.scodoc import sco_cache
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_STANDARD
class Formation(db.Model):
"""Programme pédagogique d'une formation"""
__tablename__ = "notes_formations"
__table_args__ = (db.UniqueConstraint("dept_id", "acronyme", "titre", "version"),)
id = db.Column(db.Integer, primary_key=True)
formation_id = db.synonym("id")
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
acronyme = db.Column(db.Text(), nullable=False)
titre = db.Column(db.Text(), nullable=False)
titre_officiel = db.Column(db.Text(), nullable=False)
version = db.Column(db.Integer, default=1, server_default="1")
formation_code = db.Column(
db.String(SHORT_STR_LEN),
server_default=db.text("notes_newid_fcod()"),
nullable=False,
)
# nb: la fonction SQL notes_newid_fcod doit être créée à part
type_parcours = db.Column(db.Integer, default=0, server_default="0")
code_specialite = db.Column(db.String(SHORT_STR_LEN))
# Optionnel, pour les formations type BUT
referentiel_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id")
)
ues = db.relationship("UniteEns", backref="formation", lazy="dynamic")
formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation")
ues = db.relationship("UniteEns", lazy="dynamic", backref="formation")
modules = db.relationship("Module", lazy="dynamic", backref="formation")
def __repr__(self):
return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>"
def to_html(self) -> str:
"titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
def to_dict(self):
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators: (backward compat)
e["formation_id"] = self.id
return e
def get_parcours(self):
"""get l'instance de TypeParcours de cette formation
(le TypeParcours définit le genre de formation, à ne pas confondre
avec les parcours du BUT).
"""
return sco_codes_parcours.get_parcours_from_code(self.type_parcours)
def get_titre_version(self) -> str:
"""Titre avec version"""
return f"{self.acronyme} {self.titre} v{self.version}"
def is_apc(self):
"True si formation APC avec SAE (BUT)"
return self.get_parcours().APC_SAE
def get_module_coefs(self, semestre_idx: int = None):
"""Les coefs des modules vers les UE (accès via cache)"""
from app.comp import moy_ue
if semestre_idx is None:
key = f"{self.id}"
else:
key = f"{self.id}.{semestre_idx}"
modules_coefficients = df_cache.ModuleCoefsCache.get(key)
if modules_coefficients is None:
modules_coefficients, _, _ = moy_ue.df_load_module_coefs(
self.id, semestre_idx
)
df_cache.ModuleCoefsCache.set(key, modules_coefficients)
return modules_coefficients
def has_locked_sems(self):
"True if there is a locked formsemestre in this formation"
return len(self.formsemestres.filter_by(etat=False).all()) > 0
def invalidate_module_coefs(self, semestre_idx: int = None):
"""Invalide les coefficients de modules cachés.
Si semestre_idx est None, invalide tous les semestres,
sinon invalide le semestre indiqué et le cache de la formation.
"""
if semestre_idx is None:
keys = {f"{self.id}.{m.semestre_id}" for m in self.modules}
else:
keys = f"{self.id}.{semestre_idx}"
df_cache.ModuleCoefsCache.delete_many(keys | {f"{self.id}"})
# Invalidate aussi les poids de toutes les évals de la formation
for modimpl in ModuleImpl.query.filter(
ModuleImpl.module_id == Module.id,
Module.formation_id == self.id,
):
modimpl.invalidate_evaluations_poids()
sco_cache.invalidate_formsemestre()
def invalidate_cached_sems(self):
for sem in self.formsemestres:
sco_cache.invalidate_formsemestre(formsemestre_id=sem.id)
def sanitize_old_formation(self) -> None:
"""
Corrige si nécessaire certains champs issus d'anciennes versions de ScoDoc:
- affecte à chaque module de cette formation le semestre de son UE de rattachement,
si elle en a une.
- si le module_type n'est pas renseigné, le met à STANDARD.
Devrait être appelé lorsqu'on change le type de formation vers le BUT, et aussi
lorsqu'on change le semestre d'une UE BUT.
Utile pour la migration des anciennes formations vers le BUT.
En cas de changement, invalide les caches coefs/poids.
"""
if not self.is_apc():
return
change = False
for mod in self.modules:
# --- Indices de semestres:
if (
mod.ue.semestre_idx is not None
and mod.ue.semestre_idx > 0
and mod.semestre_id != mod.ue.semestre_idx
):
mod.semestre_id = mod.ue.semestre_idx
db.session.add(mod)
change = True
# --- Types de modules
if mod.module_type is None:
mod.module_type = scu.ModuleType.STANDARD
db.session.add(mod)
change = True
# --- Numéros de modules
if Module.query.filter_by(formation_id=self.id, numero=None).count() > 0:
scu.objects_renumber(db, self.modules.all())
# --- Types d'UE (avant de rendre le type non nullable)
ues_sans_type = UniteEns.query.filter_by(formation_id=self.id, type=None)
if ues_sans_type.count() > 0:
for ue in ues_sans_type:
ue.type = 0
db.session.add(ue)
db.session.commit()
if change:
app.clear_scodoc_cache()
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:
"""Les UEs d'un parcours de la formation.
Exemple: pour avoir les UE du semestre 3, faire
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
"""
return UniteEns.query.filter_by(formation=self).filter(
UniteEns.niveau_competence_id == ApcNiveau.id,
UniteEns.type == UE_STANDARD,
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == parcour.id,
)
def query_competences_parcour(
self, parcour: ApcParcours
) -> flask_sqlalchemy.BaseQuery:
"""Les ApcCompetences d'un parcours de la formation.
None si pas de référentiel de compétences.
"""
if self.referentiel_competence_id is None:
return None
return (
ApcCompetence.query.filter_by(referentiel_id=self.referentiel_competence_id)
.join(
ApcParcoursNiveauCompetence,
ApcParcoursNiveauCompetence.competence_id == ApcCompetence.id,
)
.join(
ApcAnneeParcours,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
)
.filter(ApcAnneeParcours.parcours_id == parcour.id)
)
class Matiere(db.Model):
"""Matières: regroupe les modules d'une UE
La matière a peu d'utilité en dehors de la présentation des modules
d'une UE.
"""
__tablename__ = "notes_matieres"
__table_args__ = (db.UniqueConstraint("ue_id", "titre"),)
id = db.Column(db.Integer, primary_key=True)
matiere_id = db.synonym("id")
ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"))
titre = db.Column(db.Text())
numero = db.Column(db.Integer) # ordre de présentation
modules = db.relationship("Module", lazy="dynamic", backref="matiere")
def __repr__(self):
return f"""<{self.__class__.__name__}(id={self.id}, ue_id={
self.ue_id}, titre='{self.titre!r}')>"""
def to_dict(self):
"""as a dict, with the same conversions as in ScoDoc7"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators
e["ue_id"] = self.id
e["numero"] = e["numero"] if e["numero"] else 0
return e