forked from ScoDoc/ScoDoc
405 lines
15 KiB
Python
405 lines
15 KiB
Python
"""ScoDoc 9 models : Formations
|
|
"""
|
|
|
|
from flask import abort, g
|
|
from flask_sqlalchemy.query import Query
|
|
|
|
import app
|
|
from app import db, log
|
|
from app.comp import df_cache
|
|
from app.models import ScoDocModel, SHORT_STR_LEN
|
|
from app.models.but_refcomp import (
|
|
ApcAnneeParcours,
|
|
ApcCompetence,
|
|
ApcParcours,
|
|
ApcParcoursNiveauCompetence,
|
|
)
|
|
from app.models.events import ScolarNews
|
|
from app.models.modules import Module
|
|
from app.models.moduleimpls import ModuleImpl
|
|
from app.models.ues import UniteEns, UEParcours
|
|
from app.scodoc import sco_cache
|
|
from app.scodoc import codes_cursus
|
|
from app.scodoc import sco_utils as scu
|
|
from app.scodoc.codes_cursus import UE_STANDARD
|
|
from app.scodoc.sco_exceptions import ScoNonEmptyFormationObject, ScoValueError
|
|
|
|
|
|
class Formation(ScoDocModel):
|
|
"""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")
|
|
commentaire = db.Column(db.Text())
|
|
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", ondelete="SET NULL")
|
|
)
|
|
ues = db.relationship("UniteEns", backref="formation", lazy="dynamic")
|
|
formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation")
|
|
ues = db.relationship(
|
|
"UniteEns", lazy="dynamic", backref="formation", order_by="UniteEns.numero"
|
|
)
|
|
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}, version={self.version})>"""
|
|
|
|
def html(self) -> str:
|
|
"titre complet pour affichage"
|
|
return f"""Formation {self.titre} ({self.acronyme}) version {self.version} code <tt>{self.formation_code}</tt>"""
|
|
|
|
@classmethod
|
|
def get_formation(cls, formation_id: int | str, dept_id: int = None) -> "Formation":
|
|
"""Formation ou 404, cherche uniquement dans le département spécifié
|
|
ou le courant (g.scodoc_dept)"""
|
|
if not isinstance(formation_id, int):
|
|
try:
|
|
formation_id = int(formation_id)
|
|
except (TypeError, ValueError):
|
|
abort(404, "formation_id invalide")
|
|
if g.scodoc_dept:
|
|
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
|
if dept_id is not None:
|
|
return cls.query.filter_by(id=formation_id, dept_id=dept_id).first_or_404()
|
|
return cls.query.filter_by(id=formation_id).first_or_404()
|
|
|
|
def to_dict(self, with_refcomp_attrs=False, with_departement=True):
|
|
"""As a dict.
|
|
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
|
|
"""
|
|
e = dict(self.__dict__)
|
|
e.pop("_sa_instance_state", None)
|
|
if "referentiel_competence" in e:
|
|
e.pop("referentiel_competence")
|
|
e["code_specialite"] = e["code_specialite"] or ""
|
|
e["commentaire"] = e["commentaire"] or ""
|
|
if with_departement and self.departement:
|
|
e["departement"] = self.departement.to_dict()
|
|
else:
|
|
e.pop("departement", None)
|
|
e["formation_id"] = self.id # ScoDoc7 backward compat
|
|
if with_refcomp_attrs and self.referentiel_competence:
|
|
e["refcomp_version_orebut"] = self.referentiel_competence.version_orebut
|
|
e["refcomp_specialite"] = self.referentiel_competence.specialite
|
|
e["refcomp_type_titre"] = self.referentiel_competence.type_titre
|
|
|
|
return e
|
|
|
|
def get_cursus(self) -> codes_cursus.TypeCursus:
|
|
"""get l'instance de TypeCursus de cette formation
|
|
(le TypeCursus définit le genre de formation, à ne pas confondre
|
|
avec les parcours du BUT).
|
|
"""
|
|
return codes_cursus.get_cursus_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_cursus().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, semestre_idx: int = None):
|
|
"""True if there is a locked formsemestre in this formation.
|
|
If semestre_idx is specified, check only this index.
|
|
"""
|
|
query = self.formsemestres.filter_by(etat=False)
|
|
if semestre_idx is not None:
|
|
query = query.filter_by(semestre_id=semestre_idx)
|
|
return len(query.all()) > 0
|
|
|
|
def invalidate_module_coefs(self, semestre_idx: int = None):
|
|
"""Invalide le cache des coefficients de modules.
|
|
Si semestre_idx est None, invalide les coefs de tous les semestres,
|
|
sinon invalide le semestre indiqué et le cache de la formation.
|
|
|
|
Dans tous les cas, invalide tous les formsemestres.
|
|
"""
|
|
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):
|
|
"Invalide caches de tous les formssemestres de la formation"
|
|
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:
|
|
Pour les formations APC (BUT) seulement:
|
|
- 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.
|
|
- on n'utilise pas de matières: réaffecte tous les modules
|
|
à la première matière de leur UE.
|
|
|
|
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)
|
|
|
|
# --- Réaffectation des matières:
|
|
for ue in self.ues:
|
|
mat = ue.matieres.first()
|
|
if mat is None:
|
|
mat = Matiere()
|
|
ue.matieres.append(mat)
|
|
db.session.add(mat)
|
|
for module in ue.modules:
|
|
if module.matiere_id != mat.id:
|
|
module.matiere = mat
|
|
db.session.add(module)
|
|
change = True
|
|
|
|
db.session.commit()
|
|
if change:
|
|
app.clear_scodoc_cache()
|
|
|
|
def query_ues_parcour(
|
|
self, parcour: ApcParcours, with_sport: bool = False
|
|
) -> Query:
|
|
"""Les UEs (sans bonus, sauf si with_sport) d'un parcours de la formation
|
|
(déclarée comme faisant partie du parcours ou du tronc commun, sans aucun parcours)
|
|
Si parcour est None, les UE sans parcours.
|
|
Exemple: pour avoir les UE du semestre 3, faire
|
|
`formation.query_ues_parcour(parcour).filter(UniteEns.semestre_idx == 3)`
|
|
"""
|
|
if with_sport:
|
|
query_f = UniteEns.query.filter_by(formation=self)
|
|
else:
|
|
query_f = UniteEns.query.filter_by(formation=self, type=UE_STANDARD)
|
|
# Les UE sans parcours:
|
|
query_no_parcours = query_f.outerjoin(UEParcours).filter(
|
|
UEParcours.parcours_id == None
|
|
)
|
|
if parcour is None:
|
|
return query_no_parcours.order_by(UniteEns.numero)
|
|
# Ajoute les UE du parcours sélectionné:
|
|
return query_no_parcours.union(
|
|
query_f.join(UEParcours).filter_by(parcours_id=parcour.id)
|
|
).order_by(UniteEns.numero)
|
|
# return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
|
|
# UniteEns.niveau_competence_id == ApcNiveau.id,
|
|
# (UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
|
|
# ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
|
|
# ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
|
|
# ApcAnneeParcours.parcours_id == parcour.id,
|
|
# )
|
|
|
|
def query_competences_parcour(self, parcour: ApcParcours) -> Query:
|
|
"""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)
|
|
)
|
|
|
|
def refcomp_desassoc(self):
|
|
"""Désassocie la formation de son ref. de compétence"""
|
|
self.referentiel_competence = None
|
|
db.session.add(self)
|
|
# Niveaux des UE
|
|
for ue in self.ues:
|
|
ue.niveau_competence = None
|
|
db.session.add(ue)
|
|
# Parcours et AC des modules
|
|
for mod in self.modules:
|
|
mod.parcours = []
|
|
mod.app_critiques = []
|
|
db.session.add(mod)
|
|
db.session.commit()
|
|
|
|
|
|
class Matiere(ScoDocModel):
|
|
"""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, nullable=False, default=0) # ordre de présentation
|
|
|
|
modules = db.relationship(
|
|
"Module", lazy="dynamic", backref="matiere", cascade="all, delete-orphan"
|
|
)
|
|
_sco_dept_relations = ("UniteEns", "Formation") # accès au dept_id
|
|
|
|
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["numero"] = e["numero"] if e["numero"] else 0
|
|
e["matiere_id"] = self.id
|
|
return e
|
|
|
|
def is_locked(self) -> bool:
|
|
"""True if matiere cannot be be modified
|
|
because it contains modules used in a locked formsemestre.
|
|
"""
|
|
from app.models.formsemestre import FormSemestre
|
|
|
|
mat = (
|
|
db.session.query(Matiere)
|
|
.filter_by(id=self.id)
|
|
.join(Module)
|
|
.join(ModuleImpl)
|
|
.join(FormSemestre)
|
|
.filter_by(etat=False)
|
|
.all()
|
|
)
|
|
return bool(mat)
|
|
|
|
def can_be_deleted(self) -> bool:
|
|
"True si la matiere n'est pas utilisée dans des formsemestres"
|
|
locked = self.is_locked()
|
|
if locked:
|
|
return False
|
|
if any(m.modimpls.all() for m in self.modules):
|
|
return False
|
|
return True
|
|
|
|
def delete(self):
|
|
"Delete matière. News, inval cache."
|
|
from app.models import ScolarNews
|
|
|
|
formation = self.ue.formation
|
|
log(f"matiere.delete: matiere_id={self.id}")
|
|
if not self.can_be_deleted():
|
|
# il y a au moins un modimpl dans un module de cette matière
|
|
raise ScoNonEmptyFormationObject("Matière", self.titre)
|
|
db.session.delete(self)
|
|
db.session.commit()
|
|
# news
|
|
ScolarNews.add(
|
|
typ=ScolarNews.NEWS_FORM,
|
|
obj=formation.id,
|
|
text=f"Modification de la formation {formation.acronyme}",
|
|
)
|
|
# cache
|
|
formation.invalidate_cached_sems()
|
|
|
|
@classmethod
|
|
def create_from_dict(cls, data: dict) -> "Matiere":
|
|
"""Create matière from dict. Log, news, cache.
|
|
data must include ue_id, a valid UE id.
|
|
Commit session.
|
|
"""
|
|
# check ue
|
|
if data.get("ue_id") is None:
|
|
raise ScoValueError("UE id missing")
|
|
_ = UniteEns.get_ue(data["ue_id"])
|
|
|
|
mat = super().create_from_dict(data)
|
|
db.session.commit()
|
|
db.session.refresh(mat)
|
|
# news
|
|
formation = mat.ue.formation
|
|
ScolarNews.add(
|
|
typ=ScolarNews.NEWS_FORM,
|
|
obj=formation.id,
|
|
text=f"Modification de la formation {formation.acronyme}",
|
|
)
|
|
formation.invalidate_cached_sems()
|
|
return mat
|