############################################################################## # 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"" 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"" 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}>"