diff --git a/app/but/change_refcomp.py b/app/but/change_refcomp.py index 22efb30fe..dc91d717b 100644 --- a/app/but/change_refcomp.py +++ b/app/but/change_refcomp.py @@ -17,53 +17,11 @@ from app.models import ( ApcValidationRCUE, Formation, FormSemestreInscription, - Module, UniteEns, ) from app.scodoc.sco_exceptions import ScoValueError -def map_referentiels( - ref1: ApcReferentielCompetences, ref2: ApcReferentielCompetences -) -> str | tuple[dict[int, int], dict[int, int], dict[int, int]]: - """Build mapping between two referentiels""" - if ref1.type_structure != ref2.type_structure: - return "type_structure mismatch" - if ref1.type_departement != ref2.type_departement: - return "type_departement mismatch" - # mêmes parcours ? - parcours_by_code_1 = {p.code: p for p in ref1.parcours} - parcours_by_code_2 = {p.code: p for p in ref2.parcours} - if parcours_by_code_1.keys() != parcours_by_code_2.keys(): - return "parcours mismatch" - parcours_map = { - parcours_by_code_1[code].id: parcours_by_code_2[code].id - for code in parcours_by_code_1 - } - # mêmes compétences ? - competence_by_code_1 = {c.titre: c for c in ref1.competences} - competence_by_code_2 = {c.titre: c for c in ref2.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 formation_change_referentiel( formation: Formation, new_ref: ApcReferentielCompetences ): @@ -73,7 +31,7 @@ def formation_change_referentiel( if not isinstance(new_ref, ApcReferentielCompetences): raise ScoValueError("nouveau référentiel invalide") - r = map_referentiels(formation.referentiel_competence, new_ref) + r = formation.referentiel_competence.map_to_other_referentiel(new_ref) if isinstance(r, str): raise ScoValueError(f"référentiels incompatibles: {r}") parcours_map, competences_map, niveaux_map = r diff --git a/app/but/forms/refcomp_forms.py b/app/but/forms/refcomp_forms.py index 52b9cfc17..dd7d557d4 100644 --- a/app/but/forms/refcomp_forms.py +++ b/app/but/forms/refcomp_forms.py @@ -10,9 +10,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed from wtforms import SelectField, SubmitField +from wtforms.validators import DataRequired class FormationRefCompForm(FlaskForm): + "Choix d'un référentiel" referentiel_competence = SelectField( "Choisir parmi les référentiels déjà chargés :" ) @@ -21,6 +23,7 @@ class FormationRefCompForm(FlaskForm): class RefCompLoadForm(FlaskForm): + "Upload d'un référentiel" referentiel_standard = SelectField( "Choisir un référentiel de compétences officiel BUT" ) @@ -47,3 +50,12 @@ class RefCompLoadForm(FlaskForm): ) return False return True + + +class FormationChangeRefCompForm(FlaskForm): + "choix d'un nouveau ref. comp. pour une formation" + object_select = SelectField( + "Choisir le nouveau référentiel", validators=[DataRequired()] + ) + submit = SubmitField("Changer le référentiel de la formation") + cancel = SubmitField("Annuler") diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index aa36c08d0..22d40785a 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -8,16 +8,19 @@ 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 +from app import db, log from app.scodoc.sco_utils import ModuleType -from app.scodoc.sco_exceptions import ScoNoReferentielCompetences +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 @@ -104,6 +107,11 @@ class ApcReferentielCompetences(db.Model, XMLModel): 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: @@ -124,9 +132,11 @@ class ApcReferentielCompetences(db.Model, XMLModel): "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_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) @@ -234,6 +244,92 @@ class ApcReferentielCompetences(db.Model, XMLModel): 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ê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 + """ + 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(db.Model, XMLModel): "Compétence" @@ -374,9 +470,11 @@ class ApcNiveau(db.Model, XMLModel): "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 {}, + "app_critiques": ( + {x.code: x.to_dict() for x in self.app_critiques} + if with_app_critiques + else {} + ), } def to_dict_bul(self): @@ -464,9 +562,9 @@ class ApcNiveau(db.Model, XMLModel): return [] if competence is None: - parcour_niveaux: list[ - ApcParcoursNiveauCompetence - ] = annee_parcour.niveaux_competences + 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 diff --git a/app/models/formations.py b/app/models/formations.py index fb7529e32..15e8fa15e 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -1,5 +1,7 @@ """ScoDoc 9 models : Formations """ + +from flask import abort, g from flask_sqlalchemy.query import Query import app @@ -64,6 +66,21 @@ class Formation(db.Model): "titre complet pour affichage" return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}""" + @classmethod + def get_formation(cls, formation_id: int | str, dept_id: int = None) -> "Formation": + """Formation ou 404, cherche uniquement dans le département spécifié + ou le courant (g.scodoc_dept)""" + if not isinstance(formation_id, int): + try: + formation_id = int(formation_id) + except (TypeError, ValueError): + abort(404, "formation_id invalide") + if g.scodoc_dept: + dept_id = dept_id if dept_id is not None else g.scodoc_dept_id + if dept_id is not None: + return cls.query.filter_by(id=formation_id, dept_id=dept_id).first_or_404() + return cls.query.filter_by(id=formation_id).first_or_404() + def to_dict(self, with_refcomp_attrs=False, with_departement=True): """As a dict. Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp. diff --git a/app/templates/but/change_refcomp.j2 b/app/templates/but/change_refcomp.j2 new file mode 100644 index 000000000..b4c0a033b --- /dev/null +++ b/app/templates/but/change_refcomp.j2 @@ -0,0 +1,58 @@ +{# -*- mode: jinja-html -*- #} +{% extends "base.j2" %} +{% import 'wtf.j2' as wtf %} + +{% block app_content %} +

Changer le référentiel de compétences de la formation + {{formation.get_titre_version()}} +

+ +
+ +

Normalement, il n'est pas possible de changer une +formation de référentiel de compétences. En effet, de nombreux éléments sont +déduis du référentiel: les parcours, les compétences, les apprentissages +critiques, et donc les validations de jury des étudiants qui ont suivi la +formation. +

+ +

Cependant, dans certains cas, le ministère a publié une nouvelle version +d'un référentiel de spécialité, mais les changements ne sont que très légers: +détails des noms de parcours ou des compétences. Dans ces cas là, la structure +étant la même, il est autorisé de changer le référentiel. +

+ +

Seuls les référentiels déjà chargés et compatibles (ayant la même structure) +sont proposés dans le menu ci-dessous. +

+ +

+Attention: tout changement de référentiel entraine la perte des associations +entre les modules et les apprentissages critiques. +

+ +

+Attention: fonction expérimentale. Vérifiez vos sauvegardes. +

+ +
+ +
La formation est actuellement associée au référentiel + {{formation.referentiel_competence.get_title()}} +
+ +
+ {{ form.hidden_tag() }} +
+ {{ form.object_select.label }}
+ {{ form.object_select }} +
+
{{ form.submit() }} {{ form.cancel() }}
+
+ + +{% endblock %} diff --git a/app/templates/but/refcomp_show.j2 b/app/templates/but/refcomp_show.j2 index bd152aab4..f2a460f2c 100644 --- a/app/templates/but/refcomp_show.j2 +++ b/app/templates/but/refcomp_show.j2 @@ -27,9 +27,18 @@
  • Formations se référant à ce référentiel: