forked from ScoDoc/ScoDoc
Associer une formation BUT à un nouveau référentiel 'équivalent'.
This commit is contained in:
parent
3f6e65b9da
commit
5a5ddcacd7
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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"<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:
|
||||
"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
|
||||
|
@ -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.
|
||||
|
58
app/templates/but/change_refcomp.j2
Normal file
58
app/templates/but/change_refcomp.j2
Normal 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 %}
|
@ -27,9 +27,18 @@
|
||||
<li>Formations se référant à ce référentiel:
|
||||
<ul>
|
||||
{% for formation in ref.formations %}
|
||||
<li><a class="stdlink" href="{{
|
||||
url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id )
|
||||
}}">{{ formation.get_titre_version() }}</a></li>
|
||||
<li>
|
||||
<a class="stdlink" href="{{
|
||||
url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, formation_id=formation.id )
|
||||
}}">{{ 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 %}
|
||||
<li><em>aucune</em></li>
|
||||
{% endfor %}
|
||||
|
@ -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/<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",
|
||||
)
|
||||
|
13
ressources/referentiels/equivalences.yaml
Normal file
13
ressources/referentiels/equivalences.yaml
Normal 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
|
Loading…
Reference in New Issue
Block a user