forked from ScoDoc/ScoDoc
814 lines
28 KiB
Python
814 lines
28 KiB
Python
##############################################################################
|
|
# ScoDoc
|
|
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
|
# See LICENSE
|
|
##############################################################################
|
|
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
|
|
"""
|
|
from datetime import datetime
|
|
import functools
|
|
from operator import attrgetter
|
|
import yaml
|
|
|
|
from flask import g
|
|
from flask_sqlalchemy.query import Query
|
|
from sqlalchemy.orm import class_mapper
|
|
import sqlalchemy
|
|
|
|
from app import db, log, models
|
|
|
|
from app.scodoc.sco_utils import ModuleType
|
|
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
|
|
|
|
REFCOMP_EQUIVALENCE_FILENAME = "ressources/referentiels/equivalences.yaml"
|
|
|
|
|
|
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
|
|
def attribute_names(cls):
|
|
"liste ids (noms de colonnes) d'un modèle"
|
|
return [
|
|
prop.key
|
|
for prop in class_mapper(cls).iterate_properties
|
|
if isinstance(prop, sqlalchemy.orm.ColumnProperty)
|
|
]
|
|
|
|
|
|
class XMLModel:
|
|
"""Mixin class, to ease loading Orebut XMLs"""
|
|
|
|
_xml_attribs = {} # to be overloaded
|
|
id = "_"
|
|
|
|
@classmethod
|
|
def attr_from_xml(cls, args: dict) -> dict:
|
|
"""dict with attributes imported from Orébut XML
|
|
and renamed for our models.
|
|
The mapping is specified by the _xml_attribs
|
|
attribute in each model class.
|
|
Keep only attributes corresponding to columns in our model:
|
|
other XML attributes are simply ignored.
|
|
"""
|
|
columns = attribute_names(cls)
|
|
renamed_attributes = {cls._xml_attribs.get(k, k): v for (k, v) in args.items()}
|
|
return {k: renamed_attributes[k] for k in renamed_attributes if k in columns}
|
|
|
|
def __repr__(self):
|
|
return f'<{self.__class__.__name__} {self.id} "{self.titre if hasattr(self, "titre") else ""}">'
|
|
|
|
|
|
class ApcReferentielCompetences(models.ScoDocModel, XMLModel):
|
|
"Référentiel de compétence d'une spécialité"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
dept_id = db.Column(
|
|
db.Integer, db.ForeignKey("departement.id", ondelete="CASCADE"), index=True
|
|
)
|
|
annexe = db.Column(db.Text()) # '1', '22', ...
|
|
specialite = db.Column(db.Text()) # 'CJ', 'RT', 'INFO', ...
|
|
specialite_long = db.Column(
|
|
db.Text()
|
|
) # 'Carrière Juridique', 'Réseaux et télécommunications', ...
|
|
type_titre = db.Column(db.Text()) # 'B.U.T.'
|
|
type_structure = db.Column(db.Text()) # 'type1', 'type2', ...
|
|
type_departement = db.Column(db.Text()) # "secondaire", "tertiaire"
|
|
version_orebut = db.Column(db.Text()) # '2021-12-11 00:00:00'
|
|
_xml_attribs = { # Orébut xml attrib : attribute
|
|
"type": "type_titre",
|
|
"version": "version_orebut",
|
|
}
|
|
# ScoDoc specific fields:
|
|
scodoc_date_loaded = db.Column(db.DateTime, default=datetime.now)
|
|
scodoc_orig_filename = db.Column(db.Text())
|
|
# Relations:
|
|
competences = db.relationship(
|
|
"ApcCompetence",
|
|
backref="referentiel",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
parcours = db.relationship(
|
|
"ApcParcours",
|
|
backref="referentiel",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
order_by="ApcParcours.numero, ApcParcours.code",
|
|
)
|
|
formations = db.relationship(
|
|
"Formation",
|
|
backref="referentiel_competence",
|
|
order_by="Formation.acronyme, Formation.version",
|
|
)
|
|
validations_annee = db.relationship(
|
|
"ApcValidationAnnee",
|
|
backref="referentiel_competence",
|
|
cascade="all, delete-orphan", # cascade at ORM level
|
|
lazy="dynamic",
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
|
|
|
|
def get_title(self) -> str:
|
|
"Titre affichable"
|
|
# utilise type_titre (B.U.T.), spécialité, version
|
|
return f"{self.type_titre} {self.specialite} {self.get_version()}"
|
|
|
|
def get_version(self) -> str:
|
|
"La version, normalement sous forme de date iso yyy-mm-dd"
|
|
if not self.version_orebut:
|
|
return ""
|
|
return self.version_orebut.split()[0]
|
|
|
|
def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True):
|
|
"""Représentation complète du ref. de comp.
|
|
comme un dict.
|
|
Si parcours est une liste de parcours, restreint l'export aux parcours listés.
|
|
"""
|
|
return {
|
|
"dept_id": self.dept_id,
|
|
"annexe": self.annexe,
|
|
"specialite": self.specialite,
|
|
"specialite_long": self.specialite_long,
|
|
"type_structure": self.type_structure,
|
|
"type_departement": self.type_departement,
|
|
"type_titre": self.type_titre,
|
|
"version_orebut": self.version_orebut,
|
|
"scodoc_date_loaded": (
|
|
self.scodoc_date_loaded.isoformat() + "Z"
|
|
if self.scodoc_date_loaded
|
|
else ""
|
|
),
|
|
"scodoc_orig_filename": self.scodoc_orig_filename,
|
|
"competences": {
|
|
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
|
|
for x in self.competences
|
|
},
|
|
"parcours": {
|
|
x.code: x.to_dict()
|
|
for x in (self.parcours if parcours is None else parcours)
|
|
},
|
|
}
|
|
|
|
def get_niveaux_by_parcours(
|
|
self, annee: int, parcours: list["ApcParcours"] = None
|
|
) -> tuple[list["ApcParcours"], dict]:
|
|
"""
|
|
Construit la liste des niveaux de compétences pour chaque parcours
|
|
de ce référentiel, ou seulement pour les parcours donnés.
|
|
|
|
Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
|
|
|
|
Le tronc commun n'est pas identifié comme tel dans les référentiels Orébut:
|
|
on cherche les niveaux qui sont présents dans tous les parcours et les range sous
|
|
la clé "TC" (toujours présente mais éventuellement liste vide si pas de tronc commun).
|
|
|
|
Résultat: couple
|
|
( [ ApcParcours ],
|
|
{
|
|
"TC" : [ ApcNiveau ],
|
|
parcour.id : [ ApcNiveau ]
|
|
}
|
|
)
|
|
"""
|
|
parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
|
|
if parcours is None:
|
|
parcours = parcours_ref
|
|
niveaux_by_parcours = {
|
|
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
|
|
for parcour in parcours_ref
|
|
}
|
|
# Cherche tronc commun
|
|
if niveaux_by_parcours:
|
|
niveaux_ids_tc = set.intersection(
|
|
*[
|
|
{n.id for n in niveaux_by_parcours[parcour_id]}
|
|
for parcour_id in niveaux_by_parcours
|
|
]
|
|
)
|
|
else:
|
|
niveaux_ids_tc = set()
|
|
# Enleve les niveaux du tronc commun
|
|
niveaux_by_parcours_no_tc = {
|
|
parcour.id: [
|
|
niveau
|
|
for niveau in niveaux_by_parcours[parcour.id]
|
|
if niveau.id not in niveaux_ids_tc
|
|
]
|
|
for parcour in parcours
|
|
}
|
|
# Niveaux du TC
|
|
niveaux_tc = []
|
|
if len(parcours):
|
|
niveaux_parcours_1 = niveaux_by_parcours[parcours[0].id]
|
|
niveaux_tc = [
|
|
niveau for niveau in niveaux_parcours_1 if niveau.id in niveaux_ids_tc
|
|
]
|
|
niveaux_by_parcours_no_tc["TC"] = niveaux_tc
|
|
return parcours, niveaux_by_parcours_no_tc
|
|
|
|
def get_competences_tronc_commun(self) -> list["ApcCompetence"]:
|
|
"""Liste des compétences communes à tous les parcours du référentiel."""
|
|
parcours = self.parcours.all()
|
|
if not parcours:
|
|
return []
|
|
|
|
ids = set.intersection(
|
|
*[
|
|
{competence.id for competence in parcour.query_competences()}
|
|
for parcour in parcours
|
|
]
|
|
)
|
|
return sorted(
|
|
[
|
|
competence
|
|
for competence in parcours[0].query_competences()
|
|
if competence.id in ids
|
|
],
|
|
key=attrgetter("numero"),
|
|
)
|
|
|
|
def table_niveaux_parcours(self) -> dict:
|
|
"""Une table avec les parcours:années BUT et les niveaux
|
|
{ parcour_id : { 1 : { competence_id : ordre }}}
|
|
"""
|
|
parcours_info = {}
|
|
for parcour in self.parcours:
|
|
descr_parcour = {}
|
|
parcours_info[parcour.id] = descr_parcour
|
|
for annee in (1, 2, 3):
|
|
descr_parcour[annee] = {
|
|
niveau.competence.id: niveau.ordre
|
|
for niveau in ApcNiveau.niveaux_annee_de_parcours(
|
|
parcour, annee, self
|
|
)
|
|
}
|
|
|
|
return parcours_info
|
|
|
|
def equivalents(self) -> set["ApcReferentielCompetences"]:
|
|
"""Ensemble des référentiels du même département
|
|
qui peuvent être considérés comme "équivalents", au sens
|
|
une formation de ce référentiel pourrait changer vers un équivalent,
|
|
en ignorant les apprentissages critiques.
|
|
Pour cela, il faut avoir le même type, etc et les mêmes compétences,
|
|
niveaux et parcours (voir map_to_other_referentiel).
|
|
"""
|
|
candidats = ApcReferentielCompetences.query.filter_by(
|
|
dept_id=self.dept_id
|
|
).filter(ApcReferentielCompetences.id != self.id)
|
|
return {
|
|
referentiel
|
|
for referentiel in candidats
|
|
if not isinstance(self.map_to_other_referentiel(referentiel), str)
|
|
}
|
|
|
|
def map_to_other_referentiel(
|
|
self, other: "ApcReferentielCompetences"
|
|
) -> str | tuple[dict[int, int], dict[int, int], dict[int, int]]:
|
|
"""Build mapping between this referentiel and ref2.
|
|
If successful, returns 3 dicts mapping self ids to other ids.
|
|
Else return a string, error message.
|
|
"""
|
|
if self.type_structure != other.type_structure:
|
|
return "type_structure mismatch"
|
|
if self.type_departement != other.type_departement:
|
|
return "type_departement mismatch"
|
|
# Table d'équivalences entre refs:
|
|
equiv = self._load_config_equivalences()
|
|
# Même specialité (ou alias) ?
|
|
if self.specialite != other.specialite and other.specialite not in equiv.get(
|
|
"alias", []
|
|
):
|
|
return "specialite mismatch"
|
|
# mêmes parcours ?
|
|
eq_parcours = equiv.get("parcours", {})
|
|
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
|
|
parcours_by_code_2 = {
|
|
eq_parcours.get(p.code, p.code): p for p in other.parcours
|
|
}
|
|
if parcours_by_code_1.keys() != parcours_by_code_2.keys():
|
|
return "parcours mismatch"
|
|
parcours_map = {
|
|
parcours_by_code_1[eq_parcours.get(code, code)]
|
|
.id: parcours_by_code_2[eq_parcours.get(code, code)]
|
|
.id
|
|
for code in parcours_by_code_1
|
|
}
|
|
# mêmes compétences ?
|
|
competence_by_code_1 = {c.titre: c for c in self.competences}
|
|
competence_by_code_2 = {c.titre: c for c in other.competences}
|
|
if competence_by_code_1.keys() != competence_by_code_2.keys():
|
|
return "competences mismatch"
|
|
competences_map = {
|
|
competence_by_code_1[titre].id: competence_by_code_2[titre].id
|
|
for titre in competence_by_code_1
|
|
}
|
|
# mêmes niveaux (dans chaque compétence) ?
|
|
niveaux_map = {}
|
|
for titre in competence_by_code_1:
|
|
c1 = competence_by_code_1[titre]
|
|
c2 = competence_by_code_2[titre]
|
|
niveau_by_attr_1 = {(n.annee, n.ordre, n.libelle): n for n in c1.niveaux}
|
|
niveau_by_attr_2 = {(n.annee, n.ordre, n.libelle): n for n in c2.niveaux}
|
|
if niveau_by_attr_1.keys() != niveau_by_attr_2.keys():
|
|
return f"niveaux mismatch in comp. '{titre}'"
|
|
niveaux_map.update(
|
|
{
|
|
niveau_by_attr_1[a].id: niveau_by_attr_2[a].id
|
|
for a in niveau_by_attr_1
|
|
}
|
|
)
|
|
return parcours_map, competences_map, niveaux_map
|
|
|
|
def _load_config_equivalences(self) -> dict:
|
|
"""Load config file ressources/referentiels/equivalences.yaml
|
|
used to define equivalences between distinct referentiels
|
|
return a dict, with optional keys:
|
|
alias: list of equivalent names for speciality (eg SD == STID)
|
|
parcours: dict with equivalent parcours acronyms
|
|
"""
|
|
try:
|
|
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
|
|
doc = yaml.safe_load(f.read())
|
|
except FileNotFoundError:
|
|
log(f"_load_config_equivalences: {REFCOMP_EQUIVALENCE_FILENAME} not found")
|
|
return {}
|
|
except yaml.parser.ParserError as exc:
|
|
raise ScoValueError(
|
|
f"erreur dans le fichier {REFCOMP_EQUIVALENCE_FILENAME}"
|
|
) from exc
|
|
return doc.get(self.specialite, {})
|
|
|
|
|
|
class ApcCompetence(models.ScoDocModel, XMLModel):
|
|
"Compétence"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
referentiel_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
# les compétences dans Orébut sont identifiées par leur id unique
|
|
# (mais id_orebut n'est pas unique car le même ref. pourra être chargé dans plusieurs depts)
|
|
id_orebut = db.Column(db.Text(), nullable=True, index=True)
|
|
titre = db.Column(db.Text(), nullable=False, index=True)
|
|
titre_long = db.Column(db.Text())
|
|
couleur = db.Column(db.Text())
|
|
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
|
|
_xml_attribs = { # xml_attrib : attribute
|
|
"id": "id_orebut",
|
|
"nom_court": "titre", # was name
|
|
"libelle_long": "titre_long",
|
|
}
|
|
situations = db.relationship(
|
|
"ApcSituationPro",
|
|
backref="competence",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
composantes_essentielles = db.relationship(
|
|
"ApcComposanteEssentielle",
|
|
backref="competence",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
niveaux = db.relationship(
|
|
"ApcNiveau",
|
|
backref="competence",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<ApcCompetence {self.id} {self.titre!r}>"
|
|
|
|
def to_dict(self, with_app_critiques=True):
|
|
"repr dict recursive sur situations, composantes, niveaux"
|
|
return {
|
|
"id_orebut": self.id_orebut,
|
|
"titre": self.titre,
|
|
"titre_long": self.titre_long,
|
|
"couleur": self.couleur,
|
|
"numero": self.numero,
|
|
"situations": [x.to_dict() for x in self.situations],
|
|
"composantes_essentielles": [
|
|
x.to_dict() for x in self.composantes_essentielles
|
|
],
|
|
"niveaux": {
|
|
x.annee: x.to_dict(with_app_critiques=with_app_critiques)
|
|
for x in self.niveaux
|
|
},
|
|
}
|
|
|
|
def to_dict_bul(self) -> dict:
|
|
"dict court pour bulletins"
|
|
return {
|
|
"id_orebut": self.id_orebut,
|
|
"titre": self.titre,
|
|
"titre_long": self.titre_long,
|
|
"couleur": self.couleur,
|
|
"numero": self.numero,
|
|
}
|
|
|
|
|
|
class ApcSituationPro(models.ScoDocModel, XMLModel):
|
|
"Situation professionnelle"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
competence_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
libelle = db.Column(db.Text(), nullable=False)
|
|
|
|
# aucun attribut (le text devient le libellé)
|
|
def to_dict(self):
|
|
return {"libelle": self.libelle}
|
|
|
|
|
|
class ApcComposanteEssentielle(models.ScoDocModel, XMLModel):
|
|
"Composante essentielle"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
competence_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
libelle = db.Column(db.Text(), nullable=False)
|
|
|
|
def to_dict(self):
|
|
return {"libelle": self.libelle}
|
|
|
|
|
|
class ApcNiveau(models.ScoDocModel, XMLModel):
|
|
"""Niveau de compétence
|
|
Chaque niveau peut être associé à deux UE,
|
|
des semestres impair et pair de la même année.
|
|
"""
|
|
|
|
__tablename__ = "apc_niveau"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
competence_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
libelle = db.Column(db.Text(), nullable=False)
|
|
annee = db.Column(db.Text(), nullable=False) # "BUT1", "BUT2", "BUT3"
|
|
# L'ordre est le niveau (1,2,3) ou (1,2) suivant la competence
|
|
ordre = db.Column(db.Integer, nullable=False) # 1, 2, 3
|
|
app_critiques = db.relationship(
|
|
"ApcAppCritique",
|
|
backref="niveau",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
ues = db.relationship("UniteEns", back_populates="niveau_competence")
|
|
|
|
def __repr__(self):
|
|
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
|
|
self.annee!r} {self.competence!r}>"""
|
|
|
|
def __str__(self):
|
|
return f"""{self.competence.titre} niveau {self.ordre}"""
|
|
|
|
def to_dict(self, with_app_critiques=True):
|
|
"as a dict, recursif (ou non) sur les AC"
|
|
return {
|
|
"libelle": self.libelle,
|
|
"annee": self.annee,
|
|
"ordre": self.ordre,
|
|
"app_critiques": (
|
|
{x.code: x.to_dict() for x in self.app_critiques}
|
|
if with_app_critiques
|
|
else {}
|
|
),
|
|
}
|
|
|
|
def to_dict_bul(self):
|
|
"dict pour bulletins: indique la compétence, pas les ACs (pour l'instant ?)"
|
|
return {
|
|
"libelle": self.libelle,
|
|
"annee": self.annee,
|
|
"ordre": self.ordre,
|
|
"competence": self.competence.to_dict_bul(),
|
|
}
|
|
|
|
@functools.cached_property
|
|
def parcours(self) -> list["ApcParcours"]:
|
|
"""Les parcours passant par ce niveau.
|
|
Les associations Parcours/Niveaux/compétences ne sont jamais
|
|
changées par ScoDoc, la valeur est donc cachée.
|
|
"""
|
|
annee = int(self.annee[-1])
|
|
return (
|
|
ApcParcours.query.join(ApcAnneeParcours)
|
|
.filter_by(ordre=annee)
|
|
.join(ApcParcoursNiveauCompetence)
|
|
.join(ApcCompetence)
|
|
.join(ApcNiveau)
|
|
.filter_by(id=self.id)
|
|
.order_by(ApcParcours.numero, ApcParcours.code)
|
|
.all()
|
|
)
|
|
|
|
@functools.cached_property
|
|
def is_tronc_commun(self) -> bool:
|
|
"""Vrai si ce niveau fait partie du Tronc Commun"""
|
|
return len(self.parcours) == self.competence.referentiel.parcours.count()
|
|
|
|
@classmethod
|
|
def niveaux_annee_de_parcours(
|
|
cls,
|
|
parcour: "ApcParcours",
|
|
annee: int,
|
|
referentiel_competence: ApcReferentielCompetences = None,
|
|
competence: ApcCompetence = None,
|
|
) -> list["ApcNiveau"]:
|
|
"""Les niveaux de l'année du parcours
|
|
Si le parcour est None, tous les niveaux de l'année
|
|
(dans ce cas, spécifier referentiel_competence)
|
|
Si competence est indiquée, filtre les niveaux de cette compétence.
|
|
"""
|
|
key = (
|
|
parcour.id if parcour else None,
|
|
annee,
|
|
referentiel_competence.id if referentiel_competence else None,
|
|
competence.id if competence else None,
|
|
)
|
|
_cache = getattr(g, "_niveaux_annee_de_parcours_cache", None)
|
|
if _cache:
|
|
result = g._niveaux_annee_de_parcours_cache.get(key, False)
|
|
if result is not False:
|
|
return result
|
|
else:
|
|
g._niveaux_annee_de_parcours_cache = {}
|
|
_cache = g._niveaux_annee_de_parcours_cache
|
|
if annee not in {1, 2, 3}:
|
|
raise ValueError("annee invalide pour un parcours BUT")
|
|
referentiel_competence = (
|
|
parcour.referentiel if parcour else referentiel_competence
|
|
)
|
|
if referentiel_competence is None:
|
|
raise ScoNoReferentielCompetences()
|
|
if not parcour:
|
|
annee_formation = f"BUT{annee}"
|
|
query = ApcNiveau.query.filter(
|
|
ApcNiveau.annee == annee_formation,
|
|
ApcCompetence.id == ApcNiveau.competence_id,
|
|
ApcCompetence.referentiel_id == referentiel_competence.id,
|
|
)
|
|
if competence is not None:
|
|
query = query.filter(ApcCompetence.id == competence.id)
|
|
result = query.all()
|
|
_cache[key] = result
|
|
return result
|
|
|
|
annee_parcour: ApcAnneeParcours = parcour.annees.filter_by(ordre=annee).first()
|
|
if not annee_parcour:
|
|
_cache[key] = []
|
|
return []
|
|
|
|
if competence is None:
|
|
parcour_niveaux: list[ApcParcoursNiveauCompetence] = (
|
|
annee_parcour.niveaux_competences
|
|
)
|
|
niveaux: list[ApcNiveau] = [
|
|
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
|
|
for pn in parcour_niveaux
|
|
]
|
|
else:
|
|
niveaux: list[ApcNiveau] = (
|
|
ApcNiveau.query.filter_by(annee=f"BUT{int(annee)}")
|
|
.join(ApcCompetence)
|
|
.filter_by(id=competence.id)
|
|
.join(ApcParcoursNiveauCompetence)
|
|
.filter(ApcParcoursNiveauCompetence.niveau == ApcNiveau.ordre)
|
|
.join(ApcAnneeParcours)
|
|
.filter_by(parcours_id=parcour.id)
|
|
.all()
|
|
)
|
|
_cache[key] = niveaux
|
|
return niveaux
|
|
|
|
|
|
app_critiques_modules = db.Table(
|
|
"apc_modules_acs",
|
|
db.Column(
|
|
"module_id",
|
|
db.ForeignKey("notes_modules.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
db.Column(
|
|
"app_crit_id",
|
|
db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
db.UniqueConstraint("module_id", "app_crit_id", name="uix_module_id_app_crit_id"),
|
|
)
|
|
|
|
|
|
class ApcAppCritique(models.ScoDocModel, XMLModel):
|
|
"Apprentissage Critique BUT"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
niveau_id = db.Column(
|
|
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
code = db.Column(db.Text(), nullable=False, index=True)
|
|
libelle = db.Column(db.Text())
|
|
|
|
# modules = db.relationship(
|
|
# "Module",
|
|
# secondary="apc_modules_acs",
|
|
# lazy="dynamic",
|
|
# backref=db.backref("app_critiques", lazy="dynamic"),
|
|
# )
|
|
|
|
@classmethod
|
|
def app_critiques_ref_comp(
|
|
cls,
|
|
ref_comp: ApcReferentielCompetences,
|
|
annee: str,
|
|
competence: ApcCompetence = None,
|
|
) -> Query:
|
|
"Liste les AC de tous les parcours de ref_comp pour l'année indiquée"
|
|
assert annee in {"BUT1", "BUT2", "BUT3"}
|
|
query = cls.query.filter(
|
|
ApcAppCritique.niveau_id == ApcNiveau.id,
|
|
ApcNiveau.competence_id == ApcCompetence.id,
|
|
ApcNiveau.annee == annee,
|
|
ApcCompetence.referentiel_id == ref_comp.id,
|
|
)
|
|
if competence is not None:
|
|
query = query.filter(ApcNiveau.competence == competence)
|
|
return query
|
|
|
|
def to_dict(self, with_code=False) -> dict:
|
|
if with_code:
|
|
return {"code": self.code, "libelle": self.libelle}
|
|
return {"libelle": self.libelle}
|
|
|
|
def get_label(self) -> str:
|
|
return self.code + " - " + self.titre
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__} {self.code!r}>"
|
|
|
|
def get_saes(self):
|
|
"""Liste des SAE associées"""
|
|
return [m for m in self.modules if m.module_type == ModuleType.SAE]
|
|
|
|
|
|
parcours_modules = db.Table(
|
|
"parcours_modules",
|
|
db.Column(
|
|
"parcours_id",
|
|
db.Integer,
|
|
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
db.Column(
|
|
"module_id",
|
|
db.Integer,
|
|
db.ForeignKey("notes_modules.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
)
|
|
"""Association parcours <-> modules (many-to-many)"""
|
|
|
|
parcours_formsemestre = db.Table(
|
|
"parcours_formsemestre",
|
|
db.Column(
|
|
"parcours_id",
|
|
db.Integer,
|
|
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
db.Column(
|
|
"formsemestre_id",
|
|
db.Integer,
|
|
db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
),
|
|
)
|
|
"""Association parcours <-> formsemestre (many-to-many)"""
|
|
|
|
|
|
class ApcParcours(models.ScoDocModel, XMLModel):
|
|
"Un parcours BUT"
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
referentiel_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
|
|
code = db.Column(db.Text(), nullable=False)
|
|
libelle = db.Column(db.Text(), nullable=False)
|
|
annees = db.relationship(
|
|
"ApcAnneeParcours",
|
|
backref="parcours",
|
|
lazy="dynamic",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"
|
|
|
|
def to_dict(self, with_annees=True) -> dict:
|
|
"""dict repr.
|
|
On peut ne pas indiquer les années pour gagner de la place (export formations).
|
|
"""
|
|
d = {
|
|
"code": self.code,
|
|
"numero": self.numero,
|
|
"libelle": self.libelle,
|
|
}
|
|
if with_annees:
|
|
d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
|
|
return d
|
|
|
|
def query_competences(self) -> Query:
|
|
"Les compétences associées à ce parcours"
|
|
return (
|
|
ApcCompetence.query.join(ApcParcoursNiveauCompetence)
|
|
.join(ApcAnneeParcours)
|
|
.filter_by(parcours_id=self.id)
|
|
.order_by(ApcCompetence.numero)
|
|
)
|
|
|
|
def get_competence_by_titre(self, titre: str) -> ApcCompetence:
|
|
"La compétence de titre donné dans ce parcours, ou None"
|
|
return (
|
|
ApcCompetence.query.filter_by(titre=titre)
|
|
.join(ApcParcoursNiveauCompetence)
|
|
.join(ApcAnneeParcours)
|
|
.filter_by(parcours_id=self.id)
|
|
.order_by(ApcCompetence.numero)
|
|
.first()
|
|
)
|
|
|
|
|
|
class ApcAnneeParcours(models.ScoDocModel, XMLModel):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
parcours_id = db.Column(
|
|
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
ordre = db.Column(db.Integer)
|
|
"numéro de l'année: 1, 2, 3"
|
|
|
|
def __repr__(self):
|
|
return f"""<{self.__class__.__name__} {
|
|
self.id} ordre={self.ordre!r} parcours={self.parcours.code!r}>"""
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"ordre": self.ordre,
|
|
"competences": {
|
|
x.competence.titre: {
|
|
"niveau": x.niveau,
|
|
"id_orebut": x.competence.id_orebut,
|
|
}
|
|
for x in self.niveaux_competences
|
|
},
|
|
}
|
|
|
|
|
|
class ApcParcoursNiveauCompetence(models.ScoDocModel):
|
|
"""Association entre année de parcours et compétence.
|
|
Le "niveau" de la compétence est donné ici
|
|
(convention Orébut)
|
|
"""
|
|
|
|
competence_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
annee_parcours_id = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey("apc_annee_parcours.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
niveau = db.Column(db.Integer, nullable=False) # 1, 2, 3
|
|
competence = db.relationship(
|
|
ApcCompetence,
|
|
backref=db.backref(
|
|
"annee_parcours",
|
|
passive_deletes=True,
|
|
cascade="save-update, merge, delete, delete-orphan",
|
|
lazy="dynamic",
|
|
),
|
|
)
|
|
annee_parcours = db.relationship(
|
|
ApcAnneeParcours,
|
|
backref=db.backref(
|
|
"niveaux_competences",
|
|
passive_deletes=True,
|
|
cascade="save-update, merge, delete, delete-orphan",
|
|
),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<{self.__class__.__name__} {self.competence!r}<->{self.annee_parcours!r} niveau={self.niveau!r}>"
|