2021-12-08 22:33:32 +01:00
|
|
|
"""ScoDoc 9 models : Modules
|
|
|
|
"""
|
2024-02-14 21:45:58 +01:00
|
|
|
|
2024-10-11 14:06:02 +02:00
|
|
|
import http
|
2024-10-14 16:40:05 +02:00
|
|
|
from flask import current_app, g, url_for
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2024-10-11 14:06:02 +02:00
|
|
|
from app import db, log
|
2024-02-14 21:45:58 +01:00
|
|
|
from app import models
|
2021-12-08 22:33:32 +01:00
|
|
|
from app.models import APO_CODE_STR_LEN
|
2024-02-22 17:22:56 +01:00
|
|
|
from app.models.but_refcomp import (
|
2024-10-21 19:48:42 +02:00
|
|
|
ApcAppCritique,
|
2024-02-22 17:22:56 +01:00
|
|
|
ApcParcours,
|
|
|
|
ApcReferentielCompetences,
|
|
|
|
app_critiques_modules,
|
|
|
|
parcours_modules,
|
|
|
|
)
|
2024-10-14 16:40:05 +02:00
|
|
|
from app.models.events import ScolarNews
|
2021-12-08 22:33:32 +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_SPORT
|
2024-10-14 16:40:05 +02:00
|
|
|
from app.scodoc.sco_exceptions import (
|
|
|
|
ScoValueError,
|
|
|
|
ScoLockedFormError,
|
|
|
|
ScoNonEmptyFormationObject,
|
|
|
|
)
|
2022-11-25 22:32:06 +01:00
|
|
|
from app.scodoc.sco_utils import ModuleType
|
2021-12-08 22:33:32 +01:00
|
|
|
|
|
|
|
|
2024-02-14 21:45:58 +01:00
|
|
|
class Module(models.ScoDocModel):
|
2021-12-08 22:33:32 +01:00
|
|
|
"""Module"""
|
|
|
|
|
|
|
|
__tablename__ = "notes_modules"
|
|
|
|
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
module_id = db.synonym("id")
|
|
|
|
titre = db.Column(db.Text())
|
|
|
|
abbrev = db.Column(db.Text()) # nom court
|
|
|
|
# certains départements ont des codes infiniment longs: donc Text !
|
|
|
|
code = db.Column(db.Text(), nullable=False)
|
2023-12-15 03:37:55 +01:00
|
|
|
"code module, chaine non nullable"
|
2021-12-08 22:33:32 +01:00
|
|
|
heures_cours = db.Column(db.Float)
|
|
|
|
heures_td = db.Column(db.Float)
|
|
|
|
heures_tp = db.Column(db.Float)
|
|
|
|
coefficient = db.Column(db.Float) # coef PPN (sauf en APC)
|
|
|
|
ects = db.Column(db.Float) # Crédits ECTS
|
|
|
|
ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), index=True)
|
|
|
|
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
|
|
|
|
matiere_id = db.Column(db.Integer, db.ForeignKey("notes_matieres.id"))
|
|
|
|
# pas un id mais le numéro du semestre: 1, 2, ...
|
2021-12-16 16:27:35 +01:00
|
|
|
# note: en APC, le semestre qui fait autorité est celui de l'UE
|
2021-12-08 22:33:32 +01:00
|
|
|
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
|
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
|
|
|
code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
|
2023-11-06 22:05:38 +01:00
|
|
|
"id de l'element pedagogique Apogee correspondant"
|
|
|
|
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
|
|
|
|
"identifiant emplois du temps (unicité non imposée)"
|
2022-03-26 23:33:57 +01:00
|
|
|
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)
|
2022-02-15 21:55:21 +01:00
|
|
|
module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
|
2021-12-08 22:33:32 +01:00
|
|
|
# Relations:
|
2023-01-12 12:52:32 +01:00
|
|
|
modimpls = db.relationship(
|
|
|
|
"ModuleImpl", backref="module", lazy="dynamic", cascade="all, delete-orphan"
|
|
|
|
)
|
2021-12-08 22:33:32 +01:00
|
|
|
ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True)
|
|
|
|
tags = db.relationship(
|
|
|
|
"NotesTag",
|
|
|
|
secondary="notes_modules_tags",
|
|
|
|
lazy=True,
|
|
|
|
backref=db.backref("modules", lazy=True),
|
|
|
|
)
|
2022-05-01 23:58:41 +02:00
|
|
|
# BUT
|
|
|
|
parcours = db.relationship(
|
|
|
|
"ApcParcours",
|
|
|
|
secondary=parcours_modules,
|
|
|
|
lazy="subquery",
|
2022-05-02 14:39:06 +02:00
|
|
|
backref=db.backref("modules", lazy=True),
|
2023-06-03 23:18:54 +02:00
|
|
|
order_by="ApcParcours.numero, ApcParcours.code",
|
2022-05-02 14:39:06 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
app_critiques = db.relationship(
|
|
|
|
"ApcAppCritique",
|
|
|
|
secondary=app_critiques_modules,
|
|
|
|
lazy="subquery",
|
2022-05-01 23:58:41 +02:00
|
|
|
backref=db.backref("modules", lazy=True),
|
|
|
|
)
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2024-10-11 14:06:02 +02:00
|
|
|
_sco_dept_relations = ("Formation",) # accès au dept_id
|
2024-07-19 09:42:44 +02:00
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
def __init__(self, **kwargs):
|
|
|
|
self.ue_coefs = []
|
2024-10-11 14:06:02 +02:00
|
|
|
super().__init__(**kwargs)
|
2021-12-08 22:33:32 +01:00
|
|
|
|
|
|
|
def __repr__(self):
|
2022-10-29 08:22:17 +02:00
|
|
|
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
|
|
|
|
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2024-02-14 21:45:58 +01:00
|
|
|
@classmethod
|
|
|
|
def convert_dict_fields(cls, args: dict) -> dict:
|
|
|
|
"""Convert fields in the given dict. No other side effect.
|
|
|
|
returns: dict to store in model's db.
|
|
|
|
"""
|
|
|
|
# s'assure que ects etc est non ''
|
|
|
|
fs_empty_stored_as_nulls = {
|
|
|
|
"coefficient",
|
|
|
|
"ects",
|
|
|
|
"heures_cours",
|
|
|
|
"heures_td",
|
|
|
|
"heures_tp",
|
|
|
|
}
|
|
|
|
args_dict = {}
|
|
|
|
for key, value in args.items():
|
|
|
|
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
|
|
|
|
if key in fs_empty_stored_as_nulls and value == "":
|
|
|
|
value = None
|
2024-10-21 19:48:42 +02:00
|
|
|
args_dict[key] = value
|
|
|
|
if key == "app_critiques": # peut être liste d'ApcAppCritique ou d'ids
|
|
|
|
args_dict[key] = cls.convert_app_critiques(value)
|
2024-02-14 21:45:58 +01:00
|
|
|
|
|
|
|
return args_dict
|
|
|
|
|
2024-10-21 19:48:42 +02:00
|
|
|
@staticmethod
|
|
|
|
def convert_app_critiques(
|
|
|
|
app_crits: list, ref_comp: ApcReferentielCompetences | None = None
|
|
|
|
) -> list[ApcAppCritique]:
|
|
|
|
""" """
|
|
|
|
res = []
|
|
|
|
for x in app_crits:
|
|
|
|
app_crit = (
|
|
|
|
x
|
|
|
|
if isinstance(x, ApcAppCritique)
|
|
|
|
else db.session.get(ApcAppCritique, x)
|
|
|
|
)
|
|
|
|
if app_crit is None:
|
|
|
|
raise ScoValueError("app_critiques invalid")
|
|
|
|
if ref_comp and app_crit.niveau.competence.referentiel_id != ref_comp.id:
|
|
|
|
raise ScoValueError("app_critique hors référentiel !")
|
|
|
|
res.append(app_crit)
|
|
|
|
return res
|
|
|
|
|
2024-02-22 17:22:56 +01:00
|
|
|
@classmethod
|
2024-07-03 21:32:33 +02:00
|
|
|
def filter_model_attributes(cls, args: dict, excluded: set[str] = None) -> dict:
|
2024-02-22 17:22:56 +01:00
|
|
|
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
|
|
|
|
Add 'id' to excluded."""
|
|
|
|
# on ne peut pas affecter directement parcours
|
2024-07-03 21:32:33 +02:00
|
|
|
return super().filter_model_attributes(args, (excluded or set()) | {"parcours"})
|
|
|
|
|
2024-10-14 16:40:05 +02:00
|
|
|
@classmethod
|
|
|
|
def check_module_code_unicity(cls, code, formation_id, module_id=None) -> bool:
|
|
|
|
"true si code module unique dans la formation"
|
|
|
|
from app.models import Formation
|
|
|
|
|
|
|
|
formation = Formation.get_formation(formation_id)
|
|
|
|
query = formation.modules.filter_by(code=code)
|
|
|
|
if module_id is not None: # edition: supprime le module en cours
|
|
|
|
query = query.filter(Module.id != module_id)
|
|
|
|
return query.count() == 0
|
|
|
|
|
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 ue nor formation
|
|
|
|
- can change matiere_id, iff new matiere in same ue
|
|
|
|
- can change parcours: parcours list of ApcParcour id or instances.
|
2024-10-14 16:40:05 +02:00
|
|
|
Ne modifie pas les coefficients APC ue_coefs
|
2024-07-03 21:32:33 +02:00
|
|
|
"""
|
2024-10-14 16:40:05 +02:00
|
|
|
args = args.copy()
|
|
|
|
if "ue_coefs" in args:
|
|
|
|
del args["ue_coefs"]
|
|
|
|
if self.is_locked():
|
|
|
|
# formation verrouillée: empeche de modifier coefficient, matiere, and semestre_id
|
|
|
|
protected_fields = ("coefficient", "matiere_id", "semestre_id")
|
|
|
|
for f in protected_fields:
|
|
|
|
if f in args:
|
|
|
|
del args[f]
|
|
|
|
# Unicité du code
|
|
|
|
if "code" in args and not Module.check_module_code_unicity(
|
|
|
|
args["code"], self.formation_id, self.id
|
|
|
|
):
|
|
|
|
raise ScoValueError("code module déjà utilisé")
|
2024-07-03 21:32:33 +02:00
|
|
|
# Vérifie les changements de matiere
|
|
|
|
new_matiere_id = args.get("matiere_id", self.matiere_id)
|
|
|
|
if new_matiere_id != self.matiere_id:
|
|
|
|
# exists ?
|
|
|
|
from app.models import Matiere
|
|
|
|
|
|
|
|
matiere = db.session.get(Matiere, new_matiere_id)
|
|
|
|
if matiere is None or matiere.ue_id != self.ue_id:
|
|
|
|
raise ScoValueError("invalid matiere")
|
|
|
|
|
|
|
|
modified = super().from_dict(
|
|
|
|
args, excluded=(excluded or set()) | {"formation_id", "ue_id"}
|
|
|
|
)
|
|
|
|
|
|
|
|
existing_parcours = {p.id for p in self.parcours}
|
|
|
|
new_parcours = args.get("parcours", []) or []
|
|
|
|
if existing_parcours != set(new_parcours):
|
2024-10-14 16:40:05 +02:00
|
|
|
self.set_parcours_from_list(new_parcours)
|
2024-07-03 21:32:33 +02:00
|
|
|
return True
|
|
|
|
return modified
|
2024-02-22 17:22:56 +01:00
|
|
|
|
|
|
|
@classmethod
|
2024-10-14 16:40:05 +02:00
|
|
|
def create_from_dict(
|
|
|
|
cls,
|
|
|
|
data: dict,
|
|
|
|
inval_cache=False,
|
|
|
|
news=False,
|
|
|
|
) -> "Module":
|
2024-07-04 14:55:02 +02:00
|
|
|
"""Create from given dict, add parcours.
|
2024-10-14 16:40:05 +02:00
|
|
|
Flush session.
|
|
|
|
Si news, commit and log news.
|
|
|
|
"""
|
|
|
|
from app.models.formations import Formation
|
|
|
|
|
|
|
|
# check required arguments
|
|
|
|
for required_arg in ("code", "formation_id", "ue_id"):
|
|
|
|
if required_arg not in data:
|
|
|
|
raise ScoValueError(f"missing argument: {required_arg}")
|
|
|
|
if not data["code"]:
|
|
|
|
raise ScoValueError("module code must be non empty")
|
|
|
|
# Check formation
|
|
|
|
formation = Formation.get_formation(data["formation_id"])
|
|
|
|
ue = UniteEns.get_ue(data["ue_id"])
|
|
|
|
# refuse de créer un module APC avec semestres semestre du module != semestre de l'UE
|
|
|
|
if formation.is_apc():
|
|
|
|
if int(data.get("semestre_id", 1)) != ue.semestre_idx:
|
|
|
|
raise ScoValueError(
|
|
|
|
"Formation incompatible: indices UE et module différents"
|
|
|
|
)
|
2024-07-03 21:32:33 +02:00
|
|
|
module = super().create_from_dict(data)
|
2024-07-04 14:55:02 +02:00
|
|
|
db.session.flush()
|
2024-10-14 16:40:05 +02:00
|
|
|
module.set_parcours_from_list(data.get("parcours", []) or [])
|
|
|
|
log(f"module_create: created {module.id} with {data}")
|
|
|
|
if news:
|
|
|
|
db.session.commit()
|
|
|
|
db.session.refresh(module)
|
|
|
|
ScolarNews.add(
|
|
|
|
typ=ScolarNews.NEWS_FORM,
|
|
|
|
obj=formation.id,
|
|
|
|
text=f"Modification de la formation {formation.acronyme}",
|
|
|
|
)
|
|
|
|
if inval_cache:
|
|
|
|
formation.invalidate_cached_sems()
|
|
|
|
|
2024-07-03 21:32:33 +02:00
|
|
|
return module
|
|
|
|
|
2024-10-14 16:40:05 +02:00
|
|
|
def is_locked(self) -> bool:
|
|
|
|
"""True if module cannot be modified
|
|
|
|
because it is used in a locked formsemestre.
|
|
|
|
"""
|
|
|
|
from app.models import FormSemestre, ModuleImpl
|
|
|
|
|
|
|
|
mods = (
|
|
|
|
db.session.query(Module)
|
|
|
|
.filter_by(id=self.id)
|
|
|
|
.join(ModuleImpl)
|
|
|
|
.join(FormSemestre)
|
|
|
|
.filter_by(etat=False)
|
|
|
|
.all()
|
|
|
|
)
|
|
|
|
return bool(mods)
|
|
|
|
|
|
|
|
def can_be_deleted(self) -> bool:
|
|
|
|
"""True if module can be deleted"""
|
|
|
|
return self.modimpls.count() == 0
|
|
|
|
|
|
|
|
def delete(self):
|
|
|
|
"Delete module. News, inval cache."
|
|
|
|
if self.is_locked():
|
|
|
|
raise ScoLockedFormError()
|
|
|
|
if not self.can_be_deleted():
|
|
|
|
raise ScoNonEmptyFormationObject(
|
|
|
|
"Module",
|
|
|
|
msg=self.titre or self.code,
|
|
|
|
dest_url=url_for(
|
|
|
|
"notes.ue_table",
|
|
|
|
scodoc_dept=g.scodoc_dept,
|
|
|
|
formation_id=self.formation_id,
|
|
|
|
semestre_idx=self.ue.semestre_idx,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
formation = self.formation
|
|
|
|
db.session.delete(self)
|
|
|
|
log(f"Module.delete({self.id})")
|
|
|
|
db.session.commit()
|
|
|
|
# news
|
|
|
|
ScolarNews.add(
|
|
|
|
typ=ScolarNews.NEWS_FORM,
|
|
|
|
obj=formation.id,
|
|
|
|
text=f"Modification de la formation {formation.acronyme}",
|
|
|
|
)
|
|
|
|
formation.invalidate_cached_sems()
|
|
|
|
|
|
|
|
def set_parcours_from_list(self, parcours: list[ApcParcours | int]):
|
2024-07-03 21:32:33 +02:00
|
|
|
"""Ajoute ces parcours à la liste des parcours du module.
|
|
|
|
Chaque élément est soit un objet parcours soit un id.
|
|
|
|
S'assure que chaque parcours est dans le référentiel de compétence
|
|
|
|
associé à la formation du module.
|
|
|
|
"""
|
|
|
|
for p in parcours:
|
2024-02-22 17:22:56 +01:00
|
|
|
if isinstance(p, ApcParcours):
|
|
|
|
parcour: ApcParcours = p
|
2024-07-03 21:32:33 +02:00
|
|
|
if p.referentiel_id != self.formation.referentiel_competence.id:
|
|
|
|
raise ScoValueError("Parcours hors référentiel du module")
|
2024-02-22 17:22:56 +01:00
|
|
|
else:
|
2024-07-03 21:32:33 +02:00
|
|
|
try:
|
|
|
|
pid = int(p)
|
|
|
|
except ValueError as exc:
|
|
|
|
raise ScoValueError("id de parcours invalide") from exc
|
|
|
|
query = (
|
|
|
|
ApcParcours.query.filter_by(id=pid)
|
|
|
|
.join(ApcReferentielCompetences)
|
|
|
|
.filter_by(id=self.formation.referentiel_competence.id)
|
|
|
|
)
|
2024-02-22 17:22:56 +01:00
|
|
|
if g.scodoc_dept:
|
2024-07-03 21:32:33 +02:00
|
|
|
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
2024-02-22 17:22:56 +01:00
|
|
|
parcour: ApcParcours = query.first()
|
|
|
|
if parcour is None:
|
|
|
|
raise ScoValueError("Parcours invalide")
|
2024-07-03 21:32:33 +02:00
|
|
|
self.parcours.append(parcour)
|
2024-02-22 17:22:56 +01:00
|
|
|
|
2022-11-02 10:41:31 +01:00
|
|
|
def clone(self):
|
|
|
|
"""Create a new copy of this module."""
|
|
|
|
mod = Module(
|
|
|
|
titre=self.titre,
|
|
|
|
abbrev=self.abbrev,
|
|
|
|
code=self.code + "-copie",
|
|
|
|
heures_cours=self.heures_cours,
|
|
|
|
heures_td=self.heures_td,
|
|
|
|
heures_tp=self.heures_tp,
|
|
|
|
coefficient=self.coefficient,
|
|
|
|
ects=self.ects,
|
|
|
|
ue_id=self.ue_id,
|
|
|
|
matiere_id=self.matiere_id,
|
|
|
|
formation_id=self.formation_id,
|
|
|
|
semestre_id=self.semestre_id,
|
|
|
|
numero=self.numero, # il est conseillé de renuméroter
|
|
|
|
code_apogee="", # volontairement vide pour éviter les erreurs
|
|
|
|
module_type=self.module_type,
|
|
|
|
)
|
|
|
|
|
|
|
|
# Les tags:
|
|
|
|
for tag in self.tags:
|
|
|
|
mod.tags.append(tag)
|
|
|
|
# Les parcours
|
|
|
|
for parcour in self.parcours:
|
|
|
|
mod.parcours.append(parcour)
|
|
|
|
# Les AC
|
|
|
|
for app_critique in self.app_critiques:
|
|
|
|
mod.app_critiques.append(app_critique)
|
|
|
|
return mod
|
|
|
|
|
2024-07-03 21:32:33 +02:00
|
|
|
def to_dict(
|
|
|
|
self,
|
|
|
|
convert_objects=False,
|
|
|
|
with_matiere=False,
|
|
|
|
with_ue=False,
|
|
|
|
with_parcours_ids=False,
|
|
|
|
) -> dict:
|
2022-07-21 14:21:06 +02:00
|
|
|
"""If convert_objects, convert all attributes to native types
|
|
|
|
(suitable jor json encoding).
|
2024-07-03 21:32:33 +02:00
|
|
|
If convert_objects and with_parcours_ids, give parcours as a list of id (API)
|
2022-07-21 14:21:06 +02:00
|
|
|
"""
|
2022-07-21 14:44:19 +02:00
|
|
|
d = dict(self.__dict__)
|
|
|
|
d.pop("_sa_instance_state", None)
|
2024-07-03 21:32:33 +02:00
|
|
|
d.pop("formation", None)
|
2022-07-21 14:21:06 +02:00
|
|
|
if convert_objects:
|
2024-07-03 21:32:33 +02:00
|
|
|
if with_parcours_ids:
|
|
|
|
d["parcours"] = [p.id for p in self.parcours]
|
|
|
|
else:
|
|
|
|
d["parcours"] = [p.to_dict() for p in self.parcours]
|
2022-08-02 17:13:13 +02:00
|
|
|
d["ue_coefs"] = [
|
2024-07-03 22:42:38 +02:00
|
|
|
c.to_dict(convert_objects=False)
|
|
|
|
for c in self.ue_coefs
|
|
|
|
# note: don't convert_objects: we do wan't the details of the UEs here
|
2022-08-02 17:13:13 +02:00
|
|
|
]
|
2022-09-07 10:07:46 +02:00
|
|
|
d["app_critiques"] = {x.code: x.to_dict() for x in self.app_critiques}
|
2022-07-21 14:44:19 +02:00
|
|
|
if not with_matiere:
|
|
|
|
d.pop("matiere", None)
|
|
|
|
if not with_ue:
|
|
|
|
d.pop("ue", None)
|
|
|
|
if convert_objects and with_matiere:
|
|
|
|
d["matiere"] = self.matiere.to_dict(convert_objects=True)
|
|
|
|
if convert_objects and with_ue:
|
|
|
|
d["ue"] = self.ue.to_dict(convert_objects=True)
|
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
# ScoDoc7 output_formators: (backward compat)
|
2022-07-21 14:44:19 +02:00
|
|
|
d["module_id"] = self.id
|
|
|
|
d["heures_cours"] = 0.0 if self.heures_cours is None else self.heures_cours
|
|
|
|
d["heures_td"] = 0.0 if self.heures_td is None else self.heures_td
|
|
|
|
d["heures_tp"] = 0.0 if self.heures_tp is None else self.heures_tp
|
|
|
|
d["numero"] = 0 if self.numero is None else self.numero
|
|
|
|
d["coefficient"] = 0.0 if self.coefficient is None else self.coefficient
|
|
|
|
d["module_type"] = 0 if self.module_type is None else self.module_type
|
|
|
|
d["code_apogee"] = d["code_apogee"] or "" # pas de None
|
|
|
|
return d
|
2021-12-08 22:33:32 +01:00
|
|
|
|
|
|
|
def is_apc(self):
|
|
|
|
"True si module SAÉ ou Ressource"
|
|
|
|
return self.module_type and scu.ModuleType(self.module_type) in {
|
|
|
|
scu.ModuleType.RESSOURCE,
|
|
|
|
scu.ModuleType.SAE,
|
|
|
|
}
|
|
|
|
|
2022-08-25 12:04:10 +02:00
|
|
|
def type_name(self) -> str:
|
|
|
|
"Le nom du type de module, pour les humains (avec majuscules et accents)"
|
2021-12-08 22:33:32 +01:00
|
|
|
return scu.MODULE_TYPE_NAMES[self.module_type]
|
|
|
|
|
2022-08-25 12:04:10 +02:00
|
|
|
def type_abbrv(self) -> str:
|
|
|
|
"""Le nom du type de module, pour les styles CSS.
|
|
|
|
"mod", "malus", "res", "sae"
|
|
|
|
"""
|
2022-03-26 23:33:57 +01:00
|
|
|
return scu.ModuleType.get_abbrev(self.module_type)
|
|
|
|
|
2023-08-26 16:34:56 +02:00
|
|
|
def titre_str(self) -> str:
|
|
|
|
"Identifiant du module à afficher : abbrev ou titre ou code"
|
|
|
|
return self.abbrev or self.titre or self.code
|
|
|
|
|
2023-12-29 13:58:18 +01:00
|
|
|
def sort_key(self) -> tuple:
|
|
|
|
"""Clé de tri pour formations classiques"""
|
|
|
|
return self.numero or 0, self.code
|
|
|
|
|
2022-08-31 19:14:21 +02:00
|
|
|
def sort_key_apc(self) -> tuple:
|
|
|
|
"""Clé de tri pour avoir
|
|
|
|
présentation par type (res, sae), parcours, type, numéro
|
|
|
|
"""
|
|
|
|
if (
|
2022-09-01 15:25:34 +02:00
|
|
|
self.formation.referentiel_competence is None
|
|
|
|
or len(self.parcours)
|
|
|
|
== self.formation.referentiel_competence.parcours.count()
|
2022-08-31 19:14:21 +02:00
|
|
|
or len(self.parcours) == 0
|
|
|
|
):
|
|
|
|
key_parcours = ""
|
|
|
|
else:
|
|
|
|
key_parcours = "/".join([p.code for p in self.parcours])
|
2022-09-05 14:58:57 +02:00
|
|
|
return self.module_type, key_parcours, self.numero or 0
|
2022-08-31 19:14:21 +02:00
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
def set_ue_coef(self, ue, coef: float) -> None:
|
|
|
|
"""Set coef module vers cette UE"""
|
|
|
|
self.update_ue_coef_dict({ue.id: coef})
|
|
|
|
|
|
|
|
def set_ue_coef_dict(self, ue_coef_dict: dict) -> None:
|
|
|
|
"""set coefs vers les UE (remplace existants)
|
|
|
|
ue_coef_dict = { ue_id : coef }
|
|
|
|
Les coefs nuls (zéro) ne sont pas stockés: la relation est supprimée.
|
|
|
|
"""
|
2023-02-28 22:06:25 +01:00
|
|
|
if self.formation.has_locked_sems(self.ue.semestre_idx):
|
2023-09-14 12:08:20 +02:00
|
|
|
current_app.logger.info(
|
|
|
|
"set_ue_coef_dict: locked formation, ignoring request"
|
2023-02-28 22:06:25 +01:00
|
|
|
)
|
2023-02-06 10:13:01 +01:00
|
|
|
raise ScoValueError("Formation verrouillée")
|
2021-12-10 16:45:36 +01:00
|
|
|
changed = False
|
2021-12-08 22:33:32 +01:00
|
|
|
for ue_id, coef in ue_coef_dict.items():
|
2021-12-10 16:45:36 +01:00
|
|
|
# Existant ?
|
|
|
|
coefs = [c for c in self.ue_coefs if c.ue_id == ue_id]
|
|
|
|
if coefs:
|
|
|
|
ue_coef = coefs[0]
|
|
|
|
if coef == 0.0: # supprime ce coef
|
|
|
|
db.session.delete(ue_coef)
|
|
|
|
changed = True
|
|
|
|
elif coef != ue_coef.coef:
|
|
|
|
ue_coef.coef = coef
|
|
|
|
db.session.add(ue_coef)
|
|
|
|
changed = True
|
2021-12-08 22:33:32 +01:00
|
|
|
else:
|
2021-12-10 16:45:36 +01:00
|
|
|
# crée nouveau coef:
|
|
|
|
if coef != 0.0:
|
2023-07-11 06:57:38 +02:00
|
|
|
ue = db.session.get(UniteEns, ue_id)
|
2021-12-10 16:45:36 +01:00
|
|
|
ue_coef = ModuleUECoef(module=self, ue=ue, coef=coef)
|
2022-08-31 12:02:19 +02:00
|
|
|
db.session.add(ue_coef)
|
2021-12-10 16:45:36 +01:00
|
|
|
self.ue_coefs.append(ue_coef)
|
|
|
|
changed = True
|
|
|
|
if changed:
|
|
|
|
self.formation.invalidate_module_coefs()
|
2021-12-08 22:33:32 +01:00
|
|
|
|
|
|
|
def update_ue_coef_dict(self, ue_coef_dict: dict):
|
|
|
|
"""update coefs vers UE (ajoute aux existants)"""
|
2023-02-28 22:06:25 +01:00
|
|
|
if self.formation.has_locked_sems(self.ue.semestre_idx):
|
2023-09-14 12:08:20 +02:00
|
|
|
current_app.logger.info(
|
|
|
|
"update_ue_coef_dict: locked formation, ignoring request"
|
2023-02-28 22:06:25 +01:00
|
|
|
)
|
2023-02-06 10:13:01 +01:00
|
|
|
raise ScoValueError("Formation verrouillée")
|
2021-12-08 22:33:32 +01:00
|
|
|
current = self.get_ue_coef_dict()
|
|
|
|
current.update(ue_coef_dict)
|
|
|
|
self.set_ue_coef_dict(current)
|
|
|
|
|
|
|
|
def get_ue_coef_dict(self):
|
|
|
|
"""returns { ue_id : coef }"""
|
|
|
|
return {p.ue.id: p.coef for p in self.ue_coefs}
|
|
|
|
|
2023-03-21 21:14:38 +01:00
|
|
|
def get_ue_coef_dict_acronyme(self):
|
|
|
|
"""returns { ue_acronyme : coef }"""
|
|
|
|
return {p.ue.acronyme: p.coef for p in self.ue_coefs}
|
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
def delete_ue_coef(self, ue):
|
|
|
|
"""delete coef"""
|
2023-02-28 22:06:25 +01:00
|
|
|
if self.formation.has_locked_sems(self.ue.semestre_idx):
|
2023-09-14 12:08:20 +02:00
|
|
|
current_app.logger.info(
|
2023-06-13 21:28:37 +02:00
|
|
|
"delete_ue_coef: locked formation, ignoring request"
|
2023-02-28 22:06:25 +01:00
|
|
|
)
|
2023-02-06 10:13:01 +01:00
|
|
|
raise ScoValueError("Formation verrouillée")
|
2023-07-11 06:57:38 +02:00
|
|
|
ue_coef = db.session.get(ModuleUECoef, (self.id, ue.id))
|
2021-12-08 22:33:32 +01:00
|
|
|
if ue_coef:
|
|
|
|
db.session.delete(ue_coef)
|
|
|
|
self.formation.invalidate_module_coefs()
|
|
|
|
|
2021-12-10 01:55:13 +01:00
|
|
|
def get_ue_coefs_sorted(self):
|
2023-06-13 21:28:37 +02:00
|
|
|
"les coefs d'UE, trié par numéro et acronyme d'UE"
|
2021-12-10 01:55:13 +01:00
|
|
|
# je n'ai pas su mettre un order_by sur le backref sans avoir
|
|
|
|
# à redéfinir les relationships...
|
2023-06-15 17:14:37 +02:00
|
|
|
return sorted(self.ue_coefs, key=lambda uc: (uc.ue.numero, uc.ue.acronyme))
|
2021-12-10 01:55:13 +01:00
|
|
|
|
2022-12-09 04:24:32 +01:00
|
|
|
def ue_coefs_list(
|
|
|
|
self, include_zeros=True, ues: list["UniteEns"] = None
|
|
|
|
) -> list[tuple["UniteEns", float]]:
|
2022-01-06 22:42:26 +01:00
|
|
|
"""Liste des coefs vers les UE (pour les modules APC).
|
2022-12-09 04:24:32 +01:00
|
|
|
Si ues est spécifié, restreint aux UE indiquées.
|
|
|
|
Sinon si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
|
2022-01-25 10:45:13 +01:00
|
|
|
sauf UE bonus sport.
|
2022-01-06 22:42:26 +01:00
|
|
|
Result: List of tuples [ (ue, coef) ]
|
|
|
|
"""
|
|
|
|
if not self.is_apc():
|
|
|
|
return []
|
2022-12-09 04:24:32 +01:00
|
|
|
if include_zeros and ues is None:
|
2022-01-06 22:42:26 +01:00
|
|
|
# Toutes les UE du même semestre:
|
2022-12-09 04:24:32 +01:00
|
|
|
ues = (
|
2022-01-06 22:42:26 +01:00
|
|
|
self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx)
|
2022-01-25 10:45:13 +01:00
|
|
|
.filter(UniteEns.type != UE_SPORT)
|
2024-09-08 23:52:32 +02:00
|
|
|
.order_by(UniteEns.numero, UniteEns.acronyme)
|
2022-01-06 22:42:26 +01:00
|
|
|
.all()
|
|
|
|
)
|
2022-12-09 04:24:32 +01:00
|
|
|
if not ues:
|
|
|
|
return []
|
|
|
|
if ues:
|
2022-01-06 22:42:26 +01:00
|
|
|
coefs_dict = self.get_ue_coef_dict()
|
|
|
|
coefs_list = []
|
2022-12-09 04:24:32 +01:00
|
|
|
for ue in ues:
|
2022-01-06 22:42:26 +01:00
|
|
|
coefs_list.append((ue, coefs_dict.get(ue.id, 0.0)))
|
|
|
|
return coefs_list
|
|
|
|
# Liste seulement les coefs définis:
|
|
|
|
return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()]
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2024-05-29 11:55:28 +02:00
|
|
|
def get_ue_coefs_descr(self) -> str:
|
|
|
|
"""Description des coefficients vers les UEs (APC)"""
|
|
|
|
coefs_descr = ", ".join(
|
|
|
|
[
|
|
|
|
f"{ue.acronyme}: {co}"
|
|
|
|
for ue, co in self.ue_coefs_list()
|
|
|
|
if isinstance(co, float) and co > 0
|
|
|
|
]
|
|
|
|
)
|
|
|
|
if coefs_descr:
|
|
|
|
descr = "Coefs: " + coefs_descr
|
|
|
|
else:
|
|
|
|
descr = "(pas de coefficients) "
|
|
|
|
return descr
|
|
|
|
|
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()
|
|
|
|
|
2023-11-19 22:35:04 +01:00
|
|
|
def get_edt_ids(self) -> list[str]:
|
|
|
|
"les ids pour l'emploi du temps: à défaut, le 1er code Apogée"
|
2024-01-03 14:43:26 +01:00
|
|
|
return [
|
|
|
|
scu.normalize_edt_id(x)
|
|
|
|
for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) or []
|
|
|
|
]
|
2023-11-11 18:13:18 +01:00
|
|
|
|
2022-10-30 22:42:10 +01:00
|
|
|
def get_parcours(self) -> list[ApcParcours]:
|
|
|
|
"""Les parcours utilisant ce module.
|
|
|
|
Si tous les parcours, liste vide (!).
|
|
|
|
"""
|
|
|
|
ref_comp = self.formation.referentiel_competence
|
|
|
|
if not ref_comp:
|
|
|
|
return []
|
|
|
|
tous_parcours_ids = {p.id for p in ref_comp.parcours}
|
|
|
|
parcours_ids = {p.id for p in self.parcours}
|
|
|
|
if tous_parcours_ids == parcours_ids:
|
|
|
|
return []
|
|
|
|
return self.parcours
|
|
|
|
|
2024-01-30 22:12:55 +01:00
|
|
|
def add_tag(self, tag: "NotesTag"):
|
|
|
|
"""Add tag to module. Check if already has it."""
|
|
|
|
if tag.id in {t.id for t in self.tags}:
|
|
|
|
return
|
|
|
|
self.tags.append(tag)
|
|
|
|
db.session.add(self)
|
|
|
|
db.session.flush()
|
|
|
|
|
2024-10-11 14:06:02 +02:00
|
|
|
def set_tags(self, taglist: str | list[str] | None = None):
|
|
|
|
"""taglist may either be:
|
|
|
|
a string with tag names separated by commas ("un,deux")
|
|
|
|
or a list of strings (["un", "deux"])
|
|
|
|
Remplace les tags existants
|
|
|
|
"""
|
|
|
|
# TODO refactoring ScoTag
|
|
|
|
# TODO code à moderniser (+ revoir classe ScoTag, utiliser modèle)
|
|
|
|
# TODO Voir ItemSuiviTag et api etud_suivi
|
|
|
|
from app.scodoc.sco_tag_module import ScoTag, ModuleTag
|
|
|
|
|
|
|
|
taglist = taglist or []
|
|
|
|
if isinstance(taglist, str):
|
|
|
|
taglist = taglist.split(",")
|
|
|
|
taglist = [t.strip() for t in taglist]
|
|
|
|
taglist = [t for t in taglist if t]
|
|
|
|
log(f"module.set_tags: module_id={self.id} taglist={taglist}")
|
|
|
|
# Check tags syntax
|
|
|
|
for tag in taglist:
|
|
|
|
if not ScoTag.check_tag_title(tag):
|
|
|
|
log(f"module.set_tags({self.id}): invalid tag title")
|
|
|
|
return scu.json_error(404, "invalid tag")
|
|
|
|
|
|
|
|
newtags = set(taglist)
|
|
|
|
oldtags = set(t.title for t in self.tags)
|
|
|
|
to_del = oldtags - newtags
|
|
|
|
to_add = newtags - oldtags
|
|
|
|
|
|
|
|
# should be atomic, but it's not.
|
|
|
|
for tagname in to_add:
|
|
|
|
t = ModuleTag(tagname, object_id=self.id)
|
|
|
|
for tagname in to_del:
|
|
|
|
t = ModuleTag(tagname)
|
|
|
|
t.remove_tag_from_object(self.id)
|
|
|
|
|
|
|
|
return "", http.HTTPStatus.NO_CONTENT
|
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2024-10-29 19:18:36 +01:00
|
|
|
class ModuleUECoef(models.ScoDocModel):
|
2021-12-08 22:33:32 +01:00
|
|
|
"""Coefficients des modules vers les UE (APC, BUT)
|
|
|
|
En mode APC, ces coefs remplacent le coefficient "PPN" du module.
|
|
|
|
"""
|
|
|
|
|
|
|
|
__tablename__ = "module_ue_coef"
|
|
|
|
|
|
|
|
module_id = db.Column(
|
|
|
|
db.Integer,
|
|
|
|
db.ForeignKey("notes_modules.id", ondelete="CASCADE"),
|
|
|
|
primary_key=True,
|
|
|
|
)
|
|
|
|
ue_id = db.Column(
|
|
|
|
db.Integer,
|
|
|
|
db.ForeignKey("notes_ue.id", ondelete="CASCADE"),
|
|
|
|
primary_key=True,
|
|
|
|
)
|
|
|
|
coef = db.Column(
|
|
|
|
db.Float,
|
|
|
|
nullable=False,
|
|
|
|
)
|
|
|
|
module = db.relationship(
|
|
|
|
Module,
|
|
|
|
backref=db.backref(
|
|
|
|
"ue_coefs",
|
|
|
|
passive_deletes=True,
|
|
|
|
cascade="save-update, merge, delete, delete-orphan",
|
|
|
|
),
|
|
|
|
)
|
|
|
|
ue = db.relationship(
|
|
|
|
"UniteEns",
|
|
|
|
backref=db.backref(
|
|
|
|
"module_ue_coefs",
|
|
|
|
passive_deletes=True,
|
|
|
|
cascade="save-update, merge, delete, delete-orphan",
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
2022-07-21 14:21:06 +02:00
|
|
|
def to_dict(self, convert_objects=False) -> dict:
|
|
|
|
"""If convert_objects, convert all attributes to native types
|
2022-07-21 14:44:19 +02:00
|
|
|
(suitable for json encoding).
|
2022-07-21 14:21:06 +02:00
|
|
|
"""
|
|
|
|
d = dict(self.__dict__)
|
|
|
|
d.pop("_sa_instance_state", None)
|
2022-08-02 17:13:13 +02:00
|
|
|
if convert_objects:
|
|
|
|
d["ue"] = self.ue.to_dict(with_module_ue_coefs=False, convert_objects=True)
|
2022-07-21 14:21:06 +02:00
|
|
|
return d
|
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
|
2024-10-11 14:06:02 +02:00
|
|
|
class NotesTag(models.ScoDocModel):
|
2021-12-08 22:33:32 +01:00
|
|
|
"""Tag sur un module"""
|
|
|
|
|
|
|
|
__tablename__ = "notes_tags"
|
|
|
|
__table_args__ = (db.UniqueConstraint("title", "dept_id"),)
|
|
|
|
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
|
|
tag_id = db.synonym("id")
|
|
|
|
|
|
|
|
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
|
|
|
title = db.Column(db.Text(), nullable=False)
|
|
|
|
|
2024-10-11 14:06:02 +02:00
|
|
|
def __repr__(self):
|
|
|
|
return f"<Tag {self.id} {self.title!r}>"
|
|
|
|
|
2024-01-30 22:12:55 +01:00
|
|
|
@classmethod
|
|
|
|
def get_or_create(cls, title: str, dept_id: int | None = None) -> "NotesTag":
|
|
|
|
"""Get tag, or create it if it doesn't yet exists.
|
|
|
|
If dept_id unspecified, use current dept.
|
|
|
|
"""
|
|
|
|
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
|
|
|
tag = NotesTag.query.filter_by(dept_id=dept_id, title=title).first()
|
|
|
|
if tag is None:
|
|
|
|
tag = NotesTag(dept_id=dept_id, title=title)
|
|
|
|
db.session.add(tag)
|
|
|
|
db.session.flush()
|
|
|
|
return tag
|
|
|
|
|
2021-12-08 22:33:32 +01:00
|
|
|
|
|
|
|
# Association tag <-> module
|
|
|
|
notes_modules_tags = db.Table(
|
|
|
|
"notes_modules_tags",
|
|
|
|
db.Column(
|
|
|
|
"tag_id",
|
|
|
|
db.Integer,
|
|
|
|
db.ForeignKey("notes_tags.id", ondelete="CASCADE"),
|
|
|
|
),
|
|
|
|
db.Column(
|
|
|
|
"module_id", db.Integer, db.ForeignKey("notes_modules.id", ondelete="CASCADE")
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
from app.models.ues import UniteEns
|