From 3b984ea823fb9abc89eba451339d639e5e58efa8 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Thu, 17 Oct 2024 23:44:54 +0200
Subject: [PATCH] =?UTF-8?q?Edition=20UEs:=20assouplie=20verrouillage=20en?=
 =?UTF-8?q?=20BUT.=20+=20d=C3=A9but=20modernisation=20code.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/formations/edit_ue.py | 218 ++++++++++++++------------------------
 app/models/ues.py         |  60 ++++++++++-
 2 files changed, 134 insertions(+), 144 deletions(-)

diff --git a/app/formations/edit_ue.py b/app/formations/edit_ue.py
index 3712a854..1b9e3e33 100644
--- a/app/formations/edit_ue.py
+++ b/app/formations/edit_ue.py
@@ -45,7 +45,6 @@ from app.models import (
     FormSemestreUECoef,
     Matiere,
     Module,
-    ModuleImpl,
     UniteEns,
 )
 from app.models import ApcValidationRCUE, ScolarFormSemestreValidation, ScolarEvent
@@ -546,6 +545,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
                     formation_id, int(tf[2]["semestre_idx"])
                 )
             ue_id = do_ue_create(tf[2])
+            matiere_id = None
             if is_apc or cursus.UE_IS_MODULE or tf[2]["create_matiere"]:
                 # rappel: en APC, toutes les UE ont une matière, créée ici
                 # (inutilisée mais à laquelle les modules sont rattachés)
@@ -597,46 +597,21 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
     )
 
 
-def _add_ue_semestre_id(ues: list[dict], is_apc):
-    """ajoute semestre_id dans les ue, en regardant
-    semestre_idx ou à défaut, pour les formations non APC, le premier module
-    de chacune.
-    Les UE sans modules se voient attribuer  le numero UE_SEM_DEFAULT (1000000),
-    qui les place à la fin de la liste.
-    """
-    for ue in ues:
-        if ue["semestre_idx"] is not None:
-            ue["semestre_id"] = ue["semestre_idx"]
-        elif is_apc:
-            ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT
-        else:
-            # était le comportement ScoDoc7
-            ue = UniteEns.get_ue(ue["ue_id"])
-            module = ue.modules.first()
-            if module:
-                ue["semestre_id"] = module.semestre_id
-            else:
-                ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT
-
-
-def next_ue_numero(formation_id, semestre_id=None):
+def next_ue_numero(formation_id, semestre_id=None) -> int:
     """Numero d'une nouvelle UE dans cette formation.
     Si le semestre est specifie, cherche les UE ayant des modules de ce semestre
     """
     formation = db.session.get(Formation, formation_id)
-    ues = ue_list(args={"formation_id": formation_id})
+    ues = formation.ues.all()
     if not ues:
         return 0
     if semestre_id is None:
-        return ues[-1]["numero"] + 1000
-    else:
-        # Avec semestre: (prend le semestre du 1er module de l'UE)
-        _add_ue_semestre_id(ues, formation.get_cursus().APC_SAE)
-        ue_list_semestre = [ue for ue in ues if ue["semestre_id"] == semestre_id]
-        if ue_list_semestre:
-            return ue_list_semestre[-1]["numero"] + 10
-        else:
-            return ues[-1]["numero"] + 1000
+        return ues[-1].numero + 1000
+    # Avec semestre: (prend le semestre du 1er module de l'UE)
+    ue_list_semestre = [ue for ue in ues if ue.get_semestre_id() == semestre_id]
+    if ue_list_semestre:
+        return ue_list_semestre[-1].numero + 10
+    return ues[-1].numero + 1000
 
 
 def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
@@ -698,20 +673,22 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""):
     show_tags = scu.to_bool(request.args.get("show_tags", 0))
     locked = formation.has_locked_sems(semestre_idx)
     semestre_ids = range(1, parcours.NB_SEM + 1)
-    # transition: on requete ici via l'ORM mais on utilise les fonctions ScoDoc7
-    # basées sur des dicts
-    ues_obj = UniteEns.query.filter_by(
-        formation_id=formation_id, is_external=False
-    ).order_by(UniteEns.semestre_idx, UniteEns.numero)
+
+    ues = (
+        formation.ues.filter_by(is_external=False)
+        .order_by(UniteEns.semestre_idx, UniteEns.numero)
+        .all()
+    )
+
     # safety check: renumérote les ue s'il en manque ou s'il y a des ex-aequo.
     # cela facilite le travail de la passerelle !
