diff --git a/app/auth/models.py b/app/auth/models.py
index 440117ed9461db246a7177f2b1f52d8a9f491972..465169c578b42ab983245a130fdab7ab685ab9e3 100644
--- a/app/auth/models.py
+++ b/app/auth/models.py
@@ -157,6 +157,24 @@ class User(UserMixin, ScoDocModel):
     def __str__(self):
         return self.user_name
 
+    @classmethod
+    def get_user(cls, user_id: int | str, accept_none=False):
+        """Get user by id, user_name or User instance, ou 404 (ou None si accept_none)
+        If user_id == -1, returns None (without exception)
+        """
+        query = None
+        if isinstance(user_id, str):
+            query = db.session.query(cls).filter_by(user_name=user_id)
+        elif isinstance(user_id, int):
+            if user_id == -1:
+                return None
+            query = db.session.query(cls).filter_by(id=user_id)
+        elif isinstance(user_id, User):
+            return user_id
+        else:
+            raise ValueError("invalid user_id")
+        return query.first_or_404() if not accept_none else query.first()
+
     def set_password(self, password):
         "Set password"
         current_app.logger.info(f"set_password({self})")
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 26ed712f02708e292b2b98dbe2e82f70fb092e62..c70d2fbdf67b7138d2f95b7890553ad6859a222b 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -126,7 +126,7 @@ class ScoDocModel(db.Model):
 
     @classmethod
     def get_instance(cls, oid: int, accept_none=False):
-        """Instance du modèle ou ou 404 (ou None si accept_none),
+        """Instance du modèle ou 404 (ou None si accept_none),
         cherche uniquement dans le département courant.
 
         Ne fonctionne que si le modèle a un attribut dept_id
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index cd2f0e7d25281aef58595706852a7b4af88c663b..0de2eb85459d8723cdd7cccabd6d4954948a2940 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -139,7 +139,7 @@ class FormSemestre(models.ScoDocModel):
 
     # Relations:
     etapes = db.relationship(
-        "FormSemestreEtape", cascade="all,delete", backref="formsemestre"
+        "FormSemestreEtape", cascade="all,delete-orphan", backref="formsemestre"
     )
     modimpls = db.relationship(
         "ModuleImpl",
@@ -242,11 +242,9 @@ class FormSemestre(models.ScoDocModel):
             args["dept_id"] = g.scodoc_dept_id
         formsemestre: "FormSemestre" = cls.create_from_dict(args)
         db.session.flush()
-        for etape in args["etapes"]:
+        for etape in args.get("etapes") or []:
             formsemestre.add_etape(etape)
         db.session.commit()
-        for u in args["responsables"]:
-            formsemestre.responsables.append(u)
         # create default partition
         partition = Partition(
             formsemestre=formsemestre, partition_name=None, numero=1000000
@@ -281,11 +279,26 @@ class FormSemestre(models.ScoDocModel):
         if "date_fin" in args:
             args["date_fin"] = scu.convert_fr_date(args["date_fin"])
         if "etat" in args:
-            args["etat"] = bool(args["etat"])
+            if args["etat"] is None:
+                del args["etat"]
+            else:
+                args["etat"] = bool(args["etat"])
         if "bul_bgcolor" in args:
             args["bul_bgcolor"] = args.get("bul_bgcolor") or "white"
         if "titre" in args:
             args["titre"] = args.get("titre") or "sans titre"
+        if "capacite_accueil" in args:  # peut être un nombre, "" ou None
+            try:
+                args["capacite_accueil"] = (
+                    int(args["capacite_accueil"])
+                    if args["capacite_accueil"] not in ("", None)
+                    else None
+                )
+            except ValueError as exc:
+                raise ScoValueError("capacite_accueil invalide") from exc
+        if "responsables" in args:  # peut être liste d'uid ou de user_name ou de User
+            resp_users = [User.get_user(u) for u in args["responsables"]]
+            args["responsables"] = [u for u in resp_users if u is not None]
         return args
 
     @classmethod
@@ -1346,6 +1359,7 @@ class FormSemestre(models.ScoDocModel):
                     },
                 )
         db.session.commit()
