From a4fbc2b80ebcb585282aec9a2323507e12d0aec7 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Sun, 10 Dec 2023 20:59:32 +0100
Subject: [PATCH] Evaluations: modernisation code

---
 app/comp/res_compat.py                       |   2 +-
 app/models/evaluations.py                    |  31 +-
 app/models/events.py                         |   2 +-
 app/models/formsemestre.py                   |   2 +-
 app/models/moduleimpls.py                    |  10 +-
 app/scodoc/sco_evaluation_check_abs.py       | 108 +++---
 app/scodoc/sco_evaluations.py                |  81 ++---
 app/scodoc/sco_liste_notes.py                | 329 ++++++++++---------
 app/static/css/gt_table.css                  |  14 +-
 app/static/css/scodoc.css                    |   7 +-
 app/tables/list_etuds.py                     |   2 +-
 app/templates/assiduites/pages/calendrier.j2 |   2 +-
 app/views/assiduites.py                      |  22 +-
 tests/unit/test_bulletin.py                  |   6 +-
 14 files changed, 321 insertions(+), 297 deletions(-)

diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py
index 0e6cf7571..7d23bea44 100644
--- a/app/comp/res_compat.py
+++ b/app/comp/res_compat.py
@@ -408,7 +408,7 @@ class NotesTableCompat(ResultatsSemestre):
         de ce module.
         Évaluation "complete" ssi toutes notes saisies ou en attente.
         """
-        modimpl = db.session.get(ModuleImpl, moduleimpl_id)
+        modimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
         modimpl_results = self.modimpls_results.get(moduleimpl_id)
         if not modimpl_results:
             return []  # safeguard
diff --git a/app/models/evaluations.py b/app/models/evaluations.py
index 63fac2641..127c9715c 100644
--- a/app/models/evaluations.py
+++ b/app/models/evaluations.py
@@ -12,9 +12,7 @@ import sqlalchemy as sa
 from app import db, log
 from app.models.etudiants import Identite
 from app.models.events import ScolarNews
-from app.models.moduleimpls import ModuleImpl
 from app.models.notes import NotesNotes
-from app.models.ues import UniteEns
 
 from app.scodoc import sco_cache
 from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
@@ -67,7 +65,7 @@ class Evaluation(db.Model):
     @classmethod
     def create(
         cls,
-        moduleimpl: ModuleImpl = None,
+        moduleimpl: "ModuleImpl" = None,
         date_debut: datetime.datetime = None,
         date_fin: datetime.datetime = None,
         description=None,
@@ -114,7 +112,7 @@ class Evaluation(db.Model):
 
     @classmethod
     def get_new_numero(
-        cls, moduleimpl: ModuleImpl, date_debut: datetime.datetime
+        cls, moduleimpl: "ModuleImpl", date_debut: datetime.datetime
     ) -> int:
         """Get a new numero for an evaluation in this moduleimpl
         If necessary, renumber existing evals to make room for a new one.
@@ -145,7 +143,7 @@ class Evaluation(db.Model):
         "delete evaluation (commit) (check permission)"
         from app.scodoc import sco_evaluation_db
 