-    numeros = {ue.numero for ue in ues_obj}
-    if (None in numeros) or len(numeros) < ues_obj.count():
-        scu.objects_renumber(db, ues_obj)
+    numeros = {ue.numero for ue in ues}
+    if (None in numeros) or len(numeros) < len(ues):
+        scu.objects_renumber(db, ues)
 
-    ues_externes_obj = UniteEns.query.filter_by(
+    ues_externes = UniteEns.query.filter_by(
         formation_id=formation_id, is_external=True
-    )
+    ).all()
     # liste ordonnée des formsemestres de cette formation:
     formsemestres = sorted(
         FormSemestre.query.filter_by(formation_id=formation_id).all(),
@@ -721,29 +698,25 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""):
 
     if is_apc:
         # Pour faciliter la transition des anciens programmes non APC
-        for ue in ues_obj:
+        for ue in ues:
             ue.guess_semestre_idx()
             # vérifie qu'on a bien au moins une matière dans chaque UE
             if ue.matieres.count() < 1:
                 mat = Matiere(ue_id=ue.id)
                 db.session.add(mat)
         # donne des couleurs aux UEs crées avant
-        colorie_anciennes_ues(ues_obj)
+        colorie_anciennes_ues(ues)
         db.session.commit()
-    ues = [ue.to_dict() for ue in ues_obj]
-    ues_externes = [ue.to_dict() for ue in ues_externes_obj]
 
     # tri par semestre et numero:
-    _add_ue_semestre_id(ues, is_apc)
-    _add_ue_semestre_id(ues_externes, is_apc)
-    ues.sort(key=lambda u: (u["semestre_id"], u["numero"]))
-    ues_externes.sort(key=lambda u: (u["semestre_id"], u["numero"]))
+    ues.sort(key=lambda u: (u.get_semestre_id(), u.numero))
+    ues_externes.sort(key=lambda u: (u.get_semestre_id(), u.numero))
     # Codes dupliqués (pour aider l'utilisateur)
     seen = set()
     duplicated_codes = {
-        ue["ue_code"] for ue in ues if ue["ue_code"] in seen or seen.add(ue["ue_code"])
+        ue.ue_code for ue in ues if ue.ue_code in seen or seen.add(ue.ue_code)
     }
-    ues_with_duplicated_code = [ue for ue in ues if ue["ue_code"] in duplicated_codes]
+    ues_with_duplicated_code = [ue for ue in ues if ue.ue_code in duplicated_codes]
 
     has_perm_change = current_user.has_permission(Permission.EditFormation)
     # editable = (not locked) and has_perm_change
@@ -799,8 +772,8 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
             formation ont le même code : <tt>{
                 ', '.join([
                 '<a class="stdlink" href="' + url_for( "notes.ue_edit",
-                    scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"] )
-                + '">' + ue["acronyme"] + " (code " + ue["ue_code"] + ")</a>"
+                    scodoc_dept=g.scodoc_dept, ue_id=ue.id )
+                + '">' + ue.acronyme + " (code " + ue.ue_code + ")</a>"
                 for ue in ues_with_duplicated_code ])
             }</tt>.
             Il faut corriger cela, sinon les capitalisations et ECTS seront