+        sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
 
     def etud_validations_description_html(self, etudid: int) -> str:
         """Description textuelle des validations de jury de cet étudiant dans ce semestre"""
@@ -1461,6 +1475,15 @@ class FormSemestreEtape(models.ScoDocModel):
     # etape_apo aurait du etre not null, mais oublié
     etape_apo = db.Column(db.String(APO_CODE_STR_LEN), index=True)
 
+    @classmethod
+    def create_from_apovdi(
+        cls, formsemestre_id: int, apovdi: ApoEtapeVDI
+    ) -> "FormSemestreEtape":
+        "Crée une instance à partir d'un objet ApoEtapeVDI. Ajoute à la session."
+        etape = cls(formsemestre_id=formsemestre_id, etape_apo=str(apovdi))
+        db.session.add(etape)
+        return etape
+
     def __bool__(self):
         "Etape False if code empty"
         return self.etape_apo is not None and (len(self.etape_apo) > 0)
diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py
index 5131947ff46d6b4ee51cbe32be668fffe30a52a6..31dffb7365c635d6e2d02131b5039a9351ba17f2 100644
--- a/app/scodoc/sco_formsemestre.py
+++ b/app/scodoc/sco_formsemestre.py
@@ -39,7 +39,7 @@ import app.scodoc.sco_utils as scu
 from app import log
 from app.models import Departement
 from app.models import Formation, FormSemestre
-from app.scodoc import sco_cache, codes_cursus, sco_preferences
+from app.scodoc import codes_cursus, sco_preferences
 from app.scodoc.gen_tables import GenTable
 from app.scodoc.codes_cursus import NO_SEMESTRE_ID
 from app.scodoc.sco_exceptions import ScoInvalidIdType, ScoValueError
@@ -231,63 +231,7 @@ def etapes_apo_str(etapes):
     return ", ".join([str(x) for x in etapes])
 
 
-def do_formsemestre_create(  # DEPRECATED, use FormSemestre.create_formsemestre()
-    args, silent=False
-):
-    "create a formsemestre"
-    from app.models import ScolarNews
-    from app.scodoc import sco_groups
-
-    log("Warning: do_formsemestre_create is deprecated")
-    cnx = ndb.GetDBConnexion()
-    formsemestre_id = _formsemestreEditor.create(cnx, args)
-    if args["etapes"]:
-        args["formsemestre_id"] = formsemestre_id
-        write_formsemestre_etapes(args)
-    if args["responsables"]:
-        args["formsemestre_id"] = formsemestre_id
-        _write_formsemestre_responsables(args)
-
-    # create default partition
-    partition_id = sco_groups.partition_create(
-        formsemestre_id,
-        default=True,
-        redirect=0,
-        numero=1000000,  # à la fin
-    )
-    _ = sco_groups.create_group(partition_id, default=True)
-
-    # news
-    if "titre" not in args:
-        args["titre"] = "sans titre"
-    args["formsemestre_id"] = formsemestre_id
-    args["url"] = "Notes/formsemestre_status?formsemestre_id=%(formsemestre_id)s" % args
-    if not silent:
-        ScolarNews.add(
-            typ=ScolarNews.NEWS_SEM,
-            text='Création du semestre <a href="%(url)s">%(titre)s</a>' % args,
-            url=args["url"],
-            max_frequency=0,
-        )
-    return formsemestre_id
-
-
-def do_formsemestre_edit(sem, cnx=None, **kw):
-    """Apply modifications to formsemestre.
-    Update etapes and resps. Invalidate cache."""
-    if not cnx:
-        cnx = ndb.GetDBConnexion()
-
-    _formsemestreEditor.edit(cnx, sem, **kw)
-    write_formsemestre_etapes(sem)
-    _write_formsemestre_responsables(sem)
-
-    sco_cache.invalidate_formsemestre(
-        formsemestre_id=sem["formsemestre_id"]
-    )  # > modif formsemestre
-
-
-def read_formsemestre_responsables(formsemestre_id: int) -> list[int]:  # py3.9+ syntax
+def read_formsemestre_responsables(formsemestre_id: int) -> list[int]:  # OBSOLETE
     """recupere liste des responsables de ce semestre
     :returns: liste d'id
     """
