diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py
index 8e35b4081efe2adb6b0e100ac3a45ff4eacff723..53d68f9630f719d9c128bf50f7c50970f4348e0d 100644
--- a/app/scodoc/sco_etud.py
+++ b/app/scodoc/sco_etud.py
@@ -655,7 +655,7 @@ def log_unknown_etud():
 
 
 def get_etud_info(etudid=False, code_nip=False, filled=False) -> list:
-    """infos sur un etudiant (API). If not foud, returns empty list.
+    """infos sur un etudiant (API). If not found, returns empty list.
     On peut specifier etudid ou code_nip
     ou bien cherche dans les argumenst de la requête courante:
      etudid, code_nip, code_ine (dans cet ordre).
@@ -671,6 +671,19 @@ def get_etud_info(etudid=False, code_nip=False, filled=False) -> list:
     return etud
 
 
+# Optim par cache local, utilité non prouvée mais
+# on s'oriente vers un cahce persistent dans Redis ou bien l'utilisation de NT
+# def get_etud_info_filled_by_etudid(etudid, cnx=None) -> dict:
+#     """Infos sur un étudiant, avec cache local à la requête"""
+#     if etudid in g.stored_etud_info:
+#         return g.stored_etud_info[etudid]
+#     cnx = cnx or ndb.GetDBConnexion()
+#     etud = etudident_list(cnx, args={"etudid": etudid})
+#     fill_etuds_info(etud)
+#     g.stored_etud_info[etudid] = etud[0]
+#     return etud[0]
+
+
 def create_etud(cnx, args={}):
     """Creation d'un étudiant. génère aussi évenement et "news".
 
diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py
index 2cb9d67704a12e1e193590551cace0208dd697c2..e65a920590ecfbf41442a87ba2ff1d4f4c6b52b7 100644
--- a/app/scodoc/sco_formsemestre.py
+++ b/app/scodoc/sco_formsemestre.py
@@ -95,6 +95,8 @@ _formsemestreEditor = ndb.EditableTable(
 
 def get_formsemestre(formsemestre_id, raise_soft_exc=False):
     "list ONE formsemestre"
+    if formsemestre_id in g.stored_get_formsemestre:
+        return g.stored_get_formsemestre[formsemestre_id]
     if not isinstance(formsemestre_id, int):
         raise ValueError("formsemestre_id must be an integer !")
     sems = do_formsemestre_list(args={"formsemestre_id": formsemestre_id})
@@ -104,6 +106,7 @@ def get_formsemestre(formsemestre_id, raise_soft_exc=False):
             raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
         else:
             raise ValueError(f"semestre {formsemestre_id} inconnu !")
+    g.stored_get_formsemestre[formsemestre_id] = sems[0]
     return sems[0]
 
 
diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
index fe8decb0076c79a41d5e74b44132fd64b6a7f502..5aa948e43214aa8f410b7ab21f0fc5afa08fca85 100644
--- a/app/scodoc/sco_formsemestre_status.py
+++ b/app/scodoc/sco_formsemestre_status.py
@@ -337,7 +337,7 @@ def formsemestre_status_menubar(sem):
         submenu.append(
             {
                 "title": "%s" % partition["partition_name"],
-                "endpoint": "scolar.affectGroups",
+                "endpoint": "scolar.affect_groups",
                 "args": {"partition_id": partition["partition_id"]},
                 "enabled": enabled,
             }
@@ -507,7 +507,7 @@ def formsemestre_page_title():
 
     h = f"""<div class="formsemestre_page_title">
     <div class="infos">
-        <span class="semtitle"><a class="stdlink" title="%(session_id)s"
+        <span class="semtitle"><a class="stdlink" title="{sem['session_id']}"
         href="{url_for('notes.formsemestre_status', 
             scodoc_dept=g.scodoc_dept, formsemestre_id=sem['formsemestre_id'])}"
         >{sem['titre']}</a><a
