1
0
forked from ScoDoc/ScoDoc

BUT: associe UE aux parcours. Modification pour #487.

This commit is contained in:
Emmanuel Viennet 2022-10-30 16:07:06 +01:00
parent 59fdc80d60
commit a3593d5a74
13 changed files with 380 additions and 139 deletions

View File

@ -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</a>
</div>
</div>"""
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"""<option value="{parcour.id}" {
'selected' if ue.parcour == parcour else ''}
>{parcour.libelle}
</option>"""
)
newline = "\n"
return f"""
<div class="ue_choix_niveau">
<form class="form_ue_choix_niveau">
<div class="cont_ue_choix_niveau">
<div>
<b>Parcours&nbsp;:</b>
<select class="select_parcour"
onchange="set_ue_parcour(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_parcours", scodoc_dept=g.scodoc_dept)
}">
<option value="" {
'selected' if ue.parcour is None else ''
}>Tous</option>
{newline.join(parcours_options)}
</select>
</div>
<div>
<b>Niveau de compétence&nbsp;:</b>
<select class="select_niveau_ue"
onchange="set_ue_niveau_competence(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
}">
</select>
</div>
</div>
</form>
</div>
"""
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"""<option value="{n.id}" {'selected'
if ue.niveau_competence == n else ''}
f"""<option value="{n.id}" {
'selected' if ue.niveau_competence == n else ''}
{disabled}>{n.annee} {n.competence.titre_long}
niveau {n.ordre}</option>"""
)
options.append("""</optgroup>""")
for parcour in ref_comp.parcours:
for parcour in parcours:
if len(niveaux_by_parcours[parcour.id]):
options.append(f"""<optgroup label="Parcours {parcour.libelle}">""")
for n in niveaux_by_parcours[parcour.id]:
@ -65,46 +124,7 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
niveau {n.ordre}</option>"""
)
options.append("""</optgroup>""")
options_str = "\n".join(options)
return f"""
<div class="ue_choix_niveau">
<form class="form_ue_choix_niveau">
<b>Niveau de compétence associé:</b>
<select onchange="set_ue_niveau_competence(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
}">
<option value="" {'selected' if ue.niveau_competence is None else ''}>aucun</option>
{options_str}
</select>
</form>
</div>
"""
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 (
f"""<option value="" {'selected' if ue.niveau_competence is None else ''}>aucun</option>"""
+ "\n".join(options)
)
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

View File

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

View File

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

View File

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

View File

@ -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} )")

View File

@ -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"""<div id="ue_list_modules">
<div><b>{ue.modules.count()} modules sont rattachés

View File

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

View File

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

View File

@ -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;
}
);
}
);
}

View File

@ -25,21 +25,20 @@
<a class="smallbutton" href="{{ url_for('notes.ue_delete',
scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}}">{% if editable and not ue.modules.count() %}{{icons.delete|safe}}{% else %}{{icons.delete_disabled|safe}}{% endif %}</a>
}}">{% if editable and not ue.modules.count() %}{{icons.delete|safe}}{% else
%}{{icons.delete_disabled|safe}}{% endif %}</a>
<span class="ue_type_{{ue.type}}">
<span class="ue_color_indicator" style="background:{{
ue.color if ue.color is not none else 'blue'}}"></span>
<b>{{ue.acronyme}} <a class="discretelink" href="{{
url_for('notes.ue_infos', scodoc_dept=g.scodoc_dept, ue_id=ue.id)}}"
title="{{ue.acronyme}}: {{
url_for('notes.ue_infos', scodoc_dept=g.scodoc_dept, ue_id=ue.id)}}" title="{{ue.acronyme}}: {{
('pas de compétence associée'
if ue.niveau_competence is none
else 'compétence ' + ue.niveau_competence.annee + ' ' + ue.niveau_competence.competence.titre_long)
if ue.type == 0
else ''
}}"
>{{ue.titre}}</a>
}}">{{ue.titre}}</a>
</b>
{% set virg = joiner(", ") %}
<span class="ue_code">(
@ -65,7 +64,7 @@
}}">modifier</a>
{% endif %}
{{ form_ue_choix_niveau(formation, ue)|safe }}
{{ form_ue_choix_niveau(ue)|safe }}
{% if ue.type == 1 and ue.modules.count() == 0 %}
@ -81,8 +80,7 @@
scodoc_dept=g.scodoc_dept,
formation_id=formation.id,
default_semestre_idx=semestre_idx,
)}}"
>ajouter une UE</a>
)}}">ajouter une UE</a>
</li>
</ul>
{% endif %}

