2021-12-08 22:33:32 +01:00
|
|
|
"""ScoDoc 9 models : Formations
|
2021-08-07 15:20:30 +02:00
|
|
|
"""
|
2024-04-05 23:41:34 +02:00
|
|
|
|
|
|
|
from flask import abort, g
|
2023-04-03 17:40:45 +02:00
|
|
|
from flask_sqlalchemy.query import Query
|
2021-08-07 15:20:30 +02:00
|
|
|
|
2022-01-22 11:34:57 +01:00
|
|
|
import app
|
2021-08-07 15:20:30 +02:00
|
|
|
from app import db
|
2021-12-16 16:27:35 +01:00
|
|
|
from app.comp import df_cache
|
2024-07-19 09:42:44 +02:00
|
|
|
from app.models import ScoDocModel, SHORT_STR_LEN
|
2022-05-29 17:34:03 +02:00
|
|
|
from app.models.but_refcomp import (
|
|
|
|
ApcAnneeParcours,
|
2022-06-09 07:39:58 +02:00
|
|
|
ApcCompetence,
|
2022-05-29 17:34:03 +02:00
|
|
|
ApcParcours,
|
|
|
|
ApcParcoursNiveauCompetence,
|
|
|
|
)
|
2022-01-08 19:53:17 +01:00
|
|
|
from app.models.modules import Module
|
2022-06-24 03:34:52 +02:00
|
|
|
from app.models.moduleimpls import ModuleImpl
|
2023-04-03 17:46:31 +02:00
|
|
|
from app.models.ues import UniteEns, UEParcours
|
2021-12-16 16:27:35 +01:00
|
|
|
from app.scodoc import sco_cache
|
2023-02-12 13:36:47 +01:00
|
|
|
from app.scodoc import codes_cursus
|
2021-12-16 16:27:35 +01:00
|
|
|
from app.scodoc import sco_utils as scu
|
2023-02-12 13:36:47 +01:00
|
|
|
from app.scodoc.codes_cursus import UE_STANDARD
|
2021-08-07 15:20:30 +02:00
|
|
|
|
|
|
|
|
2024-07-19 09:42:44 +02:00
|
|
|
class Formation(ScoDocModel):
|
2021-08-07 15:20:30 +02:00
|
|
|
"""Programme pédagogique d'une formation"""
|
|
|
|
|
|
|
|
__tablename__ = "notes_formations"
|
2021-09-19 21:31:35 +02:00
|
|
|
__table_args__ = (db.UniqueConstraint("dept_id", "acronyme", "titre", "version"),)
|
2021-08-07 15:20:30 +02:00
|
|
|
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
formation_id = db.synonym("id")
|
2021-08-13 00:34:58 +02:00
|
|
|
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
|
|
|
|
2021-08-15 22:33:09 +02:00
|
|
|
acronyme = db.Column(db.Text(), nullable=False)
|
2021-08-07 15:20:30 +02:00
|
|
|
titre = db.Column(db.Text(), nullable=False)
|
2021-08-08 16:01:10 +02:00
|
|
|
titre_officiel = db.Column(db.Text(), nullable=False)
|
2021-08-10 00:23:30 +02:00
|
|
|
version = db.Column(db.Integer, default=1, server_default="1")
|
2023-01-30 18:08:40 +01:00
|
|
|
commentaire = db.Column(db.Text())
|
2021-08-08 16:01:10 +02:00
|
|
|
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
|
2021-08-10 00:23:30 +02:00
|
|
|
type_parcours = db.Column(db.Integer, default=0, server_default="0")
|
2021-08-07 15:20:30 +02:00
|
|
|
code_specialite = db.Column(db.String(SHORT_STR_LEN))
|
|
|
|
|
2021-12-02 12:08:03 +01:00
|
|
|
# Optionnel, pour les formations type BUT
|
|
|
|
referentiel_competence_id = db.Column(
|
2023-02-09 11:56:20 +01:00
|
|
|
db.Integer, db.ForeignKey("apc_referentiel_competences.id", ondelete="SET NULL")
|
2021-12-02 12:08:03 +01:00
|
|
|
)
|
2021-11-12 22:17:46 +01:00
|
|
|
ues = db.relationship("UniteEns", backref="formation", lazy="dynamic")
|
2021-08-13 00:34:58 +02:00
|
|
|
formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation")
|
2023-05-13 18:35:10 +02:00
|
|
|
ues = db.relationship(
|
|
|
|
"UniteEns", lazy="dynamic", backref="formation", order_by="UniteEns.numero"
|
|
|
|
)
|
2021-11-17 10:28:51 +01:00
|
|
|
modules = db.relationship("Module", lazy="dynamic", backref="formation")
|
2021-08-13 00:34:58 +02:00
|
|
|
|
2021-09-26 11:28:13 +02:00
|
|
|
def __repr__(self):
|
2023-01-25 15:17:52 +01:00
|
|
|
return f"""<{self.__class__.__name__}(id={self.id}, dept_id={
|
|
|
|
self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>"""
|
2021-09-26 11:28:13 +02:00
|
|
|
|
2023-06-18 09:37:13 +02:00
|
|
|
def html(self) -> str:
|
2022-06-26 09:37:50 +02:00
|
|
|
"titre complet pour affichage"
|
|
|
|
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
|
|
|
|
|
2024-04-05 23:41:34 +02:00
|
|
|
@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()
|
|
|
|
|
2023-02-20 16:25:23 +01:00
|
|
|
def to_dict(self, with_refcomp_attrs=False, with_departement=True):
|
2023-01-31 18:58:24 +01:00
|
|
|
"""As a dict.
|
2022-10-23 23:28:24 +02:00
|
|
|
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
|
|
|
|
"""
|
2021-11-30 10:54:12 +01:00
|
|
|
e = dict(self.__dict__)
|
|
|
|
e.pop("_sa_instance_state", None)
|
2022-12-19 00:56:17 +01:00
|
|
|
if "referentiel_competence" in e:
|
|
|
|
e.pop("referentiel_competence")
|
2023-02-18 00:13:00 +01:00
|
|
|
e["code_specialite"] = e["code_specialite"] or ""
|
|
|
|
e["commentaire"] = e["commentaire"] or ""
|
2023-02-20 16:25:23 +01:00
|
|
|
if with_departement and self.departement:
|
|
|
|
e["departement"] = self.departement.to_dict()
|
2023-02-21 11:08:41 +01:00
|
|
|
else:
|
|
|
|
e.pop("departement", None)
|
2022-10-23 23:28:24 +02:00
|
|
|
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
|
|
|
|
|
2021-11-30 10:54:12 +01:00
|
|
|
return e
|
|
|
|
|
2023-02-12 13:36:47 +01:00
|
|
|
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
|
2022-06-02 03:14:13 +02:00
|
|
|
avec les parcours du BUT).
|
|
|
|
"""
|
2023-02-12 13:36:47 +01:00
|
|
|
return codes_cursus.get_cursus_from_code(self.type_parcours)
|
2021-11-14 18:09:20 +01:00
|
|
|
|
2022-03-14 00:01:08 +01:00
|
|
|
def get_titre_version(self) -> str:
|
|
|
|
"""Titre avec version"""
|
|
|
|
return f"{self.acronyme} {self.titre} v{self.version}"
|
|
|
|
|
2021-12-05 20:21:51 +01:00
|
|
|
def is_apc(self):
|
|
|
|
"True si formation APC avec SAE (BUT)"
|
2023-02-12 13:36:47 +01:00
|
|
|
return self.get_cursus().APC_SAE
|
2021-12-05 20:21:51 +01:00
|
|
|
|
2021-11-29 22:18:37 +01:00
|
|
|
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
|
|
|
|
|
2023-02-17 22:07:03 +01:00
|
|
|
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
|
2021-12-16 16:27:35 +01:00
|
|
|
|
2021-11-29 22:18:37 +01:00
|
|
|
def invalidate_module_coefs(self, semestre_idx: int = None):
|
2022-07-13 18:52:07 +02:00
|
|
|
"""Invalide le cache des coefficients de modules.
|
|
|
|
Si semestre_idx est None, invalide les coefs de tous les semestres,
|
2021-11-29 22:18:37 +01:00
|
|
|
sinon invalide le semestre indiqué et le cache de la formation.
|
2022-07-13 18:52:07 +02:00
|
|
|
|
|
|
|
Dans tous les cas, invalide tous les formsemestres.
|
2021-11-29 22:18:37 +01:00
|
|
|
"""
|
|
|
|
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}"})
|
2022-06-24 03:34:52 +02:00
|
|
|
# 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()
|
|
|
|
|
2021-12-16 16:27:35 +01:00
|
|
|
sco_cache.invalidate_formsemestre()
|
|
|
|
|
|
|
|
def invalidate_cached_sems(self):
|
|
|
|
for sem in self.formsemestres:
|
|
|
|
sco_cache.invalidate_formsemestre(formsemestre_id=sem.id)
|
|
|
|
|
2022-01-08 14:01:16 +01:00
|
|
|
def sanitize_old_formation(self) -> None:
|
2021-12-16 16:27:35 +01:00
|
|
|
"""
|
2022-01-08 14:01:16 +01:00
|
|
|
Corrige si nécessaire certains champs issus d'anciennes versions de ScoDoc:
|
2022-09-08 01:20:04 +02:00
|
|
|
Pour les formations APC (BUT) seulement:
|
2022-01-08 14:01:16 +01:00
|
|
|
- affecte à chaque module de cette formation le semestre de son UE de rattachement,
|
2021-12-16 16:27:35 +01:00
|
|
|
si elle en a une.
|
2022-01-08 14:01:16 +01:00
|
|
|
- si le module_type n'est pas renseigné, le met à STANDARD.
|
2022-09-08 01:20:04 +02:00
|
|
|
- on n'utilise pas de matières: réaffecte tous les modules
|
|
|
|
à la première matière de leur UE.
|
2022-01-08 14:01:16 +01:00
|
|
|
|
2022-09-08 01:20:04 +02:00
|
|
|
Devrait être appelé lorsqu'on change le type de formation vers le BUT,
|
|
|
|
et aussi lorsqu'on change le semestre d'une UE BUT.
|
2021-12-16 16:27:35 +01:00
|
|
|
Utile pour la migration des anciennes formations vers le BUT.
|
2022-01-08 14:01:16 +01:00
|
|
|
|
|
|
|
En cas de changement, invalide les caches coefs/poids.
|
2021-12-16 16:27:35 +01:00
|
|
|
"""
|
|
|
|
if not self.is_apc():
|
|
|
|
return
|
|
|
|
change = False
|
|
|
|
for mod in self.modules:
|
2022-01-08 19:53:17 +01:00
|
|
|
# --- Indices de semestres:
|
2021-12-16 16:27:35 +01:00
|
|
|
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
|
2022-01-08 19:53:17 +01:00
|
|
|
# --- Types de modules
|
2022-01-08 14:01:16 +01:00
|
|
|
if mod.module_type is None:
|
|
|
|
mod.module_type = scu.ModuleType.STANDARD
|
|
|
|
db.session.add(mod)
|
|
|
|
change = True
|
2022-01-08 19:53:17 +01:00
|
|
|
# --- Numéros de modules
|
2022-01-08 20:07:13 +01:00
|
|
|
if Module.query.filter_by(formation_id=self.id, numero=None).count() > 0:
|
2022-01-08 19:53:17 +01:00
|
|
|
scu.objects_renumber(db, self.modules.all())
|
2022-09-08 01:20:04 +02:00
|
|
|
|
2022-01-08 20:07:13 +01:00
|
|
|
# --- 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)
|
2022-01-08 19:53:17 +01:00
|
|
|
|
2022-09-08 01:20:04 +02:00
|
|
|
# --- 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
|
|
|
|
|
2021-12-16 16:27:35 +01:00
|
|
|
db.session.commit()
|
2022-04-16 15:34:40 +02:00
|
|
|
if change:
|
|
|
|
app.clear_scodoc_cache()
|
2021-11-29 22:18:37 +01:00
|
|
|
|
2023-04-03 17:46:31 +02:00
|
|
|
def query_ues_parcour(
|
|
|
|
self, parcour: ApcParcours, with_sport: bool = False
|
2023-04-03 17:55:41 +02:00
|
|
|
) -> Query:
|
2023-05-15 11:05:51 +02:00
|
|
|
"""Les UEs (sans bonus, sauf si with_sport) d'un parcours de la formation
|
2023-04-03 17:46:31 +02:00
|
|
|
(déclarée comme faisant partie du parcours ou du tronc commun, sans aucun parcours)
|
2022-12-05 21:59:10 +01:00
|
|
|
Si parcour est None, les UE sans parcours.
|
2022-05-29 17:34:03 +02:00
|
|
|
Exemple: pour avoir les UE du semestre 3, faire
|
2023-04-03 17:46:31 +02:00
|
|
|
`formation.query_ues_parcour(parcour).filter(UniteEns.semestre_idx == 3)`
|
2022-05-29 17:34:03 +02:00
|
|
|
"""
|
2023-04-03 17:46:31 +02:00
|
|
|
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
|
2022-05-29 17:34:03 +02:00
|
|
|
)
|
2023-04-03 17:46:31 +02:00
|
|
|
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,
|
|
|
|
# )
|
2022-05-29 17:34:03 +02:00
|
|
|
|
2023-04-03 17:40:45 +02:00
|
|
|
def query_competences_parcour(self, parcour: ApcParcours) -> Query:
|
2022-06-09 07:39:58 +02:00
|
|
|
"""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)
|
|
|
|
)
|
|
|
|
|
2022-10-29 08:22:17 +02:00
|
|
|
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()
|
|
|
|
|
2021-08-07 15:20:30 +02:00
|
|
|
|
2024-07-19 09:42:44 +02:00
|
|
|
class Matiere(ScoDocModel):
|
2021-08-07 15:20:30 +02:00
|
|
|
"""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())
|
2023-04-03 17:46:31 +02:00
|
|
|
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
|
2021-08-07 15:20:30 +02:00
|
|
|
|
2021-11-12 22:17:46 +01:00
|
|
|
modules = db.relationship("Module", lazy="dynamic", backref="matiere")
|
2024-07-19 09:42:44 +02:00
|
|
|
_sco_dept_relations = ("UniteEns", "Formation") # accès au dept_id
|
2022-01-29 22:45:39 +01:00
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return f"""<{self.__class__.__name__}(id={self.id}, ue_id={
|
2022-05-22 03:26:39 +02:00
|
|
|
self.ue_id}, titre='{self.titre!r}')>"""
|
2022-01-29 22:45:39 +01:00
|
|
|
|
|
|
|
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
|
2023-02-18 00:13:00 +01:00
|
|
|
e["ue_id"] = self.id
|
2022-01-29 22:45:39 +01:00
|
|
|
return e
|