@@ -1115,7 +1088,7 @@ def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx):
 
 def _ue_table_ues(
     parcours,
-    ues: list[dict],
+    ues: list[UniteEns],
     editable,
     tag_editable,
     has_perm_change,
@@ -1132,33 +1105,25 @@ def _ue_table_ues(
     cur_ue_semestre_id = None
     iue = 0
     for ue in ues:
-        if ue["ects"] is None:
-            ue["ects_str"] = ""
-        else:
-            ue["ects_str"] = ", %g ECTS" % ue["ects"]
-        if editable:
-            klass = "span_apo_edit"
-        else:
-            klass = ""
+        ects_str = "" if ue.ects is None else f", {ue.ects:g} ECTS"
+        klass = "span_apo_edit" if editable else ""
         edit_url = url_for(
             "apiweb.ue_set_code_apogee",
             scodoc_dept=g.scodoc_dept,
-            ue_id=ue["ue_id"],
+            ue_id=ue.id,
         )
-        ue[
-            "code_apogee_str"
-        ] = f""", Apo: <span
-            class="{klass}" data-url="{edit_url}" id="{ue['ue_id']}"
+        code_apogee_str = f""", Apo: <span
+            class="{klass}" data-url="{edit_url}" id="{ue.id}"
             data-placeholder="{scu.APO_MISSING_CODE_STR}">{
-                ue["code_apogee"] or ""
+                ue.code_apogee or ""
             }</span>"""
 
-        if cur_ue_semestre_id != ue["semestre_id"]:
-            cur_ue_semestre_id = ue["semestre_id"]
-            if ue["semestre_id"] == codes_cursus.UE_SEM_DEFAULT:
+        if cur_ue_semestre_id != ue.semestre_id:
+            cur_ue_semestre_id = ue.semestre_id
+            if ue.semestre_id == codes_cursus.UE_SEM_DEFAULT:
                 lab = "Pas d'indication de semestre:"
             else:
-                lab = f"""Semestre {ue["semestre_id"]}:"""
+                lab = f"""Semestre {ue.semestre_id}:"""
             H.append(
                 f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>'
             )
@@ -1166,52 +1131,55 @@ def _ue_table_ues(
         H.append('<li class="notes_ue_list">')
         if iue != 0 and editable:
             H.append(
-                '<a href="ue_move?ue_id=%s&after=0" class="aud">%s</a>'
-                % (ue["ue_id"], arrow_up)
+                f"""<a href="{
+                    url_for( 'notes.ue_move',
+                        scodoc_dept=g.scodoc_dept, ue_id=ue.id, after=0)}"
+                    class="aud">{arrow_up}</a>"""
             )
         else:
             H.append(arrow_none)
         if iue < len(ues) - 1 and editable:
             H.append(
-                '<a href="ue_move?ue_id=%s&after=1" class="aud">%s</a>'
-                % (ue["ue_id"], arrow_down)
+                f"""<a href="{
+                    url_for( 'notes.ue_move',
+                        scodoc_dept=g.scodoc_dept, ue_id=ue.id, after=1)}"
+                    class="aud">{arrow_down}</a>"""
             )
         else:
             H.append(arrow_none)
-        ue["acro_titre"] = str(ue["acronyme"])
-        if ue["titre"] != ue["acronyme"]:
-            ue["acro_titre"] += " " + str(ue["titre"])
+        acro_titre = ue.acronyme
+        if ue.titre != ue.acronyme:
+            acro_titre += " " + (ue.titre or "")
         H.append(
-            """%(acro_titre)s <span class="ue_code">(code %(ue_code)s%(ects_str)s, coef. %(coefficient)3.2f%(code_apogee_str)s)</span>
+            f"""acro_titre <span class="ue_code">(code {ue.ue_code}{ects_str}, coef. {
+                (ue.coefficient or 0):3.2f}{code_apogee_str})</span>
             <span class="ue_coef"></span>
             """
-            % ue
         )
-        if ue["type"] != codes_cursus.UE_STANDARD:
+        if ue.type != codes_cursus.UE_STANDARD:
             H.append(
-                '<span class="ue_type">%s</span>'
-                % codes_cursus.UE_TYPE_NAME[ue["type"]]
+                f"""<span class="ue_type">{codes_cursus.UE_TYPE_NAME[ue.type]}</span>"""
             )
-        if ue["is_external"]:
+        if ue.is_external:
             # Cas spécial: si l'UE externe a plus d'un module, c'est peut être une UE
             # qui a été déclarée externe par erreur (ou suite à un bug d'import/export xml)
             # Dans ce cas, propose de changer le type (même si verrouillée)
-            if len(sco_moduleimpl.moduleimpls_in_external_ue(ue["ue_id"])) > 1:
+            if len(sco_moduleimpl.moduleimpls_in_external_ue(ue.id)) > 1:
                 H.append('<span class="ue_is_external">')
                 if has_perm_change:
                     H.append(
                         f"""<a class="stdlink" href="{
                             url_for("notes.ue_set_internal",
-                                scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"])
+                                scodoc_dept=g.scodoc_dept, ue_id=ue.id)
                             }">transformer en UE ordinaire</a>&nbsp;"""
                     )
                 H.append("</span>")
-        ue_locked, ue_locked_reason = ue_is_locked(ue["ue_id"])
+        ue_locked, ue_locked_reason = ue.is_locked()
         ue_editable = editable and not ue_locked
         if ue_editable:
             H.append(
                 f"""<a class="stdlink" href="{
-                        url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"])
+                        url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue.id)
                     }">modifier</a>"""
             )
         else:
