Associer une formation BUT à un nouveau référentiel 'équivalent'.

This commit is contained in:
Emmanuel Viennet 2024-04-05 23:41:34 +02:00
parent 3f6e65b9da
commit 5a5ddcacd7
8 changed files with 289 additions and 59 deletions

View File

@ -17,53 +17,11 @@ from app.models import (
ApcValidationRCUE, ApcValidationRCUE,
Formation, Formation,
FormSemestreInscription, FormSemestreInscription,
Module,
UniteEns, UniteEns,
) )
from app.scodoc.sco_exceptions import ScoValueError 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( def formation_change_referentiel(
formation: Formation, new_ref: ApcReferentielCompetences formation: Formation, new_ref: ApcReferentielCompetences
): ):
@ -73,7 +31,7 @@ def formation_change_referentiel(
if not isinstance(new_ref, ApcReferentielCompetences): if not isinstance(new_ref, ApcReferentielCompetences):
raise ScoValueError("nouveau référentiel invalide") 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): if isinstance(r, str):
raise ScoValueError(f"référentiels incompatibles: {r}") raise ScoValueError(f"référentiels incompatibles: {r}")
parcours_map, competences_map, niveaux_map = r parcours_map, competences_map, niveaux_map = r

View File

@ -10,9 +10,11 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed from flask_wtf.file import FileField, FileAllowed
from wtforms import SelectField, SubmitField from wtforms import SelectField, SubmitField
from wtforms.validators import DataRequired
class FormationRefCompForm(FlaskForm): class FormationRefCompForm(FlaskForm):
"Choix d'un référentiel"
referentiel_competence = SelectField( referentiel_competence = SelectField(
"Choisir parmi les référentiels déjà chargés :" "Choisir parmi les référentiels déjà chargés :"
) )
@ -21,6 +23,7 @@ class FormationRefCompForm(FlaskForm):
class RefCompLoadForm(FlaskForm): class RefCompLoadForm(FlaskForm):
"Upload d'un référentiel"
referentiel_standard = SelectField( referentiel_standard = SelectField(
"Choisir un référentiel de compétences officiel BUT" "Choisir un référentiel de compétences officiel BUT"
) )
@ -47,3 +50,12 @@ class RefCompLoadForm(FlaskForm):
) )
return False return False
return True 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")

View File

@ -8,16 +8,19 @@
from datetime import datetime from datetime import datetime
import functools import functools
from operator import attrgetter from operator import attrgetter
import yaml
from flask import g from flask import g
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper from sqlalchemy.orm import class_mapper
import sqlalchemy import sqlalchemy
from app import db from app import db, log
from app.scodoc.sco_utils import ModuleType 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 # 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): def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>" 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: def get_version(self) -> str:
"La version, normalement sous forme de date iso yyy-mm-dd" "La version, normalement sous forme de date iso yyy-mm-dd"
if not self.version_orebut: if not self.version_orebut:
@ -124,9 +132,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"type_departement": self.type_departement, "type_departement": self.type_departement,
"type_titre": self.type_titre, "type_titre": self.type_titre,
"version_orebut": self.version_orebut, "version_orebut": self.version_orebut,
"scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z" "scodoc_date_loaded": (
self.scodoc_date_loaded.isoformat() + "Z"
if self.scodoc_date_loaded if self.scodoc_date_loaded
else "", else ""
),
"scodoc_orig_filename": self.scodoc_orig_filename, "scodoc_orig_filename": self.scodoc_orig_filename,
"competences": { "competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques) x.titre: x.to_dict(with_app_critiques=with_app_critiques)
@ -234,6 +244,92 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return parcours_info 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): class ApcCompetence(db.Model, XMLModel):
"Compétence" "Compétence"
@ -374,9 +470,11 @@ class ApcNiveau(db.Model, XMLModel):
"libelle": self.libelle, "libelle": self.libelle,
"annee": self.annee, "annee": self.annee,
"ordre": self.ordre, "ordre": self.ordre,
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques} "app_critiques": (
{x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques if with_app_critiques
else {}, else {}
),
} }
def to_dict_bul(self): def to_dict_bul(self):
@ -464,9 +562,9 @@ class ApcNiveau(db.Model, XMLModel):
return [] return []
if competence is None: if competence is None:
parcour_niveaux: list[ parcour_niveaux: list[ApcParcoursNiveauCompetence] = (
ApcParcoursNiveauCompetence annee_parcour.niveaux_competences
] = annee_parcour.niveaux_competences )
niveaux: list[ApcNiveau] = [ niveaux: list[ApcNiveau] = [
pn.competence.niveaux.filter_by(ordre=pn.niveau).first() pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
for pn in parcour_niveaux for pn in parcour_niveaux

View File

@ -1,5 +1,7 @@
"""ScoDoc 9 models : Formations """ScoDoc 9 models : Formations
""" """
from flask import abort, g
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
import app import app
@ -64,6 +66,21 @@ class Formation(db.Model):
"titre complet pour affichage" "titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}""" 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): def to_dict(self, with_refcomp_attrs=False, with_departement=True):
"""As a dict. """As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp. Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.

View File

@ -0,0 +1,58 @@
{# -*- mode: jinja-html -*- #}
{% extends "base.j2" %}
{% import 'wtf.j2' as wtf %}
{% block app_content %}
<h1>Changer le référentiel de compétences de la formation
{{formation.get_titre_version()}}
</h1>
<div class="help">
<p> 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.
</p>
<p> 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.
</p>
<p>Seuls les référentiels déjà chargés et compatibles (ayant la même structure)
sont proposés dans le menu ci-dessous.
</p>
<p class="fontred">
Attention: tout changement de référentiel entraine la perte des associations
entre les modules et les apprentissages critiques.
</p>
<p class="fontred">
Attention: <b>fonction expérimentale</b>. Vérifiez vos sauvegardes.
</p>
</div>
<div>La formation est actuellement associée au référentiel
<a class="stdlink" href="{{
url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept,
refcomp_id=formation.referentiel_competence.id)
}}">{{formation.referentiel_competence.get_title()}}</a>
</div>
<form method="POST" style="margin-top: 24px;">
{{ form.hidden_tag() }}
<div>
{{ form.object_select.label }}<br>
{{ form.object_select }}
</div>
<div style="margin-top: 24px;">{{ form.submit() }} {{ form.cancel() }}</div>
</form>
{% endblock %}

