diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index 1fccbda158361f00479b8d3d7d7e4f90ac032076..5856184fcf93f2c05159c3effb88366a16729b80 100644
--- a/app/comp/moy_mod.py
+++ b/app/comp/moy_mod.py
@@ -85,6 +85,8 @@ class ModuleImplResults:
         "{ evaluation.id : bool } indique si à prendre en compte ou non."
         self.evaluations_etat = {}
         "{ evaluation_id: EvaluationEtat }"
+        self.etudids_attente = set()
+        "etudids avec au moins une note ATT dans ce module"
         self.en_attente = False
         "Vrai si au moins une évaluation a une note en attente"
         #
@@ -145,7 +147,6 @@ class ModuleImplResults:
         evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
         self.evaluations_completes = []
         self.evaluations_completes_dict = {}
-        self.en_attente = False
         for evaluation in moduleimpl.evaluations:
             eval_df = self._load_evaluation_notes(evaluation)
             # is_complete ssi tous les inscrits (non dem) au semestre ont une note
@@ -172,15 +173,20 @@ class ModuleImplResults:
                 eval_df, how="left", left_index=True, right_index=True
             )
             # Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
-            nb_att = sum(
-                evals_notes[str(evaluation.id)][list(inscrits_module)]
-                == scu.NOTES_ATTENTE
+            eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
+            eval_etudids_attente = set(
+                eval_notes_inscr.iloc[
+                    (eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
+                ].index
             )
+            self.etudids_attente |= eval_etudids_attente
             self.evaluations_etat[evaluation.id] = EvaluationEtat(
-                evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete
+                evaluation_id=evaluation.id,
+                nb_attente=len(eval_etudids_attente),
+                is_complete=is_complete,
             )
-            if nb_att > 0:
-                self.en_attente = True
+        # au moins une note en ATT dans ce modimpl:
+        self.en_attente = bool(self.etudids_attente)
 
         # Force columns names to integers (evaluation ids)
         evals_notes.columns = pd.Index([int(x) for x in evals_notes.columns], dtype=int)
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index 930909d3418bba948d820fb6112de785e5be1ad2..42da823f8381289f22bf3cd14a873fb1940ad58e 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -5,10 +5,12 @@ import pandas as pd
 import flask_sqlalchemy
 
 from app import db
+from app.auth.models import User
 from app.comp import df_cache
 from app.models.etudiants import Identite
 from app.models.modules import Module
-
+from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
+from app.scodoc.sco_permissions import Permission
 from app.scodoc import sco_utils as scu
 
 
@@ -99,6 +101,27 @@ class ModuleImpl(db.Model):
             d.pop("module", None)
         return d
 
+    def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
+        """Check if user can modify module resp.
+        If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
+        = Admin, et dir des etud. (si option l'y autorise)
+        """
+        if not self.formsemestre.etat:
+            if raise_exc:
+                raise ScoLockedSemError("Modification impossible: semestre verrouille")
+            return False
+        # -- check access
+        # admin ou resp. semestre avec flag resp_can_change_resp
+        if user.has_permission(Permission.ScoImplement):
+            return True
+        if (
+            user.id in [resp.id for resp in self.formsemestre.responsables]
+        ) and self.formsemestre.resp_can_change_ens:
+            return True
+        if raise_exc:
+            raise AccessDenied(f"Modification impossible pour {user}")
+        return False
+
 
 # Enseignants (chargés de TD ou TP) d'un moduleimpl
 notes_modules_enseignants = db.Table(
diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py
index 7896336f23eba5316e392019258d85bd793fa955..5019647083bad3338b92d79a3cc7d8d20655dd98 100644
--- a/app/scodoc/sco_exceptions.py
+++ b/app/scodoc/sco_exceptions.py
@@ -122,6 +122,14 @@ class ScoLockedFormError(ScoValueError):
         super().__init__(msg=msg, dest_url=dest_url)
 
 
+class ScoLockedSemError(ScoValueError):
+    "Modification d'un formsemestre verrouillé"
+
+    def __init__(self, msg="", dest_url=None):
+        msg = "Ce semestre est verrouillé ! " + str(msg)
+        super().__init__(msg=msg, dest_url=dest_url)
+
+
 class ScoNonEmptyFormationObject(ScoValueError):
     """On ne peut pas supprimer un module/matiere ou UE si des formsemestre s'y réfèrent"""
 
diff --git a/app/scodoc/sco_moduleimpl.py b/app/scodoc/sco_moduleimpl.py
index 0e1edbee3baca1a13b6beda5d08cfad08c4c690f..052512b0ecabc49c7fd62dc0d7be28909715faf6 100644
--- a/app/scodoc/sco_moduleimpl.py
+++ b/app/scodoc/sco_moduleimpl.py
@@ -377,7 +377,7 @@ def can_change_module_resp(moduleimpl_id):
     if not current_user.has_permission(Permission.ScoImplement) and (
         (current_user.id not in sem["responsables"]) or (not sem["resp_can_change_ens"])
     ):
-        raise AccessDenied("Modification impossible pour %s" % current_user)
+        raise AccessDenied(f"Modification impossible pour {current_user}")
     return M, sem
 
 
diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py
index bf330adb34d9aaff3764ee90ad3f18d4332bf2b0..39e97d0d2c3667bc3872f5276e317cd6f6e0c17d 100644
--- a/app/scodoc/sco_moduleimpl_inscriptions.py
+++ b/app/scodoc/sco_moduleimpl_inscriptions.py
@@ -38,6 +38,7 @@ from app.comp.res_compat import NotesTableCompat
 from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns
 
 from app import log
+from app.tables import list_etuds
 from app.scodoc.scolog import logdb
 from app.scodoc import html_sco_header
 from app.scodoc import htmlutils
@@ -520,14 +521,15 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
         H.append(f"""<th title="{ue.titre or ''}">{ue.acronyme}</th>""")
     H.append("""</tr>""")
 
-    for etudid, ues_etud in table_inscr.items():
-        etud: Identite = Identite.query.get(etudid)
+    etuds = list_etuds.etuds_sorted_from_ids(table_inscr.keys())
+    for etud in etuds:
+        ues_etud = table_inscr[etud.id]
         H.append(
-            f"""<tr><td><a class="discretelink etudinfo" id={etudid}
+            f"""<tr><td><a class="discretelink etudinfo" id={etud.id}
             href="{url_for(
                         "scolar.ficheEtud",
                         scodoc_dept=g.scodoc_dept,
-                        etudid=etudid,
+                        etudid=etud.id,
                     )}"
         >{etud.nomprenom}</a></td>"""
         )
@@ -539,7 +541,7 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
             else:
                 # Validations d'UE déjà enregistrées dans d'autres semestres
                 validations_ue = (
-                    ScolarFormSemestreValidation.query.filter_by(etudid=etudid)
+                    ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
                     .filter(
                         ScolarFormSemestreValidation.formsemestre_id
                         != res.formsemestre.id,
@@ -556,7 +558,8 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
                 )
                 validation = validations_ue[-1] if validations_ue else None
                 expl_validation = (
-                    f"""Validée ({validation.code}) le {validation.event_date.strftime("%d/%m/%Y")}"""
+                    f"""Validée ({validation.code}) le {
+                            validation.event_date.strftime("%d/%m/%Y")}"""
                     if validation
                     else ""
                 )
@@ -567,13 +570,13 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
                     title="{etud.nomprenom} {'inscrit' if est_inscr else 'non inscrit'} à l'UE {ue.acronyme}. {expl_validation}",
                     onchange="change_ue_inscr(this);"
                     data-url_inscr={
-                        url_for("notes.etud_inscrit_ue", 
-                            scodoc_dept=g.scodoc_dept, etudid=etudid,
+                        url_for("notes.etud_inscrit_ue",
+                            scodoc_dept=g.scodoc_dept, etudid=etud.id,
                             formsemestre_id=res.formsemestre.id, ue_id=ue.id)
                     }
                     data-url_desinscr={
-                        url_for("notes.etud_desinscrit_ue", 
-                            scodoc_dept=g.scodoc_dept, etudid=etudid,
+                        url_for("notes.etud_desinscrit_ue",
+                            scodoc_dept=g.scodoc_dept, etudid=etud.id,
                             formsemestre_id=res.formsemestre.id, ue_id=ue.id)
                     }
                     />
diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py
index 5bf2a7f831c61a8e116a6d7e5d54f9442ca148a7..1127a968e94a69e038d48657f176469e68aefcf9 100644
--- a/app/scodoc/sco_moduleimpl_status.py
+++ b/app/scodoc/sco_moduleimpl_status.py
@@ -36,6 +36,7 @@ from flask_login import current_user
 from app import db
 from app.auth.models import User
 from app.comp import res_sem
+from app.comp.res_common import ResultatsSemestre
 from app.comp.res_compat import NotesTableCompat
 from app.models import FormSemestre, ModuleImpl
 from app.models.evaluations import Evaluation
@@ -59,9 +60,7 @@ from app.scodoc import sco_formsemestre_status
 from app.scodoc import sco_groups
 from app.scodoc import sco_moduleimpl
 from app.scodoc import sco_permissions_check
-from app.scodoc import sco_users
-
-# ported from old DTML code in oct 2009
+from app.tables import list_etuds
 
 # menu evaluation dans moduleimpl
 def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
@@ -196,23 +195,20 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
     if not isinstance(moduleimpl_id, int):
         raise ScoInvalidIdType("moduleimpl_id must be an integer !")
     modimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
-    M = modimpl.to_dict()
+    mi_dict = modimpl.to_dict()
     formsemestre_id = modimpl.formsemestre_id
     formsemestre: FormSemestre = modimpl.formsemestre
-    Mod = sco_edit_module.module_list(args={"module_id": modimpl.module_id})[0]
+    mod_dict = sco_edit_module.module_list(args={"module_id": modimpl.module_id})[0]
     sem = sco_formsemestre.get_formsemestre(formsemestre_id)
-    F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0]
+    formation_dict = sco_formations.formation_list(
+        args={"formation_id": sem["formation_id"]}
+    )[0]
     mod_inscrits = sco_moduleimpl.do_moduleimpl_inscription_list(
         moduleimpl_id=moduleimpl_id
     )
 
     nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
-
-    # mod_evals = sco_evaluation_db.do_evaluation_list({"moduleimpl_id": moduleimpl_id})
-    # mod_evals.sort(
-    #     key=lambda x: (x["numero"], x["jour"], x["heure_debut"]), reverse=True
-    # )
-    # la plus RECENTE en tête
+    # Evaluations, la plus RECENTE en tête
     evaluations = modimpl.evaluations.order_by(
         Evaluation.numero.desc(),
         Evaluation.jour.desc(),
@@ -240,18 +236,23 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
     )
     arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags()
     #
-    module_resp = User.query.get(M["responsable_id"])
-    mod_type_name = scu.MODULE_TYPE_NAMES[Mod["module_type"]]
+    module_resp = User.query.get(modimpl.responsable_id)
+    mod_type_name = scu.MODULE_TYPE_NAMES[mod_dict["module_type"]]
     H = [
         html_sco_header.sco_header(
-            page_title=f"{mod_type_name} {Mod['code']} {Mod['titre']}"
+            page_title=f"{mod_type_name} {mod_dict['code']} {mod_dict['titre']}",
+            javascripts=["js/etud_info.js"],
+            init_qtip=True,
         ),
         f"""<h2 class="formsemestre">{mod_type_name} 
-        <tt>{Mod['code']}</tt> {Mod['titre']}
-        {"dans l'UE " + modimpl.module.ue.acronyme if modimpl.module.module_type == scu.ModuleType.MALUS else ""}
+        <tt>{mod_dict['code']}</tt> {mod_dict['titre']}
+        {"dans l'UE " + modimpl.module.ue.acronyme 
+        if modimpl.module.module_type == scu.ModuleType.MALUS 
+        else ""
+        }
         </h2>
         <div class="moduleimpl_tableaubord moduleimpl_type_{
-            scu.ModuleType(Mod['module_type']).name.lower()}">
+            scu.ModuleType(mod_dict['module_type']).name.lower()}">
         <table>
         <tr>
         <td class="fichetitre2">Responsable: </td><td class="redboldtext">