@@ -301,14 +245,6 @@ def read_formsemestre_responsables(formsemestre_id: int) -> list[int]:  # py3.9+
     return [x["responsable_id"] for x in r]
 
 
-def _write_formsemestre_responsables(sem):  # TODO old, à ré-écrire avec models
-    if sem and "responsables" in sem:
-        sem["responsables"] = [
-            uid for uid in sem["responsables"] if (uid is not None) and (uid != -1)
-        ]
-        _write_formsemestre_aux(sem, "responsables", "responsable_id")
-
-
 # ----------------------  Coefs des UE
 
 _formsemestre_uecoef_editor = ndb.EditableTable(
diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index 17f013ef97eedd9d426e861577a0b707d26f2736..0060b9b037020e514d8af3b8498d70794315c1fc 100644
--- a/app/scodoc/sco_formsemestre_edit.py
+++ b/app/scodoc/sco_formsemestre_edit.py
@@ -50,7 +50,7 @@ from app.models import (
     UniteEns,
 )
 from app.models.formations import Formation
-from app.models.formsemestre import FormSemestre
+from app.models.formsemestre import FormSemestre, FormSemestreEtape
 from app.models.but_refcomp import ApcParcours
 import app.scodoc.notesdb as ndb
 import app.scodoc.sco_utils as scu
@@ -143,7 +143,7 @@ def can_edit_sem(formsemestre_id: int = None, sem=None):
     return sem
 
 
-resp_fields = [
+RESP_FIELDS = [
     "responsable_id",
     "responsable_id2",
     "responsable_id3",
@@ -205,7 +205,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                 f"inconnu numéro {modimpl.responsable_id} resp. de {modimpl.id} !",
             )
         for index, resp in enumerate(formsemestre.responsables):
-            initvalues[resp_fields[index]] = uid2display.get(resp.id)
+            initvalues[RESP_FIELDS[index]] = uid2display.get(resp.id)
         group_tous = formsemestre.get_default_group()
         if group_tous:
             initvalues["edt_promo_id"] = group_tous.edt_id or ""
@@ -317,7 +317,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                     },
                 },
             )
-            for index, field in enumerate(resp_fields)
+            for index, field in enumerate(RESP_FIELDS)
         ],
         (
             "titre",
@@ -755,7 +755,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                             "input_type": "text_suggest",
                             "size": 50,
                             "withcheckbox": True,
-                            "title": "%s %s" % (mod.code or "", mod.titre or ""),
+                            "title": f"""{mod.code or ""} {mod.titre or ""}""",
                             "allowed_values": allowed_user_names,
                             "template": itemtemplate,
                             "text_suggest_options": {
@@ -875,157 +875,106 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                     formsemestre_id=formsemestre.id,
                 )
             )