@@ -1231,11 +1199,14 @@ def _ue_table_ues(
                 delete_disabled_icon,
             )
         )
-        if (iue >= len(ues) - 1) or ue["semestre_id"] != ues[iue + 1]["semestre_id"]:
+        if (iue >= len(ues) - 1) or (
+            ue.get_semestre_id() != ues[iue + 1].get_semestre_id()
+        ):
             H.append(
-                f"""</ul><ul><li><a href="{url_for('notes.ue_create', scodoc_dept=g.scodoc_dept,
-                formation_id=ue['formation_id'], semestre_idx=ue['semestre_id'])
-            }">Ajouter une UE dans le semestre {ue['semestre_id'] or ''}</a></li></ul>
+                f"""</ul><ul><li><a href="{
+                    url_for('notes.ue_create', scodoc_dept=g.scodoc_dept,
+                    formation_id=ue.formation_id, semestre_idx=ue.semestre_id)
+            }">Ajouter une UE dans le semestre {ue.semestre_id or ''}</a></li></ul>
             </div>
             """
             )
@@ -1246,7 +1217,7 @@ def _ue_table_ues(
 
 def _ue_table_matieres(
     parcours,
-    ue_dict: dict,
+    ue: UniteEns,
     editable,
     tag_editable,
     arrow_up,
@@ -1256,7 +1227,6 @@ def _ue_table_matieres(
     delete_disabled_icon,
 ):
     """Édition de programme: liste des matières (et leurs modules) d'une UE."""
-    ue = UniteEns.get_ue(ue_dict["ue_id"])
     H = []
     if not parcours.UE_IS_MODULE:
         H.append('<ul class="notes_matiere_list">')
@@ -1503,16 +1473,18 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
     "edit an UE"
     # check
     ue_id = args["ue_id"]
-    ue = ue_list({"ue_id": ue_id})[0]
+    ue = UniteEns.get_ue(ue_id)
     if not bypass_lock:
-        ue_locked, ue_locked_reason = ue_is_locked(ue["ue_id"])
+        ue_locked, ue_locked_reason = ue.is_locked()
         if ue_locked:
             raise ScoLockedFormError(msg=f"UE verrouillée: {ue_locked_reason}")
     # check: acronyme unique dans cette formation
     if "acronyme" in args:
         new_acro = args["acronyme"]
