diff --git a/app/api/partitions.py b/app/api/partitions.py
index 1f5ef8a1e10d7ca7e1a738faa5e4378aff41af31..4d752b65a4c22f43b6c9b63ebc4bb6a43f3de589 100644
--- a/app/api/partitions.py
+++ b/app/api/partitions.py
@@ -12,6 +12,7 @@ from operator import attrgetter
 from flask import g, request
 from flask_json import as_json
 from flask_login import login_required
+from sqlalchemy.exc import IntegrityError
 
 import app
 from app import db, log
@@ -23,6 +24,7 @@ from app.models import GroupDescr, Partition, Scolog
 from app.models.groups import group_membership
 from app.scodoc import sco_cache
 from app.scodoc import sco_groups
+from app.scodoc.sco_exceptions import ScoValueError
 from app.scodoc.sco_permissions import Permission
 from app.scodoc import sco_utils as scu
 
@@ -182,10 +184,12 @@ def set_etud_group(etudid: int, group_id: int):
     if etud.id not in {e.id for e in group.partition.formsemestre.etuds}:
         return json_error(404, "etud non inscrit au formsemestre du groupe")
 
-    sco_groups.change_etud_group_in_partition(
-        etudid, group_id, group.partition.to_dict()
-    )
-
+    try:
+        sco_groups.change_etud_group_in_partition(etudid, group)
+    except ScoValueError as exc:
+        return json_error(404, exc.args[0])
+    except IntegrityError:
+        return json_error(404, "échec de l'enregistrement")
     return {"group_id": group_id, "etudid": etudid}
 
 
diff --git a/app/models/groups.py b/app/models/groups.py
index 1d07ed2147b7ab08ade76d1ec296a14cc446b723..35d22f8e9b45e7f6f0cac053eeda8b6252b7aba4 100644
--- a/app/models/groups.py
+++ b/app/models/groups.py
@@ -8,11 +8,13 @@
 """ScoDoc models: Groups & partitions
 """
 from operator import attrgetter
+from sqlalchemy.exc import IntegrityError
 
 from app import db
 from app.models import SHORT_STR_LEN
 from app.models import GROUPNAME_STR_LEN
 from app.scodoc import sco_utils as scu
+from app.scodoc.sco_exceptions import ScoValueError
 
 
 class Partition(db.Model):
@@ -117,6 +119,40 @@ class Partition(db.Model):
             .first()
         )
 
+    def set_etud_group(self, etudid: int, group: "GroupDescr"):
+        """Affect etudid to group_id in given partition.
+        Raises IntegrityError si conflit,
+        or ValueError si ce group_id n'est pas dans cette partition
+        ou que l'étudiant n'est pas inscrit au semestre.
+        """
+        if not group.id in (g.id for g in self.groups):
+            raise ScoValueError(
+                f"""Le groupe {group.id} n'est pas dans la partition {self.partition_name or "tous"}"""
+            )
+        if etudid not in (e.id for e in self.formsemestre.etuds):
+            raise ScoValueError(
+                f"etudiant {etudid} non inscrit au formsemestre du groupe {group.id}"
+            )
+        try:
+            existing_row = (
+                db.session.query(group_membership)
+                .filter_by(etudid=etudid)
+                .join(GroupDescr)
+                .filter_by(partition_id=self.id)
+                .first()
+            )
+            if existing_row:
+                existing_row.update({"group_id": group.id})
+            else:
+                new_row = group_membership.insert().values(
+                    etudid=etudid, group_id=group.id
+                )
+                db.session.execute(new_row)
+            db.session.commit()
+        except IntegrityError:
+            db.session.rollback()
+            raise
+
 
 class GroupDescr(db.Model):
     """Description d'un groupe d'une partition"""
diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index 90f01f04eeadfab7df9431e67286a5204089c685..4d7aa131b36ca560966bc606370f4efd923cd435 100644
--- a/app/scodoc/sco_edit_ue.py
+++ b/app/scodoc/sco_edit_ue.py
@@ -546,6 +546,8 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
             ue = UniteEns.query.get(ue_id)
             flash(f"UE créée (code {ue.ue_code})")
         else:
+            if not tf[2]["numero"]:
+                tf[2]["numero"] = 0
             do_ue_edit(tf[2])
             flash("UE modifiée")
 
diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py
index 8b1da28ad36ba6c87df10b46a0beb32b48cd3b82..b02fd3379b2ba80577f3caed29e9f12cc8d51939 100644
--- a/app/scodoc/sco_groups.py
+++ b/app/scodoc/sco_groups.py
@@ -48,9 +48,9 @@ from sqlalchemy.sql import text
 from app import db
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
-from app.models import FormSemestre, Identite
+from app.models import FormSemestre, Identite, Scolog
 from app.models import GROUPNAME_STR_LEN, SHORT_STR_LEN
-from app.models.groups import GroupDescr, Partition
+from app.models.groups import GroupDescr, Partition, group_membership
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app import log, cache
@@ -94,7 +94,7 @@ groupEditor = ndb.EditableTable(
 group_list = groupEditor.list
 
 
-def get_group(group_id: int) -> dict:
+def get_group(group_id: int) -> dict:  # OBSOLETE !
     """Returns group object, with partition"""
     r = ndb.SimpleDictFetch(
         """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.*
@@ -124,7 +124,7 @@ def group_delete(group_id: int):
     )
 
 
-def get_partition(partition_id):
+def get_partition(partition_id):  # OBSOLETE
     r = ndb.SimpleDictFetch(
         """SELECT p.id AS partition_id, p.*
         FROM partition p
@@ -200,7 +200,7 @@ def get_formsemestre_etuds_groups(formsemestre_id: int) -> dict:
     return d
 
 
-def get_partition_groups(partition):
+def get_partition_groups(partition):  # OBSOLETE !
     """List of groups in this partition (list of dicts).
     Some groups may be empty."""
     return ndb.SimpleDictFetch(
@@ -637,7 +637,7 @@ def _comp_etud_origin(etud: dict, cur_formsemestre: FormSemestre):
         return ""  # parcours normal, ne le signale pas
 
 
-def set_group(etudid: int, group_id: int) -> bool:
+def set_group(etudid: int, group_id: int) -> bool:  # OBSOLETE !
     """Inscrit l'étudiant au groupe.
     Return True if ok, False si deja inscrit.
     Warning:
@@ -664,55 +664,31 @@ def set_group(etudid: int, group_id: int) -> bool:
     return True
 
 
-def change_etud_group_in_partition(etudid: int, group_id: int, partition: dict = None):
-    """Inscrit etud au groupe de cette partition,
-    et le desinscrit d'autres groupes de cette partition.
+def change_etud_group_in_partition(etudid: int, group: GroupDescr):
+    """Inscrit etud au groupe
+    (et le desinscrit d'autres groupes de cette partition.)
     """
-    log("change_etud_group_in_partition: etudid=%s group_id=%s" % (etudid, group_id))
-    # 0- La partition
-    group = get_group(group_id)
-    if partition:
-        # verifie que le groupe est bien dans cette partition:
-        if group["partition_id"] != partition["partition_id"]:
-            raise ValueError(
-                "inconsistent group/partition (group_id=%s, partition_id=%s)"
-                % (group_id, partition["partition_id"])
-            )
-    else:
-        partition = get_partition(group["partition_id"])
-    # 1- Supprime membership dans cette partition
-    ndb.SimpleQuery(
-        """DELETE FROM group_membership gm
-        WHERE EXISTS
-        (SELECT 1 FROM  group_descr gd
-            WHERE gm.etudid = %(etudid)s
-            AND gm.group_id = gd.id
-            AND gd.partition_id = %(partition_id)s)
-        """,
-        {"etudid": etudid, "partition_id": partition["partition_id"]},
-    )
-    # 2- associe au nouveau groupe
-    set_group(etudid, group_id)
+    log(f"change_etud_group_in_partition: etudid={etudid} group={group}")
 
-    # 3- log
-    formsemestre_id = partition["formsemestre_id"]
-    cnx = ndb.GetDBConnexion()
-    logdb(
-        cnx,
+    group.partition.set_etud_group(etudid, group)
+
+    # - log
+    formsemestre: FormSemestre = group.partition.formsemestre
+    Scolog.logdb(
         method="changeGroup",
         etudid=etudid,
-        msg="formsemestre_id=%s,partition_name=%s, group_name=%s"
-        % (formsemestre_id, partition["partition_name"], group["group_name"]),
+        msg=f"""formsemestre_id={formsemestre.id}, partition_name={
+            group.partition.partition_name or ""}, group_name={group.group_name or ""}""",
+        commit=True,
     )
-    cnx.commit()
 
-    # 5- Update parcours
-    formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
-    formsemestre.update_inscriptions_parcours_from_groups()
+    # - Update parcours
+    if group.partition.partition_name == scu.PARTITION_PARCOURS:
+        formsemestre.update_inscriptions_parcours_from_groups()
 
-    # 6- invalidate cache
+    # - invalidate cache
     sco_cache.invalidate_formsemestre(
-        formsemestre_id=formsemestre_id
+        formsemestre_id=formsemestre.id
     )  # > change etud group
 
 
@@ -769,7 +745,7 @@ def setGroups(
         except ValueError:
             log(f"setGroups: ignoring invalid group_id={group_id}")
             continue
-        group = get_group(group_id)
+        group: GroupDescr = GroupDescr.query.get(group_id)
         # Anciens membres du groupe:
         old_members = get_group_members(group_id)
         old_members_set = set([x["etudid"] for x in old_members])
@@ -783,7 +759,7 @@ def setGroups(
             if (etudid not in etud_groups) or (
                 group_id != etud_groups[etudid].get(partition_id, "")
             ):  # pas le meme groupe qu'actuel
-                change_etud_group_in_partition(etudid, group_id, partition)
+                change_etud_group_in_partition(etudid, group)
         # Retire les anciens membres:
         cnx = ndb.GetDBConnexion()
         cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
@@ -819,7 +795,7 @@ def setGroups(
             return xml_error(msg, code=404)
         # Place dans ce groupe les etudiants indiqués:
         for etudid in fs[1:-1]:
-            change_etud_group_in_partition(etudid, group.id, partition)
+            change_etud_group_in_partition(etudid, group.id)
 
     # Update parcours
     formsemestre.update_inscriptions_parcours_from_groups()
@@ -1460,10 +1436,10 @@ def groups_auto_repartition(partition_id=None):
         for old_group in get_partition_groups(partition):
             group_delete(old_group["group_id"])
         # Crée les nouveaux groupes
-        group_ids = []
+        groups = []
         for group_name in group_names:
             if group_name.strip():
-                group_ids.append(create_group(partition_id, group_name).id)
+                groups.append(create_group(partition_id, group_name))
         #
         nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
         identdict = nt.identdict
@@ -1481,16 +1457,16 @@ def groups_auto_repartition(partition_id=None):
         # affect aux groupes:
         n = len(identdict)
         igroup = 0
-        nbgroups = len(group_ids)
+        nbgroups = len(groups)
         while n > 0:
             for civilite in civilites:
                 if len(listes[civilite]):
                     n -= 1
                     etudid = listes[civilite].pop()[1]
-                    group_id = group_ids[igroup]
+                    group = groups[igroup]
                     igroup = (igroup + 1) % nbgroups
-                    change_etud_group_in_partition(etudid, group_id, partition)
-                    log("%s in group %s" % (etudid, group_id))
+                    change_etud_group_in_partition(etudid, group)
+                    log("%s in group %s" % (etudid, group.id))
         return flask.redirect(dest_url)
 
 
@@ -1520,10 +1496,11 @@ def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):
     Si la partition existe déjà, ses groupes sont mis à jour (les groupes devenant
      vides ne sont pas supprimés).
     """
+    # A RE-ECRIRE pour utiliser les modèles.
     from app.scodoc import sco_formsemestre_inscriptions
 
     partition_name = str(partition_name)
-    log("create_etapes_partition(%s)" % formsemestre_id)
+    log(f"create_etapes_partition({formsemestre_id})")
     ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
         args={"formsemestre_id": formsemestre_id}
     )
@@ -1542,20 +1519,17 @@ def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):
         pid = partition_create(
             formsemestre_id, partition_name=partition_name, redirect=False
         )
-    partition = get_partition(pid)
-    groups = get_partition_groups(partition)
+    partition: Partition = Partition.query.get(pid)
+    groups = partition.groups
     groups_by_names = {g["group_name"]: g for g in groups}
     for etape in etapes:
-        if not (etape in groups_by_names):
+        if etape not in groups_by_names:
             new_group = create_group(pid, etape)
-            g = get_group(new_group.id)  # XXX transition: recupere old style dict
-            groups_by_names[etape] = g
+            groups_by_names[etape] = new_group
     # Place les etudiants dans les groupes
     for i in ins:
         if i["etape"]:
-            change_etud_group_in_partition(
-                i["etudid"], groups_by_names[i["etape"]]["group_id"], partition
-            )
+            change_etud_group_in_partition(i["etudid"], groups_by_names[i["etape"]])
 
 
 def do_evaluation_listeetuds_groups(
diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py
index e7eb8dceb3c445c3d15c1d1975bd7e5cc7449ae6..5bf61fdaedd93f16ba8d3ac22812f2da04f7d032 100644
--- a/app/scodoc/sco_import_etuds.py
+++ b/app/scodoc/sco_import_etuds.py
@@ -723,7 +723,7 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
                         group = GroupDescr.query.get(group_id)
                         if group.partition.groups_editable:
                             sco_groups.change_etud_group_in_partition(
-                                args["etudid"], group_id
+                                args["etudid"], group
                             )
                         else:
                             log("scolars_import_admission: partition non editable")
diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py
index 1ebf679fb1d7f0c0f1c38d12de3470ae13e26007..50260e2704fc3621838d6afb8fcb0a2d3bc0b5e9 100644
--- a/app/scodoc/sco_inscr_passage.py
+++ b/app/scodoc/sco_inscr_passage.py
@@ -36,13 +36,12 @@ from flask import url_for, g, request
 import app.scodoc.notesdb as ndb
 import app.scodoc.sco_utils as scu
 from app import log
-from app.models import Formation, FormSemestre
+from app.models import Formation, FormSemestre, GroupDescr
 from app.scodoc.gen_tables import GenTable
 from app.scodoc import html_sco_header
 from app.scodoc import sco_cache
 from app.scodoc import codes_cursus
 from app.scodoc import sco_etud
-from app.scodoc import sco_formations
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_groups
@@ -177,6 +176,7 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
     (la liste doit avoir été vérifiée au préalable)
     En option: inscrit aux mêmes groupes que dans le semestre origine
     """
+    # TODO à ré-écrire pour utiliser le smodèle, notamment GroupDescr
     formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
     formsemestre.setup_parcours_groups()
     log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}")
@@ -220,11 +220,8 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
 
             # Inscrit aux groupes
             for partition_group in partition_groups:
-                sco_groups.change_etud_group_in_partition(
-                    etudid,
-                    partition_group["group_id"],
-                    partition_group,
-                )
+                group: GroupDescr = GroupDescr.query.get(partition_group["group_id"])
+                sco_groups.change_etud_group_in_partition(etudid, group)
 
 
 def do_desinscrit(sem, etudids):
diff --git a/sco_version.py b/sco_version.py
index 0bd507e8e5a505084a820163199d169895e33e62..95fb08eb1873504016c63ddcaf6c6960cb5e48f4 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,7 +1,7 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
-SCOVERSION = "9.4.97"
+SCOVERSION = "9.4.98"
 
 SCONAME = "ScoDoc"