@@ -857,7 +857,7 @@ def _make_listes_sem(sem, with_absences=True):
             H.append('<p class="help indent">Aucun groupe dans cette partition')
             if sco_groups.sco_permissions_check.can_change_groups(formsemestre_id):
                 H.append(
-                    f""" (<a href="{url_for("scolar.affectGroups",
+                    f""" (<a href="{url_for("scolar.affect_groups",
                     scodoc_dept=g.scodoc_dept,
                     partition_id=partition["partition_id"])
                     }" class="stdlink">créer</a>)"""
diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py
index 6512f1feb5370fef27fc7abc07e09a8b576a14d1..0a776813c083cbcb513ae10a9ffb2e41ce976b1c 100644
--- a/app/scodoc/sco_groups.py
+++ b/app/scodoc/sco_groups.py
@@ -492,6 +492,8 @@ def XMLgetGroupsInPartition(partition_id):  # was XMLgetGroupesTD
     """
     from app.scodoc import sco_formsemestre
 
+    cnx = ndb.GetDBConnexion()
+
     t0 = time.time()
     partition = get_partition(partition_id)
     formsemestre_id = partition["formsemestre_id"]
@@ -500,6 +502,7 @@ def XMLgetGroupsInPartition(partition_id):  # was XMLgetGroupesTD
     nt = sco_cache.NotesTableCache.get(formsemestre_id)  # > inscrdict
     etuds_set = set(nt.inscrdict)
     # Build XML:
+    t1 = time.time()
     doc = Element("ajax-response")
     x_response = Element("response", type="object", id="MyUpdater")
     doc.append(x_response)
@@ -513,7 +516,8 @@ def XMLgetGroupsInPartition(partition_id):  # was XMLgetGroupesTD
         )
         x_response.append(x_group)
         for e in get_group_members(group["group_id"]):
-            etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=1)[0]
+            etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0]
+            # etud = sco_etud.get_etud_info_filled_by_etudid(e["etudid"], cnx)
             x_group.append(
                 Element(
                     "etud",
@@ -540,6 +544,7 @@ def XMLgetGroupsInPartition(partition_id):  # was XMLgetGroupesTD
         doc.append(x_group)
         for etudid in etuds_set:
             etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
+            # etud = sco_etud.get_etud_info_filled_by_etudid(etudid, cnx)
             x_group.append(
                 Element(
                     "etud",
@@ -550,7 +555,8 @@ def XMLgetGroupsInPartition(partition_id):  # was XMLgetGroupesTD
                     origin=comp_origin(etud, sem),
                 )
             )
-    log("XMLgetGroupsInPartition: %s seconds" % (time.time() - t0))
+    t2 = time.time()
+    log(f"XMLgetGroupsInPartition: {t2-t0} seconds ({t1-t0}+{t2-t1})")
     # XML response:
     data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING)
     response = make_response(data)
@@ -911,7 +917,7 @@ def editPartitionForm(formsemestre_id=None):
             H.append(", ".join(lg))
             H.append(
                 f"""</td><td><a class="stdlink" href="{
-                    url_for("scolar.affectGroups",
+                    url_for("scolar.affect_groups",
                     scodoc_dept=g.scodoc_dept,
                     partition_id=p["partition_id"])
                 }">répartir</a></td>