-        ues = ue_list({"formation_id": ue["formation_id"], "acronyme": new_acro})
-        if ues and ues[0]["ue_id"] != ue_id:
+        ues = UniteEns.query.filter_by(
+            formation_id=ue.formation_id, acronyme=new_acro
+        ).all()
+        if ues and ues[0].id != ue_id:
             raise ScoValueError(
                 f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
                 (chaque UE doit avoir un acronyme unique dans la formation.)"""
@@ -1521,48 +1493,12 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
     if "ue_code" in args and not args["ue_code"]:
         del args["ue_code"]
 
-    cnx = ndb.GetDBConnexion()
-    _ueEditor.edit(cnx, args)
-
-    formation = db.session.get(Formation, ue["formation_id"])
+    ue.from_dict(args)
+    db.session.commit()
     if not dont_invalidate_cache:
         # Invalide les semestres utilisant cette formation
         # ainsi que les poids et coefs
-        formation.invalidate_module_coefs()
-
-
-def ue_is_locked(ue_id: int) -> tuple[bool, str]:
-    """True if UE should not be modified:
-    utilisée dans un formsemestre verrouillé ou validations de jury de cette UE.
-    Renvoie aussi une explication.
-    """
-    # before 9.7.23: contains modules used in a locked formsemestre
-    # starting from 9.7.23: + existence de validations de jury de cette UE
-    ue = UniteEns.query.get(ue_id)
-    if not ue:
-        return True, "inexistante"
-    if ue.formation.is_apc():
-        # en APC, interdit toute modification d'UE si utilisée dans un semestre verrouillé
-        if False in [formsemestre.etat for formsemestre in ue.formation.formsemestres]:
-            return True, "utilisée dans un semestre verrouillé"
-    else:
-        # en classique: interdit si contient des modules utilisés dans des semestres verrouillés
-        # en effet, dans certaines (très anciennes) formations, une UE peut avoir des modules de
-        # différents semestre
-        if (
-            Module.query.filter(Module.ue_id == ue_id)
-            .join(Module.modimpls)
-            .join(ModuleImpl.formsemestre)
-            .filter_by(etat=False)
-            .count()
-        ):
-            return True, "avec modules utilisés dans des semestres verrouillés"
-
-    nb_validations = ScolarFormSemestreValidation.query.filter_by(ue_id=ue_id).count()
-    if nb_validations > 0:
-        return True, f"avec {nb_validations} validations de jury"
-
-    return False, ""
+        ue.formation.invalidate_module_coefs()
 
 
 UE_PALETTE = [
diff --git a/app/models/ues.py b/app/models/ues.py
index 3e74b88b..f7ad5ea7 100644
--- a/app/models/ues.py
+++ b/app/models/ues.py
@@ -10,6 +10,7 @@ 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.models.modules import Module
+from app.scodoc import codes_cursus
 from app.scodoc import sco_utils as scu
 
 
@@ -185,10 +186,45 @@ class UniteEns(models.ScoDocModel):
         return 1 if self.semestre_idx is None else (self.semestre_idx - 1) // 2 + 1
 
     def is_locked(self) -> tuple[bool, str]:
-        """True if UE should not be modified"""
-        from app.formations import edit_ue
+        """True if UE should not be modified:
+        utilisée dans un formsemestre verrouillé ou validations de jury de cette UE.
+        Renvoie aussi une explication.
+        """
+        from app.models import FormSemestre, ModuleImpl, ScolarFormSemestreValidation
+
+        # before 9.7.23: contains modules used in a locked formsemestre
+        # starting from 9.7.23: + existence de validations de jury de cette UE
+        if self.formation.is_apc():
+            # en APC, interdit toute modification d'UE si il y a un formsemestre verrouillé
+            # de cette formation ayant le semestre de cette UE.
+            # (ne détaille pas les parcours, donc si un semestre Sn d'un parcours est verrouillé
+            # cela va verrouiller toutes les UE d'indice Sn, même si pas de ce parcours)
+            # modifié en 9.7.28
+            locked_sems = self.formation.formsemestres.filter_by(
+                etat=False, semestre_id=self.semestre_idx
+            )
+            if locked_sems.count():
+                return True, "utilisée dans un semestre verrouillé"
+        else:
+            # en classique: interdit si contient des modules utilisés dans des semestres verrouillés
+            # en effet, dans certaines (très anciennes) formations, une UE peut avoir des modules de
+            # différents semestre
+            if (
+                Module.query.filter(Module.ue_id == self.id)
+                .join(Module.modimpls)
+                .join(ModuleImpl.formsemestre)
+                .filter_by(etat=False)
+                .count()
+            ):
+                return True, "avec modules utilisés dans des semestres verrouillés"
+
+        nb_validations = ScolarFormSemestreValidation.query.filter_by(
+            ue_id=self.id
+        ).count()
+        if nb_validations > 0:
+            return True, f"avec {nb_validations} validations de jury"
 
-        return edit_ue.ue_is_locked(self.id)
+        return False, ""
 
     def can_be_deleted(self) -> bool:
         """True si l'UE n'a pas de moduleimpl rattachés
@@ -214,6 +250,24 @@ class UniteEns(models.ScoDocModel):
             db.session.commit()
         return self.semestre_idx
 
+    def get_semestre_id(self) -> int:
+        """L'indice du semestre de l'UE.
+        Regarde semestre_idx ou, pour les formations non APC,
+        le premier module de chacune.
+        Les UE sans modules se voient attribuer le numero UE_SEM_DEFAULT (1000000),
+        qui les place à la fin de la liste.
+        Contrairement à guess_semestre_idx, ne modifie pas l'UE.
+        """
+        if self.semestre_idx is not None:
+            return self.semestre_idx
+        if self.formation.is_apc():
+            return codes_cursus.UE_SEM_DEFAULT
+        # était le comportement ScoDoc7
+        module = self.modules.first()
+        if module:
+            return module.semestre_id
+        return codes_cursus.UE_SEM_DEFAULT
+
     def get_ects(self, parcour: ApcParcours = None, only_parcours=False) -> float:
         """Crédits ECTS associés à cette UE.
         En BUT, cela peut quelquefois dépendre du parcours.
-- 
GitLab