diff --git a/app/but/apc_edit_ue.py b/app/but/apc_edit_ue.py
index f553fa0fab..183bc699ab 100644
--- a/app/but/apc_edit_ue.py
+++ b/app/but/apc_edit_ue.py
@@ -8,14 +8,14 @@
Edition associations UE <-> Ref. Compétence
"""
from flask import g, url_for
-from app import db, log
-from app.models import Formation, UniteEns
-from app.models.but_refcomp import ApcNiveau
+from app.models import ApcReferentielCompetences, Formation, UniteEns
from app.scodoc import sco_codes_parcours
-def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
- """Form. HTML pour associer une UE à un niveau de compétence"""
+def form_ue_choix_niveau(ue: UniteEns) -> str:
+ """Form. HTML pour associer une UE à un niveau de compétence.
+ Le menu select lui meême est vide et rempli en JS par appel à get_ue_niveaux_options_html
+ """
if ue.type != sco_codes_parcours.UE_STANDARD:
return ""
ref_comp = ue.formation.referentiel_competence
@@ -27,11 +27,70 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
}">associer un référentiel de compétence
"""
- annee = 1 if ue.semestre_idx is None else (ue.semestre_idx + 1) // 2 # 1, 2, 3
- niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
+ # Les parcours:
+ parcours_options = []
+ for parcour in ref_comp.parcours:
+ parcours_options.append(
+ f""""""
+ )
+
+ newline = "\n"
+ return f"""
+
+
+
+ """
+
+
+def get_ue_niveaux_options_html(ue: UniteEns) -> str:
+ """fragment html avec les options du menu de sélection du
+ niveau de compétences associé à une UE.
+
+ Si l'UE n'a pas de parcours associé: présente les niveaux
+ de tous les parcours.
+ Si l'UE a un parcours: seulement les niveaux de ce parcours.
+ """
+ ref_comp: ApcReferentielCompetences = ue.formation.referentiel_competence
+ if ref_comp is None:
+ return ""
+ # Les niveaux:
+ annee = ue.annee() # 1, 2, 3
+ parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(
+ annee, parcour=ue.parcour
+ )
# Les niveaux déjà associés à d'autres UE du même semestre
- autres_ues = formation.ues.filter_by(semestre_idx=ue.semestre_idx)
+ autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
niveaux_autres_ues = {
oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id
}
@@ -44,13 +103,13 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
else:
disabled = ""
options.append(
- f""""""
)
options.append("""""")
- for parcour in ref_comp.parcours:
+ for parcour in parcours:
if len(niveaux_by_parcours[parcour.id]):
options.append(f"""""")
- options_str = "\n".join(options)
- return f"""
-
-
-
- """
-
-
-def set_ue_niveau_competence(ue_id: int, niveau_id: int):
- """Associe le niveau et l'UE"""
- ue = UniteEns.query.get_or_404(ue_id)
-
- autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
- niveaux_autres_ues = {
- oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id
- }
- if niveau_id in niveaux_autres_ues:
- log(
- f"set_ue_niveau_competence: denying association of {ue} to already associated {niveau_id}"
- )
- return "", 409 # conflict
- if niveau_id == "":
- niveau = ""
- # suppression de l'association
- ue.niveau_competence = None
- else:
- niveau = ApcNiveau.query.get_or_404(niveau_id)
- ue.niveau_competence = niveau
- db.session.add(ue)
- db.session.commit()
- log(f"set_ue_niveau_competence( {ue}, {niveau} )")
-
- return "", 204
+ return (
+ f""""""
+ + "\n".join(options)
+ )
diff --git a/app/but/jury_but.py b/app/but/jury_but.py
index 15ae29d90a..a94a5c36f9 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -774,7 +774,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def list_ue_parcour_etud(
formsemestre: FormSemestre, etud: Identite, res: ResultatsSemestreBUT
) -> tuple[ApcParcours, list[UniteEns]]:
- """Parcour dans lequel l'étudiant est inscrit, et liste des UEs pour ce semestre"""
+ """Parcour dans lequel l'étudiant est inscrit,
+ et liste des UEs à valider pour ce semestre
+ """
if res.etuds_parcour_id[etud.id] is None:
parcour = None
# pas de parcour: prend toutes les UEs (non bonus)
diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py
index dd995331df..1b59c84c31 100644
--- a/app/models/but_refcomp.py
+++ b/app/models/but_refcomp.py
@@ -107,11 +107,15 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"parcours": {x.code: x.to_dict() for x in self.parcours},
}
- def get_niveaux_by_parcours(self, annee) -> dict:
+ def get_niveaux_by_parcours(
+ self, annee, parcour: "ApcParcours" = None
+ ) -> tuple[list["ApcParcours"], dict]:
"""
Construit la liste des niveaux de compétences pour chaque parcours
- de ce référentiel.
+ de ce référentiel, ou seulement pour le parcours donné.
+
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).
@@ -122,10 +126,14 @@ class ApcReferentielCompetences(db.Model, XMLModel):
parcour.id : [ ApcNiveau ]
}
"""
- parcours = self.parcours.order_by(ApcParcours.numero).all()
+ parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
+ if parcour is None:
+ parcours = parcours_ref
+ else:
+ parcours = [parcour]
niveaux_by_parcours = {
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
- for parcour in parcours
+ for parcour in parcours_ref
}
# Cherche tronc commun
if niveaux_by_parcours:
@@ -154,7 +162,7 @@ class ApcReferentielCompetences(db.Model, XMLModel):
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
+ return parcours, niveaux_by_parcours_no_tc
class ApcCompetence(db.Model, XMLModel):
@@ -436,6 +444,7 @@ class ApcParcours(db.Model, XMLModel):
lazy="dynamic",
cascade="all, delete-orphan",
)
+ ues = db.relationship("UniteEns", back_populates="parcour")
def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"
diff --git a/app/models/formations.py b/app/models/formations.py
index 4f048d0d4f..985b81fc5c 100644
--- a/app/models/formations.py
+++ b/app/models/formations.py
@@ -205,8 +205,9 @@ class Formation(db.Model):
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
"""
return UniteEns.query.filter_by(formation=self).filter(
- UniteEns.niveau_competence_id == ApcNiveau.id,
UniteEns.type == UE_STANDARD,
+ UniteEns.niveau_competence_id == ApcNiveau.id,
+ (UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == parcour.id,
diff --git a/app/models/ues.py b/app/models/ues.py
index dcc4aabbbc..0de7e83b04 100644
--- a/app/models/ues.py
+++ b/app/models/ues.py
@@ -1,9 +1,11 @@
"""ScoDoc 9 models : Unités d'Enseignement (UE)
"""
-from app import db
+from app import db, log
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
+from app.models.but_refcomp import ApcNiveau, ApcParcours
+from app.scodoc.sco_exceptions import ScoFormationConflict
from app.scodoc import sco_utils as scu
@@ -49,6 +51,9 @@ class UniteEns(db.Model):
niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"))
niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
+ parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True)
+ parcour = db.relationship("ApcParcours", back_populates="ues")
+
# relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
modules = db.relationship("Module", lazy="dynamic", backref="ue")
@@ -83,6 +88,12 @@ class UniteEns(db.Model):
e.pop("module_ue_coefs", None)
return e
+ def annee(self) -> int:
+ """L'année dans la formation (commence à 1).
+ En APC seulement, en classic renvoie toujours 1.
+ """
+ return 1 if self.semestre_idx is None else (self.semestre_idx - 1) // 2 + 1
+
def is_locked(self):
"""True if UE should not be modified
(contains modules used in a locked formsemestre)
@@ -135,3 +146,72 @@ class UniteEns(db.Model):
if self.code_apogee:
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
+
+ def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int):
+ "raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre"
+ # Les UE du même semestre que nous:
+ ues_sem = self.formation.ues.filter_by(semestre_idx=self.semestre_idx)
+ if (new_niveau_id, new_parcour_id) in (
+ (oue.niveau_competence_id, oue.parcour_id)
+ for oue in ues_sem
+ if oue.id != self.id
+ ):
+ log(
+ f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé"
+ )
+ raise ScoFormationConflict()
+
+ def set_niveau_competence(self, niveau: ApcNiveau):
+ """Associe cette UE au niveau de compétence indiqué.
+ Le niveau doit être dans le parcours de l'UE, s'il y en a un.
+ Assure que ce soit la seule dans son parcours.
+ Sinon, raises ScoFormationConflict.
+
+ Si niveau est None, désassocie.
+ """
+ if niveau is not None:
+ self._check_apc_conflict(niveau.id, self.parcour_id)
+ # Le niveau est-il dans le parcours ? Sinon, erreur
+ if self.parcour and niveau.id not in (
+ n.id
+ for n in niveau.niveaux_annee_de_parcours(
+ self.parcour, self.annee(), self.formation.referentiel_competence
+ )
+ ):
+ log(
+ f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}"
+ )
+ return
+
+ self.niveau_competence = niveau
+
+ db.session.add(self)
+ db.session.commit()
+ log(f"ue.set_niveau_competence( {self}, {niveau} )")
+
+ def set_parcour(self, parcour: ApcParcours):
+ """Associe cette UE au parcours indiqué.
+ Assure que ce soit la seule dans son parcours.
+ Sinon, raises ScoFormationConflict.
+
+ Si niveau est None, désassocie.
+ """
+ if (parcour is not None) and self.niveau_competence is not None:
+ self._check_apc_conflict(self.niveau_competence.id, parcour.id)
+ self.parcour = parcour
+ # Le niveau est-il dans ce parcours ? Sinon, l'enlève
+ if (
+ parcour
+ and self.niveau_competence
+ and self.niveau_competence.id
+ not in (
+ n.id
+ for n in self.niveau_competence.niveaux_annee_de_parcours(
+ parcour, self.annee(), self.formation.referentiel_competence
+ )
+ )
+ ):
+ self.niveau_competence = None
+ db.session.add(self)
+ db.session.commit()
+ log(f"ue.set_parcour( {self}, {parcour} )")
diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index 830e92d6bc..37806a0065 100644
--- a/app/scodoc/sco_edit_ue.py
+++ b/app/scodoc/sco_edit_ue.py
@@ -458,7 +458,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
if tf[0] == 0:
niveau_competence_div = ""
if ue and is_apc:
- niveau_competence_div = apc_edit_ue.form_ue_choix_niveau(formation, ue)
+ niveau_competence_div = apc_edit_ue.form_ue_choix_niveau(ue)
if ue and ue.modules.count() and ue.semestre_idx is not None:
modules_div = f"""
{ue.modules.count()} modules sont rattachés
diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py
index 77e60c9109..856c963f9d 100644
--- a/app/scodoc/sco_exceptions.py
+++ b/app/scodoc/sco_exceptions.py
@@ -172,8 +172,9 @@ class ScoInvalidDateError(ScoValueError):
pass
-# Pour les API JSON
class APIInvalidParams(Exception):
+ """Exception pour les API JSON"""
+
status_code = 400
def __init__(self, message, status_code=None, payload=None):
@@ -184,6 +185,11 @@ class APIInvalidParams(Exception):
self.payload = payload
def to_dict(self):
+ "dict"
rv = dict(self.payload or ())
rv["message"] = self.message
return rv
+
+
+class ScoFormationConflict(Exception):
+ """Conflit cohérence formation (APC)"""
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index b5eac2543e..22e0395acf 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -2282,6 +2282,25 @@ div.formation_list_ues div.ue_choix_niveau b {
font-weight: normal;
}
+div.cont_ue_choix_niveau {
+ display: inline-flex;
+ flex-wrap: wrap;
+}
+
+div.cont_ue_choix_niveau>div {
+ display: inline-flex;
+ margin-left: 8px;
+ align-items: center;
+}
+
+div.cont_ue_choix_niveau select {
+ margin-left: 4px;
+}
+
+div.cont_ue_choix_niveau select.select_niveau_ue {
+ width: 490px;
+}
+
div#ue_list_modules {
background-color: rgb(251, 225, 165);
border: 1px solid blue;
diff --git a/app/static/js/edit_ue.js b/app/static/js/edit_ue.js
index c85ffacf17..ebaaf139d5 100644
--- a/app/static/js/edit_ue.js
+++ b/app/static/js/edit_ue.js
@@ -11,6 +11,7 @@ $().ready(function () {
});
update_bonus_description();
}
+ update_menus_niveau_competence();
});
function update_bonus_description() {
@@ -36,6 +37,22 @@ function update_ue_list() {
});
}
+function set_ue_parcour(elem) {
+ let ue_id = elem.dataset.ue_id;
+ let parcour_id = elem.value;
+ let set_ue_parcour_url = elem.dataset.setter;
+ $.post(set_ue_parcour_url,
+ {
+ ue_id: ue_id,
+ parcour_id: parcour_id,
+ },
+ function (result) {
+ sco_message("UE associée au parcours");
+ update_menus_niveau_competence();
+ }
+ );
+}
+
function set_ue_niveau_competence(elem) {
let ue_id = elem.dataset.ue_id;
let niveau_id = elem.value;
@@ -46,7 +63,6 @@ function set_ue_niveau_competence(elem) {
niveau_id: niveau_id,
},
function (result) {
- // alert("niveau de compétence enregistré"); // XXX #frontend à améliorer
sco_message("niveau de compétence enregistré");
update_menus_niveau_competence();
@@ -57,18 +73,33 @@ function set_ue_niveau_competence(elem) {
// Met à jour les niveaux utilisés (disabled) ou non affectés
// dans les menus d'association UE <-> niveau
function update_menus_niveau_competence() {
- let selected_niveaux = [];
- document.querySelectorAll("form.form_ue_choix_niveau select").forEach(
- elem => { selected_niveaux.push(elem.value); }
- );
+ // let selected_niveaux = [];
+ // document.querySelectorAll("form.form_ue_choix_niveau select").forEach(
+ // elem => { selected_niveaux.push(elem.value); }
+ // );
- document.querySelectorAll("form.form_ue_choix_niveau select").forEach(
+ // document.querySelectorAll("form.form_ue_choix_niveau select").forEach(
+ // elem => {
+ // for (let i = 0; i < elem.options.length; i++) {
+ // elem.options[i].disabled = (i != elem.options.selectedIndex)
+ // && (selected_niveaux.indexOf(elem.options[i].value) != -1)
+ // && (elem.options[i].value != "");
+ // }
+ // }
+ // );
+
+ // nouveau:
+ document.querySelectorAll("select.select_niveau_ue").forEach(
elem => {
- for (let i = 0; i < elem.options.length; i++) {
- elem.options[i].disabled = (i != elem.options.selectedIndex)
- && (selected_niveaux.indexOf(elem.options[i].value) != -1)
- && (elem.options[i].value != "");
- }
+ let ue_id = elem.dataset.ue_id;
+ $.get("get_ue_niveaux_options_html",
+ {
+ ue_id: ue_id,
+ },
+ function (result) {
+ elem.innerHTML = result;
+ }
+ );
}
);
}
\ No newline at end of file
diff --git a/app/templates/pn/form_ues.html b/app/templates/pn/form_ues.html
index 6e56d2f77e..dfe5856ac2 100644
--- a/app/templates/pn/form_ues.html
+++ b/app/templates/pn/form_ues.html
@@ -3,88 +3,86 @@