From 29defb6f00a293fb61e3e4425633f4dfdd8f26b6 Mon Sep 17 00:00:00 2001
From: ilona <ilona@scodoc.org>
Date: Mon, 14 Oct 2024 16:40:05 +0200
Subject: [PATCH] =?UTF-8?q?Backend=20'Modules':=20utilise=20uniquement=20m?=
 =?UTF-8?q?od=C3=A8les.=20+=20code=20modernization.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/formations/edit_module.py               | 284 ++++----------
 app/formations/edit_ue.py                   |  26 +-
 app/formations/formation_io.py              |  31 +-
 app/models/formations.py                    |   7 +-
 app/models/moduleimpls.py                   |  29 +-
 app/models/modules.py                       | 128 +++++-
 app/pe/moys/pe_ressemtag.py                 |  11 +-
 app/scodoc/sco_bulletins_json.py            |   6 +-
 app/scodoc/sco_formsemestre_edit.py         |  26 +-
 app/scodoc/sco_formsemestre_status.py       |   1 -
 app/scodoc/sco_moduleimpl.py                |  12 -
 app/scodoc/sco_placement.py                 |   9 +-
 app/scodoc/sco_ue_external.py               |  13 +-
 app/views/notes.py                          |  16 +-
 app/views/scolar.py                         |   1 -
 sco_version.py                              |   2 +
 tests/api/setup_test_api.py                 |   5 +-
 tests/api/test_api_formations.py            |   2 +-
 tests/api/tools_test_api.py                 | 408 ++++++++++----------
 tests/scenarios/test_scenario1_formation.py |  11 +-
 tests/unit/sco_fake_gen.py                  |  18 +-
 tests/unit/test_formations.py               |  26 +-
 22 files changed, 525 insertions(+), 547 deletions(-)

diff --git a/app/formations/edit_module.py b/app/formations/edit_module.py
index 71fd3ade7..b8b389d9a 100644
--- a/app/formations/edit_module.py
+++ b/app/formations/edit_module.py
@@ -33,168 +33,28 @@ from flask import flash, url_for, render_template
 from flask import g, request
 from flask_login import current_user
 
-from app import db, log
+from app import db
 from app import models
-from app.formations import edit_matiere
 from app.models import APO_CODE_STR_LEN
 from app.models import Formation, Matiere, Module, UniteEns
 from app.models import FormSemestre, ModuleImpl
-from app.models import ScolarNews
 from app.models.but_refcomp import ApcAppCritique, ApcParcours
 
-import app.scodoc.notesdb as ndb
 import app.scodoc.sco_utils as scu
 from app.scodoc.TrivialFormulator import TrivialFormulator
 from app.scodoc.sco_permissions import Permission
 from app.scodoc.sco_exceptions import (
     ScoValueError,
-    ScoLockedFormError,
-    ScoGenError,
     ScoNonEmptyFormationObject,
 )
 from app.scodoc import codes_cursus
-from app.scodoc import sco_moduleimpl
-
-_moduleEditor = ndb.EditableTable(
-    "notes_modules",
-    "module_id",
-    (
-        "module_id",
-        "titre",
-        "code",
-        "abbrev",
-        "heures_cours",
-        "heures_td",
-        "heures_tp",
-        "coefficient",
-        "ue_id",
-        "matiere_id",
-        "formation_id",
-        "semestre_id",
-        "numero",
-        "code_apogee",
-        "module_type",
-        "edt_id",
-        #'ects'
-    ),
-    sortkey="numero, code, titre",
-    output_formators={
-        "heures_cours": ndb.float_null_is_zero,
-        "heures_td": ndb.float_null_is_zero,
-        "heures_tp": ndb.float_null_is_zero,
-        "numero": ndb.int_null_is_zero,
-        "coefficient": ndb.float_null_is_zero,
-        "module_type": ndb.int_null_is_zero,
-        #'ects' : ndb.float_null_is_null
-    },
-)
-
-
-def module_list(*args, **kw):
-    "list modules"
-    cnx = ndb.GetDBConnexion()
-    return _moduleEditor.list(cnx, *args, **kw)
-
-
-def do_module_create(args) -> int:
-    "Create a module. Returns id of new object."
-    formation = db.session.get(Formation, args["formation_id"])
-    # refuse de créer un module APC avec semestres incohérents:
-    if formation.is_apc():
-        ue = db.session.get(UniteEns, args["ue_id"])
-        if int(args.get("semestre_id", 0)) != ue.semestre_idx:
-            raise ScoValueError("Formation incompatible: contacter le support ScoDoc")
-    # create
-    module = Module.create_from_dict(args)
-    db.session.commit()
-    log(f"do_module_create: created {module.id} with {args}")
-
-    # news
-    ScolarNews.add(
-        typ=ScolarNews.NEWS_FORM,
-        obj=formation.id,
-        text=f"Modification de la formation {formation.acronyme}",
-    )
-    formation.invalidate_cached_sems()
-    return module.id
-
-
-def module_create(
-    matiere_id=None, module_type=None, semestre_id=None, formation_id=None
-):
-    """Formulaire de création d'un module
-    Si matiere_id est spécifié, le module sera créé dans cette matière (cas normal).
-    Sinon, donne le choix de l'UE de rattachement et utilise la première
-    matière de cette UE (si elle n'existe pas, la crée).
-    """
-    return module_edit(
-        create=True,
-        matiere_id=matiere_id,
-        module_type=module_type,
-        semestre_id=semestre_id,
-        formation_id=formation_id,
-    )
-
-
-def can_delete_module(module):
-    "True si le module n'est pas utilisée dans des formsemestre"
-    return len(module.modimpls.all()) == 0
-
-
-def do_module_delete(oid):
-    "delete module"
-    module = Module.query.get_or_404(oid)
-    mod = module_list({"module_id": oid})[0]  # sco7
-    if module_is_locked(module.id):
-        raise ScoLockedFormError()
-    if not can_delete_module(module):
-        raise ScoNonEmptyFormationObject(
-            "Module",
-            msg=module.titre,
-            dest_url=url_for(
-                "notes.ue_table",
-                scodoc_dept=g.scodoc_dept,
-                formation_id=module.formation_id,
-                semestre_idx=module.ue.semestre_idx,
-            ),
-        )
-
-    # S'il y a des moduleimpls, on ne peut pas detruire le module !
-    mods = sco_moduleimpl.moduleimpl_list(module_id=oid)
-    if mods:
-        err_page = f"""
-        <h3>Destruction du module impossible car il est utilisé dans des
-         semestres existants !</h3>
-        <p class="help">Il faut d'abord supprimer le semestre (ou en retirer
-        ce module).
-        Mais il est peut être préférable de laisser ce programme intact et
-        d'en créer une nouvelle version pour la modifier sans affecter
-        les semestres déjà en place.
-        </p>
-        <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
-            formation_id=mod["formation_id"])}">reprendre</a>
-        """
-        raise ScoGenError(err_page)
-    # delete
-    cnx = ndb.GetDBConnexion()
-    _moduleEditor.delete(cnx, oid)
-
-    # news
-    formation = module.formation
-    ScolarNews.add(
-        typ=ScolarNews.NEWS_FORM,
-        obj=mod["formation_id"],
-        text=f"Modification de la formation {formation.acronyme}",
-    )
-    formation.invalidate_cached_sems()
 
 
 def module_delete(module_id=None):
-    """Delete a module"""
+    """Formulaire suppression d'un module"""
     module = Module.query.get_or_404(module_id)
-    mod = module_list(args={"module_id": module_id})[0]  # sco7
 
-    if not can_delete_module(module):
+    if not module.can_be_deleted():
         raise ScoNonEmptyFormationObject(
             "Module",
             msg=module.titre,
@@ -221,7 +81,7 @@ def module_delete(module_id=None):
         request.base_url,
         scu.get_request_args(),
         (("module_id", {"input_type": "hidden"}),),
-        initvalues=mod,
+        initvalues=module.to_dict(),
         submitlabel="Confirmer la suppression",
         cancelbutton="Annuler",
     )
@@ -231,37 +91,38 @@ def module_delete(module_id=None):
             title="Suppression d'un module",
             content="\n".join(H) + tf[1],
         )
-    elif tf[0] == -1:
-        return flask.redirect(dest_url)
-    else:
-        do_module_delete(module_id)
+    if tf[0] == -1:  # cancel
         return flask.redirect(dest_url)
 
+    module.delete()
+    return flask.redirect(dest_url)
+
 
 def do_module_edit(vals: dict) -> None:
     "edit a module"
     # check
-    mod = module_list({"module_id": vals["module_id"]})[0]
-    if module_is_locked(mod["module_id"]):
-        # formation verrouillée: empeche de modifier certains champs:
-        vals = vals.copy()
-        protected_fields = ("coefficient", "ue_id", "matiere_id", "semestre_id")
-        for f in protected_fields:
-            if f in vals:
-                del vals[f]
+    module = Module.get_instance(vals["module_id"])
     # edit