@@ -259,18 +260,14 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
         <span class="blacktt">({module_resp.user_name})</span>
         """,
     ]
-    try:
-        sco_moduleimpl.can_change_module_resp(moduleimpl_id)
+    if modimpl.can_change_ens_by(current_user):
         H.append(
-            """<a class="stdlink" href="edit_moduleimpl_resp?moduleimpl_id=%s">modifier</a>"""
-            % moduleimpl_id
+            f"""<a class="stdlink" href="{url_for("notes.edit_moduleimpl_resp",
+                scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
+                }" >modifier</a>"""
         )
-    except:
-        pass
     H.append("""</td><td>""")
-    H.append(
-        ", ".join([sco_users.user_info(m["ens_id"])["nomprenom"] for m in M["ens"]])
-    )
+    H.append(", ".join([u.get_nomprenom() for u in modimpl.enseignants]))
     H.append("""</td><td>""")
     try:
         sco_moduleimpl.can_change_ens(moduleimpl_id)
@@ -302,7 +299,8 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
     H.append("""</td><td></td></tr>""")
     # 3ieme ligne: Formation
     H.append(
-        """<tr><td class="fichetitre2">Formation: </td><td>%(titre)s</td></tr>""" % F
+        """<tr><td class="fichetitre2">Formation: </td><td>%(titre)s</td></tr>"""
+        % formation_dict
     )
     # Ligne: Inscrits
     H.append(
@@ -312,15 +310,18 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
     if current_user.has_permission(Permission.ScoEtudInscrit):
         H.append(
             """<a class="stdlink" style="margin-left:2em;" href="moduleimpl_inscriptions_edit?moduleimpl_id=%s">modifier</a>"""
-            % M["moduleimpl_id"]
+            % mi_dict["moduleimpl_id"]
         )
     H.append("</td></tr>")
     # Ligne: règle de calcul
-    has_expression = sco_compute_moy.moduleimpl_has_expression(M)
+    has_expression = sco_compute_moy.moduleimpl_has_expression(mi_dict)
     if has_expression:
         H.append(
-            '<tr><td class="fichetitre2" colspan="4">Règle de calcul: <span class="formula" title="mode de calcul de la moyenne du module">moyenne=<tt>%s</tt></span>'
-            % M["computation_expr"]
+            f"""<tr>
+            <td class="fichetitre2" colspan="4">Règle de calcul:
+            <span class="formula" title="mode de calcul de la moyenne du module"
+            >moyenne=<tt>{mi_dict["computation_expr"]}</tt>
+            </span>"""
         )
         H.append("""<span class="warning">inutilisée dans cette version de ScoDoc""")
         if sco_moduleimpl.can_change_ens(moduleimpl_id, raise_exc=False):
@@ -380,20 +381,24 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
     #
     if formsemestre_has_decisions(formsemestre_id):
         H.append(
-            """<ul class="tf-msg"><li class="tf-msg warning">Décisions de jury saisies: seul le responsable du semestre peut saisir des notes (il devra modifier les décisions de jury).</li></ul>"""
+            """<ul class="tf-msg">
+            <li class="tf-msg warning">Décisions de jury saisies: seul le responsable du
+            semestre peut saisir des notes (il devra modifier les décisions de jury).
+            </li>
+            </ul>"""
         )
     #
     H.append(
-        """<p><form name="f"><span style="font-size:120%%; font-weight: bold;">%d évaluations :</span>
+        f"""<p><form name="f">
+<span style="font-size:120%%; font-weight: bold;">{nb_evaluations} évaluations :</span>
 <span style="padding-left: 30px;">
-<input type="hidden" name="moduleimpl_id" value="%s"/>"""
-        % (nb_evaluations, moduleimpl_id)
+<input type="hidden" name="moduleimpl_id" value="{moduleimpl_id}"/>"""
     )
     #
     # Liste les noms de partitions
     partitions = sco_groups.get_partitions_list(sem["formsemestre_id"])
     H.append(
-        """Afficher les groupes 
+        """Afficher les groupes
         de&nbsp;<select name="partition_id" onchange="document.f.submit();">"""
     )
     been_selected = False
@@ -409,8 +414,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
         if name is None:
             name = "Tous"
         H.append(
-            """<option value="%s" %s>%s</option>"""
-            % (partition["partition_id"], selected, name)
+            f"""<option value="{partition['partition_id']}" {selected}>{name}</option>"""
         )
     H.append(
         """</select>
@@ -420,20 +424,21 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
 </form>
 </p>
 """
-        % M
+        % mi_dict
     )
 
     # -------- Tableau des evaluations
     top_table_links = ""
     if can_edit_evals:
         top_table_links = f"""<a class="stdlink" href="{
-                url_for("notes.evaluation_create", scodoc_dept=g.scodoc_dept, moduleimpl_id=M['moduleimpl_id'])
+                url_for("notes.evaluation_create", scodoc_dept=g.scodoc_dept, moduleimpl_id=mi_dict['moduleimpl_id'])
                 }">Créer nouvelle évaluation</a>
             """
         if nb_evaluations > 0:
             top_table_links += f"""
             <a class="stdlink" style="margin-left:2em;" href="{
-                url_for("notes.moduleimpl_evaluation_renumber", scodoc_dept=g.scodoc_dept, moduleimpl_id=M['moduleimpl_id'],
+                url_for("notes.moduleimpl_evaluation_renumber", 
+                scodoc_dept=g.scodoc_dept, moduleimpl_id=mi_dict['moduleimpl_id'],
                 redirect=1)
             }">Trier par date</a>
             """
@@ -477,31 +482,35 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
         f"""</td></tr>
 </table>
 
+<div class="list_etuds_attente">
+ {_html_modimpl_etuds_attente(nt, modimpl)}
+</div>
+
 </div>
 
 <!-- LEGENDE -->
 <hr>
 <h4>Légende</h4>
 <ul>
-<li>{scu.icontag("edit_img")} : modifie description de l'évaluation 
+<li>{scu.icontag("edit_img")} : modifie description de l'évaluation
     (date, heure, coefficient, ...)
 </li>
 <li>{scu.icontag("notes_img")} : saisie des notes</li>
-<li>{scu.icontag("delete_img")} : indique qu'il n'y a aucune note 
+<li>{scu.icontag("delete_img")} : indique qu'il n'y a aucune note
         entrée (cliquer pour supprimer cette évaluation)
 </li>
-<li>{scu.icontag("status_orange_img")} : indique qu'il manque 
+<li>{scu.icontag("status_orange_img")} : indique qu'il manque
         quelques notes dans cette évaluation
 </li>
-<li>{scu.icontag("status_green_img")} : toutes les notes sont 
+<li>{scu.icontag("status_green_img")} : toutes les notes sont
         entrées (cliquer pour les afficher)
 </li>
-<li>{scu.icontag("status_visible_img")} : indique que cette évaluation 
+<li>{scu.icontag("status_visible_img")} : indique que cette évaluation
         sera mentionnée dans les bulletins au format "intermédiaire"
 </li>
 </ul>
 
-<p>Rappel : seules les notes des évaluations complètement saisies 
+<p>Rappel : seules les notes des évaluations complètement saisies
     (affichées en vert) apparaissent dans les bulletins.
 </p>
     """
@@ -844,3 +853,22 @@ def _evaluation_poids_html(evaluation: Evaluation, max_poids: float = 0.0) -> st
         + "</div>"
     )
     return H
+
+
+def _html_modimpl_etuds_attente(res: ResultatsSemestre, modimpl: ModuleImpl) -> str:
+    """Affiche la liste des étudiants ayant au moins une note en attente dans ce modimpl"""
+    m_res = res.modimpls_results.get(modimpl.id)
+    if m_res:
+        if not m_res.etudids_attente:
+            return "<div><em>Aucun étudiant n'a de notes en attente.</em></div>"
+        elif len(m_res.etudids_attente) < 10:
+            return f"""
+            <h4>Étudiants avec une note en attente&nbsp;:</h4>
+            {list_etuds.html_table_etuds(m_res.etudids_attente)}
+            """
+        else:
+            return f"""<div class="warning"><em>{
+                len(m_res.etudids_attente)
+                } étudiants ont des notes en attente.</em></div>"""
+
+    return ""
diff --git a/app/static/css/gt_table.css b/app/static/css/gt_table.css
index 450ff68f3a317d42bb66d09293df7a8b9f803eaf..d35ca909fcb172a116b70fee62a3c9447dccf66f 100644
--- a/app/static/css/gt_table.css
+++ b/app/static/css/gt_table.css
@@ -641,4 +641,9 @@ table.dataTable.order-column.stripe.hover tbody tr.even:hover td.sorting_1 {
 table.dataTable.gt_table {
   width: auto;
   padding-right: 5px;
+}
+
+/* Tables non centrées */
+table.dataTable.gt_table.gt_left {
+  margin-left: 16px;
 }
\ No newline at end of file
diff --git a/app/tables/list_etuds.py b/app/tables/list_etuds.py
new file mode 100644
index 0000000000000000000000000000000000000000..175cf0726b6f73cd39510895eef9f530cc22e5e2
--- /dev/null
+++ b/app/tables/list_etuds.py
@@ -0,0 +1,117 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Liste simple d'étudiants
+"""
+
+from flask import g, url_for
+from app.models import Identite
+from app.tables import table_builder as tb
+
+
+class TableEtud(tb.Table):
+    """Table listant des étudiants
+    Peut-être sous-classée pour ajouter des colonnes.
+    L'id de la ligne est etuid, et le row stocke etud.
+    """
+
+    def __init__(
+        self,
+        etuds: list[Identite] = None,
+        classes: list[str] = None,
+        row_class=None,
+        with_foot_titles=False,
+        **kwargs,
+    ):
+        self.rows: list["RowEtud"] = []  # juste pour que VSCode nous aide sur .rows
+        classes = classes or ["gt_table", "gt_left"]
+        super().__init__(
+            row_class=row_class or RowEtud,
+            classes=classes,
+            with_foot_titles=with_foot_titles,
+            **kwargs,
+        )
+        self.add_etuds(etuds)
+
+    def add_etuds(self, etuds: list[Identite]):
+        "Ajoute des étudiants à la table"
+        for etud in etuds:
+            row = self.row_class(self, etud)
+            row.add_etud_cols()
+            self.add_row(row)
+
+
+class RowEtud(tb.Row):
+    "Ligne de la table d'étudiants"
+    # pour le moment très simple, extensible (codes, liens bulletins, ...)
+    def __init__(self, table: TableEtud, etud: Identite, *args, **kwargs):
+        super().__init__(table, etud.id, *args, **kwargs)
+        self.etud = etud
+
+    def add_etud_cols(self):
+        """Ajoute colonnes étudiant: codes, noms"""
+        etud = self.etud
+        self.table.group_titles.update(
+            {
+                "etud_codes": "Codes",
+                "identite_detail": "",
+                "identite_court": "",
+            }
+        )
+        # --- Codes (seront cachés, mais exportés en excel)
+        # self.add_cell("etudid", "etudid", etud.id, "etud_codes")
+        # self.add_cell(
+        #     "code_nip",
+        #     "code_nip",
+        #     etud.code_nip or "",
+        #     "etud_codes",
+        # )
+
+        # --- Identité étudiant
+        # url_bulletin = url_for(
+        #     "notes.formsemestre_bulletinetud",
+        #     scodoc_dept=g.scodoc_dept,
+        #     formsemestre_id=res.formsemestre.id,
+        #     etudid=etud.id,
+        # )
+        url_bulletin = None  # pour extension future
+        self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
+        self.add_cell(
+            "nom_disp",
+            "Nom",
+            etud.nom_disp(),
+            "identite_detail",
+            data={"order": etud.sort_key},
+            target=url_bulletin,
+            target_attrs={"class": "etudinfo discretelink", "id": str(etud.id)},
+        )
+        self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
+        # self.add_cell(
+        #     "nom_short",
+        #     "Nom",
+        #     etud.nom_short,
+        #     "identite_court",
+        #     data={
+        #         "order": etud.sort_key,
+        #         "etudid": etud.id,
+        #         "nomprenom": etud.nomprenom,
+        #     },
+        #     target=url_bulletin,
+        #     target_attrs={"class": "etudinfo", "id": str(etud.id)},
+        # )
+
+
+def etuds_sorted_from_ids(etudids) -> list[Identite]:
+    "Liste triée d'etuds à partir d'une collections d'etudids"
+    etuds = [Identite.query.get_or_404(etudid) for etudid in etudids]
+    return sorted(etuds, key=lambda etud: etud.sort_key)
+
+
+def html_table_etuds(etudids) -> str:
+    """Table HTML simple des étudiants indiqués"""
+    etuds = etuds_sorted_from_ids(etudids)
+    table = TableEtud(etuds)
+    return table.html()
diff --git a/app/tables/recap.py b/app/tables/recap.py
index 12f0fcc747c0ac92ee53c9e882857eb38885f909..c48acced790c260f31e4caeaab89b4bd64dd2c0d 100644
--- a/app/tables/recap.py
+++ b/app/tables/recap.py
@@ -38,8 +38,6 @@ class TableRecap(tb.Table):
         moy_sae_<modimpl_id>_<ue_id>, ... les moyennes de SAE dans l'UE
 
     On ajoute aussi des classes:
-    - pour les lignes:
-        selected_row pour l'étudiant sélectionné
     - les colonnes:
         - la moyenne générale a la classe col_moy_gen
         - les colonnes SAE ont la classe col_sae
diff --git a/app/tables/table_builder.py b/app/tables/table_builder.py
index 50a61f559e5f4341f118ac91377fb530daae9a2e..829f3642a565f90e5ebb8ee2c29069aabbe64110 100644
--- a/app/tables/table_builder.py
+++ b/app/tables/table_builder.py
@@ -68,6 +68,7 @@ class Table(Element):
         classes: list[str] = None,
         attrs: dict[str, str] = None,
         data: dict = None,
+        with_foot_titles=True,
         row_class=None,
         xls_sheet_name="feuille",
         xls_before_table=[],  # liste de cellules a placer avant la table
@@ -100,8 +101,10 @@ class Table(Element):
         self.head_title_row: "Row" = Row(
             self, "title_head", cell_elt="th", classes=["titles"]
         )
-        self.foot_title_row: "Row" = Row(
-            self, "title_foot", cell_elt="th", classes=["titles"]
+        self.foot_title_row: "Row" = (
+            Row(self, "title_foot", cell_elt="th", classes=["titles"])
+            if with_foot_titles
+            else None
         )
         self.empty_cell = Cell.empty()
         # Excel (xls) spécifique:
@@ -119,8 +122,10 @@ class Table(Element):
         """
         self.sort_columns()
         # Titres
-        self.add_head_row(self.head_title_row)
-        self.add_foot_row(self.foot_title_row)
+        if self.head_title_row:
+            self.add_head_row(self.head_title_row)
+        if self.foot_title_row:
+            self.add_foot_row(self.foot_title_row)
 
     def get_row_by_id(self, row_id) -> "Row":
         "return the row, or None"
@@ -261,18 +266,23 @@ class Table(Element):
         title = title or ""
         if col_id not in self.titles:
             self.titles[col_id] = title
-            self.head_title_row.cells[col_id] = self.head_title_row.add_cell(
-                col_id,
-                None,
-                title,
-                classes=classes,
-                group=self.column_group.get(col_id),
-            )
-            self.foot_title_row.cells[col_id] = self.foot_title_row.add_cell(
-                col_id, None, title, classes=classes
-            )
-
-        return self.head_title_row.cells.get(col_id), self.foot_title_row.cells[col_id]
+            if self.head_title_row:
+                self.head_title_row.cells[col_id] = self.head_title_row.add_cell(
+                    col_id,
+                    None,
+                    title,
+                    classes=classes,
+                    group=self.column_group.get(col_id),
+                )
+            if self.foot_title_row:
+                self.foot_title_row.cells[col_id] = self.foot_title_row.add_cell(
+                    col_id, None, title, classes=classes
+                )
+        head_cell = (
+            self.head_title_row.cells.get(col_id) if self.head_title_row else None
+        )
+        foot_cell = self.foot_title_row.cells[col_id] if self.foot_title_row else None
+        return head_cell, foot_cell
 
     def excel(self, wb: Workbook = None):
         """Simple Excel representation of the table."""
diff --git a/app/views/notes.py b/app/views/notes.py
index 75f02ab6de47954faac59beaaeaa6cd7e39cb27b..7ac2834a9dbb243b58ac7cccbd892b0abc018290 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -1043,15 +1043,18 @@ def edit_enseignants_form(moduleimpl_id):
 @scodoc
 @permission_required(Permission.ScoView)
 @scodoc7func
-def edit_moduleimpl_resp(moduleimpl_id):
+def edit_moduleimpl_resp(moduleimpl_id: int):
     """Changement d'un enseignant responsable de module
     Accessible par Admin et dir des etud si flag resp_can_change_ens
     """
-    M, sem = sco_moduleimpl.can_change_module_resp(moduleimpl_id)
+    modimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
+    modimpl.can_change_ens_by(current_user, raise_exc=True)  # access control
     H = [
         html_sco_header.html_sem_header(
-            'Modification du responsable du <a href="moduleimpl_status?moduleimpl_id=%s">module %s</a>'
-            % (moduleimpl_id, M["module"]["titre"]),
+            f"""Modification du responsable du <a href="{
+                url_for("notes.moduleimpl_status",
+                    scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
+            }">module {modimpl.module.titre or ""}</a>""",
             javascripts=["libjs/AutoSuggest.js"],
             cssstyles=["css/autosuggest_inquisitor.css"],
             bodyOnLoad="init_tf_form('')",
@@ -1065,9 +1068,9 @@ def edit_moduleimpl_resp(moduleimpl_id):
         uid2display[u["id"]] = u["nomplogin"]
     allowed_user_names = list(uid2display.values())
 
-    initvalues = M
+    initvalues = modimpl.to_dict(with_module=False)
     initvalues["responsable_id"] = uid2display.get(
-        M["responsable_id"], M["responsable_id"]
+        modimpl.responsable_id, modimpl.responsable_id
     )
     form = [
         ("moduleimpl_id", {"input_type": "hidden"}),
@@ -1112,9 +1115,8 @@ def edit_moduleimpl_resp(moduleimpl_id):
         )
     else:
         responsable_id = User.get_user_id_from_nomplogin(tf[2]["responsable_id"])
-        if (
-            not responsable_id
-        ):  # presque impossible: tf verifie les valeurs (mais qui peuvent changer entre temps)
+        if not responsable_id:
+            # presque impossible: tf verifie les valeurs (mais qui peuvent changer entre temps)
             return flask.redirect(
                 url_for(
                     "notes.moduleimpl_status",
@@ -1123,16 +1125,15 @@ def edit_moduleimpl_resp(moduleimpl_id):
                 )
             )
 
-        sco_moduleimpl.do_moduleimpl_edit(
-            {"moduleimpl_id": moduleimpl_id, "responsable_id": responsable_id},
-            formsemestre_id=sem["formsemestre_id"],
-        )
+        modimpl.responsable_id = responsable_id
+        db.session.add(modimpl)
+        db.session.commit()
+        flash("Responsable modifié")
         return flask.redirect(
             url_for(
                 "notes.moduleimpl_status",
                 scodoc_dept=g.scodoc_dept,
                 moduleimpl_id=moduleimpl_id,
-                head_message="responsable modifié",
             )
         )
 
diff --git a/sco_version.py b/sco_version.py
index b4ee5fb499e3044a391c512fae9b5e76b031e646..6561608c52c6e8abcaee72687a362a83e9ea1f8b 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,7 +1,7 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
-SCOVERSION = "9.4.39"
+SCOVERSION = "9.4.40"
 
 SCONAME = "ScoDoc"