From 5a5ddcacd7bc8df47fee6ade96f01caf41451e4c Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 5 Apr 2024 23:41:34 +0200 Subject: [PATCH] =?UTF-8?q?Associer=20une=20formation=20BUT=20=C3=A0=20un?= =?UTF-8?q?=20nouveau=20r=C3=A9f=C3=A9rentiel=20'=C3=A9quivalent'.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/change_refcomp.py | 44 +------- app/but/forms/refcomp_forms.py | 12 +++ app/models/but_refcomp.py | 120 ++++++++++++++++++++-- app/models/formations.py | 17 +++ app/templates/but/change_refcomp.j2 | 58 +++++++++++ app/templates/but/refcomp_show.j2 | 15 ++- app/views/refcomp.py | 69 ++++++++++++- ressources/referentiels/equivalences.yaml | 13 +++ 8 files changed, 289 insertions(+), 59 deletions(-) create mode 100644 app/templates/but/change_refcomp.j2 create mode 100644 ressources/referentiels/equivalences.yaml 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:
      {% for formation in ref.formations %} -
    • {{ formation.get_titre_version() }}
    • +
    • + {{ formation.get_titre_version() }} + {% if referentiels_equivalents %} + (changer) + {% endif %} +
    • {% else %}
    • aucune
    • {% endfor %} diff --git a/app/views/refcomp.py b/app/views/refcomp.py index f956bde78..b6402a260 100644 --- a/app/views/refcomp.py +++ b/app/views/refcomp.py @@ -3,6 +3,7 @@ PN / Référentiel de compétences Emmanuel Viennet, 2021 """ + from pathlib import Path import re @@ -19,11 +20,20 @@ from app import db, log from app.decorators import scodoc, permission_required from app.models import Formation from app.models.but_refcomp import ApcReferentielCompetences +from app.but import change_refcomp from app.but.import_refcomp import orebut_import_refcomp -from app.but.forms.refcomp_forms import FormationRefCompForm, RefCompLoadForm +from app.but.forms.refcomp_forms import ( + FormationChangeRefCompForm, + FormationRefCompForm, + RefCompLoadForm, +) from app.scodoc.gen_tables import GenTable from app.scodoc import sco_utils as scu -from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError +from app.scodoc.sco_exceptions import ( + ScoFormatError, + ScoNoReferentielCompetences, + ScoValueError, +) from app.scodoc.sco_permissions import Permission from app.views import notes_bp as bp from app.views import ScoData @@ -47,9 +57,12 @@ def refcomp(refcomp_id): def refcomp_show(refcomp_id): """Affichage du référentiel de compétences.""" referentiel_competence = ApcReferentielCompetences.query.get_or_404(refcomp_id) + # Autres référentiels "équivalents" pour proposer de changer les formations: + referentiels_equivalents = referentiel_competence.equivalents() return render_template( "but/refcomp_show.j2", ref=referentiel_competence, + referentiels_equivalents=referentiels_equivalents, title="Référentiel de compétences", data_source=url_for( "notes.refcomp", @@ -279,3 +292,55 @@ def refcomp_load(formation_id=None): formation=formation, title="Chargement réf. compétences", ) + + +@bp.route("/formation//change_refcomp", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.EditFormation) +def formation_change_refcomp(formation_id: int): + """Tente de changer le ref. de comp. de la formation""" + formation = Formation.get_formation(formation_id) + ref_comp: ApcReferentielCompetences = formation.referentiel_competence + if ref_comp is None: + raise ScoNoReferentielCompetences(formation=formation) + # Autres référentiels "équivalents" pour proposer de changer les formations: + referentiels_equivalents = ref_comp.equivalents() + form = FormationChangeRefCompForm() + form.object_select.choices = [ + (ref.id, ref.get_title()) for ref in referentiels_equivalents + ] + if request.method == "POST" and form.cancel.data: # cancel button + return redirect( + url_for( + "notes.refcomp_show", + scodoc_dept=g.scodoc_dept, + refcomp_id=formation.referentiel_competence.id, + ) + ) + if form.validate_on_submit(): + try: + new_ref_id = int(form.object_select.data) + except TypeError as exc: + raise ScoValueError("nouveau refcomp id invalide") from exc + new_ref = None + for ref in referentiels_equivalents: + if ref.id == new_ref_id: + new_ref = ref + break + if new_ref is None: + raise ScoValueError("nouveau refcomp invalide") + change_refcomp.formation_change_referentiel(formation, new_ref) + flash("Formation changée de référentiel") + return redirect( + url_for( + "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id + ) + ) + + return render_template( + "but/change_refcomp.j2", + form=form, + formation=formation, + referentiels_equivalents=referentiels_equivalents, + title="Changer de référentiel de compétences", + ) diff --git a/ressources/referentiels/equivalences.yaml b/ressources/referentiels/equivalences.yaml new file mode 100644 index 000000000..a938afbdc --- /dev/null +++ b/ressources/referentiels/equivalences.yaml @@ -0,0 +1,13 @@ +# Certains référentiels de compétences peuvent être considérés +# comme équivalents +# Si ils ont la même structure +# Voir ApcReferentielCompetences.map_to_other_referentiel + +# Mappings: nouveau : ancien +QLIO: # la clé est 'specialite' + parcours: # codes de parcours + OSC: MSC + QMI: MQSE + # competences: # titres de compétences ('nom_court' dans le XML) + +SD: STID