-        modimpl: ModuleImpl = self.moduleimpl
+        modimpl: "ModuleImpl" = self.moduleimpl
         if not modimpl.can_edit_evaluation(current_user):
             raise AccessDenied(
                 f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
@@ -239,7 +237,7 @@ class Evaluation(db.Model):
         check_convert_evaluation_args(self.moduleimpl, data)
         if data.get("numero") is None:
             data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
-        for k in self.__dict__.keys():
+        for k in self.__dict__:
             if k != "_sa_instance_state" and k != "id" and k in data:
                 setattr(self, k, data[k])
 
@@ -257,7 +255,7 @@ class Evaluation(db.Model):
 
     @classmethod
     def moduleimpl_evaluation_renumber(
-        cls, moduleimpl: ModuleImpl, only_if_unumbered=False
+        cls, moduleimpl: "ModuleImpl", only_if_unumbered=False
     ):
         """Renumber evaluations in this moduleimpl, according to their date. (numero=0: oldest one)
         Needed because previous versions of ScoDoc did not have eval numeros
@@ -394,6 +392,8 @@ class Evaluation(db.Model):
         """set poids vers les UE (remplace existants)
         ue_poids_dict = { ue_id : poids }
         """
+        from app.models.ues import UniteEns
+
         L = []
         for ue_id, poids in ue_poids_dict.items():
             ue = db.session.get(UniteEns, ue_id)
@@ -474,7 +474,7 @@ class EvaluationUEPoids(db.Model):
         backref=db.backref("ue_poids", cascade="all, delete-orphan"),
     )
     ue = db.relationship(
-        UniteEns,
+        "UniteEns",
         backref=db.backref("evaluation_ue_poids", cascade="all, delete-orphan"),
     )
 
@@ -506,7 +506,7 @@ def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
     return e_dict
 
 
-def check_convert_evaluation_args(moduleimpl: ModuleImpl, data: dict):
+def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
     """Check coefficient, dates and duration, raises exception if invalid.
     Convert date and time strings to date and time objects.
 
@@ -606,19 +606,6 @@ def heure_to_time(heure: str) -> datetime.time:
     return datetime.time(int(h), int(m))
 
 
-def _time_duration_HhM(heure_debut: str, heure_fin: str) -> int:
-    """duree (nb entier de minutes) entre deux heures a notre format
-    ie 12h23
-    """
-    if heure_debut and heure_fin:
-        h0, m0 = [int(x) for x in heure_debut.split("h")]
-        h1, m1 = [int(x) for x in heure_fin.split("h")]
-        d = (h1 - h0) * 60 + (m1 - m0)
-        return d
-    else:
-        return None
-
-
 def _moduleimpl_evaluation_insert_before(
     evaluations: list[Evaluation], next_eval: Evaluation
 ) -> int:
diff --git a/app/models/events.py b/app/models/events.py
index 06dbe558d..fc9fbde0c 100644
--- a/app/models/events.py
+++ b/app/models/events.py
@@ -13,7 +13,6 @@ from app import email
 from app import log
 from app.auth.models import User
 from app.models import SHORT_STR_LEN
-from app.models.moduleimpls import ModuleImpl
 import app.scodoc.sco_utils as scu
 from app.scodoc import sco_preferences
 
@@ -181,6 +180,7 @@ class ScolarNews(db.Model):
         None si inexistant
         """
         from app.models.formsemestre import FormSemestre
+        from app.models.moduleimpls import ModuleImpl
 
         formsemestre_id = None
         if self.type == self.NEWS_INSCR:
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 10875cbff..8ddf542c4 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -390,7 +390,7 @@ class FormSemestre(db.Model):
                 Module.numero,
                 Module.code,
                 Evaluation.numero,
-                Evaluation.date_debut.desc(),
+                Evaluation.date_debut,
             )
             .all()
         )
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index 77eba7da8..7b9c35968 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -3,12 +3,14 @@
 """
 import pandas as pd
 from flask_sqlalchemy.query import Query
+import sqlalchemy as sa
 
 from app import db
 from app.auth.models import User
 from app.comp import df_cache
 from app.models import APO_CODE_STR_LEN
 from app.models.etudiants import Identite
+from app.models.evaluations import Evaluation
 from app.models.modules import Module
 from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
 from app.scodoc.sco_permissions import Permission
@@ -38,7 +40,13 @@ class ModuleImpl(db.Model):
     # formule de calcul moyenne:
     computation_expr = db.Column(db.Text())
 
-    evaluations = db.relationship("Evaluation", lazy="dynamic", backref="moduleimpl")
+    evaluations = db.relationship(
+        "Evaluation",
+        lazy="dynamic",
+        backref="moduleimpl",
+        order_by=(Evaluation.numero, Evaluation.date_debut),
+    )
+    "évaluations, triées par numéro et dates croissants, donc la plus ancienne d'abord."
     enseignants = db.relationship(
         "User",
         secondary="notes_modules_enseignants",
diff --git a/app/scodoc/sco_evaluation_check_abs.py b/app/scodoc/sco_evaluation_check_abs.py
index 41299056a..f10c3111a 100644
--- a/app/scodoc/sco_evaluation_check_abs.py
+++ b/app/scodoc/sco_evaluation_check_abs.py
@@ -28,6 +28,7 @@
 """Vérification des absences à une évaluation
 """
 from flask import url_for, g
+from flask_sqlalchemy.query import Query
 
 from app import db
 from app.models import Evaluation, FormSemestre, Identite, Assiduite
@@ -37,9 +38,6 @@ from app.scodoc import sco_evaluations
 from app.scodoc import sco_evaluation_db
 from app.scodoc import sco_groups
 
-from flask_sqlalchemy.query import Query
-from sqlalchemy import or_, and_
-
 
 def evaluation_check_absences(evaluation: Evaluation):
     """Vérifie les absences au moment de cette évaluation.
@@ -78,11 +76,11 @@ def evaluation_check_absences(evaluation: Evaluation):
 
     # Les notes:
     notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
-    ValButAbs = []  # une note mais noté absent
-    AbsNonSignalee = []  # note ABS mais pas noté absent
-    ExcNonSignalee = []  # note EXC mais pas noté absent
-    ExcNonJust = []  #  note EXC mais absent non justifie
-    AbsButExc = []  # note ABS mais justifié
+    note_but_abs = []  # une note mais noté absent
+    abs_non_signalee = []  # note ABS mais pas noté absent
+    exc_non_signalee = []  # note EXC mais pas noté absent
+    exc_non_just = []  #  note EXC mais absent non justifie
+    abs_but_exc = []  # note ABS mais justifié
     for etudid in etudids:
         if etudid in notes_db:
             val = notes_db[etudid]["value"]
@@ -92,50 +90,43 @@ def evaluation_check_absences(evaluation: Evaluation):
                 and val != scu.NOTES_ATTENTE
             ) and etudid in abs_etudids:
                 # note valide et absent
-                ValButAbs.append(etudid)
+                note_but_abs.append(etudid)
             if val is None and not etudid in abs_etudids:
                 # absent mais pas signale comme tel
-                AbsNonSignalee.append(etudid)
+                abs_non_signalee.append(etudid)
             if val == scu.NOTES_NEUTRALISE and not etudid in abs_etudids:
                 # Neutralisé mais pas signale absent
-                ExcNonSignalee.append(etudid)
+                exc_non_signalee.append(etudid)
             if val == scu.NOTES_NEUTRALISE and etudid in abs_nj_etudids:
                 # EXC mais pas justifié
-                ExcNonJust.append(etudid)
+                exc_non_just.append(etudid)
             if val is None and etudid in just_etudids:
                 # ABS mais justificatif
-                AbsButExc.append(etudid)
+                abs_but_exc.append(etudid)
 
-    return ValButAbs, AbsNonSignalee, ExcNonSignalee, ExcNonJust, AbsButExc
+    return note_but_abs, abs_non_signalee, exc_non_signalee, exc_non_just, abs_but_exc
 
 
 def evaluation_check_absences_html(
     evaluation: Evaluation, with_header=True, show_ok=True
 ):
     """Affiche état vérification absences d'une évaluation"""
-    am, pm = evaluation.is_matin(), evaluation.is_apresmidi()
-    # 1 si matin, 0 si apres midi, 2 si toute la journee:
-    match am, pm:
-        case False, True:
-            demijournee = 0
-        case True, False:
-            demijournee = 1
-        case _:
-            demijournee = 2
-
     (
-        ValButAbs,
-        AbsNonSignalee,
-        ExcNonSignalee,
-        ExcNonJust,
-        AbsButExc,
+        note_but_abs,  # une note alors qu'il était signalé abs
+        abs_non_signalee,  # note ABS alors que pas signalé abs
+        exc_non_signalee,  # note EXC alors que pas signalé abs
+        exc_non_just,  # note EXC alors que pas de justif
+        abs_but_exc,  # note ABS alors qu'il y a un justif
     ) = evaluation_check_absences(evaluation)
 
     if with_header:
         H = [
-            html_sco_header.html_sem_header("Vérification absences à l'évaluation"),
+            html_sco_header.html_sem_header(
+                "Vérification absences à l'évaluation",
+                formsemestre_id=evaluation.moduleimpl.formsemestre_id,
+            ),
             sco_evaluations.evaluation_describe(evaluation_id=evaluation.id),
-            """<p class="help">Vérification de la cohérence entre les notes saisies 
+            """<p class="help">Vérification de la cohérence entre les notes saisies
             et les absences signalées.</p>""",
         ]
     else:
@@ -148,10 +139,10 @@ def evaluation_check_absences_html(
                 } """
         ]
         if (
-            not ValButAbs
-            and not AbsNonSignalee
-            and not ExcNonSignalee
-            and not ExcNonJust
+            not note_but_abs
+            and not abs_non_signalee
+            and not exc_non_signalee
+            and not exc_non_just
         ):
             H.append(': <span class="eval_check_absences_ok">ok</span>')
         H.append("</h2>")
@@ -171,46 +162,50 @@ def evaluation_check_absences_html(
             )
             if linkabs:
                 url = url_for(
-                    "assiduites.signal_evaluation_abs",
+                    "assiduites.signale_evaluation_abs",
                     etudid=etudid,
                     evaluation_id=evaluation.id,
                     scodoc_dept=g.scodoc_dept,
                 )
                 H.append(
-                    f"""<a class="stdlink" href="{url}">signaler cette absence</a>"""
+                    f"""<a style="margin-left: 16px;" class="stdlink" href="{
+                        url}">signaler cette absence</a>"""
                 )
             H.append("</li>")
         H.append("</ul>")
 
-    if ValButAbs or show_ok:
+    if note_but_abs or show_ok:
         H.append(
-            "<h3>Etudiants ayant une note alors qu'ils sont signalés absents:</h3>"
+            "<h3>Étudiants ayant une note alors qu'ils sont signalés absents:</h3>"
         )
-        etudlist(ValButAbs)
+        etudlist(note_but_abs)
 
-    if AbsNonSignalee or show_ok:
+    if abs_non_signalee or show_ok:
         H.append(
-            """<h3>Etudiants avec note "ABS" alors qu'ils ne sont <em>pas</em> signalés absents:</h3>"""
+            """<h3>Étudiants avec note "ABS" alors qu'ils ne sont
+            <em>pas</em> signalés absents:</h3>"""
         )
-        etudlist(AbsNonSignalee, linkabs=True)
+        etudlist(abs_non_signalee, linkabs=True)
 
-    if ExcNonSignalee or show_ok:
+    if exc_non_signalee or show_ok:
         H.append(
-            """<h3>Etudiants avec note "EXC" alors qu'ils ne sont <em>pas</em> signalés absents:</h3>"""
+            """<h3>Étudiants avec note "EXC" alors qu'ils ne sont
+            <em>pas</em> signalés absents:</h3>"""
         )
-        etudlist(ExcNonSignalee)
+        etudlist(exc_non_signalee)
 
-    if ExcNonJust or show_ok:
+    if exc_non_just or show_ok:
         H.append(
-            """<h3>Etudiants avec note "EXC" alors qu'ils sont absents <em>non justifiés</em>:</h3>"""
+            """<h3>Étudiants avec note "EXC" alors qu'ils sont absents
+            <em>non justifiés</em>:</h3>"""
         )
-        etudlist(ExcNonJust)
+        etudlist(exc_non_just)
 
-    if AbsButExc or show_ok:
+    if abs_but_exc or show_ok:
         H.append(
-            """<h3>Etudiants avec note "ABS" alors qu'ils ont une <em>justification</em>:</h3>"""
+            """<h3>Étudiants avec note "ABS" alors qu'ils ont une <em>justification</em>:</h3>"""
         )
-        etudlist(AbsButExc)
+        etudlist(abs_but_exc)
 
     if with_header:
         H.append(html_sco_header.sco_footer())
@@ -226,7 +221,8 @@ def formsemestre_check_absences_html(formsemestre_id):
         html_sco_header.html_sem_header(
             "Vérification absences aux évaluations de ce semestre",
         ),
-        """<p class="help">Vérification de la cohérence entre les notes saisies et les absences signalées.
+        """<p class="help">Vérification de la cohérence entre les notes saisies
+            et les absences signalées.
           Sont listés tous les modules avec des évaluations.<br>Aucune action n'est effectuée:
           il vous appartient de corriger les erreurs détectées si vous le jugez nécessaire.
           </p>""",
@@ -237,14 +233,12 @@ def formsemestre_check_absences_html(formsemestre_id):
             H.append(
                 f"""<div class="module_check_absences">
                     <h2><a href="{
-                        url_for("notes.moduleimpl_status", 
+                        url_for("notes.moduleimpl_status",
                                 scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
                     }">{modimpl.module.code or ""}: {modimpl.module.abbrev or ""}</a>
                     </h2>"""
             )
-            for evaluation in modimpl.evaluations.order_by(
-                Evaluation.numero, Evaluation.date_debut
-            ):
+            for evaluation in modimpl.evaluations:
                 H.append(
                     evaluation_check_absences_html(
                         evaluation,
diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py
index 0e35c688f..8ae20755f 100644
--- a/app/scodoc/sco_evaluations.py
+++ b/app/scodoc/sco_evaluations.py
@@ -33,8 +33,8 @@ import operator
 
 from flask import url_for
 from flask import g
-from flask_login import current_user
 from flask import request
+from flask_login import current_user
 
 from app import db
 from app.auth.models import User
@@ -50,11 +50,9 @@ from app.scodoc import html_sco_header
 from app.scodoc import sco_cal
 from app.scodoc import sco_evaluation_db
 from app.scodoc import sco_edit_module
-from app.scodoc import sco_edit_ue
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_groups
 from app.scodoc import sco_moduleimpl
-from app.scodoc import sco_permissions_check
 from app.scodoc import sco_preferences
 from app.scodoc import sco_users
 import sco_version
@@ -76,22 +74,21 @@ def notes_moyenne_median_mini_maxi(notes):
     if not n:
         return None, None, None, None
     moy = sum(notes) / n
-    median = ListMedian(notes)
+    median = list_median(notes)
     mini = min(notes)
     maxi = max(notes)
     return moy, median, mini, maxi
 
 
-def ListMedian(L):
+def list_median(a_list: list):
     """Median of a list L"""
-    n = len(L)
+    n = len(a_list)
     if not n:
         raise ValueError("empty list")
-    L.sort()
+    a_list.sort()
     if n % 2:
-        return L[n // 2]
-    else:
-        return (L[n // 2] + L[n // 2 - 1]) / 2
+        return a_list[n // 2]
+    return (a_list[n // 2] + a_list[n // 2 - 1]) / 2
 
 
 # --------------------------------------------------------------------
@@ -190,39 +187,39 @@ def do_evaluation_etat(
 
     # On considere une note "manquante" lorsqu'elle n'existe pas
     # ou qu'elle est en attente (ATT)
-    GrNbMissing = collections.defaultdict(int)  # group_id : nb notes manquantes
-    GrNotes = collections.defaultdict(list)  # group_id: liste notes valides
-    TotalNbMissing = 0
-    TotalNbAtt = 0
-    groups = {}  # group_id : group
+    group_nb_missing = collections.defaultdict(int)  # group_id : nb notes manquantes
+    group_notes = collections.defaultdict(list)  # group_id: liste notes valides
+    total_nb_missing = 0
+    total_nb_att = 0
+    group_by_id = {}  # group_id : group
     etud_groups = sco_groups.get_etud_groups_in_partition(partition_id)
 
     for i in ins:
         group = etud_groups.get(i["etudid"], None)
-        if group and not group["group_id"] in groups:
-            groups[group["group_id"]] = group
+        if group and not group["group_id"] in group_by_id:
+            group_by_id[group["group_id"]] = group
         #
-        isMissing = False
+        is_missing = False
         if i["etudid"] in etuds_notes_dict:
             val = etuds_notes_dict[i["etudid"]]["value"]
             if val == scu.NOTES_ATTENTE:
-                isMissing = True
-                TotalNbAtt += 1
+                is_missing = True
+                total_nb_att += 1
             if group:
-                GrNotes[group["group_id"]].append(val)
+                group_notes[group["group_id"]].append(val)
         else:
             if group:
-                _ = GrNotes[group["group_id"]]  # create group
-            isMissing = True
-        if isMissing:
-            TotalNbMissing += 1
+                _ = group_notes[group["group_id"]]  # create group
+            is_missing = True
+        if is_missing:
+            total_nb_missing += 1
             if group:
-                GrNbMissing[group["group_id"]] += 1
+                group_nb_missing[group["group_id"]] += 1
 
-    gr_incomplets = [x for x in GrNbMissing.keys()]
+    gr_incomplets = list(group_nb_missing.keys())
     gr_incomplets.sort()
     if (
-        (TotalNbMissing > 0)
+        (total_nb_missing > 0)
         and (E["evaluation_type"] != scu.EVALUATION_RATTRAPAGE)
         and (E["evaluation_type"] != scu.EVALUATION_SESSION2)
     ):
@@ -231,12 +228,12 @@ def do_evaluation_etat(
         complete = True
 
     complete = (
-        (TotalNbMissing == 0)
+        (total_nb_missing == 0)
         or (E["evaluation_type"] == scu.EVALUATION_RATTRAPAGE)
         or (E["evaluation_type"] == scu.EVALUATION_SESSION2)
     )
-    evalattente = (TotalNbMissing > 0) and (
-        (TotalNbMissing == TotalNbAtt) or E["publish_incomplete"]
+    evalattente = (total_nb_missing > 0) and (
+        (total_nb_missing == total_nb_att) or E["publish_incomplete"]
     )
     # mais ne met pas en attente les evals immediates sans aucune notes:
     if E["publish_incomplete"] and nb_notes == 0:
@@ -244,12 +241,12 @@ def do_evaluation_etat(
 
     # Calcul moyenne dans chaque groupe de TD
     gr_moyennes = []  # group : {moy,median, nb_notes}
-    for group_id, notes in GrNotes.items():
+    for group_id, notes in group_notes.items():
         gr_moy, gr_median, gr_mini, gr_maxi = notes_moyenne_median_mini_maxi(notes)
         gr_moyennes.append(
             {
                 "group_id": group_id,
-                "group_name": groups[group_id]["group_name"],
+                "group_name": group_by_id[group_id]["group_name"],
                 "gr_moy": scu.fmt_note(gr_moy, E["note_max"]),
                 "gr_median": scu.fmt_note(gr_median, E["note_max"]),
                 "gr_mini": scu.fmt_note(gr_mini, E["note_max"]),
@@ -276,7 +273,7 @@ def do_evaluation_etat(
         "last_modif": last_modif,
         "gr_incomplets": gr_incomplets,
         "gr_moyennes": gr_moyennes,
-        "groups": groups,
+        "groups": group_by_id,
         "evalcomplete": complete,
         "evalattente": evalattente,
         "is_malus": is_malus,
@@ -413,7 +410,7 @@ def do_evaluation_etat_in_sem(formsemestre_id):
 
 
 def do_evaluation_etat_in_mod(nt, moduleimpl_id):
-    """"""
+    """état des évaluations dans ce module"""
     evals = nt.get_mod_evaluation_etat_list(moduleimpl_id)
     etat = _eval_etat(evals)
     # Il y a-t-il des notes en attente dans ce module ?
@@ -426,7 +423,7 @@ def formsemestre_evaluations_cal(formsemestre_id):
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
 
-    evaluations = formsemestre.get_evaluations()  # TODO
+    evaluations = formsemestre.get_evaluations()
     nb_evals = len(evaluations)
 
     color_incomplete = "#FF6060"
@@ -642,7 +639,7 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
 
 
 #  -------------- VIEWS
-def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True):
+def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True) -> str:
     """HTML description of evaluation, for page headers
     edit_in_place: allow in-place editing when permitted (not implemented)
     """
@@ -696,7 +693,15 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True):
                     date_debut=evaluation.date_debut.isoformat(),
                     date_fin=evaluation.date_fin.isoformat(),
                     )
-                    }">absences ce jour</a></span>"""
+                    }">absences ce jour</a>
+                    </span>
+                    <span class="evallink"><a class="stdlink" href="{url_for(
+                        'notes.evaluation_check_absences_html',
+                        scodoc_dept=g.scodoc_dept,
+                        evaluation_id = evaluation.id)
+                    }">vérifier notes vs absences</a>
+                    </span>
+                    """
             )
         else:
             H.append("<p><em>sans date</em> ")
diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py
index 2f0659938..467808b33 100644
--- a/app/scodoc/sco_liste_notes.py
+++ b/app/scodoc/sco_liste_notes.py
@@ -33,18 +33,15 @@ import numpy as np
 import flask
 from flask import url_for, g, request
 
-from app import db, log
-from app import models
 from app.comp import res_sem
 from app.comp import moy_mod
 from app.comp.moy_mod import ModuleImplResults
-from app.comp.res_compat import NotesTableCompat
 from app.comp.res_but import ResultatsSemestreBUT
+from app.comp.res_compat import NotesTableCompat
 from app.models import FormSemestre, Module
 from app.models.etudiants import Identite
 from app.models.evaluations import Evaluation
 from app.models.moduleimpls import ModuleImpl
-import app.scodoc.notesdb as ndb
 from app.scodoc.TrivialFormulator import TrivialFormulator
 
 from app.scodoc.sco_etud import etud_sort_key
@@ -54,58 +51,58 @@ from app.scodoc import sco_groups
 from app.scodoc import sco_preferences
 from app.scodoc import sco_users
 import app.scodoc.sco_utils as scu
-import sco_version
 from app.scodoc.gen_tables import GenTable
 from app.scodoc.htmlutils import histogram_notes
+import sco_version
 
 
 def do_evaluation_listenotes(
     evaluation_id=None, moduleimpl_id=None, fmt="html"
-) -> tuple[str, str]:
+) -> tuple[str | flask.Response, str]:
     """
     Affichage des notes d'une évaluation (si evaluation_id)
     ou de toutes les évaluations d'un module (si moduleimpl_id)
     """
     mode = None
-    if moduleimpl_id:
+    evaluations: list[Evaluation] = []
+    if moduleimpl_id is not None:
         mode = "module"
-        evals = sco_evaluation_db.get_evaluations_dict({"moduleimpl_id": moduleimpl_id})
-    elif evaluation_id:
+        modimpl = ModuleImpl.query.get_or_404(moduleimpl_id)
+        evaluations = modimpl.evaluations.all()
+    elif evaluation_id is not None:
         mode = "eval"
-        evals = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id})
+        evaluations = Evaluation.query.filter_by(id=evaluation_id).all()
     else:
         raise ValueError("missing argument: evaluation or module")
-    if not evals:
+    if not evaluations:
         return "<p>Aucune évaluation !</p>", "ScoDoc"
+    evaluation = evaluations[0]
+    modimpl = evaluation.moduleimpl  # il y a au moins une evaluation
 
-    eval_dict = evals[0]  # il y a au moins une evaluation
-    modimpl = db.session.get(ModuleImpl, eval_dict["moduleimpl_id"])
     # description de l'evaluation
-    if mode == "eval":
+    if evaluation_id is not None:
         H = [sco_evaluations.evaluation_describe(evaluation_id=evaluation_id)]
-        page_title = f"Notes {eval_dict['description'] or modimpl.module.code}"
+        page_title = f"Notes {evaluation.description or modimpl.module.code}"
     else:
         H = []
         page_title = f"Notes {modimpl.module.code}"
     # groupes
-    groups = sco_groups.do_evaluation_listegroupes(
-        eval_dict["evaluation_id"], include_default=True
-    )
+    groups = sco_groups.do_evaluation_listegroupes(evaluation.id, include_default=True)
     grlabs = [g["group_name"] or "tous" for g in groups]  # legendes des boutons
     grnams = [str(g["group_id"]) for g in groups]  # noms des checkbox
 
-    if len(evals) > 1:
+    if len(evaluations) > 1:
         descr = [
             (
                 "moduleimpl_id",
-                {"default": eval_dict["moduleimpl_id"], "input_type": "hidden"},
+                {"default": modimpl.id, "input_type": "hidden"},
             )
         ]
     else:
         descr = [
             (
                 "evaluation_id",
-                {"default": eval_dict["evaluation_id"], "input_type": "hidden"},
+                {"default": evaluation.id, "input_type": "hidden"},
             )
         ]
     if len(grnams) > 1:
@@ -148,7 +145,8 @@ def do_evaluation_listenotes(
                 "allowed_values": ("yes",),
                 "labels": ('listing "anonyme"',),
                 "attributes": ('onclick="document.tf.submit();"',),
-                "template": '<tr><td class="tf-fieldlabel">%(label)s</td><td class="tf-field">%(elem)s &nbsp;&nbsp;',
+                "template": """<tr><td class="tf-fieldlabel">%(label)s</td>
+                <td class="tf-field">%(elem)s &nbsp;&nbsp;""",
             },
         ),
         (
@@ -205,7 +203,7 @@ def do_evaluation_listenotes(
                 url_for(
                     "notes.moduleimpl_status",
                     scodoc_dept=g.scodoc_dept,
-                    moduleimpl_id=eval_dict["moduleimpl_id"],
+                    moduleimpl_id=modimpl.id,
                 )
             ),
             "",
@@ -219,7 +217,7 @@ def do_evaluation_listenotes(
         return (
             _make_table_notes(
                 tf[1],
-                evals,
+                evaluations,
                 fmt=fmt,
                 note_sur_20=note_sur_20,
                 anonymous_listing=anonymous_listing,
@@ -234,37 +232,36 @@ def do_evaluation_listenotes(
 
 def _make_table_notes(
     html_form,
-    evals,
+    evaluations: list[Evaluation],
     fmt: str = "",
     note_sur_20=False,
     anonymous_listing=False,
     hide_groups=False,
     with_emails=False,
-    group_ids: list[int] = None,
+    group_ids: list[int] | None = None,
     mode="module",  # "eval" or "module"
 ) -> str:
     """Table liste notes (une seule évaluation ou toutes celles d'un module)"""
     group_ids = group_ids or []
-    if not evals:
+    if not evaluations:
         return "<p>Aucune évaluation !</p>"
-    E = evals[0]
-    moduleimpl_id = E["moduleimpl_id"]
-    modimpl = ModuleImpl.query.get_or_404(moduleimpl_id)
+    evaluation = evaluations[0]
+    modimpl = evaluation.moduleimpl
     module: Module = modimpl.module
     formsemestre: FormSemestre = modimpl.formsemestre
-    is_apc = module.formation.get_cursus().APC_SAE
+    is_apc = module.formation.is_apc()
     if is_apc:
         res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
         is_conforme = modimpl.check_apc_conformity(res)
-        evals_poids, ues = moy_mod.load_evaluations_poids(moduleimpl_id)
+        evals_poids, ues = moy_mod.load_evaluations_poids(modimpl.id)
         if not ues:
             is_apc = False
     else:
         evals_poids, ues = None, None
         is_conforme = True
     # (debug) check that all evals are in same module:
-    for e in evals:
-        if e["moduleimpl_id"] != moduleimpl_id:
+    for e in evaluations:
+        if e.moduleimpl_id != modimpl.id:
             raise ValueError("invalid evaluations list")
 
     if fmt == "xls":
@@ -302,11 +299,14 @@ def _make_table_notes(
     }
     rows = []
 
-    class KeyManager(dict):  # comment : key (pour regrouper les comments a la fin)
+    class KeyManager(dict):
+        "comment : key (pour regrouper les comments a la fin)"
+
         def __init__(self):
             self.lastkey = 1
 
-        def nextkey(self):
+        def nextkey(self) -> str:
+            "get new key (int)"
             r = self.lastkey
             self.lastkey += 1
             # self.lastkey = chr(ord(self.lastkey)+1)
@@ -323,7 +323,7 @@ def _make_table_notes(
         anonymous_lst_key = "etudid"
 
     etudid_etats = sco_groups.do_evaluation_listeetuds_groups(
-        E["evaluation_id"], groups, include_demdef=True
+        evaluation.id, groups, include_demdef=True
     )
     for etudid, etat in etudid_etats:
         css_row_class = None
@@ -360,7 +360,8 @@ def _make_table_notes(
                     formsemestre_id=formsemestre.id,
                     etudid=etudid,
                 ),
-                "_nomprenom_td_attrs": f"""id="{etudid}" class="etudinfo" data-sort="{etud.sort_key}" """,
+                "_nomprenom_td_attrs": f"""id="{etudid}" class="etudinfo" data-sort="{
+                    etud.sort_key}" """,
                 "prenom": etud.prenom.lower().capitalize(),
                 "nom_usuel": etud.nom_usuel,
                 "nomprenom": etud.nomprenom,
@@ -408,10 +409,12 @@ def _make_table_notes(
         "comment": "",
     }
     # Ajoute les notes de chaque évaluation:
-    for e in evals:
-        e["eval_state"] = sco_evaluations.do_evaluation_etat(e["evaluation_id"])
+    evals_state: dict[int, dict] = {}
+    for e in evaluations:
+        evals_state[e.id] = sco_evaluations.do_evaluation_etat(e.id)
         notes, nb_abs, nb_att = _add_eval_columns(
             e,
+            evals_state[e.id],
             evals_poids,
             ues,
             rows,
@@ -426,7 +429,7 @@ def _make_table_notes(
             keep_numeric,
             fmt=fmt,
         )
-        columns_ids.append(e["evaluation_id"])
+        columns_ids.append(e.id)
     #
     if anonymous_listing:
         rows.sort(key=lambda x: x["code"] or "")
@@ -436,12 +439,12 @@ def _make_table_notes(
 
     # Si module, ajoute la (les) "moyenne(s) du module:
     if mode == "module":
-        if len(evals) > 1:
+        if len(evaluations) > 1:
             # Moyenne de l'étudiant dans le module
             # Affichée même en APC à titre indicatif
             _add_moymod_column(
                 formsemestre.id,
-                moduleimpl_id,
+                modimpl.id,
                 rows,
                 columns_ids,
                 titles,
@@ -473,7 +476,7 @@ def _make_table_notes(
     if with_emails:
         columns_ids += ["email", "emailperso"]
     # Ajoute lignes en tête et moyennes
-    if len(evals) > 0 and fmt != "bordereau":
+    if len(evaluations) > 0 and fmt != "bordereau":
         rows_head = [row_coefs]
         if is_apc:
             rows_head.append(row_poids)
@@ -481,22 +484,22 @@ def _make_table_notes(
         rows = rows_head + rows
     rows.append(row_moys)
     # ajout liens HTMl vers affichage une evaluation:
-    if fmt == "html" and len(evals) > 1:
+    if fmt == "html" and len(evaluations) > 1:
         rlinks = {"_table_part": "head"}
-        for e in evals:
-            rlinks[e["evaluation_id"]] = "afficher"
+        for e in evaluations:
+            rlinks[e.id] = "afficher"
             rlinks[
-                "_" + str(e["evaluation_id"]) + "_help"
+                "_" + str(e.id) + "_help"
             ] = "afficher seulement les notes de cette évaluation"
-            rlinks["_" + str(e["evaluation_id"]) + "_target"] = url_for(
+            rlinks["_" + str(e.id) + "_target"] = url_for(
                 "notes.evaluation_listenotes",
                 scodoc_dept=g.scodoc_dept,
-                evaluation_id=e["evaluation_id"],
+                evaluation_id=e.id,
             )
-            rlinks["_" + str(e["evaluation_id"]) + "_td_attrs"] = ' class="tdlink" '
+            rlinks["_" + str(e.id) + "_td_attrs"] = ' class="tdlink" '
         rows.append(rlinks)
 
-    if len(evals) == 1:  # colonne "Rem." seulement si une eval
+    if len(evaluations) == 1:  # colonne "Rem." seulement si une eval
         if fmt == "html":  # pas d'indication d'origine en pdf (pour affichage)
             columns_ids.append("expl_key")
         elif fmt == "xls" or fmt == "xml":
@@ -514,68 +517,84 @@ def _make_table_notes(
         gl = "&hide_groups%3Alist=yes" + gl
     if with_emails:
         gl = "&with_emails%3Alist=yes" + gl
-    if len(evals) == 1:
-        evalname = "%s-%s" % (module.code, ndb.DateDMYtoISO(E["jour"]))
-        hh = "%s, %s (%d étudiants)" % (E["description"], gr_title, len(etudid_etats))
-        filename = scu.make_filename("notes_%s_%s" % (evalname, gr_title_filename))
+    if len(evaluations) == 1:
+        evalname = f"""{module.code}-{
+            evaluation.date_debut.replace(tzinfo=None).isoformat()
+            if evaluation.date_debut else ""}"""
+        hh = "%s, %s (%d étudiants)" % (
+            evaluation.description,
+            gr_title,
+            len(etudid_etats),
+        )
+        filename = scu.make_filename(f"notes_{evalname}_{gr_title_filename}")
 
         if fmt == "bordereau":
-            hh = " %d étudiants" % (len(etudid_etats))
-            hh += " %d absent" % (nb_abs)
-            if nb_abs > 1:
-                hh += "s"
-            hh += ", %d en attente." % (nb_att)
-
+            hh = f""" {len(etudid_etats)} étudiants {
+                nb_abs} absent{'s' if nb_abs > 1 else ''}, {nb_att} en attente."""
             # Attention: ReportLab supporte seulement '<br/>', pas '<br>' !
             pdf_title = f"""<br/> BORDEREAU DE SIGNATURES
             <br/><br/>{formsemestre.titre or ''}
             <br/>({formsemestre.mois_debut()} - {formsemestre.mois_fin()})
             semestre {formsemestre.semestre_id} {formsemestre.modalite or ""}
             <br/>Notes du module {module.code} - {module.titre}
-            <br/>Évaluation : {e["description"]}
+            <br/>Évaluation : {evaluation.description}
             """
-            if len(e["jour"]) > 0:
-                pdf_title += " (%(jour)s)" % e
-            pdf_title += "(noté sur %(note_max)s )<br/><br/>" % e
+            if evaluation.date_debut:
+                pdf_title += f" ({evaluation.date_debut.strftime('%d/%m/%Y')})"
+            pdf_title += "(noté sur {evaluation.note_max} )<br/><br/>"
         else:
             hh = " %s, %s (%d étudiants)" % (
-                E["description"],
+                evaluation.description,
                 gr_title,
                 len(etudid_etats),
             )
-            if len(e["jour"]) > 0:
-                pdf_title = "%(description)s (%(jour)s)" % e
+            if evaluation.date_debut:
+                pdf_title = f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})"
             else:
-                pdf_title = "%(description)s " % e
+                pdf_title = evaluation.description or f"évaluation dans {module.code}"
 
         caption = hh
         html_title = ""
-        base_url = "evaluation_listenotes?evaluation_id=%s" % E["evaluation_id"] + gl
-        html_next_section = (
-            '<div class="notes_evaluation_stats">%d absents, %d en attente.</div>'
-            % (nb_abs, nb_att)
+        base_url = (
+            url_for(
+                "notes.evaluation_listenotes",
+                scodoc_dept=g.scodoc_dept,
+                evaluation_id=evaluation.id,
+            )
+            + gl
         )
+        html_next_section = f"""<div class="notes_evaluation_stats">{nb_abs} absents,
+            {nb_att} en attente.</div>"""
     else:
+        # Plusieurs évaluations (module)
         filename = scu.make_filename("notes_%s_%s" % (module.code, gr_title_filename))
         title = f"Notes {module.type_name()} {module.code} {module.titre}"
         title += f""" semestre {formsemestre.titre_mois()}"""
         if gr_title and gr_title != "tous":
-            title += " %s" % gr_title
+            title += " {gr_title}"
         caption = title
         html_next_section = ""
         if fmt == "pdf" or fmt == "bordereau":
             caption = ""  # same as pdf_title
         pdf_title = title
-        html_title = f"""<h2 class="formsemestre">Notes {module.type_name()} <a href="{
-                url_for("notes.moduleimpl_status", 
-                scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
+        html_title = f"""<h2 class="formsemestre">Notes {module.type_name()}
+            <a class="stdlink" href="{
+                url_for("notes.moduleimpl_status",
+                scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
             }">{module.code} {module.titre}</a></h2>
             """
         if not is_conforme:
             html_title += (
                 """<div class="warning">Poids des évaluations non conformes !</div>"""
             )
-        base_url = "evaluation_listenotes?moduleimpl_id=%s" % moduleimpl_id + gl
+        base_url = (
+            url_for(
+                "notes.evaluation_listenotes",
+                scodoc_dept=g.scodoc_dept,
+                moduleimpl_id=modimpl.id,
+            )
+            + gl
+        )
     # display
     tab = GenTable(
         titles=titles,
@@ -600,64 +619,70 @@ def _make_table_notes(
     if fmt != "html":
         return t
 
-    if len(evals) > 1:
+    if len(evaluations) > 1:
         all_complete = True
-        for e in evals:
-            if not e["eval_state"]["evalcomplete"]:
+        for e in evaluations:
+            if not evals_state[e.id]["evalcomplete"]:
                 all_complete = False
         if all_complete:
             eval_info = """<span class="eval_info"><span class="eval_complete">Évaluations
             prises en compte dans les moyennes.</span>"""
         else:
             eval_info = """<span class="eval_info help">
-            Les évaluations en vert et orange sont prises en compte dans les moyennes. 
+            Les évaluations en vert et orange sont prises en compte dans les moyennes.
             Celles en rouge n'ont pas toutes leurs notes."""
         if is_apc:
-            eval_info += """ <span>La moyenne indicative est la moyenne des moyennes d'UE, et n'est pas utilisée en BUT. 
-            Les moyennes sur le groupe sont estimées sans les absents (sauf pour les moyennes des moyennes d'UE) ni les démissionnaires.</span>"""
+            eval_info += """ <span>La moyenne indicative est la moyenne des moyennes d'UE,
+            et n'est pas utilisée en BUT.
+            Les moyennes sur le groupe sont estimées sans les absents
+            (sauf pour les moyennes des moyennes d'UE) ni les démissionnaires.</span>"""
         eval_info += """</span>"""
         return html_form + eval_info + t + "<p></p>"
+    # Une seule evaluation: ajoute histogramme
+    histo = histogram_notes(notes)
+    # 2 colonnes: histo, comments
+    C = [
+        f"""<br><a class="stdlink" href="{base_url}&fmt=bordereau">Bordereau de Signatures (version PDF)</a>
+        <table>
+        <tr><td>
+        <div><h4>Répartition des notes:</h4>
+        {histo}
+        </div>
+        </td>
+        <td style="padding-left: 50px; vertical-align: top;"><p>
+        """
+    ]
+    commentkeys = list(key_mgr.items())  # [ (comment, key), ... ]
+    commentkeys.sort(key=lambda x: int(x[1]))
+    for comment, key in commentkeys:
+        C.append(f"""<span class="colcomment">({key})</span> <em>{comment}</em><br>""")
+    if commentkeys:
+        C.append(
+            f"""<span><a class=stdlink" href="{ url_for(
+                    'notes.evaluation_list_operations', scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id )
+                }">Gérer les opérations</a></span><br>
+            """
+        )
+    eval_info = "xxx"
+    if evals_state[evaluation.id]["evalcomplete"]:
+        eval_info = '<span class="eval_info eval_complete">Evaluation prise en compte dans les moyennes</span>'
+    elif evals_state[evaluation.id]["evalattente"]:
+        eval_info = '<span class="eval_info eval_attente">Il y a des notes en attente (les autres sont prises en compte)</span>'
     else:
-        # Une seule evaluation: ajoute histogramme
-        histo = histogram_notes(notes)
-        # 2 colonnes: histo, comments
-        C = [
-            f'<br><a class="stdlink" href="{base_url}&fmt=bordereau">Bordereau de Signatures (version PDF)</a>',
-            "<table><tr><td><div><h4>Répartition des notes:</h4>"
-            + histo
-            + "</div></td>\n",
-            '<td style="padding-left: 50px; vertical-align: top;"><p>',
-        ]
-        commentkeys = list(key_mgr.items())  # [ (comment, key), ... ]
-        commentkeys.sort(key=lambda x: int(x[1]))
-        for comment, key in commentkeys:
-            C.append(
-                '<span class="colcomment">(%s)</span> <em>%s</em><br>' % (key, comment)
-            )
-        if commentkeys:
-            C.append(
-                '<span><a class=stdlink" href="evaluation_list_operations?evaluation_id=%s">Gérer les opérations</a></span><br>'
-                % E["evaluation_id"]
-            )
-        eval_info = "xxx"
-        if E["eval_state"]["evalcomplete"]:
-            eval_info = '<span class="eval_info eval_complete">Evaluation prise en compte dans les moyennes</span>'
-        elif E["eval_state"]["evalattente"]:
-            eval_info = '<span class="eval_info eval_attente">Il y a des notes en attente (les autres sont prises en compte)</span>'
-        else:
-            eval_info = '<span class="eval_info eval_incomplete">Notes incomplètes, évaluation non prise en compte dans les moyennes</span>'
+        eval_info = '<span class="eval_info eval_incomplete">Notes incomplètes, évaluation non prise en compte dans les moyennes</span>'
 
-        return (
-            sco_evaluations.evaluation_describe(evaluation_id=E["evaluation_id"])
-            + eval_info
-            + html_form
-            + t
-            + "\n".join(C)
-        )
+    return (
+        sco_evaluations.evaluation_describe(evaluation_id=evaluation.id)
+        + eval_info
+        + html_form
+        + t
+        + "\n".join(C)
+    )
 
 
 def _add_eval_columns(
-    e,
+    evaluation: Evaluation,
+    eval_state,
     evals_poids,
     ues,
     rows,
@@ -678,24 +703,24 @@ def _add_eval_columns(
     nb_att = 0
     sum_notes = 0
     notes = []  # liste des notes numeriques, pour calcul histogramme uniquement
-    evaluation_id = e["evaluation_id"]
-    e_o = db.session.get(Evaluation, evaluation_id)  # XXX en attendant ré-écriture
-    inscrits = e_o.moduleimpl.formsemestre.etudids_actifs  # set d'etudids
-    notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
+    inscrits = evaluation.moduleimpl.formsemestre.etudids_actifs  # set d'etudids
+    notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
 
-    if len(e["jour"]) > 0:
-        titles[evaluation_id] = "%(description)s (%(jour)s)" % e
+    if evaluation.date_debut:
+        titles[
+            evaluation.id
+        ] = f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})"
     else:
-        titles[evaluation_id] = "%(description)s " % e
+        titles[evaluation.id] = f"{evaluation.description} "
 
-    if e["eval_state"]["evalcomplete"]:
+    if eval_state["evalcomplete"]:
         klass = "eval_complete"
-    elif e["eval_state"]["evalattente"]:
+    elif eval_state["evalattente"]:
         klass = "eval_attente"
     else:
         klass = "eval_incomplete"
-        titles[evaluation_id] += " (non prise en compte)"
-    titles[f"_{evaluation_id}_td_attrs"] = f'class="{klass}"'
+        titles[evaluation.id] += " (non prise en compte)"
+    titles[f"_{evaluation.id}_td_attrs"] = f'class="{klass}"'
 
     for row in rows:
         etudid = row["etudid"]
@@ -712,8 +737,8 @@ def _add_eval_columns(
                 and val != scu.NOTES_NEUTRALISE
                 and val != scu.NOTES_ATTENTE
             ):
-                if e["note_max"] > 0:
-                    valsur20 = val * 20.0 / e["note_max"]  # remet sur 20
+                if evaluation.note_max > 0:
+                    valsur20 = val * 20.0 / evaluation.note_max  # remet sur 20
                 else:
                     valsur20 = 0
                 notes.append(valsur20)  # toujours sur 20 pour l'histogramme
@@ -731,7 +756,7 @@ def _add_eval_columns(
                 comment,
             )
         else:
-            if (etudid in inscrits) and e["publish_incomplete"]:
+            if (etudid in inscrits) and evaluation.publish_incomplete:
                 # Note manquante mais prise en compte immédiate: affiche ATT
                 val = scu.NOTES_ATTENTE
                 val_fmt = "ATT"
@@ -746,11 +771,11 @@ def _add_eval_columns(
         )
 
         if val is None:
-            row[f"_{evaluation_id}_td_attrs"] = f'class="etudabs {cell_class}" '
+            row[f"_{evaluation.id}_td_attrs"] = f'class="etudabs {cell_class}" '
             if not row.get("_css_row_class", ""):
                 row["_css_row_class"] = "etudabs"
         else:
-            row[f"_{evaluation_id}_td_attrs"] = f'class="{cell_class}" '
+            row[f"_{evaluation.id}_td_attrs"] = f'class="{cell_class}" '
         # regroupe les commentaires
         if explanation:
             if explanation in K:
@@ -763,8 +788,8 @@ def _add_eval_columns(
 
         row.update(
             {
-                evaluation_id: val_fmt,
-                "_" + str(evaluation_id) + "_help": explanation,
+                evaluation.id: val_fmt,
+                "_" + str(evaluation.id) + "_help": explanation,
                 # si plusieurs evals seront ecrasés et non affichés:
                 "comment": explanation,
                 "expl_key": expl_key,
@@ -772,36 +797,38 @@ def _add_eval_columns(
             }
         )
 
-        row_coefs[evaluation_id] = "coef. %s" % e["coefficient"]
+        row_coefs[evaluation.id] = f"coef. {evaluation.coefficient:g}"
         if is_apc:
             if fmt == "html":
-                row_poids[evaluation_id] = _mini_table_eval_ue_poids(
-                    evaluation_id, evals_poids, ues
+                row_poids[evaluation.id] = _mini_table_eval_ue_poids(
+                    evaluation.id, evals_poids, ues
                 )
             else:
-                row_poids[evaluation_id] = e_o.get_ue_poids_str()
+                row_poids[evaluation.id] = evaluation.get_ue_poids_str()
         if note_sur_20:
             nmax = 20.0
         else:
-            nmax = e["note_max"]
+            nmax = evaluation.note_max
         if keep_numeric:
-            row_note_max[evaluation_id] = nmax
+            row_note_max[evaluation.id] = nmax
         else:
-            row_note_max[evaluation_id] = "/ %s" % nmax
+            row_note_max[evaluation.id] = f"/ {nmax}"
 
         if nb_notes > 0:
-            row_moys[evaluation_id] = scu.fmt_note(
+            row_moys[evaluation.id] = scu.fmt_note(
                 sum_notes / nb_notes, keep_numeric=keep_numeric
             )
             row_moys[
-                "_" + str(evaluation_id) + "_help"
+                "_" + str(evaluation.id) + "_help"
             ] = "moyenne sur %d notes (%s le %s)" % (
                 nb_notes,
-                e["description"],
-                e["jour"],
+                evaluation.description,
+                evaluation.date_debut.strftime("%d/%m/%Y")
+                if evaluation.date_debut
+                else "",
             )
         else:
-            row_moys[evaluation_id] = ""
+            row_moys[evaluation.id] = ""
 
     return notes, nb_abs, nb_att  # pour histogramme
 
diff --git a/app/static/css/gt_table.css b/app/static/css/gt_table.css
index e3d3246bd..d40c493c4 100644
--- a/app/static/css/gt_table.css
+++ b/app/static/css/gt_table.css
@@ -50,7 +50,6 @@ table.dataTable thead .sorting_desc,
 table.dataTable thead .sorting_asc_disabled,
 table.dataTable thead .sorting_desc_disabled {
   cursor: pointer;
-  *cursor: hand;
   background-repeat: no-repeat;
   background-position: center right;
 }
@@ -83,9 +82,9 @@ table.dataTable tbody tr.selected {
   background-color: #b0bed9;
 }
 
-table.dataTable tbody th,
-table.dataTable tbody td {
-  padding: 8px 10px;
+table.dataTable.gt_table tbody th,
+table.dataTable.gt_table tbody td {
+  padding: 2px 2px;
 }
 
 table.dataTable.row-border tbody th,
@@ -138,6 +137,10 @@ table.dataTable.display tbody tr:hover.selected {
   background-color: #a9b7d1;
 }
 
+table.dataTable.with-highlight tr:hover td {
+  background-color: rgba(255, 255, 0, 0.415);
+}
+
 table.dataTable.order-column tbody tr > .sorting_1,
 table.dataTable.order-column tbody tr > .sorting_2,
 table.dataTable.order-column tbody tr > .sorting_3,
@@ -368,7 +371,6 @@ table.dataTable td {
 .dataTables_wrapper {
   position: relative;
   clear: both;
-  *zoom: 1;
   zoom: 1;
 }
 
@@ -408,7 +410,6 @@ table.dataTable td {
   text-align: center;
   text-decoration: none !important;
   cursor: pointer;
-  *cursor: hand;
   color: #333333 !important;
   border: 1px solid transparent;
   border-radius: 2px;
@@ -760,4 +761,3 @@ table.dataTable.gt_table.gt_left td,
 table.dataTable.gt_table.gt_left th {
   text-align: left;
 }
-scodoc;css
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index d8a76a93d..4ce23c777 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -1139,8 +1139,13 @@ a.redlink:hover {
 }
 
 a.discretelink,
-a:discretelink:visited {
+a.discretelink:visited {
   color: black;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+table.gt_table a.discretelink,
+table.gt_table a.discretelink:visited {
   text-decoration: none;
 }
 
diff --git a/app/tables/list_etuds.py b/app/tables/list_etuds.py
index cb0de46c6..bd3c5ff9d 100644
--- a/app/tables/list_etuds.py
+++ b/app/tables/list_etuds.py
@@ -29,7 +29,7 @@ class TableEtud(tb.Table):
     ):
         etuds = etuds or []
         self.rows: list["RowEtud"] = []  # juste pour que VSCode nous aide sur .rows
-        classes = classes or ["gt_table", "gt_left"]
+        classes = classes or ["gt_table", "gt_left", "with-highlight"]
         super().__init__(
             row_class=row_class or RowEtud,
             classes=classes,
diff --git a/app/templates/assiduites/pages/calendrier.j2 b/app/templates/assiduites/pages/calendrier.j2
index 635571ba3..0fa311817 100644
--- a/app/templates/assiduites/pages/calendrier.j2
+++ b/app/templates/assiduites/pages/calendrier.j2
@@ -3,7 +3,7 @@
 
 <div class="pageContent">
     {{minitimeline | safe }}
-    <h2>Assiduité de {{sco.etud.nomprenom}}</h2>
+    <h2>Assiduité de {{sco.etud.html_link_fiche()|safe}}</h2>
 
     <div class="options">
         <input type="checkbox" id="show_pres" name="show_pres" class="memo"><label for="show_pres">afficher les présences</label>
diff --git a/app/views/assiduites.py b/app/views/assiduites.py
index cfe15723c..c611bb53a 100644
--- a/app/views/assiduites.py
+++ b/app/views/assiduites.py
@@ -1641,19 +1641,17 @@ def signal_assiduites_diff():
     ).build()
 
 
-@bp.route("/SignalEvaluationAbs/<int:evaluation_id>/<int:etudid>")
+@bp.route("/signale_evaluation_abs/<int:evaluation_id>/<int:etudid>")
 @scodoc
-@permission_required(Permission.ScoView)
-def signal_evaluation_abs(etudid: int = None, evaluation_id: int = None):
+@permission_required(Permission.AbsChange)
+def signale_evaluation_abs(etudid: int = None, evaluation_id: int = None):
     """
     Signale l'absence d'un étudiant à une évaluation
-    Si la durée de l'évaluation est inférieur à 1 jour
-    Alors l'absence sera sur la période de l'évaluation
-    Sinon L'utilisateur sera redirigé vers la page de saisie des absences de l'étudiant
+    Si la durée de l'évaluation est inférieure à 1 jour
+    l'absence sera sur la période de l'évaluation
+    sinon l'utilisateur sera redirigé vers la page de saisie des absences de l'étudiant
     """
     etud = Identite.get_etud(etudid)
-
-    # Récupération de l'évaluation concernée
     evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
 
     delta: datetime.timedelta = evaluation.date_fin - evaluation.date_debut
@@ -1683,9 +1681,9 @@ def signal_evaluation_abs(etudid: int = None, evaluation_id: int = None):
             etat=scu.EtatAssiduite.ABSENT,
             moduleimpl=evaluation.moduleimpl,
         )
-    except ScoValueError as see:
+    except ScoValueError as exc:
         # En cas d'erreur
-        msg: str = see.args[0]
+        msg: str = exc.args[0]
         if "Duplication" in msg:
             msg = """Une autre saisie concerne déjà cette période.
             En cliquant sur continuer vous serez redirigé vers la page de
@@ -1703,12 +1701,12 @@ def signal_evaluation_abs(etudid: int = None, evaluation_id: int = None):
                 scodoc_dept=g.scodoc_dept,
                 duplication="oui",
             )
-            raise ScoValueError(msg, dest) from see
+            raise ScoValueError(msg, dest) from exc
 
     db.session.add(assiduite_unique)
     db.session.commit()
 
-    # on flash pour indiquer que l'absence a bien été créée puis on revient sur la page de l'évaluation
+    # on flash puis on revient sur la page de l'évaluation
     flash("L'absence a bien été créée")
     # rediriger vers la page d'évaluation
     return redirect(
diff --git a/tests/unit/test_bulletin.py b/tests/unit/test_bulletin.py
index e92d87291..b64dfe5f5 100644
--- a/tests/unit/test_bulletin.py
+++ b/tests/unit/test_bulletin.py
@@ -1,6 +1,6 @@
 """Tests unitaires : bulletins de notes
 
-Utiliser comme: 
+Utiliser comme:
     pytest tests/unit/test_sco_basic.py
 
 Au besoin, créer un base de test neuve:
@@ -69,8 +69,8 @@ def test_bulletin_data_classic(test_client):
     min_eval_1 = float(note_eval_1["min"])
     max_eval_1 = float(note_eval_1["max"])
     # la valeur actuelle est 12.34, on s'assure qu'elle n'est pas extrême:
-    assert min_eval_1 > 0
-    assert max_eval_1 < 20
+    assert min_eval_1 > 0  # 12.34
+    assert max_eval_1 < 20  # 12.34
 
     # Saisie note pour changer min/max:
     # Met le max à 20:
-- 
GitLab