-    cnx = ndb.GetDBConnexion()
-    _moduleEditor.edit(cnx, vals)
-    db.session.get(Formation, mod["formation_id"]).invalidate_cached_sems()
-
+    modif = module.from_dict(vals)
+    if modif:
+        module.formation.invalidate_cached_sems()
 
-def check_module_code_unicity(code, field, formation_id, module_id=None):
-    "true si code module unique dans la formation"
-    modules = module_list(args={"code": code, "formation_id": formation_id})
-    if module_id:  # edition: supprime le module en cours
-        modules = [m for m in modules if m["module_id"] != module_id]
 
-    return len(modules) == 0
+def module_create(
+    matiere_id=None, module_type=None, semestre_id=None, formation_id=None
+):
+    """Formulaire de création d'un module
+    Si matiere_id est spécifié, le module sera créé dans cette matière (cas normal).
+    Sinon, donne le choix de l'UE de rattachement et utilise la première
+    matière de cette UE (si elle n'existe pas, la crée).
+    """
+    return module_edit(
+        create=True,
+        matiere_id=matiere_id,
+        module_type=module_type,
+        semestre_id=semestre_id,
+        formation_id=formation_id,
+    )
 
 
 def module_edit(
@@ -278,14 +139,12 @@ def module_edit(
         Sinon, donne le choix de l'UE de rattachement et utilise la première matière
         de cette UE (si elle n'existe pas, la crée).
     """
-    from app.scodoc import sco_tag_module
-
     # --- Détermination de la formation
     orig_semestre_idx = semestre_id
     ue = None
     if create:
         if matiere_id:
-            matiere = Matiere.query.get_or_404(matiere_id)
+            matiere = Matiere.get_instance(matiere_id)
             ue = matiere.ue
             formation = ue.formation
             orig_semestre_idx = ue.semestre_idx if semestre_id is None else semestre_id
@@ -300,7 +159,7 @@ def module_edit(
         ue = module.ue
         module_dict = module.to_dict()
         formation = module.formation
-        unlocked = not module_is_locked(module_id)
+        unlocked = not module.is_locked()
 
     parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
     is_apc = parcours.APC_SAE  # BUT
@@ -326,17 +185,14 @@ def module_edit(
             if (module and module.matiere and (module.matiere.id == mat.id))
             or (mat.id == mat.ue.matieres.first().id)
         ]
-        mat_names = [
-            "S%s / %s" % (mat.ue.semestre_idx, mat.ue.acronyme) for mat in matieres
-        ]
+        mat_names = [f"S{mat.ue.semestre_idx} / {mat.ue.acronyme}" for mat in matieres]
     else:
-        mat_names = ["%s / %s" % (mat.ue.acronyme, mat.titre or "") for mat in matieres]
+        mat_names = ["{mat.ue.acronyme} / {mat.titre or ''}" for mat in matieres]
 
     if module:  # edition
-        ue_mat_ids = ["%s!%s" % (mat.ue.id, mat.id) for mat in matieres]
-        module_dict["ue_matiere_id"] = "%s!%s" % (
-            module_dict["ue_id"],
-            module_dict["matiere_id"],
+        ue_mat_ids = [f"{mat.ue.id}!{mat.id}" for mat in matieres]
+        module_dict["ue_matiere_id"] = (
+            f"{module_dict['ue_id']}!{module_dict['matiere_id']}"
         )
 
     semestres_indices = list(range(1, parcours.NB_SEM + 1))
@@ -433,8 +289,8 @@ def module_edit(
                 "explanation": """code du module (issu du programme, exemple M1203,
                 R2.01, ou SAÉ 3.4. Doit être unique dans la formation)""",
                 "allow_null": False,
-                "validator": lambda val, field, formation_id=formation.id: check_module_code_unicity(
-                    val, field, formation_id, module_id=module.id if module else None
+                "validator": lambda val, _, formation_id=formation.id: Module.check_module_code_unicity(
+                    val, formation_id, module_id=module.id if module else None
                 ),
             },
         ),
@@ -602,7 +458,8 @@ def module_edit(
                         "title": "UE de rattachement",
                         "explanation": "utilisée notamment pour les malus",
                         "labels": [
-                            f"S{u.semestre_idx if u.semestre_idx is not None else '.'} / {u.acronyme} {u.titre}"
+                            f"""S{u.semestre_idx if u.semestre_idx is not None else '.'
+                            } / {u.acronyme} {u.titre}"""
                             for u in ues
                         ],
                         "allowed_values": [u.id for u in ues],
@@ -631,7 +488,8 @@ def module_edit(
                     "input_type": "menu",
                     "type": "int",
                     "title": parcours.SESSION_NAME.capitalize(),
-                    "explanation": f"{parcours.SESSION_NAME} de début du module dans la formation standard",
+                    "explanation": f"""{parcours.SESSION_NAME
+                        } de début du module dans la formation standard""",
                     "labels": [str(x) for x in semestres_indices],
                     "allowed_values": semestres_indices,
                     "enabled": unlocked,
@@ -846,8 +704,7 @@ def module_edit(
                 tf[2]["matiere_id"] = matiere.id
 
         tf[2]["semestre_id"] = ue.semestre_idx
-        module_id = do_module_create(tf[2])
-        module = db.session.get(Module, module_id)
+        module = Module.create_from_dict(tf[2], news=True)
     else:  # EDITION MODULE
         # l'UE de rattachement peut changer
         tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!")
@@ -892,6 +749,7 @@ def module_edit(
         ]
     db.session.add(module)
     db.session.commit()
+    module.formation.invalidate_cached_sems()
     return flask.redirect(
         url_for(
             "notes.ue_table",
@@ -904,28 +762,36 @@ def module_edit(
 
 def module_table(formation_id):
     """Liste des modules de la formation
-    (XXX inutile ou a revoir)
+    (affichage debug)
     """
-    if not formation_id:
-        raise ScoValueError("invalid formation !")
-    formation: Formation = Formation.query.get_or_404(formation_id)
+    formation = Formation.get_formation(formation_id)
+    editable = current_user.has_permission(Permission.EditFormation)
+
     H = [
-        f"""<h2>Listes des modules dans la formation {formation.titre} ({formation.acronyme}</h2>
+        f"""<h2>Listes des modules dans la formation
+            {formation.titre} ({formation.acronyme} (debug)
+        </h2>
         <ul class="notes_module_list">
         """,
     ]
-    editable = current_user.has_permission(Permission.EditFormation)
 
-    for module_dict in module_list(args={"formation_id": formation_id}):
-        H.append('<li class="notes_module_list">%s' % module_dict)
+    for module in formation.modules:
+        m_dict = module.to_dict()
+        m_dict["parcours"] = [p.code for p in module.parcours]
+        str_module = str(m_dict).replace(",", ",\n")
+        H.append(
+            f'<li class="notes_module_list"><pre style="margin-bottom: 1px;">{str_module}</pre>'
+        )
         if editable:
             H.append(
-                '<a href="module_edit?module_id=%(module_id)s">modifier</a>'
-                % module_dict
-            )
-            H.append(
-                '<a href="module_delete?module_id=%(module_id)s">supprimer</a>'
-                % module_dict
+                f"""
+                <a class="stdlink" href="{
+                    url_for('notes.module_edit', scodoc_dept=g.scodoc_dept, module_id=module.id)
+                }">modifier</a>
+                <a class="stdlink" href="{
+                    url_for('notes.module_delete', scodoc_dept=g.scodoc_dept, module_id=module.id)
+                }">supprimer</a>
+                """
             )
         H.append("</li>")
     H.append("</ul>")
@@ -936,23 +802,6 @@ def module_table(formation_id):
     )
 
 
-def module_is_locked(module_id):
-    """True if module should not be modified
-    (used in a locked formsemestre)
-    """
-    r = ndb.SimpleDictFetch(
-        """SELECT mi.id
-        FROM notes_modules mod, notes_formsemestre sem, notes_moduleimpl mi
-        WHERE mi.module_id = mod.id
-        AND mi.formsemestre_id = sem.id
-        AND mi.module_id = %(module_id)s
-        AND sem.etat = false
-        """,
-        {"module_id": module_id},
-    )
-    return len(r) > 0
-
-
 def formation_add_malus_modules(
     formation_id: int, semestre_id: int = None, titre=None, redirect=True
 ):
@@ -966,7 +815,7 @@ def formation_add_malus_modules(
         ues = ues.filter_by(semestre_idx=semestre_id)
     for ue in ues:
         if ue.type == codes_cursus.UE_STANDARD:
-            if ue_add_malus_module(ue, titre=titre) != None:
+            if ue_add_malus_module(ue, titre=titre) is not None:
                 nb += 1
 
     flash(f"Modules de malus ajoutés dans {nb} UEs du S{semestre_id}")
@@ -1002,7 +851,8 @@ def ue_add_malus_module(ue: UniteEns, titre=None, code=None) -> int:
             # c'est ennuyeux: dans ce cas, on pourrait demander à indiquer explicitement
             # le semestre ? ou affecter le malus au semestre 1 ???
             raise ScoValueError(
-                "Impossible d'ajouter un malus si l'UE n'a pas de numéro de semestre et ne comporte pas d'autres modules"
+                """Impossible d'ajouter un malus si l'UE n'a pas de numéro de semestre
+                et ne comporte pas d'autres modules"""
             )
     else:
         semestre_id = ue.semestre_idx
diff --git a/app/formations/edit_ue.py b/app/formations/edit_ue.py
index dbf2a1dbd..3712a8543 100644
--- a/app/formations/edit_ue.py
+++ b/app/formations/edit_ue.py
@@ -37,7 +37,6 @@ from flask_login import current_user
 
 from app import db, log
 from app.but import apc_edit_ue
-from app.formations import edit_matiere, edit_module
 from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
 from app.models import (
     Formation,
@@ -66,7 +65,6 @@ from app.scodoc import codes_cursus
 from app.scodoc import sco_edit_apc
 from app.scodoc import sco_groups
 from app.scodoc import sco_moduleimpl
-from app.scodoc import sco_tag_module
 
 _ueEditor = ndb.EditableTable(
     "notes_ue",
@@ -375,7 +373,8 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
                     "type": "float",
                     "min_value": 0,
                     "title": "Coef. RCUE",
-                    "explanation": """pondération utilisée pour le calcul de la moyenne du RCUE. Laisser à 1, sauf si votre établissement a explicitement décidé de pondérations.
+                    "explanation": """pondération utilisée pour le calcul de la moyenne du RCUE.
+                    Laisser à 1, sauf si votre établissement a explicitement décidé de pondérations.
                     """,
                     "defaut": 1.0,
                     "allow_null": False,
@@ -421,7 +420,8 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
             {
                 "title": "Code Apogée",
                 "size": 25,
-                "explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules",
+                "explanation": """(optionnel) code élément pédagogique Apogée
+                    ou liste de codes ELP séparés par des virgules""",
                 "max_length": APO_CODE_STR_LEN,
             },
         ),
@@ -445,7 +445,8 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
                 "input_type": "boolcheckbox",
                 "title": "UE externe",
                 "readonly": not create,  # ne permet pas de transformer une UE existante en externe
-                "explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement",
+                "explanation": """réservé pour les capitalisations d'UEs
+                    effectuées à l'extérieur de l'établissement""",
             },
         ),
         (
@@ -465,7 +466,8 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
                     "input_type": "boolcheckbox",
                     "default": True,
                     "title": "Créer matière identique",
-                    "explanation": "créer immédiatement une matière dans cette UE (utile si on n'utilise pas de matières)",
+                    "explanation": """créer immédiatement une matière dans cette UE
+                        (utile si on n'utilise pas de matières)""",
                 },
             )
         )
@@ -553,7 +555,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
                 matiere_id = matiere.id
             if cursus.UE_IS_MODULE:
                 # dans ce mode, crée un (unique) module dans l'UE:
-                _ = edit_module.do_module_create(
+                _ = Module.create_from_dict(
                     {
                         "titre": tf[2]["titre"],
                         "code": tf[2]["acronyme"],
@@ -565,6 +567,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
                         "semestre_id": tf[2]["semestre_idx"],
                     },
                 )
+                db.session.commit()
             ue = db.session.get(UniteEns, ue_id)
             flash(f"UE créée (code {ue.ue_code})")
         else:
@@ -608,9 +611,10 @@ def _add_ue_semestre_id(ues: list[dict], is_apc):
             ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT
         else:
             # était le comportement ScoDoc7
-            modules = edit_module.module_list(args={"ue_id": ue["ue_id"]})
-            if modules:
-                ue["semestre_id"] = modules[0]["semestre_id"]
+            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
 
@@ -1323,7 +1327,6 @@ def _ue_table_modules(
     arrow_none,
     delete_icon,
     delete_disabled_icon,
-    unit_name="matière",
     add_suppress_link=True,  # lien "supprimer cette matière"
     empty_list_msg="Aucun élément dans cette matière",
     create_element_msg="créer un module",
@@ -1361,7 +1364,6 @@ def _ue_table_modules(
         H.append("</span>")
 
         mod_editable = editable
-        # and not edit_module.module_is_locked(Mod['module_id'])
         if mod_editable:
             H.append(
                 f"""<a class="discretelink" title="Modifier le module numéro %(numero)s, utilisé par
diff --git a/app/formations/formation_io.py b/app/formations/formation_io.py
index bbf265cc5..680db80fe 100644
--- a/app/formations/formation_io.py
+++ b/app/formations/formation_io.py
@@ -36,7 +36,7 @@ from flask_login import current_user
 import app.scodoc.sco_utils as scu
 from app import db
 from app import log
-from app.formations import edit_module, edit_ue
+from app.formations import edit_ue
 from app.models import Formation, FormSemestre, Matiere, Module, UniteEns
 from app.models import ScolarNews
 from app.models.but_refcomp import (
@@ -49,7 +49,6 @@ from app.models.but_refcomp import (
 from app.scodoc import sco_cache
 from app.scodoc import codes_cursus
 from app.scodoc import sco_preferences
-from app.scodoc import sco_tag_module
 from app.scodoc import sco_xml
 from app.scodoc.gen_tables import GenTable
 from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError
@@ -123,11 +122,11 @@ def formation_export_dict(
                 del mat_d["id"]
                 del mat_d["matiere_id"]
                 del mat_d["ue_id"]
-            mods = edit_module.module_list({"matiere_id": matiere_id})
-            mods.sort(key=lambda m: (m["numero"] or 0, m["code"]))
-            mat_d["module"] = mods
-            for mod_d in mods:
-                module: Module = db.session.get(Module, mod_d["module_id"])
+            mat = db.session.get(Matiere, matiere_id)
+            mods = mat.modules.all()
+            mods.sort(key=lambda m: (m.numero, m.code))
+            mat_d["module"] = [mod.to_dict() for mod in mods]
+            for module, mod_d in zip(mods, mat_d["module"]):
                 if export_tags:
                     tags = [t.title for t in module.tags]
                     if tags:
@@ -200,7 +199,8 @@ def formation_export(
     if fmt is None:
         return f_dict
 
-    filename = f"scodoc_formation_{formation.departement.acronym}_{formation.acronyme or ''}_v{formation.version}"
+    filename = f"""scodoc_formation_{formation.departement.acronym
+        }_{formation.acronyme or ''}_v{formation.version}"""
     return scu.sendResult(
         f_dict,
         name="formation",
@@ -290,14 +290,14 @@ def formation_import_xml(doc: str | bytes, import_tags=True, use_local_refcomp=F
 
     try:
         f = dom.getElementsByTagName("formation")[0]  # or dom.documentElement
-        D = sco_xml.xml_to_dicts(f)
+        xml_dicts = sco_xml.xml_to_dicts(f)
     except Exception as exc:
         raise ScoFormatError(
             """Ce document xml ne correspond pas à un programme exporté par ScoDoc.
             (élément 'formation' inexistant par exemple)."""
         ) from exc
-    assert D[0] == "formation"
-    f_dict = D[1]
+    assert xml_dicts[0] == "formation"
+    f_dict = xml_dicts[1]
     f_dict["dept_id"] = g.scodoc_dept_id
     # Pour les clonages, on prend le refcomp_id donné:
     referentiel_competence_id = (
@@ -334,7 +334,7 @@ def formation_import_xml(doc: str | bytes, import_tags=True, use_local_refcomp=F
     modules_a_coefficienter = []  # Liste des modules avec coefs APC
     with sco_cache.DeferredSemCacheManager():
         # -- create UEs
-        for ue_info in D[2]:
+        for ue_info in xml_dicts[2]:
             assert ue_info[0] == "ue"
             ue_info[1]["formation_id"] = formation.id
             if "ue_id" in ue_info[1]:
@@ -414,11 +414,12 @@ def formation_import_xml(doc: str | bytes, import_tags=True, use_local_refcomp=F
                     mod_info[1]["ue_id"] = ue_id
                     if not "module_type" in mod_info[1]:
                         mod_info[1]["module_type"] = scu.ModuleType.STANDARD
-                    mod_id = edit_module.do_module_create(mod_info[1])
+                    module = Module.create_from_dict(
+                        mod_info[1], news=True, inval_cache=True
+                    )
                     if xml_module_id:
-                        modules_old2new[int(xml_module_id)] = mod_id
+                        modules_old2new[int(xml_module_id)] = module.id
                     if len(mod_info) > 2:
-                        module: Module = db.session.get(Module, mod_id)
                         tag_names = []
                         ue_coef_dict = {}
                         for child in mod_info[2]:
diff --git a/app/models/formations.py b/app/models/formations.py
index 5ae12d6f2..559b6fe7d 100644
--- a/app/models/formations.py
+++ b/app/models/formations.py
@@ -66,7 +66,8 @@ class Formation(ScoDocModel):
 
     def html(self) -> str:
         "titre complet pour affichage"
-        return f"""Formation {self.titre} ({self.acronyme}) version {self.version} code <tt>{self.formation_code}</tt>"""
+        return f"""Formation {self.titre} ({self.acronyme}) version {self.version
+            } code <tt>{self.formation_code}</tt>"""
 
     @classmethod
     def get_formation(cls, formation_id: int | str, dept_id: int = None) -> "Formation":
@@ -334,7 +335,7 @@ class Matiere(ScoDocModel):
         return e
 
     def is_locked(self) -> bool:
-        """True if matiere cannot be be modified
+        """True if matiere can't modified
         because it contains modules used in a locked formsemestre.
         """
         from app.models.formsemestre import FormSemestre
@@ -361,8 +362,6 @@ class Matiere(ScoDocModel):
 
     def delete(self):
         "Delete matière. News, inval cache."
-        from app.models import ScolarNews
-
         formation = self.ue.formation
         log(f"matiere.delete: matiere_id={self.id}")
         if not self.can_be_deleted():
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index 281d28c3b..c3a3c2698 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -7,14 +7,15 @@ from flask_login import current_user
 from flask_sqlalchemy.query import Query
 
 import app
-from app import db
+from app import db, log
 from app.auth.models import User
 from app.comp import df_cache
 from app.models import APO_CODE_STR_LEN, ScoDocModel
 from app.models.etudiants import Identite
 from app.models.evaluations import Evaluation
 from app.models.modules import Module
-from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
+from app.scodoc import sco_cache
+from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError, ScoValueError
 from app.scodoc.sco_permissions import Permission
 from app.scodoc import sco_utils as scu
 
@@ -65,6 +66,30 @@ class ModuleImpl(ScoDocModel):
     def __repr__(self):
         return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>"
 
+    @classmethod
+    def create_from_dict(cls, data: dict) -> "ModuleImpl":
+        """Create modimpl from dict. Log, inval. cache.
+        data must include valid formsemestre_id, module_id and responsable_id
+        Commit session.
+        """
+        from app.models import FormSemestre
+
+        # check required args
+        for required_arg in ("formsemestre_id", "module_id", "responsable_id"):
+            if required_arg not in data:
+                raise ScoValueError(f"missing argument: {required_arg}")
+        _ = FormSemestre.get_formsemestre(data["formsemestre_id"])
+        _ = Module.get_instance(data["module_id"])
+        if not db.session.get(User, data["responsable_id"]):
+            abort(404, "responsable_id invalide")
+
+        modimpl = super().create_from_dict(data)
+        db.session.commit()
+        db.session.refresh(modimpl)
+        log(f"ModuleImpl.create: created {modimpl.id} with {data}")
+        sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id)
+        return modimpl
+
     def get_codes_apogee(self) -> set[str]:
         """Les codes Apogée (codés en base comme "VRT1,VRT2").
         (si non renseigné, ceux du module)
diff --git a/app/models/modules.py b/app/models/modules.py
index 50ff5633e..37cded355 100644
--- a/app/models/modules.py
+++ b/app/models/modules.py
@@ -2,7 +2,7 @@
 """
 
 import http
-from flask import current_app, g
+from flask import current_app, g, url_for
 
 from app import db, log
 from app import models
@@ -13,9 +13,14 @@ from app.models.but_refcomp import (
     app_critiques_modules,
     parcours_modules,
 )
+from app.models.events import ScolarNews
 from app.scodoc import sco_utils as scu
 from app.scodoc.codes_cursus import UE_SPORT
-from app.scodoc.sco_exceptions import ScoValueError
+from app.scodoc.sco_exceptions import (
+    ScoValueError,
+    ScoLockedFormError,
+    ScoNonEmptyFormationObject,
+)
 from app.scodoc.sco_utils import ModuleType
 
 
@@ -115,13 +120,39 @@ class Module(models.ScoDocModel):
         # on ne peut pas affecter directement parcours
         return super().filter_model_attributes(args, (excluded or set()) | {"parcours"})
 
+    @classmethod
+    def check_module_code_unicity(cls, code, formation_id, module_id=None) -> bool:
+        "true si code module unique dans la formation"
+        from app.models import Formation
+
+        formation = Formation.get_formation(formation_id)
+        query = formation.modules.filter_by(code=code)
+        if module_id is not None:  # edition: supprime le module en cours
+            query = query.filter(Module.id != module_id)
+        return query.count() == 0
+
     def from_dict(self, args: dict, excluded: set[str] | None = None) -> bool:
         """Update object's fields given in dict. Add to session but don't commit.
         True if modification.
         - can't change ue nor formation
         - can change matiere_id, iff new matiere in same ue
         - can change parcours: parcours list of ApcParcour id or instances.
+        Ne modifie pas les coefficients APC ue_coefs
         """
+        args = args.copy()
+        if "ue_coefs" in args:
+            del args["ue_coefs"]
+        if self.is_locked():
+            # formation verrouillée: empeche de modifier coefficient, matiere, and semestre_id
+            protected_fields = ("coefficient", "matiere_id", "semestre_id")
+            for f in protected_fields:
+                if f in args:
+                    del args[f]
+        # Unicité du code
+        if "code" in args and not Module.check_module_code_unicity(
+            args["code"], self.formation_id, self.id
+        ):
+            raise ScoValueError("code module déjà utilisé")
         # Vérifie les changements de matiere
         new_matiere_id = args.get("matiere_id", self.matiere_id)
         if new_matiere_id != self.matiere_id:
@@ -139,20 +170,103 @@ class Module(models.ScoDocModel):
         existing_parcours = {p.id for p in self.parcours}
         new_parcours = args.get("parcours", []) or []
         if existing_parcours != set(new_parcours):
-            self._set_parcours_from_list(new_parcours)
+            self.set_parcours_from_list(new_parcours)
             return True
         return modified
 
     @classmethod
-    def create_from_dict(cls, data: dict) -> "Module":
+    def create_from_dict(
+        cls,
+        data: dict,
+        inval_cache=False,
+        news=False,
+    ) -> "Module":
         """Create from given dict, add parcours.
-        Flush session."""
+        Flush session.
+        Si news, commit and log news.
+        """
+        from app.models.formations import Formation
+
+        # check required arguments
+        for required_arg in ("code", "formation_id", "ue_id"):
+            if required_arg not in data:
+                raise ScoValueError(f"missing argument: {required_arg}")
+        if not data["code"]:
+            raise ScoValueError("module code must be non empty")
+        # Check formation
+        formation = Formation.get_formation(data["formation_id"])
+        ue = UniteEns.get_ue(data["ue_id"])
+        # refuse de créer un module APC avec semestres semestre du module != semestre de l'UE
+        if formation.is_apc():
+            if int(data.get("semestre_id", 1)) != ue.semestre_idx:
+                raise ScoValueError(
+                    "Formation incompatible: indices UE et module différents"
+                )
         module = super().create_from_dict(data)
         db.session.flush()
-        module._set_parcours_from_list(data.get("parcours", []) or [])
+        module.set_parcours_from_list(data.get("parcours", []) or [])
+        log(f"module_create: created {module.id} with {data}")
+        if news:
+            db.session.commit()
+            db.session.refresh(module)
+            ScolarNews.add(
+                typ=ScolarNews.NEWS_FORM,
+                obj=formation.id,
+                text=f"Modification de la formation {formation.acronyme}",
+            )
+        if inval_cache:
+            formation.invalidate_cached_sems()
+
         return module
 
-    def _set_parcours_from_list(self, parcours: list[ApcParcours | int]):
+    def is_locked(self) -> bool:
+        """True if module cannot be modified
+        because it is used in a locked formsemestre.
+        """
+        from app.models import FormSemestre, ModuleImpl
+
+        mods = (
+            db.session.query(Module)
+            .filter_by(id=self.id)
+            .join(ModuleImpl)
+            .join(FormSemestre)
+            .filter_by(etat=False)
+            .all()
+        )
+        return bool(mods)
+
+    def can_be_deleted(self) -> bool:
+        """True if module can be deleted"""
+        return self.modimpls.count() == 0
+
+    def delete(self):
+        "Delete module. News, inval cache."
+        if self.is_locked():
+            raise ScoLockedFormError()
+        if not self.can_be_deleted():
+            raise ScoNonEmptyFormationObject(
+                "Module",
+                msg=self.titre or self.code,
+                dest_url=url_for(
+                    "notes.ue_table",
+                    scodoc_dept=g.scodoc_dept,
+                    formation_id=self.formation_id,
+                    semestre_idx=self.ue.semestre_idx,
+                ),
+            )
+        formation = self.formation
+        db.session.delete(self)
+        log(f"Module.delete({self.id})")
+        db.session.commit()
+        # news
+        ScolarNews.add(
+            typ=ScolarNews.NEWS_FORM,
+            obj=formation.id,
+            text=f"Modification de la formation {formation.acronyme}",
+        )
+        formation.invalidate_cached_sems()
+
+    def set_parcours_from_list(self, parcours: list[ApcParcours | int]):
         """Ajoute ces parcours à la liste des parcours du module.
         Chaque élément est soit un objet parcours soit un id.
         S'assure que chaque parcours est dans le référentiel de compétence
diff --git a/app/pe/moys/pe_ressemtag.py b/app/pe/moys/pe_ressemtag.py
index e424e8f3e..acd59ad29 100644
--- a/app/pe/moys/pe_ressemtag.py
+++ b/app/pe/moys/pe_ressemtag.py
@@ -41,8 +41,8 @@ from app import ScoValueError
 from app import comp
 from app.comp.res_but import ResultatsSemestreBUT
 from app.models import FormSemestre, UniteEns
-import app.pe.pe_affichage as pe_affichage
-import app.pe.pe_etudiant as pe_etudiant
+from app.pe import pe_affichage
+from app.pe import pe_etudiant
 from app.pe.moys import pe_tabletags, pe_moytag
 from app.scodoc import sco_tag_module
 from app.scodoc import codes_cursus as sco_codes
@@ -59,13 +59,18 @@ class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag):
     def __init__(
         self,
         formsemestre: FormSemestre,
-        options={"moyennes_tags": True, "moyennes_ue_res_sae": False},
+        options: dict | None = None,
     ):
         """
         Args:
             formsemestre: le ``FormSemestre`` sur lequel il se base
             options: Un dictionnaire d'options
         """
+        options = (
+            {"moyennes_tags": True, "moyennes_ue_res_sae": False}
+            if options is None
+            else options
+        )
         ResultatsSemestreBUT.__init__(self, formsemestre)
         pe_tabletags.TableTag.__init__(self)
 
diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py
index 174614578..1352ea655 100644
--- a/app/scodoc/sco_bulletins_json.py
+++ b/app/scodoc/sco_bulletins_json.py
@@ -491,9 +491,9 @@ def dict_decision_jury(
                 ]
 
             d["decision_ue"] = []
-            if decision[
-                "decisions_ue"
-            ]:  # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id): always publish (car utile pour export Apogee)
+            if decision["decisions_ue"]:
+                # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id):
+                # always publish (car utile pour export Apogee)
                 for ue_id in decision["decisions_ue"].keys():
                     ue = edit_ue.ue_list({"ue_id": ue_id})[0]
                     d["decision_ue"].append(
diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index 5c5b541a2..17f013ef9 100644
--- a/app/scodoc/sco_formsemestre_edit.py
+++ b/app/scodoc/sco_formsemestre_edit.py
@@ -34,7 +34,6 @@ import sqlalchemy as sa
 
 from app import db
 from app.auth.models import User
-from app.formations import edit_module
 from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
 from app.models import (
     ApcValidationAnnee,
@@ -939,7 +938,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                     "formsemestre_id": formsemestre_id,
                     "responsable_id": tf[2][f"MI{module_id}"],
                 }
-                _ = sco_moduleimpl.do_moduleimpl_create(modargs)
+                _ = ModuleImpl.create_from_dict(modargs)
         else:
             # Modification du semestre:
             # on doit creer les modules nouvellement selectionnés
@@ -971,27 +970,23 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                     "formsemestre_id": formsemestre.id,
                     "responsable_id": tf[2]["MI" + str(module_id)],
                 }
-                moduleimpl_id = sco_moduleimpl.do_moduleimpl_create(modargs)
-                mod = edit_module.module_list({"module_id": module_id})[0]
-                msg += [
-                    "création de %s (%s)" % (mod["code"] or "?", mod["titre"] or "?")
-                ]
+                modimpl = ModuleImpl.create_from_dict(modargs)
+                assert modimpl.module_id == module_id
+                mod = modimpl.module
+                msg += [f"""création de {mod.code or "?"} ({mod.titre or "?"})"""]
                 # INSCRIPTIONS DES ETUDIANTS
-                log(
-                    'inscription module: %s = "%s"'
-                    % ("%s!group_id" % module_id, tf[2]["%s!group_id" % module_id])
-                )
-                group_id = tf[2]["%s!group_id" % module_id]
+                group_id = tf[2][f"{module_id}!group_id"]
+                log(f"""inscription module: {module_id}!group_id = '{group_id}'""")
                 if group_id:
                     etudids = [
                         x["etudid"] for x in sco_groups.get_group_members(group_id)
                     ]
                     log(
                         "inscription module:module_id=%s,moduleimpl_id=%s: %s"
-                        % (module_id, moduleimpl_id, etudids)
+                        % (module_id, modimpl.id, etudids)
                     )
                     sco_moduleimpl.do_moduleimpl_inscrit_etuds(
-                        moduleimpl_id,
+                        modimpl.id,
                         formsemestre.id,
                         etudids,
                     )
@@ -1002,7 +997,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                 else:
                     log(
                         "inscription module:module_id=%s,moduleimpl_id=%s: aucun etudiant inscrit"
-                        % (module_id, moduleimpl_id)
+                        % (module_id, modimpl.id)
                     )
             #
             ok, diag = formsemestre_delete_moduleimpls(
@@ -1022,7 +1017,6 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                 sco_moduleimpl.do_moduleimpl_edit(
                     modargs, formsemestre_id=formsemestre.id
                 )
-                mod = edit_module.module_list({"module_id": module_id})[0]
     # --- Association des parcours
     if formsemestre is None:
         formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
index 17907c1a5..bb8d33763 100755
--- a/app/scodoc/sco_formsemestre_status.py
+++ b/app/scodoc/sco_formsemestre_status.py
@@ -40,7 +40,6 @@ from app.but.cursus_but import formsemestre_warning_apc_setup
 from app.comp import res_sem
 from app.comp.res_common import ResultatsSemestre
 from app.comp.res_compat import NotesTableCompat
-from app.formations import formation_io
 from app.models import (
     Evaluation,
     Formation,
diff --git a/app/scodoc/sco_moduleimpl.py b/app/scodoc/sco_moduleimpl.py
index c315477c8..6891f8d66 100644
--- a/app/scodoc/sco_moduleimpl.py
+++ b/app/scodoc/sco_moduleimpl.py
@@ -51,18 +51,6 @@ _moduleimplEditor = ndb.EditableTable(
 )
 
 
-def do_moduleimpl_create(args):
-    "create a moduleimpl"
-    # TODO remplacer par une methode de ModuleImpl qui appelle
-    # super().create_from_dict() puis invalide le formsemestre
-    cnx = ndb.GetDBConnexion()
-    r = _moduleimplEditor.create(cnx, args)
-    sco_cache.invalidate_formsemestre(
-        formsemestre_id=args["formsemestre_id"]
-    )  # > creation moduleimpl
-    return r
-
-
 def do_moduleimpl_delete(oid, formsemestre_id=None):
     "delete moduleimpl (desinscrit tous les etudiants)"
     cnx = ndb.GetDBConnexion()
diff --git a/app/scodoc/sco_placement.py b/app/scodoc/sco_placement.py
index 67fe31568..23eca4ae7 100644
--- a/app/scodoc/sco_placement.py
+++ b/app/scodoc/sco_placement.py
@@ -48,8 +48,7 @@ from wtforms import (
     HiddenField,
     SelectMultipleField,
 )
-from app.formations import edit_module
-from app.models import Evaluation, ModuleImpl
+from app.models import Evaluation, Module, ModuleImpl
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app.scodoc import sco_preferences
@@ -243,9 +242,9 @@ class PlacementRunner:
         self.moduleimpl_data = sco_moduleimpl.moduleimpl_list(
             moduleimpl_id=self.moduleimpl_id
         )[0]
-        self.module_data = edit_module.module_list(
-            args={"module_id": self.moduleimpl_data["module_id"]}
-        )[0]
+        self.module_data = Module.get_module(
+            self.moduleimpl_data["module_id"]
+        ).to_dict()
         self.sem = sco_formsemestre.get_formsemestre(
             self.moduleimpl_data["formsemestre_id"]
         )
diff --git a/app/scodoc/sco_ue_external.py b/app/scodoc/sco_ue_external.py
index 97f97259c..fa85beebf 100644
--- a/app/scodoc/sco_ue_external.py
+++ b/app/scodoc/sco_ue_external.py
@@ -56,12 +56,12 @@ Solution proposée (nov 2014):
 import flask
 from flask import flash, g, request, render_template, url_for
 from flask_login import current_user
-from app.formations import edit_module, edit_ue
+from app.formations import edit_ue
 from app.models.formsemestre import FormSemestre
 
 
 from app import db, log
-from app.models import Evaluation, Identite, Matiere, ModuleImpl, UniteEns
+from app.models import Evaluation, Identite, Matiere, Module, ModuleImpl, UniteEns
 from app.scodoc import codes_cursus
 from app.scodoc import sco_moduleimpl
 from app.scodoc import sco_saisie_notes
@@ -114,7 +114,7 @@ def external_ue_create(
         {"ue_id": ue_id, "titre": titre or acronyme, "numero": 1}
     )
 
-    module_id = edit_module.do_module_create(
+    module = Module.create_from_dict(
         {
             "titre": "UE extérieure",
             "code": acronyme,
@@ -125,11 +125,13 @@ def external_ue_create(
             "semestre_id": formsemestre.semestre_id,
             "module_type": scu.ModuleType.STANDARD,
         },
+        news=True,
+        inval_cache=True,
     )
 
-    moduleimpl_id = sco_moduleimpl.do_moduleimpl_create(
+    modimpl = ModuleImpl.create_from_dict(
         {
-            "module_id": module_id,
+            "module_id": module.id,
             "formsemestre_id": formsemestre_id,
             # affecte le 1er responsable du semestre comme resp. du module
             "responsable_id": (
@@ -139,7 +141,6 @@ def external_ue_create(
             ),
         },
     )
-    modimpl = db.session.get(ModuleImpl, moduleimpl_id)
     assert modimpl
     return modimpl
 
diff --git a/app/views/notes.py b/app/views/notes.py
index 855eceedd..6100e45c8 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -2575,21 +2575,21 @@ def check_sem_integrity(formsemestre_id, fix=False):
     bad_sem = []
     formations_set = set()  # les formations mentionnées dans les UE et modules
     for modimpl in modimpls:
-        mod = edit_module.module_list({"module_id": modimpl["module_id"]})[0]
-        formations_set.add(mod["formation_id"])
-        ue = UniteEns.query.get_or_404(mod["ue_id"])
+        mod = Module.get_instance(modimpl["module_id"])
+        formations_set.add(mod.formation_id)
+        ue = mod.ue
         ue_dict = ue.to_dict()
         formations_set.add(ue_dict["formation_id"])
-        if ue_dict["formation_id"] != mod["formation_id"]:
-            modimpl["mod"] = mod
+        if ue_dict["formation_id"] != mod.formation_id:
+            modimpl["mod"] = mod.to_dict()
             modimpl["ue"] = ue_dict
             bad_ue.append(modimpl)
-        if sem["formation_id"] != mod["formation_id"]:
+        if sem["formation_id"] != mod.formation_id:
             bad_sem.append(modimpl)
-            modimpl["mod"] = mod
+            modimpl["mod"] = mod.to_dict()
 
     H = [
-        "<p>formation_id=%s" % sem["formation_id"],
+        f"""<p>formation_id={sem["formation_id"]}""",
     ]
     if bad_ue:
         H += [
diff --git a/app/views/scolar.py b/app/views/scolar.py
index 59cdba472..5ddaf4c93 100644
--- a/app/views/scolar.py
+++ b/app/views/scolar.py
@@ -86,7 +86,6 @@ from app.scodoc import (
     sco_archives_etud,
     sco_bug_report,
     sco_cache,
-    sco_debouche,
     sco_dept,
     sco_dump_db,
     sco_etud,
diff --git a/sco_version.py b/sco_version.py
index e9ebfa7cd..92963199f 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,6 +1,8 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
+"Infos sur version ScoDoc"
+
 SCOVERSION = "9.7.28"
 
 SCONAME = "ScoDoc"
diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py
index 097877e04..9b0e38c86 100644
--- a/tests/api/setup_test_api.py
+++ b/tests/api/setup_test_api.py
@@ -117,7 +117,7 @@ def GET(
         print("reply", reply.text)
         raise APIError(
             errmsg or f"""erreur get {url} !""",
-            reply.json(),
+            reply if reply.status_code == 404 else reply.json(),
             status_code=reply.status_code,
         )
     if raw:
@@ -220,7 +220,8 @@ def check_failure_get(path: str, headers: dict, err: str = None):
         # ^ Renvoi un 404
     except APIError as api_err:
         if err is not None:
-            assert api_err.payload["message"] == err
+            if "message" in api_err.payload:
+                assert api_err.payload["message"] == err
     else:
         raise APIError("Le GET n'aurait pas du fonctionner")
 
diff --git a/tests/api/test_api_formations.py b/tests/api/test_api_formations.py
index ae7b1e49d..8192961bc 100644
--- a/tests/api/test_api_formations.py
+++ b/tests/api/test_api_formations.py
@@ -177,7 +177,7 @@ def test_formation_export(api_headers):
                 assert isinstance(module["heures_td"], float)
                 assert isinstance(module["heures_tp"], float)
                 assert isinstance(module["coefficient"], float)
-                assert isinstance(module["ects"], str)
+                assert isinstance(module["ects"], str) if "ects" in module else True
                 assert isinstance(module["semestre_id"], int)
                 assert isinstance(module["numero"], int)
                 assert isinstance(module["code_apogee"], str)
diff --git a/tests/api/tools_test_api.py b/tests/api/tools_test_api.py
index 66c3cfc0f..2d0486e5a 100644
--- a/tests/api/tools_test_api.py
+++ b/tests/api/tools_test_api.py
@@ -1,5 +1,6 @@
 """Utilitaires pour les tests de l'API
 """
+
 import json
 
 
@@ -37,11 +38,11 @@ def verify_occurences_ids_etuds(json_response) -> bool:
 
 
 DEPARTEMENT_FIELDS = [
-    "id",
     "acronym",
+    "date_creation",
     "description",
+    "id",
     "visible",
-    "date_creation",
 ]
 
 # Champs "données personnelles"
@@ -67,17 +68,17 @@ ETUD_FIELDS = {
 
 
 FORMATION_FIELDS = {
-    "dept_id",
     "acronyme",
-    "titre_officiel",
-    "formation_code",
     "code_specialite",
+    "dept_id",
+    "formation_code",
+    "formation_id",
     "id",
+    "referentiel_competence_id",
+    "titre_officiel",
     "titre",
-    "version",
     "type_parcours",
-    "referentiel_competence_id",
-    "formation_id",
+    "version",
 }
 
 FORMATION_EXPORT_FIELDS = {
@@ -95,39 +96,42 @@ FORMATION_EXPORT_FIELDS = {
 
 FORMATION_EXPORT_UE_FIELDS = {
     "acronyme",
-    "numero",
-    "titre",
-    "type",
-    "ue_code",
-    "ects",
-    "is_external",
     "code_apogee",
     "coefficient",
-    "semestre_idx",
     "color",
-    "reference",
+    "ects",
+    "is_external",
     "matiere",
+    "numero",
+    "reference",
+    "semestre_idx",
+    "titre",
+    "type",
+    "ue_code",
 }
 
 FORMATION_EXPORT_UE_MATIERE_FIELDS = {
-    "titre",
-    "numero",
     "module",
+    "numero",
+    "titre",
 }
 
 FORMATION_EXPORT_UE_MATIERE_MODULE_FIELDS = {
-    "titre",
     "abbrev",
+    "app_critiques",
+    "code_apogee",
     "code",
+    "coefficient",
+    "coefficients",
+    "edt_id",
     "heures_cours",
     "heures_td",
-    "coefficient",
-    "ects",
-    "semestre_id",
-    "numero",
-    "code_apogee",
+    "heures_tp",
     "module_type",
-    "coefficients",
+    "numero",
+    "parcours",
+    "semestre_id",
+    "titre",
 }
 
 FORMATION_EXPORT_UE_MATIERE_MODULE_COEF_FIELDS = {
@@ -136,42 +140,42 @@ FORMATION_EXPORT_UE_MATIERE_MODULE_COEF_FIELDS = {
 }
 
 FORMSEMESTRE_FIELDS = [
-    "titre",
-    "gestion_semestrielle",
-    "scodoc7_id",
-    "date_debut",
+    "block_moyenne_generale",
+    "block_moyennes",
     "bul_bgcolor",
+    "bul_hide_xml",
+    "date_debut_iso",
+    "date_debut",
+    "date_fin_iso",
     "date_fin",
-    "resp_can_edit",
+    "departement",
     "dept_id",
-    "etat",
-    "resp_can_change_ens",
-    "id",
-    "modalite",
+    "elt_annee_apo",
+    "elt_sem_apo",
     "ens_can_edit_eval",
+    "etape_apo",
+    "etat",
     "formation_id",
+    "formation",
+    "formsemestre_id",
     "gestion_compensation",
-    "elt_sem_apo",
+    "gestion_semestrielle",
+    "id",
+    "modalite",
+    "parcours",
+    "resp_can_change_ens",
+    "resp_can_edit",
+    "responsables",
+    "scodoc7_id",
     "semestre_id",
-    "bul_hide_xml",
-    "elt_annee_apo",
-    "block_moyenne_generale",
-    "formsemestre_id",
-    "titre_num",
     "titre_formation",
-    "date_debut_iso",
-    "date_fin_iso",
-    "responsables",
-    "parcours",
-    "departement",
-    "formation",
-    "etape_apo",
-    "block_moyennes",
+    "titre_num",
+    "titre",
 ]
 
 FSEM_FIELDS = {
-    "block_moyennes",
     "block_moyenne_generale",
+    "block_moyennes",
     "bul_bgcolor",
     "bul_hide_xml",
     "date_debut_iso",
@@ -198,123 +202,123 @@ FSEM_FIELDS = {
 }
 
 MODIMPL_FIELDS = {
-    "id",
-    "formsemestre_id",
     "computation_expr",
-    "module_id",
-    "responsable_id",
-    "moduleimpl_id",
     "ens",
+    "formsemestre_id",
+    "id",
+    "module_id",
     "module",
+    "moduleimpl_id",
+    "responsable_id",
 }
 
 MODULE_FIELDS = {
-    "heures_tp",
+    "abbrev",
     "code_apogee",
-    "titre",
+    "code",
     "coefficient",
-    "module_type",
-    "id",
     "ects",
-    "abbrev",
-    "ue_id",
-    "code",
     "formation_id",
     "heures_cours",
-    "matiere_id",
     "heures_td",
-    "semestre_id",
-    "numero",
+    "heures_tp",
+    "id",
+    "matiere_id",
     "module_id",
+    "module_type",
+    "numero",
+    "semestre_id",
+    "titre",
+    "ue_id",
 }
 
 UE_FIELDS = {
-    "semestre_idx",
-    "type",
+    "acronyme",
+    "code_apogee",
+    "coefficient",
+    "color",
+    "ects",
     "formation_id",
-    "ue_code",
     "id",
-    "ects",
-    "acronyme",
     "is_external",
     "numero",
-    "code_apogee",
+    "semestre_idx",
     "titre",
-    "coefficient",
-    "color",
+    "type",
+    "ue_code",
     "ue_id",
 }
 
 BULLETIN_FIELDS = {
-    "version",
-    "type",
     "date",
-    "publie",
+    "etat_inscription",
     "etudiant",
     "formation",
     "formsemestre_id",
-    "etat_inscription",
     "options",
+    "publie",
     "ressources",
     "saes",
-    "ues",
     "semestre",
+    "type",
+    "ues",
+    "version",
 }
 
 
 BULLETIN_ETUDIANT_FIELDS = {
+    "boursier",
     "civilite",
     "code_ine",
     "code_nip",
+    "codepostaldomicile",
     "date_naissance",
-    "dept_id",
     "dept_acronym",
+    "dept_id",
+    "dept_naissance",
+    "description",
+    "domicile",
     "email",
     "emailperso",
     "etudid",
-    "nom",
-    "prenom",
-    "nomprenom",
+    "fax",
+    "fiche_url",
+    "id",
     "lieu_naissance",
-    "dept_naissance",
     "nationalite",
-    "boursier",
-    "fiche_url",
+    "nom",
+    "nomprenom",
+    "paysdomicile",
     "photo_url",
-    "id",
-    "domicile",
-    "villedomicile",
+    "prenom",
     "telephone",
-    "fax",
-    "description",
-    "codepostaldomicile",
-    "paysdomicile",
     "telephonemobile",
     "typeadresse",
+    "villedomicile",
 }
 
 BULLETIN_FORMATION_FIELDS = {"id", "acronyme", "titre_officiel", "titre"}
 
 BULLETIN_OPTIONS_FIELDS = {
-    "show_abs",
     "show_abs_modules",
-    "show_ects",
+    "show_abs",
     "show_codemodules",
+    "show_coef",
+    "show_date_inscr",
+    "show_ects",
     "show_matieres",
-    "show_rangs",
-    "show_ue_rangs",
+    "show_minmax_eval",
+    "show_minmax_mod",
+    "show_minmax",
     "show_mod_rangs",
     "show_moypromo",
-    "show_minmax",
-    "show_minmax_mod",
-    "show_minmax_eval",
-    "show_coef",
-    "show_ue_cap_details",
-    "show_ue_cap_current",
+    "show_rangs",
     "show_temporary",
-    "temporary_txt",
+    "show_ue_cap_current",
+    "show_ue_cap_details",
+    "show_ue_rangs",
     "show_uevalid",
-    "show_date_inscr",
+    "temporary_txt",
 }
 
 BULLETIN_RESSOURCES_FIELDS = {
@@ -346,23 +350,23 @@ BULLETIN_SAES_FIELDS = {
 
 ########### RESSOURCES ET SAES ###########
 BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_FIELDS = {
+    "code_apogee",
+    "evaluations",
     "id",
+    "moyenne",
     "titre",
-    "code_apogee",
     "url",
-    "moyenne",
-    "evaluations",
 }
 
 BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_FIELDS = {
-    "id",
-    "description",
+    "coef",
     "date",
+    "description",
     "heure_debut",
     "heure_fin",
-    "coef",
-    "poids",
+    "id",
     "note",
+    "poids",
     "url",
 }
 
@@ -373,10 +377,10 @@ BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_POIDS_FIELDS = {
 }
 
 BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_NOTE_FIELDS = {
-    "value",
-    "min",
     "max",
+    "min",
     "moy",
+    "value",
 }
 
 
@@ -384,19 +388,19 @@ BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_NOTE_FIELDS = {
 BULLETIN_UES_FIELDS = {"RT1.1", "RT2.1", "RT3.1"}
 
 BULLETIN_UES_UE_FIELDS = {
-    "id",
-    "titre",
-    "numero",
-    "type",
+    "bonus",
+    "capitalise",
     "color",
     "competence",
-    "moyenne",
-    "bonus",
+    "ECTS",
+    "id",
     "malus",
-    "capitalise",
+    "moyenne",
+    "numero",
     "ressources",
     "saes",
-    "ECTS",
+    "titre",
+    "type",
 }
 
 BULLETIN_UES_UE_MOYENNE_FIELDS = {"value", "min", "max", "moy", "rang", "total"}
@@ -461,16 +465,16 @@ BULLETIN_UES_UE_ECTS_FIELDS = {"acquis", "total"}
 
 ########### SEMESTRE ###########
 BULLETIN_SEMESTRE_FIELDS = {
-    "etapes",
+    "absences",
+    "annee_universitaire",
     "date_debut",
     "date_fin",
-    "annee_universitaire",
-    "numero",
-    "inscription",
-    "groupes",
-    "absences",
     "ECTS",
+    "etapes",
+    "groupes",
+    "inscription",
     "notes",
+    "numero",
     "rang",
 }
 
@@ -484,78 +488,78 @@ BULLETIN_SEMESTRE_RANG_FIELDS = {"value", "total"}
 
 
 EVAL_FIELDS = {
-    "id",
-    "description",
+    "coefficient",
     "date_debut",
     "date_fin",
-    "coefficient",
+    "description",
+    "etat",
     "evaluation_type",
+    "id",
     "moduleimpl_id",
-    "note_max",
-    "numero",
-    "poids",
-    "publish_incomplete",
-    "visibulletin",
-    "etat",
     "nb_inscrits",
-    "nb_notes_manquantes",
     "nb_notes_abs",
     "nb_notes_att",
     "nb_notes_exc",
+    "nb_notes_manquantes",
+    "note_max",
+    "numero",
+    "poids",
+    "publish_incomplete",
     "saisie_notes",
+    "visibulletin",
 }
 
 SAISIE_NOTES_FIELDS = {"datetime_debut", "datetime_fin", "datetime_mediane"}
 
 REF_COMP_FIELDS = {
-    "dept_id",
     "annexe",
-    "specialite",
+    "competences",
+    "dept_id",
+    "parcours",
+    "scodoc_date_loaded",
+    "scodoc_orig_filename",
     "specialite_long",
-    "type_structure",
+    "specialite",
     "type_departement",
+    "type_structure",
     "type_titre",
     "version_orebut",
-    "scodoc_date_loaded",
-    "scodoc_orig_filename",
-    "competences",
-    "parcours",
 }
 
 ABSENCES_FIELDS = {
-    "jour",
-    "matin",
-    "estabs",
-    "estjust",
-    "description",
     "begin",
+    "description",
     "end",
+    "estabs",
+    "estjust",
+    "jour",
+    "matin",
 }
 
 ABSENCES_GROUP_ETAT_FIELDS = {"etudid", "list_abs"}
 
 
 FORMSEMESTRE_ETUD_FIELDS = {
-    "id",
-    "code_nip",
+    "civilite",
     "code_ine",
-    "nom",
+    "code_nip",
+    "groups",
+    "id",
     "nom_usuel",
+    "nom",
     "prenom",
-    "civilite",
-    "groups",
 }
 
 FORMSEMESTRE_ETUS_GROUPS_FIELDS = {
-    "partition_id",
-    "id",
-    "formsemestre_id",
-    "partition_name",
-    "numero",
     "bul_show_rank",
-    "show_in_lists",
+    "formsemestre_id",
     "group_id",
     "group_name",
+    "id",
+    "numero",
+    "partition_id",
+    "partition_name",
+    "show_in_lists",
 }
 
 EVALUATIONS_FIELDS = {
@@ -573,107 +577,107 @@ EVALUATIONS_FIELDS = {
 }
 
 NOTES_FIELDS = {
-    "etudid",
-    "evaluation_id",
-    "value",
     "comment",
     "date",
+    "etudid",
+    "evaluation_id",
     "uid",
+    "value",
 }
 
 
 PARTITIONS_FIELDS = {
-    "id",
+    "bul_show_rank",
     "formsemestre_id",
-    "partition_name",
+    "id",
     "numero",
-    "bul_show_rank",
+    "partition_name",
     "show_in_lists",
 }
 
 PARTITION_GROUPS_ETUD_FIELDS = {
-    "id",
+    "civilite",
+    "code_ine",
+    "code_nip",
     "dept_id",
+    "id",
+    "nom_usuel",
     "nom",
     "prenom",
-    "nom_usuel",
-    "civilite",
-    "code_nip",
-    "code_ine",
 }
 
 FORMSEMESTRE_BULLETINS_FIELDS = {
-    "version",
-    "type",
     "date",
-    "publie",
+    "etat_inscription",
     "etudiant",
     "formation",
     "formsemestre_id",
-    "etat_inscription",
     "options",
+    "publie",
     "ressources",
     "saes",
-    "ues",
     "semestre",
+    "type",
+    "ues",
+    "version",
 }
 
 FORMSEMESTRE_BULLETINS_ETU_FIELDS = {
+    "boursier",
     "civilite",
     "code_ine",
     "code_nip",
+    "codepostaldomicile",
     "date_naissance",
-    "dept_id",
     "dept_acronym",
+    "dept_id",
+    "dept_naissance",
+    "description",
+    "domicile",
     "email",
     "emailperso",
     "etudid",
-    "nom",
-    "prenom",
-    "nomprenom",
-    "lieu_naissance",
-    "dept_naissance",
-    "nationalite",
-    "boursier",
+    "fax",
     "fiche_url",
-    "photo_url",
     "id",
-    "codepostaldomicile",
+    "lieu_naissance",
+    "nationalite",
+    "nom",
+    "nomprenom",
     "paysdomicile",
+    "photo_url",
+    "prenom",
+    "telephone",
     "telephonemobile",
     "typeadresse",
-    "domicile",
     "villedomicile",
-    "telephone",
-    "fax",
-    "description",
 }
 
 FORMSEMESTRE_BULLETINS_FORMATION_FIELDS = {
-    "id",
     "acronyme",
+    "id",
     "titre_officiel",
     "titre",
 }
 
 FORMSEMESTRE_BULLETINS_OPT_FIELDS = {
-    "show_abs",
     "show_abs_modules",
-    "show_ects",
+    "show_abs",
     "show_codemodules",
+    "show_coef",
+    "show_date_inscr",
+    "show_ects",
     "show_matieres",
-    "show_rangs",
-    "show_ue_rangs",
+    "show_minmax_eval",
+    "show_minmax_mod",
+    "show_minmax",
     "show_mod_rangs",
     "show_moypromo",
-    "show_minmax",
-    "show_minmax_mod",
-    "show_minmax_eval",
-    "show_coef",
-    "show_ue_cap_details",
-    "show_ue_cap_current",
+    "show_rangs",
     "show_temporary",
-    "temporary_txt",
+    "show_ue_cap_current",
+    "show_ue_cap_details",
+    "show_ue_rangs",
     "show_uevalid",
-    "show_date_inscr",
+    "temporary_txt",
 }
diff --git a/tests/scenarios/test_scenario1_formation.py b/tests/scenarios/test_scenario1_formation.py
index eb93f3d6b..e1903da87 100644
--- a/tests/scenarios/test_scenario1_formation.py
+++ b/tests/scenarios/test_scenario1_formation.py
@@ -12,7 +12,8 @@ Usage: pytest tests/scenarios/test_scenario1_formation.py
 # code écrit par Fares Amer, mai 2021 et porté sur ScoDoc 8 en août 2021
 
 from tests.unit import sco_fake_gen
-from app.formations import edit_module, formation_io
+from app.formations import formation_io
+from app.models import Formation
 
 
 @pytest.mark.skip  # test obsolete
@@ -52,11 +53,11 @@ def run_scenario1():
     ]
 
     # --- Implémentation des modules
-    modules = edit_module.module_list({"formation_id": formation_id})
+    formation = Formation.get_formation(formation_id)
     mods_imp = []
-    for mod in modules:
+    for mod in formation.modules:
         mi = G.create_moduleimpl(
-            module_id=mod["module_id"],
-            formsemestre_id=sems[mod["semestre_id"] - 1]["formsemestre_id"],
+            module_id=mod.id,
+            formsemestre_id=sems[mod.semestre_id - 1]["formsemestre_id"],
         )
         mods_imp.append(mi)
diff --git a/tests/unit/sco_fake_gen.py b/tests/unit/sco_fake_gen.py
index ef3976826..d9545c961 100644
--- a/tests/unit/sco_fake_gen.py
+++ b/tests/unit/sco_fake_gen.py
@@ -16,7 +16,7 @@ import typing
 
 from app import db, log
 from app.auth.models import User
-from app.formations import edit_matiere, edit_module, edit_ue
+from app.formations import edit_ue
 from app.models import (
     Departement,
     Evaluation,
@@ -24,13 +24,13 @@ from app.models import (
     FormationModalite,
     Identite,
     Matiere,
+    Module,
     ModuleImpl,
 )
 from app.scodoc import codes_cursus
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_formsemestre_validation
-from app.scodoc import sco_moduleimpl
 from app.scodoc import sco_saisie_notes
 from app.scodoc import sco_synchro_etuds
 from app.scodoc import sco_utils as scu
@@ -228,11 +228,8 @@ class ScoFake(object):
         matiere = db.session.get(Matiere, matiere_id)
         ue_id = matiere.ue.id
         formation_id = matiere.ue.formation.id
-        oid = edit_module.do_module_create(locals())
-        oids = edit_module.module_list(args={"module_id": oid})
-        if not oids:
-            raise ScoValueError(f"module not created ! (oid={oid})")
-        return oid
+        module = Module.create_from_dict(locals(), news=True, inval_cache=True)
+        return module.id
 
     @logging_meth
     def create_formsemestre(
@@ -276,11 +273,8 @@ class ScoFake(object):
     ) -> int:
         if not responsable_id:
             responsable_id = self.default_user.id
-        oid = sco_moduleimpl.do_moduleimpl_create(locals())
-        oids = sco_moduleimpl.moduleimpl_list(moduleimpl_id=oid)  # API inconsistency
-        if not oids:
-            raise ScoValueError("moduleimpl not created !")
-        return oid
+        modimpl = ModuleImpl.create_from_dict(locals())
+        return modimpl.id
 
     @logging_meth
     def inscrit_etudiant(self, formsemestre_id: int, etud: dict):
diff --git a/tests/unit/test_formations.py b/tests/unit/test_formations.py
index e234e1ff5..a68114891 100644
--- a/tests/unit/test_formations.py
+++ b/tests/unit/test_formations.py
@@ -34,8 +34,7 @@
 # - moduleimpl_list
 # - do_module_impl_with_module_list
 # - do_formsemestre_delete
-# - module_list
-# - do_module_delete
+# - Module.delete
 # - ue_list
 # - do_ue_delete
 # - do_formation_delete
@@ -52,7 +51,7 @@ from app.formations import (
     edit_ue,
     formation_io,
 )
-from app.models import Formation, Matiere, ModuleImpl
+from app.models import Formation, Matiere, Module, ModuleImpl
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_exceptions
 from app.scodoc import sco_formsemestre_edit
@@ -259,7 +258,7 @@ def test_formations(test_client):
         formsemestre_id=sem2["formsemestre_id"]
     )
 
-    li_module = edit_module.module_list()
+    li_module = Module.query.all()
     assert len(li_module) == 4
     # Suppression impossible car utilisé dans le semestre formsemestre_idt:
     module3 = db.session.get(ModuleImpl, mi3).module
@@ -268,13 +267,14 @@ def test_formations(test_client):
 
     sco_formsemestre_edit.do_formsemestre_delete(formsemestre_idt)
 
-    li_module2_before = edit_module.module_list()
+    li_module2_before = Module.query.all()
 
-    edit_module.do_module_delete(module3.id)
-    edit_module.do_module_delete(module_id_t)
+    module3.delete()
+    module_t = db.session.get(Module, module_id_t)
+    module_t.delete()
 
     # deuxieme methode de supression d'un module
-    li_module2_after = edit_module.module_list()
+    li_module2_after = Module.query.all()
 
     assert (
         len(li_module2_after) == len(li_module2_before) - 2
@@ -340,14 +340,14 @@ def test_import_formation(test_client, filename="formation-exemple-1.xml"):
         )
     ]
     # et les modules
-    modules = edit_module.module_list({"formation_id": formation_id})
-    for mod in modules:
+    formation = Formation.get_formation(formation_id)
+    for mod in formation.modules:
         moduleimpl_id = G.create_moduleimpl(
-            module_id=mod["module_id"],
-            formsemestre_id=formsemestre_ids[mod["semestre_id"] - 1],
+            module_id=mod.id,
+            formsemestre_id=formsemestre_ids[mod.semestre_id - 1],
         )
         mi = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
-        assert mi["module_id"] == mod["module_id"]
+        assert mi["module_id"] == mod.id
 
     # --- Export formation en XML
     doc1 = formation_io.formation_export(formation_id, fmt="xml").get_data(as_text=True)
-- 
GitLab