ScoDoc/app/models/ues.py

311 lines
12 KiB
Python

"""ScoDoc 9 models : Unités d'Enseignement (UE)
"""
import pandas as pd
from app import db, log
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ApcNiveau, ApcParcours
from app.models.modules import Module
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.scodoc import sco_utils as scu
class UniteEns(db.Model):
"""Unité d'Enseignement (UE)"""
__tablename__ = "notes_ue"
id = db.Column(db.Integer, primary_key=True)
ue_id = db.synonym("id")
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
acronyme = db.Column(db.Text(), nullable=False)
numero = db.Column(db.Integer) # ordre de présentation
titre = db.Column(db.Text())
# Le semestre_idx n'est pas un id mais le numéro du semestre: 1, 2, ...
# En ScoDoc7 et pour les formations classiques, il est NULL
# (le numéro du semestre étant alors déterminé par celui des modules de l'UE)
# Pour les formations APC, il est obligatoire (de 1 à 6 pour le BUT):
semestre_idx = db.Column(db.Integer, nullable=True, index=True)
# Type d'UE: 0 normal ("fondamentale"), 1 "sport", 2 "projet et stage (LP)",
# 4 "élective"
type = db.Column(db.Integer, default=0, server_default="0")
# Les UE sont "compatibles" (pour la capitalisation) ssi elles ont ^m code
# note: la fonction SQL notes_newid_ucod doit être créée à part
ue_code = db.Column(
db.String(SHORT_STR_LEN),
server_default=db.text("notes_newid_ucod()"),
nullable=False,
)
ects = db.Column(db.Float) # nombre de credits ECTS
is_external = db.Column(db.Boolean(), default=False, server_default="false")
# id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
# coef UE, utilise seulement si l'option use_ue_coefs est activée:
coefficient = db.Column(db.Float)
# coef. pour le calcul de moyennes de RCUE. Par défaut, 1.
coef_rcue = db.Column(db.Float, nullable=False, default=1.0, server_default="1.0")
color = db.Column(db.Text())
# BUT
niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"))
niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
# Une ue appartient soit à tous les parcours (tronc commun), soit à un seul:
parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True)
parcour = db.relationship("ApcParcours", back_populates="ues")
# relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
modules = db.relationship("Module", lazy="dynamic", backref="ue")
dispense_ues = db.relationship(
"DispenseUE",
back_populates="ue",
cascade="all, delete",
passive_deletes=True,
)
def __repr__(self):
return f"""<{self.__class__.__name__}(id={self.id}, formation_id={
self.formation_id}, acronyme='{self.acronyme}', semestre_idx={
self.semestre_idx} {
'EXTERNE' if self.is_external else ''})>"""
def clone(self):
"""Create a new copy of this ue.
Ne copie pas le code, ni le code Apogée, ni les liens au réf. de comp.
(parcours et niveau).
"""
ue = UniteEns(
formation_id=self.formation_id,
acronyme=self.acronyme + "-copie",
numero=self.numero,
titre=self.titre,
semestre_idx=self.semestre_idx,
type=self.type,
ue_code="", # ne duplique pas le code
ects=self.ects,
is_external=self.is_external,
code_apogee="", # ne copie pas les codes Apo
coefficient=self.coefficient,
coef_rcue=self.coef_rcue,
color=self.color,
)
return ue
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7
(except ECTS: keep None)
If convert_objects, convert all attributes to native types
(suitable jor json encoding).
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
e.pop("evaluation_ue_poids", None)
# ScoDoc7 output_formators
e["ue_id"] = self.id
e["numero"] = e["numero"] if e["numero"] else 0
e["ects"] = e["ects"]
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None
if with_module_ue_coefs:
if convert_objects:
e["module_ue_coefs"] = [
c.to_dict(convert_objects=True) for c in self.module_ue_coefs
]
else:
e.pop("module_ue_coefs", None)
return e
def annee(self) -> int:
"""L'année dans la formation (commence à 1).
En APC seulement, en classic renvoie toujours 1.
"""
return 1 if self.semestre_idx is None else (self.semestre_idx - 1) // 2 + 1
def is_locked(self):
"""True if UE should not be modified
(contains modules used in a locked formsemestre)
"""
# XXX todo : à ré-écrire avec SQLAlchemy
from app.scodoc import sco_edit_ue
return sco_edit_ue.ue_is_locked(self.id)
def can_be_deleted(self) -> bool:
"""True si l'UE n'a pas de moduleimpl rattachés
(pas un seul module de cette UE n'a de modimpl)
"""
return (self.modules.count() == 0) or not any(
m.modimpls.all() for m in self.modules
)
def guess_semestre_idx(self) -> None:
"""Lorsqu'on prend une ancienne formation non APC,
les UE n'ont pas d'indication de semestre.
Cette méthode fixe le semestre en prenant celui du premier module,
ou à défaut le met à 1.
"""
if self.semestre_idx is None:
module = self.modules.first()
if module is None:
self.semestre_idx = 1
else:
self.semestre_idx = module.semestre_id
db.session.add(self)
db.session.commit()
def get_ressources(self):
"Liste des modules ressources rattachés à cette UE"
return self.modules.filter_by(module_type=scu.ModuleType.RESSOURCE).all()
def get_saes(self):
"Liste des modules SAE rattachés à cette UE"
return self.modules.filter_by(module_type=scu.ModuleType.SAE).all()
def get_modules_not_apc(self):
"Listes des modules non SAE et non ressource (standards, mais aussi bonus...)"
return self.modules.filter(
(Module.module_type != scu.ModuleType.SAE),
(Module.module_type != scu.ModuleType.RESSOURCE),
).all()
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")"""
if self.code_apogee:
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int):
"raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre"
# Les UE du même semestre que nous:
ues_sem = self.formation.ues.filter_by(semestre_idx=self.semestre_idx)
if (new_niveau_id, new_parcour_id) in (
(oue.niveau_competence_id, oue.parcour_id)
for oue in ues_sem
if oue.id != self.id
):
log(
f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé"
)
raise ScoFormationConflict()
def set_niveau_competence(self, niveau: ApcNiveau):
"""Associe cette UE au niveau de compétence indiqué.
Le niveau doit être dans le parcours de l'UE, s'il y en a un.
Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
"""
if niveau is not None:
self._check_apc_conflict(niveau.id, self.parcour_id)
# Le niveau est-il dans le parcours ? Sinon, erreur
if self.parcour and niveau.id not in (
n.id
for n in niveau.niveaux_annee_de_parcours(
self.parcour, self.annee(), self.formation.referentiel_competence
)
):
log(
f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}"
)
return
self.niveau_competence = niveau
db.session.add(self)
db.session.commit()
log(f"ue.set_niveau_competence( {self}, {niveau} )")
def set_parcour(self, parcour: ApcParcours):
"""Associe cette UE au parcours indiqué.
Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
"""
if (parcour is not None) and self.niveau_competence is not None:
self._check_apc_conflict(self.niveau_competence.id, parcour.id)
self.parcour = parcour
# Le niveau est-il dans ce parcours ? Sinon, l'enlève
if (
parcour
and self.niveau_competence
and self.niveau_competence.id
not in (
n.id
for n in self.niveau_competence.niveaux_annee_de_parcours(
parcour, self.annee(), self.formation.referentiel_competence
)
)
):
self.niveau_competence = None
db.session.add(self)
db.session.commit()
log(f"ue.set_parcour( {self}, {parcour} )")
class DispenseUE(db.Model):
"""Dispense d'UE
Utilisé en APC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée
qu'ils ne refont pas.
La dispense d'UE n'est PAS une validation:
- elle n'est pas affectée par les décisions de jury (pas effacée)
- elle est associée à un formsemestre
- elle ne permet pas la délivrance d'ECTS ou du diplôme.
On utilise cette dispense et non une "inscription" par souci d'efficacité:
en général, la grande majorité des étudiants suivront toutes les UEs de leur parcours,
la dispense étant une exception.
"""
__table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id", "etudid"),)
id = db.Column(db.Integer, primary_key=True)
formsemestre_id = formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
)
ue_id = db.Column(
db.Integer,
db.ForeignKey(UniteEns.id, ondelete="CASCADE"),
index=True,
nullable=False,
)
ue = db.relationship("UniteEns", back_populates="dispense_ues")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etud = db.relationship("Identite", back_populates="dispense_ues")
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} {self.id} etud={
repr(self.etud)} ue={repr(self.ue)}>"""
@classmethod
def load_formsemestre_dispense_ues_set(
cls, formsemestre: "FormSemestre", etudids: pd.Index, ues: list[UniteEns]
) -> set[tuple[int, int]]:
"""Construit l'ensemble des
etudids = modimpl_inscr_df.index, # les etudids
ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport
Résultat: set de (etudid, ue_id).
"""
# Prend toutes les dispenses obtenues par des étudiants de ce formsemestre,
# puis filtre sur inscrits et ues
ue_ids = {ue.id for ue in ues}
dispense_ues = {
(dispense_ue.etudid, dispense_ue.ue_id)
for dispense_ue in DispenseUE.query.filter_by(
formsemestre_id=formsemestre.id
)
if dispense_ue.etudid in etudids and dispense_ue.ue_id in ue_ids
}
return dispense_ues