@@ -1173,7 +1179,7 @@ def group_set_name(group_id, group_name, redirect=1):
     if redirect:
         return flask.redirect(
             url_for(
-                "scolar.affectGroups",
+                "scolar.affect_groups",
                 scodoc_dept=g.scodoc_dept,
                 partition_id=group["partition_id"],
             )
@@ -1216,7 +1222,7 @@ def group_rename(group_id):
     elif tf[0] == -1:
         return flask.redirect(
             url_for(
-                "scolar.affectGroups",
+                "scolar.affect_groups",
                 scodoc_dept=g.scodoc_dept,
                 partition_id=group["partition_id"],
             )
@@ -1236,7 +1242,7 @@ def groups_auto_repartition(partition_id=None):
     formsemestre_id = partition["formsemestre_id"]
     # renvoie sur page édition groupes
     dest_url = url_for(
-        "scolar.affectGroups", scodoc_dept=g.scodoc_dept, partition_id=partition_id
+        "scolar.affect_groups", scodoc_dept=g.scodoc_dept, partition_id=partition_id
     )
     if not sco_permissions_check.can_change_groups(formsemestre_id):
         raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
diff --git a/app/scodoc/sco_groups_edit.py b/app/scodoc/sco_groups_edit.py
index 75eb12b496465898cd345d8bf2bfe8740b422fd8..bc41969006695ceb847c1f694d69552f9c66670e 100644
--- a/app/scodoc/sco_groups_edit.py
+++ b/app/scodoc/sco_groups_edit.py
@@ -27,70 +27,33 @@
 
 """Formulaires gestion des groupes
 """
+from flask import render_template
 
 from app.scodoc import html_sco_header
 from app.scodoc import sco_groups
 from app.scodoc.sco_exceptions import AccessDenied
 
 
-def affectGroups(partition_id):
+def affect_groups(partition_id):
     """Formulaire affectation des etudiants aux groupes de la partition.
     Permet aussi la creation et la suppression de groupes.
     """
-    # Ported from DTML and adapted to new group management (nov 2009)
+    # réécrit pour 9.0.47 avec un template
     partition = sco_groups.get_partition(partition_id)
     formsemestre_id = partition["formsemestre_id"]
     if not sco_groups.sco_permissions_check.can_change_groups(formsemestre_id):
         raise AccessDenied("vous n'avez pas la permission d'effectuer cette opération")
-
-    H = [
-        html_sco_header.sco_header(
+    return render_template(
+        "scolar/affect_groups.html",
+        sco_header=html_sco_header.sco_header(
             page_title="Affectation aux groupes",
             javascripts=["js/groupmgr.js"],
             cssstyles=["css/groups.css"],
         ),
-        """<h2 class="formsemestre">Affectation aux groupes de %s</h2><form id="sp">"""
-        % partition["partition_name"],
-    ]
-
-    H += [
-        """</select></form>""",
-        """<p>Faites glisser les étudiants d'un groupe à l'autre. Les modifications ne sont enregistrées que lorsque vous cliquez sur le bouton "<em>Enregistrer ces groupes</em>". Vous pouvez créer de nouveaux groupes. Pour <em>supprimer</em> un groupe, utiliser le lien "suppr." en haut à droite de sa boite. Vous pouvez aussi <a class="stdlink" href="groups_auto_repartition?partition_id=%(partition_id)s">répartir automatiquement les groupes</a>.
-</p>"""
-        % partition,
-        """<div id="gmsg" class="head_message"></div>""",
-        """<div id="ginfo"></div>""",
-        """<div id="savedinfo"></div>""",
-        """<form name="formGroup" id="formGroup" onSubmit="return false;">""",
-        """<input type="hidden" name="partition_id" value="%s"/>""" % partition_id,
-        """<input name="groupName" size="6"/>
-<input type="button" onClick="createGroup();" value="Créer groupe"/>
-&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-<input type="button" onClick="submitGroups( target='gmsg' );" value="Enregistrer ces groupes" />
-&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
-<input type="button" onClick="document.location = 'formsemestre_status?formsemestre_id=%s'" value="Annuler" />&nbsp;&nbsp;&nbsp;&nbsp;
-Editer groupes de
-<select name="other_partition_id" onchange="GotoAnother();">"""
-        % formsemestre_id,
-    ]
-    for p in sco_groups.get_partitions_list(formsemestre_id, with_default=False):
-        H.append('<option value="%s"' % p["partition_id"])
-        if p["partition_id"] == partition_id:
-            H.append(" selected")
-        H.append(">%s</option>" % p["partition_name"])
-    H += [
-        """</select>
-</form>
-
-<div id="groups">
-</div>
-
-<div style="clear: left; margin-top: 15px;">
-<p class="help"></p>
-</div>
-
-</div>
-""",
-        html_sco_header.sco_footer(),
-    ]
-    return "\n".join(H)
+        sco_footer=html_sco_header.sco_footer(),
+        partition=partition,
+        partitions_list=sco_groups.get_partitions_list(
+            formsemestre_id, with_default=False
+        ),
+        formsemestre_id=formsemestre_id,
+    )
diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py
index 4fb8c57acc9c6d67f41da937bbcb72be857bae18..cc91728485e411f174df660fd4ddf9880ea26cf3 100644
--- a/app/scodoc/sco_inscr_passage.py
+++ b/app/scodoc/sco_inscr_passage.py
@@ -390,7 +390,7 @@ def formsemestre_inscr_passage(
             ):  # il y a au moins une vraie partition
                 H.append(
                     f"""<li><a class="stdlink" href="{
-                        url_for("scolar.affectGroups",
+                        url_for("scolar.affect_groups",
                 scodoc_dept=g.scodoc_dept, partition_id=partition["partition_id"])
                 }">Répartir les groupes de {partition["partition_name"]}</a></li>
                 """
diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py
index c2ac6691e762a4f73918d7ce37f09c127f51bfd4..a263ac79972ac95e4cbba9582b9df6143bf4bfdb 100644
--- a/app/scodoc/sco_liste_notes.py
+++ b/app/scodoc/sco_liste_notes.py
@@ -315,7 +315,7 @@ def _make_table_notes(
 
         rows.append(
             {
-                "code": code,
+                "code": str(code),  # INE, NIP ou etudid
                 "_code_td_attrs": 'style="padding-left: 1em; padding-right: 2em;"',
                 "etudid": etudid,
                 "nom": etud["nom"].upper(),
@@ -376,7 +376,9 @@ def _make_table_notes(
     if anonymous_listing:
         rows.sort(key=lambda x: x["code"])
     else:
-        rows.sort(key=lambda x: (x["nom"], x["prenom"]))  # sort by nom, prenom
+        rows.sort(
+            key=lambda x: (x["nom"] or "", x["prenom"] or "")
+        )  # sort by nom, prenom
 
     # Si module, ajoute moyenne du module:
     if len(evals) > 1:
diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py
index 649358a6d382d0af2ac99a3c0667f1aa345492ec..c91ccf6d3d5a10e42074cdc30626ec061515dae6 100644
--- a/app/scodoc/sco_synchro_etuds.py
+++ b/app/scodoc/sco_synchro_etuds.py
@@ -271,7 +271,7 @@ def formsemestre_synchro_etuds(
             if partitions:  # il y a au moins une vraie partition
                 H.append(
                     f"""<li><a class="stdlink" href="{
-                        url_for("scolar.affectGroups",
+                        url_for("scolar.affect_groups",
                 scodoc_dept=g.scodoc_dept,
                 partition_id=partitions[0]["partition_id"]
                 )}">Répartir les groupes de {partitions[0]["partition_name"]}</a></li>
diff --git a/app/static/js/groupmgr.js b/app/static/js/groupmgr.js
index 7d16adc9b1e346c0d72b47a5abcc7452351dd985..de0e512d7b1d5e9a8695704fa04d0161ff9fe30c 100644
--- a/app/static/js/groupmgr.js
+++ b/app/static/js/groupmgr.js
@@ -402,7 +402,7 @@ function GotoAnother() {
     if (groups_unsaved) {
         alert("Enregistrez ou annulez vos changement avant !");
     } else
-        document.location = SCO_URL + '/affectGroups?partition_id=' + document.formGroup.other_partition_id.value;
+        document.location = SCO_URL + '/affect_groups?partition_id=' + document.formGroup.other_partition_id.value;
 }
 
 
diff --git a/app/templates/scolar/affect_groups.html b/app/templates/scolar/affect_groups.html
new file mode 100644
index 0000000000000000000000000000000000000000..4d17847e67464d21422275e9f4624befccc2f232
--- /dev/null
+++ b/app/templates/scolar/affect_groups.html
@@ -0,0 +1,43 @@
+
+{{ sco_header|safe }}
+<h2 class="formsemestre">Affectation aux groupes de {{ partition["partition_name"] }}</h2>
+
+<p>Faites glisser les étudiants d'un groupe à l'autre. Les modifications ne 
+sont enregistrées que lorsque vous cliquez sur le bouton "<em>Enregistrer ces groupes</em>".
+Vous pouvez créer de nouveaux groupes. Pour <em>supprimer</em> un groupe, utiliser le lien 
+"suppr." en haut à droite de sa boite. 
+Vous pouvez aussi <a class="stdlink" 
+href="{{ url_for('scolar.groups_auto_repartition', scodoc_dept=g.scodoc_dept, partition_id=partition['partition_id']) }}"
+>répartir automatiquement les groupes</a>. 
+</p>
+
+<div id="gmsg" class="head_message"></div>
+<div id="ginfo"></div>
+<div id="savedinfo"></div>
+<form name="formGroup" id="formGroup" onSubmit="return false;">
+<input type="hidden" name="partition_id" value="{{ partition['partition_id'] }}"/>
+<input name="groupName" size="6"/>
+<input type="button" onClick="createGroup();" value="Créer groupe"/>
+&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+<input type="button" onClick="submitGroups( target='gmsg' );" value="Enregistrer ces groupes" />
+&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+<input type="button" 
+    onClick="document.location = '{{ url_for( 'notes.formsemestre_status', scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id) }}'" 
+    value="Annuler" />&nbsp;&nbsp;&nbsp;&nbsp;Éditer groupes de
+<select name="other_partition_id" onchange="GotoAnother();">
+{% for p in partitions_list %}
+    <option value="{{ p['id'] }}" {{"selected" if p["partition_id"] == partition_id }}>{{p["partition_name"]}}</option>
+{% endfor %}
+</select>
+</form>
+
+<div id="groups">
+</div>
+
+<div style="clear: left; margin-top: 15px;">
+    <p class="help"></p>
+</div>
+
+</div>
+
+{{ sco_footer|safe }}
diff --git a/app/views/__init__.py b/app/views/__init__.py
index a2dae02fe74fa02c397c9b678bb23af725448027..9dbefc44513de034d68f263a27a8c9f3474c7931 100644
--- a/app/views/__init__.py
+++ b/app/views/__init__.py
@@ -30,6 +30,9 @@ def start_scodoc_request():
     if current_user.is_authenticated:
         current_user.last_seen = datetime.datetime.utcnow()
         db.session.commit()
+    # caches locaux (durée de vie=la requête en cours)
+    g.stored_get_formsemestre = {}
+    # g.stored_etud_info = {} optim en cours, voir si utile
 
 
 @scodoc_bp.teardown_app_request
diff --git a/app/views/scolar.py b/app/views/scolar.py
index ab02fa3130517a6ad59f2e5ecf1be91adaec469d..3e5f3c3d469cd6f8227f5dd800d30d086029913e 100644
--- a/app/views/scolar.py
+++ b/app/views/scolar.py
@@ -651,8 +651,8 @@ def formChangeCoordonnees(etudid):
 
 # --- Gestion des groupes:
 sco_publish(
-    "/affectGroups",
-    sco_groups_edit.affectGroups,
+    "/affect_groups",
+    sco_groups_edit.affect_groups,
     Permission.ScoView,
     methods=["GET", "POST"],
 )