View File

@ -27,9 +27,18 @@
<li>Formations se référant à ce référentiel: <li>Formations se référant à ce référentiel:
<ul> <ul>
{% for formation in ref.formations %} {% for formation in ref.formations %}
<li><a class="stdlink" href="{{ <li>
<a class="stdlink" href="{{
url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id ) url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id )
}}">{{ formation.get_titre_version() }}</a></li> }}">{{ formation.get_titre_version() }}</a>
{% if referentiels_equivalents %}
<a style="margin-left: 8px;" class="stdlink" href="
{{ url_for('notes.formation_change_refcomp',
scodoc_dept=g.scodoc_dept, formation_id=formation.id )
}}
">(changer)</a>
{% endif %}
</li>
{% else %} {% else %}
<li><em>aucune</em></li> <li><em>aucune</em></li>
{% endfor %} {% endfor %}

View File

@ -3,6 +3,7 @@ PN / Référentiel de compétences
Emmanuel Viennet, 2021 Emmanuel Viennet, 2021
""" """
from pathlib import Path from pathlib import Path
import re import re
@ -19,11 +20,20 @@ from app import db, log
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.models import Formation from app.models import Formation
from app.models.but_refcomp import ApcReferentielCompetences 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.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.gen_tables import GenTable
from app.scodoc import sco_utils as scu 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.scodoc.sco_permissions import Permission
from app.views import notes_bp as bp from app.views import notes_bp as bp
from app.views import ScoData from app.views import ScoData
@ -47,9 +57,12 @@ def refcomp(refcomp_id):
def refcomp_show(refcomp_id): def refcomp_show(refcomp_id):
"""Affichage du référentiel de compétences.""" """Affichage du référentiel de compétences."""
referentiel_competence = ApcReferentielCompetences.query.get_or_404(refcomp_id) 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( return render_template(
"but/refcomp_show.j2", "but/refcomp_show.j2",
ref=referentiel_competence, ref=referentiel_competence,
referentiels_equivalents=referentiels_equivalents,
title="Référentiel de compétences", title="Référentiel de compétences",
data_source=url_for( data_source=url_for(
"notes.refcomp", "notes.refcomp",
@ -279,3 +292,55 @@ def refcomp_load(formation_id=None):
formation=formation, formation=formation,
title="Chargement réf. compétences", title="Chargement réf. compétences",
) )
@bp.route("/formation/<int:formation_id>/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",
)

View File

@ -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