forked from ScoDoc/ScoDoc
315 lines
12 KiB
Python
315 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()
|
|
# Invalidation du cache
|
|
self.formation.invalidate_cached_sems()
|
|
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()
|
|
# Invalidation du cache
|
|
self.formation.invalidate_cached_sems()
|
|
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
|