diff --git a/app/but/apc_edit_ue.py b/app/but/apc_edit_ue.py index f553fa0f..183bc699 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""" +
+
+
+
+ Parcours : + +
+
+ Niveau de compétence : + +
+
+
+
+ """ + + +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"""""") 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}""" ) options.append("""""") - options_str = "\n".join(options) - return f""" -
-
- Niveau de compétence associé: - -
-
- """ - - -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 15ae29d9..a94a5c36 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 dd995331..1b59c84c 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 4f048d0d..985b81fc 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 dcc4aabb..0de7e83b 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 830e92d6..37806a00 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 77e60c91..856c963f 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 b5eac254..22e0395a 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 c85ffacf..ebaaf139 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 6e56d2f7..dfe5856a 100644 --- a/app/templates/pn/form_ues.html +++ b/app/templates/pn/form_ues.html @@ -3,88 +3,86 @@
Unités d'Enseignement (UEs)
{% for semestre_idx in semestre_ids %} -
Semestre S{{semestre_idx}} (ECTS: {{ects_by_sem[semestre_idx] | safe}})
-
+
    + {% for ue in ues_by_sem[semestre_idx] %} +
  • + {% if editable and not loop.first %} + {{icons.arrow_up|safe}} - {% else %} - {{icons.arrow_none|safe}} - {% endif %} - {% if editable and not loop.last %} - {{icons.arrow_down|safe}} - {% else %} - {{icons.arrow_none|safe}} - {% endif %} - - - {% if editable and not ue.modules.count() %}{{icons.delete|safe}}{% else %}{{icons.delete_disabled|safe}}{% endif %} - - + }}">{% if editable and not ue.modules.count() %}{{icons.delete|safe}}{% else + %}{{icons.delete_disabled|safe}}{% endif %} + + {{ue.acronyme}} {{ue.titre}} + }}">{{ue.titre}} - {% set virg = joiner(", ") %} - ( - {%- if ue.ue_code -%}{{ virg() }}code {{ue.ue_code}} {%- endif -%} - {{ virg() }} - {%- if ue.type == 0 -%} - {{ue.ects - if ue.ects is not none - else 'aucun'|safe - }} ECTS - {%- endif -%} - {%- if ue.code_apogee -%} - {{ virg() }} Apo {{ue.code_apogee}} - {%- endif -%} + {% set virg = joiner(", ") %} + ( + {%- if ue.ue_code -%}{{ virg() }}code {{ue.ue_code}} {%- endif -%} + {{ virg() }} + {%- if ue.type == 0 -%} + {{ue.ects + if ue.ects is not none + else 'aucun'|safe + }} ECTS + {%- endif -%} + {%- if ue.code_apogee -%} + {{ virg() }} Apo {{ue.code_apogee}} + {%- endif -%} ) - - - - {% if editable and not ue.is_locked() %} - modifier - {% endif %} + {% endif %} - {{ form_ue_choix_niveau(formation, ue)|safe }} + {{ form_ue_choix_niveau(ue)|safe }} - {% if ue.type == 1 and ue.modules.count() == 0 %} - aucun module rattaché ! - {% endif %} -
  • - {% endfor %} -
- {% if editable %} - + {% if editable %} + - {% endif %} - {% endfor %} + )}}">ajouter une UE + + + {% endif %} + {% endfor %}
\ No newline at end of file diff --git a/app/views/notes.py b/app/views/notes.py index 3fca69d9..1b2da4a6 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -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 diff --git a/migrations/versions/dbb4a0b19dbb_association_ue_parcours.py b/migrations/versions/dbb4a0b19dbb_association_ue_parcours.py new file mode 100644 index 00000000..42cd8116 --- /dev/null +++ b/migrations/versions/dbb4a0b19dbb_association_ue_parcours.py @@ -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 ### diff --git a/sco_version.py b/sco_version.py index a4c2177c..6951f910 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.3.60" +SCOVERSION = "9.3.60-dev" SCONAME = "ScoDoc"