2021-12-08 22:33:32 +01:00
|
|
|
"""ScoDoc 9 models : Unités d'Enseignement (UE)
|
|
|
|
"""
|
|
|
|
|
2024-07-03 21:32:33 +02:00
|
|
|
from flask import abort, g
|
2023-01-13 23:23:18 +01:00
|
|
|
import pandas as pd
|
|
|
|
|
2022-10-30 16:07:06 +01:00
|
|
|
from app import db, log
|
2024-01-26 14:57:50 +01:00
|
|
|
from app import models
|
2021-12-08 22:33:32 +01:00
|
|
|
from app.models import APO_CODE_STR_LEN
|
|
|
|
from app.models import SHORT_STR_LEN
|
2022-10-30 16:07:06 +01:00
|
|
|
from app.models.but_refcomp import ApcNiveau, ApcParcours
|
2022-12-01 13:00:14 +01:00
|
|
|
from app.models.modules import Module
|
2024-10-17 23:44:54 +02:00
|
|
|
from app.scodoc import codes_cursus
|
2021-12-08 22:33:32 +01:00
|
|
|
from app.scodoc import sco_utils as scu
|
|
|
|
|
|
|
|
|
2024-01-26 14:57:50 +01:00
|
|
|
class UniteEns(models.ScoDocModel):
|
2021-12-08 22:33:32 +01:00
|
|
|
"""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)
|
2023-04-03 17:46:31 +02:00
|
|
|
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
|
2021-12-08 22:33:32 +01:00
|
|
|
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,
|
|
|
|
)
|
2023-04-11 13:48:57 +02:00
|
|
|
ects = db.Column(db.Float) # nombre de credits ECTS (sauf si parcours spécifié)
|
2021-12-08 22:33:32 +01:00
|
|
|
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)
|
|
|
|
|
2024-06-07 17:58:02 +02:00
|
|
|
# id de l'élément Apogée du RCUE (utilisé pour les UEs de sem. pair du BUT)
|
|
|
|
code_apogee_rcue = db.Column(db.String(APO_CODE_STR_LEN))
|
2022-06-24 12:39:54 +02:00
|
|
|
# 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")
|
|
|
|
|
2022-01-25 10:45:13 +01:00
|
|
|
color = db.Column(db.Text())
|
|
|
|
|
2022-04-29 08:17:04 +02:00
|
|
|
# BUT
|
2023-02-09 11:56:20 +01:00
|
|
|
niveau_competence_id = db.Column(
|
|
|
|
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="SET NULL")
|
|
|
|
)
|
2022-04-29 08:17:04 +02:00
|
|
|
niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
|
|
|
|
|
2023-04-03 17:46:31 +02:00
|
|
|
# Une UE appartient soit à tous les parcours (tronc commun), soit à un sous-ensemble
|
|
|
|
parcours = db.relationship(
|
2023-06-03 23:18:54 +02:00
|
|
|
ApcParcours,
|
|
|
|
secondary="ue_parcours",
|
|
|
|
backref=db.backref("ues", lazy=True),
|
|
|
|
order_by="ApcParcours.numero, ApcParcours.code",
|
2023-02-09 11:56:20 +01:00
|
|
|
)
|
2022-10-30 16:07:06 +01:00
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
# relations
|
|
|
|
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
|
|
|
|
modules = db.relationship("Module", lazy="dynamic", backref="ue")
|
2022-12-01 13:00:14 +01:00
|
|
|
dispense_ues = db.relationship(
|
|
|
|
"DispenseUE",
|
|
|
|
back_populates="ue",
|
|
|
|
cascade="all, delete",
|
|
|
|
passive_deletes=True,
|
|
|
|
)
|
2021-12-08 22:33:32 +01:00
|
|
|
|
|
|
|
def __repr__(self):
|
2022-01-08 14:01:16 +01:00
|
|
|
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 ''})>"""
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2022-11-02 10:41:31 +01:00
|
|
|
def clone(self):
|
2024-01-26 14:57:50 +01:00
|
|
|
"""Create a new copy of this ue, add to session.
|
2022-11-02 10:41:31 +01:00
|
|
|
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,
|
|
|
|
)
|
2024-01-26 14:57:50 +01:00
|
|
|
db.session.add(ue)
|
2022-11-02 10:41:31 +01:00
|
|
|
return ue
|
|
|
|
|
2024-01-26 14:57:50 +01:00
|
|
|
@classmethod
|
|
|
|
def convert_dict_fields(cls, args: dict) -> dict:
|
|
|
|
"""Convert fields from the given dict to model's attributes values. No side effect.
|
|
|
|
|
|
|
|
args: dict with args in application.
|
|
|
|
returns: dict to store in model's db.
|
|
|
|
"""
|
|
|
|
args = args.copy()
|
|
|
|
if "type" in args:
|
|
|
|
args["type"] = int(args["type"] or 0)
|
|
|
|
if "is_external" in args:
|
|
|
|
args["is_external"] = scu.to_bool(args["is_external"])
|
|
|
|
if "ects" in args:
|
2024-10-18 23:37:16 +02:00
|
|
|
args["ects"] = None if args["ects"] is None else float(args["ects"])
|
2024-01-26 14:57:50 +01:00
|
|
|
|
|
|
|
return args
|
|
|
|
|
2024-07-03 21:32:33 +02:00
|
|
|
def from_dict(self, args: dict, excluded: set[str] | None = None) -> bool:
|
|
|
|
"""Update object's fields given in dict. Add to session but don't commit.
|
|
|
|
True if modification.
|
|
|
|
- can't change formation nor niveau_competence
|
|
|
|
"""
|
|
|
|
return super().from_dict(
|
|
|
|
args,
|
|
|
|
excluded=(excluded or set()) | {"formation_id", "niveau_competence_id"},
|
|
|
|
)
|
|
|
|
|
2022-08-02 17:13:13 +02:00
|
|
|
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
|
2023-04-11 13:48:57 +02:00
|
|
|
"""as a dict, with the same conversions as in ScoDoc7.
|
2022-07-21 14:21:06 +02:00
|
|
|
If convert_objects, convert all attributes to native types
|
2023-03-20 17:17:39 +01:00
|
|
|
(suitable for json encoding).
|
2022-02-28 11:00:24 +01:00
|
|
|
"""
|
2023-06-21 12:33:45 +02:00
|
|
|
# cache car très utilisé par anciens codes
|
|
|
|
key = (self.id, convert_objects, with_module_ue_coefs)
|
|
|
|
_cache = getattr(g, "_ue_to_dict_cache", None)
|
|
|
|
if _cache:
|
|
|
|
result = g._ue_to_dict_cache.get(key, False)
|
|
|
|
if result is not False:
|
|
|
|
return result
|
|
|
|
else:
|
|
|
|
g._ue_to_dict_cache = {}
|
|
|
|
_cache = g._ue_to_dict_cache
|
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
e = dict(self.__dict__)
|
|
|
|
e.pop("_sa_instance_state", None)
|
2022-07-21 14:21:06 +02:00
|
|
|
e.pop("evaluation_ue_poids", None)
|
2021-12-08 22:33:32 +01:00
|
|
|
# ScoDoc7 output_formators
|
|
|
|
e["ue_id"] = self.id
|
|
|
|
e["numero"] = e["numero"] if e["numero"] else 0
|
|
|
|
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
|
2022-02-08 23:58:02 +01:00
|
|
|
e["code_apogee"] = e["code_apogee"] or "" # pas de None
|
2023-05-14 15:00:15 +02:00
|
|
|
e["ects_by_parcours"] = {
|
|
|
|
parcour.code: self.get_ects(parcour) for parcour in self.parcours
|
|
|
|
}
|
|
|
|
e["parcours"] = []
|
|
|
|
for parcour in self.parcours:
|
|
|
|
p_dict = parcour.to_dict(with_annees=False)
|
|
|
|
ects = self.get_ects(parcour, only_parcours=True)
|
|
|
|
if ects is not None:
|
|
|
|
p_dict["ects"] = ects
|
|
|
|
e["parcours"].append(p_dict)
|
|
|
|
|
2022-08-02 17:13:13 +02:00
|
|
|
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)
|
2023-06-21 12:33:45 +02:00
|
|
|
_cache[key] = e
|
2021-12-08 22:33:32 +01:00
|
|
|
return e
|
|
|
|
|
2022-10-30 16:07:06 +01:00
|
|
|
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
|
|
|
|
|
2024-09-16 17:01:47 +02:00
|
|
|
def is_locked(self) -> tuple[bool, str]:
|
2024-10-17 23:44:54 +02:00
|
|
|
"""True if UE should not be modified:
|
|
|
|
utilisée dans un formsemestre verrouillé ou validations de jury de cette UE.
|
|
|
|
Renvoie aussi une explication.
|
|
|
|
"""
|
2024-10-18 23:37:16 +02:00
|
|
|
from app.models import ModuleImpl, ScolarFormSemestreValidation
|
2024-10-17 23:44:54 +02:00
|
|
|
|
|
|
|
# before 9.7.23: contains modules used in a locked formsemestre
|
|
|
|
# starting from 9.7.23: + existence de validations de jury de cette UE
|
|
|
|
if self.formation.is_apc():
|
|
|
|
# en APC, interdit toute modification d'UE si il y a un formsemestre verrouillé
|
|
|
|
# de cette formation ayant le semestre de cette UE.
|
|
|
|
# (ne détaille pas les parcours, donc si un semestre Sn d'un parcours est verrouillé
|
|
|
|
# cela va verrouiller toutes les UE d'indice Sn, même si pas de ce parcours)
|
|
|
|
# modifié en 9.7.28
|
|
|
|
locked_sems = self.formation.formsemestres.filter_by(
|
|
|
|
etat=False, semestre_id=self.semestre_idx
|
|
|
|
)
|
|
|
|
if locked_sems.count():
|
|
|
|
return True, "utilisée dans un semestre verrouillé"
|
|
|
|
else:
|
|
|
|
# en classique: interdit si contient des modules utilisés dans des semestres verrouillés
|
|
|
|
# en effet, dans certaines (très anciennes) formations, une UE peut avoir des modules de
|
|
|
|
# différents semestre
|
|
|
|
if (
|
|
|
|
Module.query.filter(Module.ue_id == self.id)
|
|
|
|
.join(Module.modimpls)
|
|
|
|
.join(ModuleImpl.formsemestre)
|
|
|
|
.filter_by(etat=False)
|
|
|
|
.count()
|
|
|
|
):
|
|
|
|
return True, "avec modules utilisés dans des semestres verrouillés"
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2024-10-17 23:44:54 +02:00
|
|
|
nb_validations = ScolarFormSemestreValidation.query.filter_by(
|
|
|
|
ue_id=self.id
|
|
|
|
).count()
|
|
|
|
if nb_validations > 0:
|
|
|
|
return True, f"avec {nb_validations} validations de jury"
|
|
|
|
|
|
|
|
return False, ""
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2022-06-08 17:42:52 +02:00
|
|
|
def can_be_deleted(self) -> bool:
|
2022-07-13 18:52:07 +02:00
|
|
|
"""True si l'UE n'a pas de moduleimpl rattachés
|
|
|
|
(pas un seul module de cette UE n'a de modimpl)
|
2022-06-08 17:42:52 +02:00
|
|
|
"""
|
|
|
|
return (self.modules.count() == 0) or not any(
|
|
|
|
m.modimpls.all() for m in self.modules
|
|
|
|
)
|
|
|
|
|
2024-09-14 16:42:12 +02:00
|
|
|
def guess_semestre_idx(self) -> int:
|
2021-12-08 22:33:32 +01:00
|
|
|
"""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()
|
2024-09-14 16:42:12 +02:00
|
|
|
return self.semestre_idx
|
2021-12-11 16:46:15 +01:00
|
|
|
|
2024-10-17 23:44:54 +02:00
|
|
|
def get_semestre_id(self) -> int:
|
|
|
|
"""L'indice du semestre de l'UE.
|
|
|
|
Regarde semestre_idx ou, pour les formations non APC,
|
|
|
|
le premier module de chacune.
|
|
|
|
Les UE sans modules se voient attribuer le numero UE_SEM_DEFAULT (1000000),
|
|
|
|
qui les place à la fin de la liste.
|
|
|
|
Contrairement à guess_semestre_idx, ne modifie pas l'UE.
|
|
|
|
"""
|
|
|
|
if self.semestre_idx is not None:
|
|
|
|
return self.semestre_idx
|
|
|
|
if self.formation.is_apc():
|
|
|
|
return codes_cursus.UE_SEM_DEFAULT
|
|
|
|
# était le comportement ScoDoc7
|
|
|
|
module = self.modules.first()
|
|
|
|
if module:
|
|
|
|
return module.semestre_id
|
|
|
|
return codes_cursus.UE_SEM_DEFAULT
|
|
|
|
|
2023-04-11 13:48:57 +02:00
|
|
|
def get_ects(self, parcour: ApcParcours = None, only_parcours=False) -> float:
|
|
|
|
"""Crédits ECTS associés à cette UE.
|
|
|
|
En BUT, cela peut quelquefois dépendre du parcours.
|
|
|
|
Si only_parcours, renvoie None si pas de valeur spéciquement définie dans
|
|
|
|
le parcours indiqué.
|
|
|
|
"""
|
|
|
|
if parcour is not None:
|
2023-06-21 12:33:45 +02:00
|
|
|
key = (parcour.id, self.id, only_parcours)
|
|
|
|
ue_ects_cache = getattr(g, "_ue_ects_cache", None)
|
|
|
|
if ue_ects_cache:
|
|
|
|
ects = g._ue_ects_cache.get(key, False)
|
|
|
|
if ects is not False:
|
|
|
|
return ects
|
|
|
|
else:
|
|
|
|
g._ue_ects_cache = {}
|
|
|
|
ue_ects_cache = g._ue_ects_cache
|
2023-04-11 13:48:57 +02:00
|
|
|
ue_parcour = UEParcours.query.filter_by(
|
|
|
|
ue_id=self.id, parcours_id=parcour.id
|
|
|
|
).first()
|
|
|
|
if ue_parcour is not None and ue_parcour.ects is not None:
|
2023-06-21 12:33:45 +02:00
|
|
|
ue_ects_cache[key] = ue_parcour.ects
|
2023-04-11 13:48:57 +02:00
|
|
|
return ue_parcour.ects
|
|
|
|
if only_parcours:
|
2023-06-21 12:33:45 +02:00
|
|
|
ue_ects_cache[key] = None
|
2023-04-11 13:48:57 +02:00
|
|
|
return None
|
|
|
|
return self.ects
|
|
|
|
|
|
|
|
def set_ects(self, ects: float, parcour: ApcParcours = None):
|
|
|
|
"""Fixe les crédits. Do not commit.
|
|
|
|
Si le parcours n'est pas spécifié, affecte les ECTS par défaut de l'UE.
|
|
|
|
Si ects est None et parcours indiqué, efface l'association.
|
|
|
|
"""
|
|
|
|
if parcour is not None:
|
|
|
|
ue_parcour = UEParcours.query.filter_by(
|
|
|
|
ue_id=self.id, parcours_id=parcour.id
|
|
|
|
).first()
|
|
|
|
if ects is None:
|
|
|
|
if ue_parcour:
|
|
|
|
db.session.delete(ue_parcour)
|
|
|
|
else:
|
|
|
|
if ue_parcour is None:
|
|
|
|
ue_parcour = UEParcours(parcours_id=parcour.id, ue_id=self.id)
|
|
|
|
ue_parcour.ects = float(ects)
|
|
|
|
db.session.add(ue_parcour)
|
|
|
|
else:
|
|
|
|
self.ects = ects
|
|
|
|
log(f"ue.set_ects( ue_id={self.id}, acronyme={self.acronyme}, ects={ects} )")
|
|
|
|
db.session.add(self)
|
|
|
|
|
2024-07-03 21:32:33 +02:00
|
|
|
@classmethod
|
|
|
|
def get_ue(cls, ue_id: int, accept_none=False) -> "UniteEns":
|
|
|
|
"""UE ou 404 (ou None si accept_none),
|
|
|
|
cherche uniquement dans le département courant.
|
|
|
|
Si accept_none, return None si l'id est invalide ou inexistant.
|
|
|
|
"""
|
|
|
|
if not isinstance(ue_id, int):
|
|
|
|
try:
|
|
|
|
ue_id = int(ue_id)
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
if accept_none:
|
|
|
|
return None
|
|
|
|
abort(404, "ue_id invalide")
|
|
|
|
|
|
|
|
query = cls.query.filter_by(id=ue_id)
|
|
|
|
if g.scodoc_dept:
|
|
|
|
from app.models import Formation
|
|
|
|
|
|
|
|
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
|
|
|
|
|
|
|
|
if accept_none:
|
|
|
|
return query.first()
|
|
|
|
return query.first_or_404()
|
|
|
|
|
2021-12-11 16:46:15 +01:00
|
|
|
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()
|
2022-07-02 00:00:29 +02:00
|
|
|
|
|
|
|
def get_codes_apogee(self) -> set[str]:
|
|
|
|
"""Les codes Apogée (codés en base comme "VRT1,VRT2")"""
|
|
|
|
if self.code_apogee:
|
2022-07-02 11:17:04 +02:00
|
|
|
return {x.strip() for x in self.code_apogee.split(",") if x}
|
2022-07-02 00:00:29 +02:00
|
|
|
return set()
|
2022-10-30 16:07:06 +01:00
|
|
|
|
2024-06-09 15:18:03 +02:00
|
|
|
def get_codes_apogee_rcue(self) -> set[str]:
|
|
|
|
"""Les codes Apogée RCUE (codés en base comme "VRT1,VRT2")"""
|
|
|
|
if self.code_apogee_rcue:
|
|
|
|
return {x.strip() for x in self.code_apogee_rcue.split(",") if x}
|
|
|
|
return set()
|
|
|
|
|
2023-04-03 17:46:31 +02:00
|
|
|
def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
|
2023-04-10 11:25:46 +02:00
|
|
|
"""set des ids de niveaux communs à tous les parcours listés"""
|
|
|
|
return set.intersection(
|
2023-04-03 17:46:31 +02:00
|
|
|
*[
|
|
|
|
{
|
|
|
|
n.id
|
|
|
|
for n in self.niveau_competence.niveaux_annee_de_parcours(
|
|
|
|
parcour, self.annee(), self.formation.referentiel_competence
|
|
|
|
)
|
|
|
|
}
|
|
|
|
for parcour in parcours
|
|
|
|
]
|
|
|
|
)
|
2022-10-30 16:07:06 +01:00
|
|
|
|
2023-04-03 17:46:31 +02:00
|
|
|
def check_niveau_unique_dans_parcours(
|
|
|
|
self, niveau: ApcNiveau, parcours=list[ApcParcours]
|
|
|
|
) -> tuple[bool, str]:
|
|
|
|
"""Vérifie que
|
|
|
|
- le niveau est dans au moins l'un des parcours listés;
|
|
|
|
- et que l'un des parcours associé à cette UE ne contient pas
|
|
|
|
déjà une UE associée au niveau donné dans une autre année.
|
|
|
|
Renvoie: (True, "") si ok, sinon (False, message).
|
|
|
|
"""
|
|
|
|
# Le niveau est-il dans l'un des parcours listés ?
|
|
|
|
if parcours:
|
|
|
|
if niveau.id not in self._parcours_niveaux_ids(parcours):
|
|
|
|
log(
|
|
|
|
f"Le niveau {niveau} ne fait pas partie des parcours de l'UE {self}."
|
|
|
|
)
|
|
|
|
return (
|
|
|
|
False,
|
|
|
|
f"""Le niveau {
|
|
|
|
niveau.libelle} ne fait pas partie des parcours de l'UE {self.acronyme}.""",
|
|
|
|
)
|
2022-10-30 16:07:06 +01:00
|
|
|
|
2023-04-03 17:46:31 +02:00
|
|
|
for parcour in parcours or [None]:
|
|
|
|
if parcour is None:
|
|
|
|
code_parcour = "TC"
|
|
|
|
ues_meme_niveau = [
|
|
|
|
ue
|
|
|
|
for ue in self.formation.query_ues_parcour(None).filter(
|
|
|
|
UniteEns.niveau_competence == niveau
|
|
|
|
)
|
|
|
|
]
|
|
|
|
else:
|
|
|
|
code_parcour = parcour.code
|
|
|
|
ues_meme_niveau = [
|
|
|
|
ue
|
|
|
|
for ue in parcour.ues
|
2023-05-13 18:35:10 +02:00
|
|
|
if ue.id != self.id
|
|
|
|
and ue.formation_id == self.formation_id
|
2023-04-03 17:46:31 +02:00
|
|
|
and ue.niveau_competence_id == niveau.id
|
|
|
|
]
|
|
|
|
if ues_meme_niveau:
|
2023-04-13 09:58:38 +02:00
|
|
|
msg_parc = f"parcours {code_parcour}" if parcour else "tronc commun"
|
2023-04-03 17:46:31 +02:00
|
|
|
if len(ues_meme_niveau) > 1: # deja 2 UE sur ce niveau
|
|
|
|
msg = f"""Niveau "{
|
2023-04-13 09:58:38 +02:00
|
|
|
niveau.libelle}" déjà associé à deux UE du {msg_parc}"""
|
|
|
|
log(
|
|
|
|
f"check_niveau_unique_dans_parcours(niveau_id={niveau.id}): "
|
|
|
|
+ msg
|
|
|
|
)
|
2023-04-03 17:46:31 +02:00
|
|
|
return False, msg
|
|
|
|
# s'il y a déjà une UE associée à ce niveau, elle doit être dans l'autre semestre
|
|
|
|
# de la même année scolaire
|
|
|
|
other_semestre_idx = self.semestre_idx + (
|
|
|
|
2 * (self.semestre_idx % 2) - 1
|
|
|
|
)
|
|
|
|
if ues_meme_niveau[0].semestre_idx != other_semestre_idx:
|
2023-05-13 18:35:10 +02:00
|
|
|
msg = f"""Erreur: niveau "{
|
|
|
|
niveau.libelle}" déjà associé à une autre UE du semestre S{
|
|
|
|
ues_meme_niveau[0].semestre_idx} du {msg_parc}"""
|
2023-04-13 09:58:38 +02:00
|
|
|
log(
|
|
|
|
f"check_niveau_unique_dans_parcours(niveau_id={niveau.id}): "
|
|
|
|
+ msg
|
|
|
|
)
|
2023-04-03 17:46:31 +02:00
|
|
|
return False, msg
|
|
|
|
|
|
|
|
return True, ""
|
|
|
|
|
2024-07-08 12:33:48 +02:00
|
|
|
def is_used_in_validation_rcue(self) -> bool:
|
|
|
|
"""Vrai si cette UE est utilisée dans une validation enregistrée d'RCUE."""
|
|
|
|
from app.models.but_validations import ApcValidationRCUE
|
|
|
|
|
|
|
|
return (
|
|
|
|
ApcValidationRCUE.query.filter(
|
|
|
|
db.or_(
|
|
|
|
ApcValidationRCUE.ue1_id == self.id,
|
|
|
|
ApcValidationRCUE.ue2_id == self.id,
|
|
|
|
)
|
|
|
|
).count()
|
|
|
|
> 0
|
|
|
|
)
|
|
|
|
|
|
|
|
def set_niveau_competence(self, niveau: ApcNiveau | None) -> tuple[bool, str]:
|
2022-10-30 16:07:06 +01:00
|
|
|
"""Associe cette UE au niveau de compétence indiqué.
|
2023-04-03 17:46:31 +02:00
|
|
|
Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas
|
|
|
|
de tronc commun).
|
2022-10-30 16:07:06 +01:00
|
|
|
Assure que ce soit la seule dans son parcours.
|
|
|
|
Sinon, raises ScoFormationConflict.
|
|
|
|
|
|
|
|
Si niveau est None, désassocie.
|
2024-07-08 12:33:48 +02:00
|
|
|
|
|
|
|
Si l'UE est utilisée dans un validation de RCUE, on ne peut plus la changer de niveau.
|
|
|
|
|
|
|
|
Returns
|
|
|
|
- True if (de)association done, False on error.
|
|
|
|
- Error message (string)
|
2022-10-30 16:07:06 +01:00
|
|
|
"""
|
2023-04-10 11:25:46 +02:00
|
|
|
# Sanity checks
|
|
|
|
if not self.formation.referentiel_competence:
|
|
|
|
return (
|
|
|
|
False,
|
|
|
|
"La formation n'est pas associée à un référentiel de compétences",
|
|
|
|
)
|
2024-07-08 12:33:48 +02:00
|
|
|
# UE utilisée dans des validations RCUE ?
|
|
|
|
if self.is_used_in_validation_rcue():
|
|
|
|
return (
|
|
|
|
False,
|
|
|
|
"UE utilisée dans un RCUE validé: son niveau ne peut plus être modifié",
|
|
|
|
)
|
2023-04-13 09:58:38 +02:00
|
|
|
if niveau is not None:
|
|
|
|
if self.niveau_competence_id is not None:
|
|
|
|
return (
|
|
|
|
False,
|
2023-04-19 11:56:33 +02:00
|
|
|
f"""{self.acronyme} déjà associée à un niveau de compétences ({
|
|
|
|
self.id}, {self.niveau_competence_id})""",
|
2023-04-13 09:58:38 +02:00
|
|
|
)
|
|
|
|
if (
|
|
|
|
niveau.competence.referentiel.id
|
|
|
|
!= self.formation.referentiel_competence.id
|
|
|
|
):
|
|
|
|
return (
|
|
|
|
False,
|
|
|
|
"Le niveau n'appartient pas au référentiel de la formation",
|
|
|
|
)
|
|
|
|
if niveau.id == self.niveau_competence_id:
|
|
|
|
return True, "" # nothing to do
|
|
|
|
if self.niveau_competence_id is not None:
|
|
|
|
ok, error_message = self.check_niveau_unique_dans_parcours(
|
|
|
|
niveau, self.parcours
|
|
|
|
)
|
|
|
|
if not ok:
|
|
|
|
return ok, error_message
|
|
|
|
elif self.niveau_competence_id is None:
|
2023-04-11 23:56:50 +02:00
|
|
|
return True, "" # nothing to do
|
2022-10-30 16:07:06 +01:00
|
|
|
self.niveau_competence = niveau
|
|
|
|
db.session.add(self)
|
|
|
|
db.session.commit()
|
2023-01-23 15:29:08 +01:00
|
|
|
# Invalidation du cache
|
|
|
|
self.formation.invalidate_cached_sems()
|
2022-10-30 16:07:06 +01:00
|
|
|
log(f"ue.set_niveau_competence( {self}, {niveau} )")
|
2023-04-03 17:46:31 +02:00
|
|
|
return True, ""
|
2022-10-30 16:07:06 +01:00
|
|
|
|
2023-04-03 17:46:31 +02:00
|
|
|
def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]:
|
|
|
|
"""Associe cette UE aux parcours indiqués.
|
|
|
|
Si un niveau est déjà associé, vérifie sa cohérence.
|
|
|
|
Renvoie (True, "") si ok, sinon (False, error_message)
|
2022-10-30 16:07:06 +01:00
|
|
|
"""
|
2023-04-19 11:56:33 +02:00
|
|
|
msg = ""
|
2024-04-12 01:05:02 +02:00
|
|
|
# Safety check
|
|
|
|
if self.formation.referentiel_competence is None:
|
|
|
|
return False, "pas de référentiel de compétence"
|
|
|
|
# Si tous les parcours, aucun (tronc commun)
|
|
|
|
if {p.id for p in parcours} == {
|
|
|
|
p.id for p in self.formation.referentiel_competence.parcours
|
|
|
|
}:
|
|
|
|
parcours = []
|
2023-04-10 11:25:46 +02:00
|
|
|
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
|
|
|
|
prev_niveau = self.niveau_competence
|
2022-10-30 16:07:06 +01:00
|
|
|
if (
|
2023-04-03 17:46:31 +02:00
|
|
|
parcours
|
2022-10-30 16:07:06 +01:00
|
|
|
and self.niveau_competence
|
2023-04-03 17:46:31 +02:00
|
|
|
and self.niveau_competence.id not in self._parcours_niveaux_ids(parcours)
|
2022-10-30 16:07:06 +01:00
|
|
|
):
|
|
|
|
self.niveau_competence = None
|
2023-04-19 11:56:33 +02:00
|
|
|
msg = " (niveau compétence désassocié !)"
|
2023-04-03 17:46:31 +02:00
|
|
|
|
|
|
|
if parcours and self.niveau_competence:
|
|
|
|
ok, error_message = self.check_niveau_unique_dans_parcours(
|
|
|
|
self.niveau_competence, parcours
|
|
|
|
)
|
|
|
|
if not ok:
|
2024-04-15 18:06:26 +02:00
|
|
|
self.formation.invalidate_cached_sems()
|
2023-04-10 11:25:46 +02:00
|
|
|
self.niveau_competence = prev_niveau # restore
|
2023-04-03 17:46:31 +02:00
|
|
|
return False, error_message
|
|
|
|
|
|
|
|
self.parcours = parcours
|
2022-10-30 16:07:06 +01:00
|
|
|
db.session.add(self)
|
|
|
|
db.session.commit()
|
2023-01-23 15:29:08 +01:00
|
|
|
# Invalidation du cache
|
|
|
|
self.formation.invalidate_cached_sems()
|
2023-04-03 17:46:31 +02:00
|
|
|
log(f"ue.set_parcours( {self}, {parcours} )")
|
2023-04-19 11:56:33 +02:00
|
|
|
return True, "parcours enregistrés" + msg
|
2023-04-03 17:46:31 +02:00
|
|
|
|
2023-04-10 11:25:46 +02:00
|
|
|
def add_parcour(self, parcour: ApcParcours) -> tuple[bool, str]:
|
|
|
|
"""Ajoute ce parcours à ceux de l'UE"""
|
|
|
|
if parcour.id in {p.id for p in self.parcours}:
|
2023-04-19 11:56:33 +02:00
|
|
|
return True, "" # déjà présent
|
2023-04-10 11:25:46 +02:00
|
|
|
if parcour.referentiel.id != self.formation.referentiel_competence.id:
|
|
|
|
return False, "Le parcours n'appartient pas au référentiel de la formation"
|
|
|
|
|
|
|
|
return self.set_parcours(self.parcours + [parcour])
|
|
|
|
|
2023-04-03 17:46:31 +02:00
|
|
|
|
2024-10-29 19:18:36 +01:00
|
|
|
class UEParcours(models.ScoDocModel):
|
2023-04-03 17:46:31 +02:00
|
|
|
"""Association ue <-> parcours, indiquant les ECTS"""
|
|
|
|
|
|
|
|
__tablename__ = "ue_parcours"
|
2023-04-11 13:48:57 +02:00
|
|
|
ue_id = db.Column(
|
|
|
|
db.Integer,
|
|
|
|
db.ForeignKey("notes_ue.id", ondelete="CASCADE"),
|
|
|
|
primary_key=True,
|
|
|
|
)
|
2023-04-03 17:46:31 +02:00
|
|
|
parcours_id = db.Column(
|
2023-04-11 13:48:57 +02:00
|
|
|
db.Integer,
|
|
|
|
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
|
|
|
primary_key=True,
|
2023-04-03 17:46:31 +02:00
|
|
|
)
|
|
|
|
ects = db.Column(db.Float, nullable=True) # si NULL, on prendra les ECTS de l'UE
|
2022-12-01 13:00:14 +01:00
|
|
|
|
2023-04-11 13:48:57 +02:00
|
|
|
def __repr__(self):
|
|
|
|
return f"<UEParcours( ue_id={self.ue_id}, parcours_id={self.parcours_id}, ects={self.ects})>"
|
|
|
|
|
2022-12-01 13:00:14 +01:00
|
|
|
|
2024-10-29 19:18:36 +01:00
|
|
|
class DispenseUE(models.ScoDocModel):
|
2022-12-01 13:00:14 +01:00
|
|
|
"""Dispense d'UE
|
2023-03-27 23:57:10 +02:00
|
|
|
Utilisé en APC (BUT) pour indiquer
|
|
|
|
- les étudiants redoublants avec une UE capitalisée qu'ils ne refont pas.
|
|
|
|
- les étudiants "non inscrit" à une UE car elle ne fait pas partie de leur Parcours.
|
|
|
|
|
2023-01-13 23:23:18 +01:00
|
|
|
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.
|
2022-12-01 13:00:14 +01:00
|
|
|
"""
|
|
|
|
|
2023-04-06 13:26:28 +02:00
|
|
|
__tablename__ = "dispenseUE"
|
2023-01-13 23:23:18 +01:00
|
|
|
__table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id", "etudid"),)
|
2022-12-01 13:00:14 +01:00
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
2023-01-13 23:23:18 +01:00
|
|
|
formsemestre_id = formsemestre_id = db.Column(
|
|
|
|
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
|
|
|
|
)
|
2022-12-01 13:00:14 +01:00
|
|
|
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)}>"""
|
2023-01-13 23:23:18 +01:00
|
|
|
|
|
|
|
@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
|