View File

@ -33,11 +33,10 @@ Emmanuel Viennet, 2021
from operator import itemgetter
import time
from xml.etree import ElementTree
import flask
from flask import abort, flash, redirect, render_template, url_for
from flask import current_app, g, request
from flask import g, request
from flask_login import current_user
from app import db
@ -52,6 +51,7 @@ from app.but import jury_but_view
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import ScolarNews
from app.models.but_refcomp import ApcNiveau, ApcParcours
from app.models.config import ScoDocSiteConfig
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
@ -59,6 +59,7 @@ from app.models.formsemestre import FormSemestreUEComputationExpr
from app.models.moduleimpls import ModuleImpl
from app.models.modules import Module
from app.models.ues import UniteEns
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.views import notes_bp as bp
from app.decorators import (
@ -403,10 +404,50 @@ sco_publish(
@scodoc
@permission_required(Permission.ScoChangeFormation)
def set_ue_niveau_competence():
"associe UE et niveau"
"""Associe UE et niveau.
Si le niveau_id est "", désassocie."""
ue_id = request.form.get("ue_id")
niveau_id = request.form.get("niveau_id")
return apc_edit_ue.set_ue_niveau_competence(ue_id, niveau_id)
if niveau_id == "":
niveau_id = None
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
niveau = None if niveau_id is None else ApcNiveau.query.get_or_404(niveau_id)
try:
ue.set_niveau_competence(niveau)
except ScoFormationConflict:
return "", 409 # conflict
return "", 204
@bp.route("/set_ue_parcours", methods=["POST"])
@scodoc
@permission_required(Permission.ScoChangeFormation)
def set_ue_parcours():
"""Associe UE et parcours BUT.
Si le parcour_id est "", désassocie."""
ue_id = request.form.get("ue_id")
parcour_id = request.form.get("parcour_id")
if parcour_id == "":
parcour_id = None
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
parcour = None if parcour_id is None else ApcParcours.query.get_or_404(parcour_id)
try:
ue.set_parcour(parcour)
except ScoFormationConflict:
return "", 409 # conflict
return "", 204
@bp.route("/get_ue_niveaux_options_html")
@scodoc
@permission_required(Permission.ScoView)
def get_ue_niveaux_options_html():
"""fragment html avec les options du menu de sélection du
niveau de compétences associé à une UE
"""
ue_id = request.args.get("ue_id")
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
return apc_edit_ue.get_ue_niveaux_options_html(ue)
@bp.route("/ue_list") # backward compat

View File

@ -0,0 +1,34 @@
"""Association UE/Parcours
Revision ID: dbb4a0b19dbb
Revises: 6bc3f51154b4
Create Date: 2022-10-29 19:06:12.897905
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "dbb4a0b19dbb"
down_revision = "6bc3f51154b4"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("notes_ue", sa.Column("parcour_id", sa.Integer(), nullable=True))
op.create_index(
op.f("ix_notes_ue_parcour_id"), "notes_ue", ["parcour_id"], unique=False
)
op.create_foreign_key(None, "notes_ue", "apc_parcours", ["parcour_id"], ["id"])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "notes_ue", type_="foreignkey")
op.drop_index(op.f("ix_notes_ue_parcour_id"), table_name="notes_ue")
op.drop_column("notes_ue", "parcour_id")
# ### end Alembic commands ###

View File

@ -1,7 +1,7 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
SCOVERSION = "9.3.60"
SCOVERSION = "9.3.60-dev"
SCONAME = "ScoDoc"