diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index 0084112e..e8b9f0f3 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -1,514 +1,515 @@ -############################################################################## -# ScoDoc -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# See LICENSE -############################################################################## -"""ScoDoc 9 models : Référentiel Compétence BUT 2021 -""" -from datetime import datetime - -import flask_sqlalchemy -from sqlalchemy.orm import class_mapper -import sqlalchemy - -from app import db - -from app.scodoc.sco_utils import ModuleType -from app.scodoc.sco_exceptions import ScoValueError - - -# 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(db.Model, 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"), index=True) - annexe = db.Column(db.Text()) - specialite = db.Column(db.Text()) - specialite_long = db.Column(db.Text()) - type_titre = db.Column(db.Text()) - type_structure = db.Column(db.Text()) - type_departement = db.Column(db.Text()) # "secondaire", "tertiaire" - version_orebut = db.Column(db.Text()) - _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.utcnow) - 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", - ) - formations = db.relationship("Formation", backref="referentiel_competence") - - def __repr__(self): - return f"" - - def to_dict(self): - """Représentation complète du ref. de comp. - comme un dict. - """ - 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() for x in self.competences}, - "parcours": {x.code: x.to_dict() for x in self.parcours}, - } - - def get_niveaux_by_parcours(self, annee) -> dict: - """ - Construit la liste des niveaux de compétences pour chaque parcours - de ce référentiel. - 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: - { - "TC" : [ ApcNiveau ], - parcour.id : [ ApcNiveau ] - } - """ - parcours = self.parcours.order_by(ApcParcours.numero).all() - niveaux_by_parcours = { - parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self) - for parcour in parcours - } - # 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 niveaux_by_parcours_no_tc - - -class ApcCompetence(db.Model, XMLModel): - "Compétence" - id = db.Column(db.Integer, primary_key=True) - referentiel_id = db.Column( - db.Integer, db.ForeignKey("apc_referentiel_competences.id"), 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) # 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): - "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() 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(db.Model, XMLModel): - "Situation professionnelle" - id = db.Column(db.Integer, primary_key=True) - competence_id = db.Column( - db.Integer, db.ForeignKey("apc_competence.id"), 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(db.Model, XMLModel): - "Composante essentielle" - id = db.Column(db.Integer, primary_key=True) - competence_id = db.Column( - db.Integer, db.ForeignKey("apc_competence.id"), nullable=False - ) - libelle = db.Column(db.Text(), nullable=False) - - def to_dict(self): - return {"libelle": self.libelle} - - -class ApcNiveau(db.Model, 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"), 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 to_dict(self): - "as a dict, recursif 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}, - } - - 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(), - } - - @classmethod - def niveaux_annee_de_parcours( - cls, - parcour: "ApcParcours", - annee: int, - referentiel_competence: ApcReferentielCompetences = None, - ) -> flask_sqlalchemy.BaseQuery: - """Les niveaux de l'année du parcours - Si le parcour est None, tous les niveaux de l'année - """ - if annee not in {1, 2, 3}: - raise ValueError("annee invalide pour un parcours BUT") - if referentiel_competence is None: - raise ScoValueError( - "Pas de référentiel de compétences associé à la formation !" - ) - annee_formation = f"BUT{annee}" - if parcour is None: - return ApcNiveau.query.filter( - ApcNiveau.annee == annee_formation, - ApcCompetence.id == ApcNiveau.competence_id, - ApcCompetence.referentiel_id == referentiel_competence.id, - ) - else: - return ApcNiveau.query.filter( - ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, - ApcParcours.id == ApcAnneeParcours.parcours_id, - ApcParcours.referentiel == parcour.referentiel, - ApcParcoursNiveauCompetence.competence_id == ApcCompetence.id, - ApcCompetence.id == ApcNiveau.competence_id, - ApcAnneeParcours.parcours == parcour, - ApcNiveau.annee == annee_formation, - ) - - -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"), - primary_key=True, - ), -) - - -class ApcAppCritique(db.Model, XMLModel): - "Apprentissage Critique BUT" - id = db.Column(db.Integer, primary_key=True) - niveau_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"), 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, - ) -> flask_sqlalchemy.BaseQuery: - "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) -> dict: - 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"), 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"), 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(db.Model, XMLModel): - id = db.Column(db.Integer, primary_key=True) - referentiel_id = db.Column( - db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False - ) - numero = db.Column(db.Integer) # 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 - - -class ApcAnneeParcours(db.Model, XMLModel): - id = db.Column(db.Integer, primary_key=True) - parcours_id = db.Column( - db.Integer, db.ForeignKey("apc_parcours.id"), 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(db.Model): - """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}>" +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## +"""ScoDoc 9 models : Référentiel Compétence BUT 2021 +""" +from datetime import datetime + +import flask_sqlalchemy +from sqlalchemy.orm import class_mapper +import sqlalchemy + +from app import db + +from app.scodoc.sco_utils import ModuleType +from app.scodoc.sco_exceptions import ScoValueError + + +# 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(db.Model, 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"), index=True) + annexe = db.Column(db.Text()) + specialite = db.Column(db.Text()) + specialite_long = db.Column(db.Text()) + type_titre = db.Column(db.Text()) + type_structure = db.Column(db.Text()) + type_departement = db.Column(db.Text()) # "secondaire", "tertiaire" + version_orebut = db.Column(db.Text()) + _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.utcnow) + 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", + ) + formations = db.relationship("Formation", backref="referentiel_competence") + + def __repr__(self): + return f"" + + def to_dict(self): + """Représentation complète du ref. de comp. + comme un dict. + """ + 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() for x in self.competences}, + "parcours": {x.code: x.to_dict() for x in self.parcours}, + } + + def get_niveaux_by_parcours(self, annee) -> dict: + """ + Construit la liste des niveaux de compétences pour chaque parcours + de ce référentiel. + 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: + { + "TC" : [ ApcNiveau ], + parcour.id : [ ApcNiveau ] + } + """ + parcours = self.parcours.order_by(ApcParcours.numero).all() + niveaux_by_parcours = { + parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self) + for parcour in parcours + } + # 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 niveaux_by_parcours_no_tc + + +class ApcCompetence(db.Model, XMLModel): + "Compétence" + id = db.Column(db.Integer, primary_key=True) + referentiel_id = db.Column( + db.Integer, db.ForeignKey("apc_referentiel_competences.id"), 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) # 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): + "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() 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(db.Model, XMLModel): + "Situation professionnelle" + id = db.Column(db.Integer, primary_key=True) + competence_id = db.Column( + db.Integer, db.ForeignKey("apc_competence.id"), 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(db.Model, XMLModel): + "Composante essentielle" + id = db.Column(db.Integer, primary_key=True) + competence_id = db.Column( + db.Integer, db.ForeignKey("apc_competence.id"), nullable=False + ) + libelle = db.Column(db.Text(), nullable=False) + + def to_dict(self): + return {"libelle": self.libelle} + + +class ApcNiveau(db.Model, 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"), 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 to_dict(self): + "as a dict, recursif 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}, + } + + 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(), + } + + @classmethod + def niveaux_annee_de_parcours( + cls, + parcour: "ApcParcours", + annee: int, + referentiel_competence: ApcReferentielCompetences = None, + ) -> flask_sqlalchemy.BaseQuery: + """Les niveaux de l'année du parcours + Si le parcour est None, tous les niveaux de l'année + """ + if annee not in {1, 2, 3}: + raise ValueError("annee invalide pour un parcours BUT") + if referentiel_competence is None: + raise ScoValueError( + "Pas de référentiel de compétences associé à la formation !" + ) + annee_formation = f"BUT{annee}" + if parcour is None: + return ApcNiveau.query.filter( + ApcNiveau.annee == annee_formation, + ApcCompetence.id == ApcNiveau.competence_id, + ApcCompetence.referentiel_id == referentiel_competence.id, + ) + else: + return ApcNiveau.query.filter( + ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, + ApcParcours.id == ApcAnneeParcours.parcours_id, + ApcParcours.referentiel == parcour.referentiel, + ApcParcoursNiveauCompetence.competence_id == ApcCompetence.id, + ApcCompetence.id == ApcNiveau.competence_id, + ApcAnneeParcours.parcours == parcour, + ApcNiveau.annee == annee_formation, + ) + + +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"), + primary_key=True, + ), +) + + +class ApcAppCritique(db.Model, XMLModel): + "Apprentissage Critique BUT" + id = db.Column(db.Integer, primary_key=True) + niveau_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"), 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, + ) -> flask_sqlalchemy.BaseQuery: + "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) -> dict: + 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"), 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"), 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(db.Model, XMLModel): + "Un parcours BUT" + id = db.Column(db.Integer, primary_key=True) + referentiel_id = db.Column( + db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False + ) + numero = db.Column(db.Integer) # 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 + + +class ApcAnneeParcours(db.Model, XMLModel): + id = db.Column(db.Integer, primary_key=True) + parcours_id = db.Column( + db.Integer, db.ForeignKey("apc_parcours.id"), 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(db.Model): + """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}>" diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py index fd5db9b2..8cfce023 100644 --- a/tests/api/setup_test_api.py +++ b/tests/api/setup_test_api.py @@ -1,112 +1,112 @@ -# -*- coding: utf-8 -*- - -"""Test API - -Utilisation : - créer les variables d'environnement: (indiquer les valeurs - pour le serveur ScoDoc que vous voulez interroger) - - export SCODOC_URL="https://scodoc.xxx.net/" - export API_USER="xxx" - export SCODOC_PASSWD="xxx" - export CHECK_CERTIFICATE=0 # ou 1 si serveur de production avec certif SSL valide - - (on peut aussi placer ces valeurs dans un fichier .env du répertoire tests/api). -""" -import os -import requests -from dotenv import load_dotenv -import pytest - -# --- Lecture configuration (variables d'env ou .env) -try: - BASEDIR = os.path.abspath(os.path.dirname(__file__)) -except NameError: - BASEDIR = "/opt/scodoc/tests/api" - -load_dotenv(os.path.join(BASEDIR, ".env")) -CHECK_CERTIFICATE = bool(os.environ.get("CHECK_CERTIFICATE", False)) -SCODOC_URL = os.environ["SCODOC_URL"] or "http://localhost:5000" -API_URL = SCODOC_URL + "/ScoDoc/api" -API_USER = os.environ.get("API_USER", "test") -API_PASSWORD = os.environ.get("API_PASSWD", "test") -API_USER_ADMIN = os.environ.get("API_USER_ADMIN", "admin_api") -API_PASSWORD_ADMIN = os.environ.get("API_PASSWD_ADMIN", "admin_api") -DEPT_ACRONYM = "TAPI" -print(f"SCODOC_URL={SCODOC_URL}") -print(f"API URL={API_URL}") - - -class APIError(Exception): - def __init__(self, message: str = "", payload=None): - self.message = message - self.payload = payload or {} - - -def get_auth_headers(user, password) -> dict: - "Demande de jeton, dict à utiliser dans les en-têtes de requêtes http" - ans = requests.post(API_URL + "/tokens", auth=(user, password)) - if ans.status_code != 200: - raise APIError(f"Echec demande jeton par {user}") - token = ans.json()["token"] - return {"Authorization": f"Bearer {token}"} - - -@pytest.fixture -def api_headers() -> dict: - """Jeton, utilisateur API ordinaire""" - return get_auth_headers(API_USER, API_PASSWORD) - - -@pytest.fixture -def api_admin_headers() -> dict: - """Jeton, utilisateur API SuperAdmin""" - return get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN) - - -def GET(path: str, headers: dict = None, errmsg=None, dept=None): - """Get and returns as JSON - Special case for non json result (image or pdf): return Content-Disposition string (inline or attachment) - """ - if dept: - url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path - else: - url = API_URL + path - r = requests.get(url, headers=headers or {}, verify=CHECK_CERTIFICATE) - if r.status_code != 200: - raise APIError(errmsg or f"""erreur status={r.status_code} !""", r.json()) - - if r.headers.get("Content-Type", None) == "application/json": - return r.json() # decode la reponse JSON - elif r.headers.get("Content-Type", None) in [ - "image/jpg", - "image/png", - "application/pdf", - ]: - retval = { - "Content-Type": r.headers.get("Content-Type", None), - "Content-Disposition": r.headers.get("Content-Disposition", None), - } - return retval - else: - raise APIError( - "Unknown returned content {r.headers.get('Content-Type', None} !\n" - ) - return r.json() # decode la reponse JSON - - -def POST_JSON(path: str, data: dict = {}, headers: dict = None, errmsg=None, dept=None): - """Post""" - if dept: - url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path - else: - url = API_URL + path - r = requests.post( - url, - json=data, - headers=headers or {}, - verify=CHECK_CERTIFICATE, - ) - if r.status_code != 200: - raise APIError(errmsg or f"erreur status={r.status_code} !", r.json()) - return r.json() # decode la reponse JSON +# -*- coding: utf-8 -*- + +"""Test API + +Utilisation : + créer les variables d'environnement: (indiquer les valeurs + pour le serveur ScoDoc que vous voulez interroger) + + export SCODOC_URL="https://scodoc.xxx.net/" + export API_USER="xxx" + export SCODOC_PASSWD="xxx" + export CHECK_CERTIFICATE=0 # ou 1 si serveur de production avec certif SSL valide + + (on peut aussi placer ces valeurs dans un fichier .env du répertoire tests/api). +""" +import os +import requests +from dotenv import load_dotenv +import pytest + +# --- Lecture configuration (variables d'env ou .env) +try: + BASEDIR = os.path.abspath(os.path.dirname(__file__)) +except NameError: + BASEDIR = "/opt/scodoc/tests/api" + +load_dotenv(os.path.join(BASEDIR, ".env")) +CHECK_CERTIFICATE = bool(os.environ.get("CHECK_CERTIFICATE", False)) +SCODOC_URL = os.environ["SCODOC_URL"] or "http://localhost:5000" +API_URL = SCODOC_URL + "/ScoDoc/api" +API_USER = os.environ.get("API_USER", "test") +API_PASSWORD = os.environ.get("API_PASSWORD", os.environ.get("API_PASSWD", "test")) +API_USER_ADMIN = os.environ.get("API_USER_ADMIN", "admin_api") +API_PASSWORD_ADMIN = os.environ.get("API_PASSWD_ADMIN", "admin_api") +DEPT_ACRONYM = "TAPI" +print(f"SCODOC_URL={SCODOC_URL}") +print(f"API URL={API_URL}") + + +class APIError(Exception): + def __init__(self, message: str = "", payload=None): + self.message = message + self.payload = payload or {} + + +def get_auth_headers(user, password) -> dict: + "Demande de jeton, dict à utiliser dans les en-têtes de requêtes http" + ans = requests.post(API_URL + "/tokens", auth=(user, password)) + if ans.status_code != 200: + raise APIError(f"Echec demande jeton par {user}") + token = ans.json()["token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def api_headers() -> dict: + """Jeton, utilisateur API ordinaire""" + return get_auth_headers(API_USER, API_PASSWORD) + + +@pytest.fixture +def api_admin_headers() -> dict: + """Jeton, utilisateur API SuperAdmin""" + return get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN) + + +def GET(path: str, headers: dict = None, errmsg=None, dept=None): + """Get and returns as JSON + Special case for non json result (image or pdf): return Content-Disposition string (inline or attachment) + """ + if dept: + url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path + else: + url = API_URL + path + r = requests.get(url, headers=headers or {}, verify=CHECK_CERTIFICATE) + if r.status_code != 200: + raise APIError(errmsg or f"""erreur status={r.status_code} !""", r.json()) + + if r.headers.get("Content-Type", None) == "application/json": + return r.json() # decode la reponse JSON + elif r.headers.get("Content-Type", None) in [ + "image/jpg", + "image/png", + "application/pdf", + ]: + retval = { + "Content-Type": r.headers.get("Content-Type", None), + "Content-Disposition": r.headers.get("Content-Disposition", None), + } + return retval + else: + raise APIError( + "Unknown returned content {r.headers.get('Content-Type', None} !\n" + ) + return r.json() # decode la reponse JSON + + +def POST_JSON(path: str, data: dict = {}, headers: dict = None, errmsg=None, dept=None): + """Post""" + if dept: + url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path + else: + url = API_URL + path + r = requests.post( + url, + json=data, + headers=headers or {}, + verify=CHECK_CERTIFICATE, + ) + if r.status_code != 200: + raise APIError(errmsg or f"erreur status={r.status_code} !", r.json()) + return r.json() # decode la reponse JSON