-        else:
-            return redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept))
-    else:
-        if tf[2]["gestion_compensation_lst"]:
-            tf[2]["gestion_compensation"] = True
-        else:
-            tf[2]["gestion_compensation"] = False
-        if tf[2]["gestion_semestrielle_lst"]:
-            tf[2]["gestion_semestrielle"] = True
-        else:
-            tf[2]["gestion_semestrielle"] = False
-        if tf[2]["bul_publish_xml_lst"]:
-            tf[2]["bul_hide_xml"] = False
-        else:
-            tf[2]["bul_hide_xml"] = True
-        # remap les identifiants de responsables:
-        for field in resp_fields:
-            resp = User.get_user_from_nomplogin(tf[2][field])
-            tf[2][field] = resp.id if resp else -1
-        tf[2]["responsables"] = []
-        for field in resp_fields:
-            if tf[2][field]:
-                tf[2]["responsables"].append(tf[2][field])
-        for module_id in tf[2]["tf-checked"]:
-            mod_resp = User.get_user_from_nomplogin(tf[2][module_id])
-            if mod_resp is None:
-                # Si un module n'a pas de responsable (ou inconnu),
-                # l'affecte au 1er directeur des etudes:
-                mod_resp_id = tf[2]["responsable_id"]
-            else:
-                mod_resp_id = mod_resp.id
-            tf[2][module_id] = mod_resp_id
-
-        # etapes:
-        tf[2]["etapes"] = []
-        if etapes:  # menus => case supplementaire pour saisie manuelle, indicée 0
-            start_i = 0
-        else:
-            start_i = 1
-        for n in range(start_i, scu.EDIT_NB_ETAPES + 1):
-            tf[2]["etapes"].append(
-                ApoEtapeVDI(
-                    etape=tf[2]["etape_apo" + str(n)], vdi=tf[2]["vdi_apo" + str(n)]
-                )
-            )
-        # Modules sélectionnés:
-        # (retire le "MI" du début du nom de champs)
-        module_ids_checked = [int(x[2:]) for x in tf[2]["tf-checked"]]
-        _formsemestre_check_ue_bonus_unicity(module_ids_checked)
-        if not edit:
-            if is_apc:
-                _formsemestre_check_module_list(
-                    module_ids_checked, tf[2]["semestre_id"]
-                )
-            # création du semestre
-            formsemestre_id = sco_formsemestre.do_formsemestre_create(tf[2])
-            # création des modules
-            for module_id in module_ids_checked:
-                modargs = {
-                    "module_id": module_id,
-                    "formsemestre_id": formsemestre_id,
-                    "responsable_id": tf[2][f"MI{module_id}"],
-                }
-                _ = ModuleImpl.create_from_dict(modargs)
-        else:
-            # Modification du semestre:
-            # on doit creer les modules nouvellement selectionnés
-            # modifier ceux à modifier, et DETRUIRE ceux qui ne sont plus selectionnés.
-            # Note: la destruction échouera s'il y a des objets dépendants
-            #       (eg des évaluations définies)
-            module_ids_tocreate = [
-                x for x in module_ids_checked if not x in module_ids_existing
-            ]
-            if is_apc:
-                _formsemestre_check_module_list(
-                    module_ids_tocreate, tf[2]["semestre_id"]
-                )
-            # modules existants à modifier
-            module_ids_toedit = [
-                x for x in module_ids_checked if x in module_ids_existing
-            ]
-            # modules à détruire
-            module_ids_todelete = [
-                x for x in module_ids_existing if not x in module_ids_checked
-            ]
-            #
-            sco_formsemestre.do_formsemestre_edit(tf[2])
-            #
-            msg = []
-            for module_id in module_ids_tocreate:
-                modargs = {
-                    "module_id": module_id,
-                    "formsemestre_id": formsemestre.id,
-                    "responsable_id": tf[2]["MI" + str(module_id)],
-                }
-                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
-                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, modimpl.id, etudids)
-                    )
-                    sco_moduleimpl.do_moduleimpl_inscrit_etuds(
-                        modimpl.id,
-                        formsemestre.id,
-                        etudids,
-                    )
-                    msg += [
-                        "inscription de %d étudiants au module %s"
-                        % (len(etudids), mod["code"] or "(module sans code)")
-                    ]
-                else:
-                    log(
-                        "inscription module:module_id=%s,moduleimpl_id=%s: aucun etudiant inscrit"
-                        % (module_id, modimpl.id)
-                    )
-            #
-            ok, diag = formsemestre_delete_moduleimpls(
-                formsemestre.id, module_ids_todelete
-            )
-            msg += diag
-            for module_id in module_ids_toedit:
-                moduleimpl_id = sco_moduleimpl.moduleimpl_list(
-                    formsemestre_id=formsemestre.id, module_id=module_id
-                )[0]["moduleimpl_id"]
-                modargs = {
-                    "moduleimpl_id": moduleimpl_id,
-                    "module_id": module_id,
-                    "formsemestre_id": formsemestre.id,
-                    "responsable_id": tf[2]["MI" + str(module_id)],
-                }
-                sco_moduleimpl.do_moduleimpl_edit(
-                    modargs, formsemestre_id=formsemestre.id
-                )
-    # --- Association des parcours
-    if formsemestre is None:
-        formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+        return redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept))
+    # Edition ou modification du semestre
+    tf[2]["gestion_compensation"] = bool(tf[2]["gestion_compensation_lst"])
+    tf[2]["gestion_semestrielle"] = bool(tf[2]["gestion_semestrielle_lst"])
+    tf[2]["bul_hide_xml"] = not bool(tf[2]["bul_publish_xml_lst"])
+    _remap_resp_modimpls(tf[2])
     if "parcours" in tf[2]:
-        formsemestre.parcours = [
+        tf[2]["parcours"] = [
             db.session.get(ApcParcours, int(parcour_id_str))
             for parcour_id_str in tf[2]["parcours"]
         ]
-    # --- Id edt du groupe par défault
+    # Modules sélectionnés:
+    # (retire le "MI" du début du nom de champs)
+    module_ids_checked = [int(x[2:]) for x in tf[2]["tf-checked"]]
+    _formsemestre_check_ue_bonus_unicity(module_ids_checked)
+    if not edit:
+        if is_apc:
+            _formsemestre_check_module_list(module_ids_checked, tf[2]["semestre_id"])
+        # création du semestre
+        formsemestre = FormSemestre.create_formsemestre(tf[2])
+        # création des modules
+        for module_id in module_ids_checked:
+            modargs = {
+                "module_id": module_id,
+                "formsemestre_id": formsemestre.id,
+                "responsable_id": tf[2][f"MI{module_id}"],
+            }
+            _ = ModuleImpl.create_from_dict(modargs)
+    else:
+        # Modification du semestre:
+        # on doit creer les modules nouvellement selectionnés
+        # modifier ceux à modifier, et DETRUIRE ceux qui ne sont plus selectionnés.
+        # Note: la destruction échouera s'il y a des objets dépendants
+        #       (eg des évaluations définies)
+        module_ids_tocreate = [
+            x for x in module_ids_checked if not x in module_ids_existing
+        ]
+        if is_apc:
+            _formsemestre_check_module_list(module_ids_tocreate, tf[2]["semestre_id"])
+        # modules existants à modifier
+        module_ids_toedit = [x for x in module_ids_checked if x in module_ids_existing]
+        # modules à détruire
+        module_ids_todelete = [
+            x for x in module_ids_existing if not x in module_ids_checked
+        ]
+        #
+        formsemestre.from_dict(tf[2])
+        sco_cache.invalidate_formsemestre(formsemestre.id)
+        #
+        msg = []
+        for module_id in module_ids_tocreate:
+            modargs = {
+                "module_id": module_id,
+                "formsemestre_id": formsemestre.id,
+                "responsable_id": tf[2]["MI" + str(module_id)],
+            }
+            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
+            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(
+                    f"""inscription module:module_id={module_id},moduleimpl_id={
+                        modimpl.id}: {etudids}"""
+                )
+                sco_moduleimpl.do_moduleimpl_inscrit_etuds(
+                    modimpl.id,
+                    formsemestre.id,
+                    etudids,
+                )
+                msg += [
+                    f"""inscription de {len(etudids)} étudiants au module {
+                        mod.code or "(module sans code)"}"""
+                ]
+            else:
+                log(
+                    f"""inscription module:module_id={module_id},moduleimpl_id={
+                        modimpl.id}: aucun etudiant inscrit"""
+                )
+        #
+        ok, diag = formsemestre_delete_moduleimpls(formsemestre.id, module_ids_todelete)
+        msg += diag
+        for module_id in module_ids_toedit:
+            moduleimpl_id = sco_moduleimpl.moduleimpl_list(
+                formsemestre_id=formsemestre.id, module_id=module_id
+            )[0]["moduleimpl_id"]
+            modargs = {
+                "moduleimpl_id": moduleimpl_id,
+                "module_id": module_id,
+                "formsemestre_id": formsemestre.id,
+                "responsable_id": tf[2]["MI" + str(module_id)],
+            }
+            sco_moduleimpl.do_moduleimpl_edit(modargs, formsemestre_id=formsemestre.id)
+    # --- Etapes
+    _set_apo_etapes(formsemestre, tf[2], etapes)
+    # --- id edt du groupe par défault
     if "edt_promo_id" in tf[2]:
         group_tous = formsemestre.get_default_group()
         if group_tous:
@@ -1041,6 +990,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
     formsemestre.update_inscriptions_parcours_from_groups()
     # --- Fin
     if edit:
+        log(f"""formsemestre_edit: {formsemestre}""")
         if msg:
             return f"""
                 <div class="ue_warning"><span>Attention !<ul>
@@ -1057,25 +1007,68 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
                         scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
                 }">retour au tableau de bord</a>
             """
-        else:
-            flash("Semestre modifié")
-            return flask.redirect(
-                url_for(
-                    "notes.formsemestre_status",
-                    scodoc_dept=g.scodoc_dept,
-                    formsemestre_id=formsemestre.id,
-                    check_parcours=0,
-                )
-            )
-    else:
-        flash("Nouveau semestre créé")
+        flash("Semestre modifié")
         return flask.redirect(
             url_for(
                 "notes.formsemestre_status",
                 scodoc_dept=g.scodoc_dept,
                 formsemestre_id=formsemestre.id,
+                check_parcours=0,
             )
         )
+    flash("Nouveau semestre créé")
+    return flask.redirect(
+        url_for(
+            "notes.formsemestre_status",
+            scodoc_dept=g.scodoc_dept,
+            formsemestre_id=formsemestre.id,
+        )
+    )
+
+
+def _remap_resp_modimpls(args: dict):
+    "remap les identifiants de responsables de modules"
+    for field in RESP_FIELDS:
+        resp = User.get_user_from_nomplogin(args[field])
+        args[field] = resp.id if resp else -1
+    args["responsables"] = []
+    for field in RESP_FIELDS:
+        if args[field]:
+            args["responsables"].append(args[field])
+    for module_id in args["tf-checked"]:
+        mod_resp = User.get_user_from_nomplogin(args[module_id])
+        if mod_resp is None:
+            # Si un module n'a pas de responsable (ou inconnu),
+            # l'affecte au 1er directeur des etudes:
+            mod_resp_id = args["responsable_id"]
+        else:
+            mod_resp_id = mod_resp.id
+        args[module_id] = mod_resp_id
+
+
+def _set_apo_etapes(formsemestre: FormSemestre, args: dict, etapes: list[str]):
+    """Affecte les étapes Apo du semestre,
+    à partir de args["etape_apo<n>"] et  args["vdi_apo<n>]
+    """
+    # menus => case supplementaire pour saisie manuelle, indicée 0
+    start_i = 0 if etapes else 1
+    apo_etapes_vdi = []
+    for n in range(start_i, scu.EDIT_NB_ETAPES + 1):
+        apo_etapes_vdi.append(
+            ApoEtapeVDI(etape=args["etape_apo" + str(n)], vdi=args["vdi_apo" + str(n)])
+        )
+    # uniques:
+    apo_etapes_vdi_uniq = {str(e): e for e in apo_etapes_vdi if str(e)}.values()
+    formsemestre.etapes = []
+    db.session.add(formsemestre)
+    db.session.flush()
+    formsemestre.etapes = [
+        FormSemestreEtape.create_from_apovdi(formsemestre.id, etape_vdi)
+        for etape_vdi in apo_etapes_vdi_uniq
+    ]
+    db.session.add(formsemestre)
+    db.session.flush()
+    log(f"setting etapes: {formsemestre}: {formsemestre.etapes}")
 
 
 def _formsemestre_check_module_list(module_ids, semestre_idx):
@@ -1711,7 +1704,8 @@ def formsemestre_edit_options(formsemestre_id):
     """dialog to change formsemestre options
     (accessible par EditFormSemestre ou dir. etudes)
     """
-    ok, err = sco_permissions_check.check_access_diretud(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    ok, err = sco_permissions_check.check_access_diretud(formsemestre)
     if not ok:
         return err
     return sco_preferences.SemPreferences(formsemestre_id).edit(
@@ -1719,46 +1713,46 @@ def formsemestre_edit_options(formsemestre_id):
     )
 
 
-def formsemestre_change_publication_bul(
-    formsemestre_id, dialog_confirmed=False, redirect=True
-):
-    """Change etat publication bulletins sur portail"""
-    ok, err = sco_permissions_check.check_access_diretud(formsemestre_id)
+def formsemestre_change_publication_bul(formsemestre_id, dialog_confirmed=False):
+    """Change état publication bulletins sur portail"""
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    ok, err = sco_permissions_check.check_access_diretud(formsemestre)
     if not ok:
         return err
-    sem = sco_formsemestre.get_formsemestre(formsemestre_id)
-    etat = not sem["bul_hide_xml"]
+    etat = not formsemestre.bul_hide_xml
 
+    status_url = url_for(
+        "notes.formsemestre_status",
+        scodoc_dept=g.scodoc_dept,
+        formsemestre_id=formsemestre.id,
+    )
     if not dialog_confirmed:
-        if etat:
-            msg = "non"
-        else:
-            msg = ""
+        msg = "non" if etat else ""
         return scu.confirm_dialog(
-            "<h2>Confirmer la %s publication des bulletins ?</h2>" % msg,
+            f"<h2>Confirmer la {msg} publication des bulletins ?</h2>",
             help_msg="""Il est parfois utile de désactiver la diffusion des bulletins,
             par exemple pendant la tenue d'un jury ou avant harmonisation des notes.
             <br>
-            Ce réglage n'a d'effet que si votre établissement a interfacé ScoDoc et un portail étudiant.
+            Ce réglage n'a d'effet que si votre établissement a interfacé ScoDoc avec
+            une passerelle étudiant.
             """,
             dest_url="",
-            cancel_url="formsemestre_status?formsemestre_id=%s" % formsemestre_id,
+            cancel_url=status_url,
             parameters={"bul_hide_xml": etat, "formsemestre_id": formsemestre_id},
         )
 
-    args = {"formsemestre_id": formsemestre_id, "bul_hide_xml": etat}
-    sco_formsemestre.do_formsemestre_edit(args)
-    if redirect:
-        return flask.redirect(
-            "formsemestre_status?formsemestre_id=%s" % formsemestre_id
-        )
-    return None
+    formsemestre.bul_hide_xml = etat
+    db.session.add(formsemestre)
+    db.session.commit()
+    log(f"formsemestre_change_publication_bul: {formsemestre} -> {etat}")
+
+    return flask.redirect(status_url)
 
 
 def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
     """Changement manuel des coefficients des UE capitalisées."""
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
-    ok, err = sco_permissions_check.check_access_diretud(formsemestre_id)
+    ok, err = sco_permissions_check.check_access_diretud(formsemestre)
     if not ok:
         return err
 
diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py
index 29dbc0a0bfca9c3ee50130e59647370499fedbe9..dece55410f1077ed92e757c780a70a8665d83bf1 100644
--- a/app/scodoc/sco_permissions.py
+++ b/app/scodoc/sco_permissions.py
@@ -23,7 +23,11 @@ _SCO_PERMISSIONS = (
     (1 << 9, "EditFormationTags", "Tagguer les formations"),
     (1 << 10, "EditAllNotes", "Modifier toutes les notes"),
     (1 << 11, "EditAllEvals", "Modifier toutes les évaluations"),
-    (1 << 12, "EditFormSemestre", "Mettre en place une formation (créer un semestre)"),
+    (
+        1 << 12,
+        "EditFormSemestre",
+        "Mettre en place ou modifier un semestre de formation",
+    ),
     (1 << 13, "AbsChange", "Saisir des absences ou justificatifs"),
     (1 << 14, "AbsAddBillet", "Saisir des billets d'absences"),
     # changer adresse/photo ou pour envoyer bulletins par mail ou pour debouche
diff --git a/app/scodoc/sco_permissions_check.py b/app/scodoc/sco_permissions_check.py
index eebaa16286a191f45ef2051d2627aaa785b5d0cd..6f98aafd22b30906d44d91e0ec92ff71ca0eab38 100644
--- a/app/scodoc/sco_permissions_check.py
+++ b/app/scodoc/sco_permissions_check.py
@@ -35,16 +35,11 @@ def can_edit_suivi():
     return current_user.has_permission(Permission.EtudChangeAdr)
 
 
-def check_access_diretud(
-    formsemestre_id, required_permission=Permission.EditFormSemestre
-):
+def check_access_diretud(formsemestre: FormSemestre):
     """Check if access granted: responsable or EditFormSemestre
     Return True|False, HTML_error_page
     """
-    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
-    if (not current_user.has_permission(required_permission)) and (
-        current_user.id not in (u.id for u in formsemestre.responsables)
-    ):
+    if not formsemestre.can_be_edited_by(current_user):
         return (
             False,
             render_template(
diff --git a/app/views/notes.py b/app/views/notes.py
index 6100e45c84152f947221ad80c486041dadfd7d0a..22d1a16fb0a0797ceae6156a5459ff1c8c54fe70 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -2568,8 +2568,7 @@ def check_sem_integrity(formsemestre_id, fix=False):
     """Debug.
     Check that ue and module formations are consistents
     """
-    sem = sco_formsemestre.get_formsemestre(formsemestre_id)
-
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
     bad_ue = []
     bad_sem = []
@@ -2584,12 +2583,12 @@ def check_sem_integrity(formsemestre_id, fix=False):
             modimpl["mod"] = mod.to_dict()
             modimpl["ue"] = ue_dict
             bad_ue.append(modimpl)
-        if sem["formation_id"] != mod.formation_id:
+        if formsemestre.formation_id != mod.formation_id:
             bad_sem.append(modimpl)
             modimpl["mod"] = mod.to_dict()
 
     H = [
-        f"""<p>formation_id={sem["formation_id"]}""",
+        f"""<p>formation_id={formsemestre.formation_id}""",
     ]
     if bad_ue:
         H += [
@@ -2605,22 +2604,24 @@ def check_sem_integrity(formsemestre_id, fix=False):
         H.append("<p>Aucun problème à signaler !</p>")
     else:
         log(f"check_sem_integrity: problem detected: formations_set={formations_set}")
-        if sem["formation_id"] in formations_set:
-            formations_set.remove(sem["formation_id"])
+        if formsemestre.formation_id in formations_set:
+            formations_set.remove(formsemestre.formation_id)
         if len(formations_set) == 1:
             if fix:
                 log(f"check_sem_integrity: trying to fix {formsemestre_id}")
                 formation_id = formations_set.pop()
-                if sem["formation_id"] != formation_id:
-                    sem["formation_id"] = formation_id
-                    sco_formsemestre.do_formsemestre_edit(sem)
+                if formsemestre.formation_id != formation_id:
+                    formsemestre.formation_id = formation_id
+                    db.session.add(formsemestre)
+                    db.session.commit()
+                    sco_cache.invalidate_formsemestre(formsemestre.id)
                 H.append("""<p class="alert">Problème réparé: vérifiez</p>""")
             else:
                 H.append(
                     f"""
                 <p class="alert">Problème détecté réparable:
-                <a href="check_sem_integrity?formsemestre_id={
-                    formsemestre_id}&fix=1">réparer maintenant</a></p>
+                <a href="{url_for( "notes.check_sem_integrity", scodoc_dept=g.scodoc_dept,
+                    formsemestre_id=formsemestre_id, fix=1)}">réparer maintenant</a></p>
                 """
                 )
         else:
diff --git a/sco_version.py b/sco_version.py
index 2ca6bc865ce9471883c5a9a6cd82b503accf233e..7fba3c3f0440c3a93aa3e746c6009c70eea51cbe 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -3,7 +3,7 @@
 
 "Infos sur version ScoDoc"
 
-SCOVERSION = "9.7.29"
+SCOVERSION = "9.7.30"
 
 SCONAME = "ScoDoc"
 
diff --git a/tests/unit/sco_fake_gen.py b/tests/unit/sco_fake_gen.py
index 06a10f3f6aaceb73f21323d0da221dd24d2a32e0..65b707f2a5a38acafded38c058a984244d1124c5 100644
--- a/tests/unit/sco_fake_gen.py
+++ b/tests/unit/sco_fake_gen.py
@@ -22,13 +22,13 @@ from app.models import (
     Evaluation,
     Formation,
     FormationModalite,
+    FormSemestre,
     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_saisie_notes
@@ -255,11 +255,8 @@ class ScoFake(object):
         if responsables is None:
             responsables = (self.default_user.id,)
         titre = titre or "sans titre"
-        oid = sco_formsemestre.do_formsemestre_create(locals())
-        oids = sco_formsemestre.do_formsemestre_list(args={"formsemestre_id": oid})
-        if not oids:
-            raise ScoValueError("formsemestre not created !")
-        return oid
+        formsemestre = FormSemestre.create_formsemestre(locals())
+        return formsemestre.id
 
     @logging_meth
     def create_moduleimpl(