From 9b825c0fb10a1739da75f7a7bf2dcda0ec8380e4 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Sun, 14 Jul 2024 22:20:37 +0200
Subject: [PATCH] =?UTF-8?q?Saisie=20notes=20multi-=C3=A9valuations.=20Clos?=
 =?UTF-8?q?es=20#942.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/models/evaluations.py                     |  10 +-
 app/scodoc/sco_excel.py                       |  51 +-
 app/scodoc/sco_saisie_excel.py                | 452 ++++++++++++------
 app/scodoc/sco_saisie_notes.py                |  62 ++-
 app/static/css/scodoc.css                     |  13 +
 app/templates/formsemestre/import_notes.j2    |  34 +-
 .../formsemestre/import_notes_after.j2        |  54 +++
 app/views/notes.py                            |   8 +-
 sco_version.py                                |   2 +-
 9 files changed, 487 insertions(+), 199 deletions(-)
 create mode 100644 app/templates/formsemestre/import_notes_after.j2

diff --git a/app/models/evaluations.py b/app/models/evaluations.py
index 4af3ab84..d2607f0d 100644
--- a/app/models/evaluations.py
+++ b/app/models/evaluations.py
@@ -267,10 +267,12 @@ class Evaluation(models.ScoDocModel):
 
     @classmethod
     def get_evaluation(
-        cls, evaluation_id: int | str, dept_id: int = None
+        cls, evaluation_id: int | str, dept_id: int = None, accept_none=False
     ) -> "Evaluation":
-        """Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant."""
-        from app.models import FormSemestre, ModuleImpl
+        """Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant.
+        Si accept_none, return None si l'id est invalide ou n'existe pas.
+        """
+        from app.models import FormSemestre
 
         if not isinstance(evaluation_id, int):
             try:
@@ -282,6 +284,8 @@ class Evaluation(models.ScoDocModel):
         query = cls.query.filter_by(id=evaluation_id)
         if dept_id is not None:
             query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id)
+        if accept_none:
+            return query.first()
         return query.first_or_404()
 
     @classmethod
diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py
index f82a04f9..da605093 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -60,12 +60,12 @@ class COLORS(Enum):
     LIGHT_YELLOW = "FFFFFF99"
 
 
-# Un style est enregistré comme un dictionnaire qui précise la valeur d'un attribut dans la liste suivante:
+# Un style est enregistré comme un dictionnaire avec des attributs dans la liste suivante:
 # font, border, number_format, fill,...
 # (cf https://openpyxl.readthedocs.io/en/stable/styles.html#working-with-styles)
 
 
-def xldate_as_datetime(xldate, datemode=0):
+def xldate_as_datetime(xldate):
     """Conversion d'une date Excel en datetime python
     Deux formats de chaîne acceptés:
      * JJ/MM/YYYY (chaîne naïve)
@@ -187,8 +187,8 @@ def excel_make_style(
 
 class ScoExcelSheet:
     """Représente une feuille qui peut être indépendante ou intégrée dans un ScoExcelBook.
-    En application des directives de la bibliothèque sur l'écriture optimisée, l'ordre des opérations
-    est imposé:
+    En application des directives de la bibliothèque sur l'écriture optimisée,
+    l'ordre des opérations est imposé:
     * instructions globales (largeur/maquage des colonnes et ligne, ...)
     * construction et ajout des cellules et ligne selon le sens de lecture (occidental)
     ligne de haut en bas et cellules de gauche à droite (i.e. A1, A2, .. B1, B2, ..)
@@ -199,7 +199,7 @@ class ScoExcelSheet:
         """Création de la feuille. sheet_name
         -- le nom de la feuille default_style
         -- le style par défaut des cellules ws
-        -- None si la feuille est autonome (dans ce cas elle crée son propre wb), sinon c'est la worksheet
+        -- None si la feuille est autonome (elle crée son propre wb), sinon c'est la worksheet
         créée par le workbook propriétaire un workbook est créé et associé à cette feuille.
         """
         # Le nom de la feuille ne peut faire plus de 31 caractères.
@@ -228,7 +228,8 @@ class ScoExcelSheet:
         fill=None,
         number_format=None,
         font=None,
-    ):
+    ) -> dict:
+        "création d'un dict"
         style = {}
         if font is not None:
             style["font"] = font
@@ -393,7 +394,7 @@ class ScoExcelSheet:
         if isinstance(value, datetime.date):
             cell.data_type = "d"
             cell.number_format = FORMAT_DATE_DDMMYY
-        elif isinstance(value, int) or isinstance(value, float):
+        elif isinstance(value, (int, float)):
             cell.data_type = "n"
         else:
             cell.data_type = "s"
@@ -432,10 +433,11 @@ class ScoExcelSheet:
         Ce flux pourra ensuite être repris dans send_excel_file (classeur mono feille)
         ou pour la génération d'un classeur multi-feuilles
         """
-        for row in self.column_dimensions.keys():
-            self.ws.column_dimensions[row] = self.column_dimensions[row]
-        for row in self.row_dimensions.keys():
-            self.ws.row_dimensions[row] = self.row_dimensions[row]
+        for k, v in self.column_dimensions.items():
+            self.ws.column_dimensions[k] = v
+
+        for k, v in self.row_dimensions.items():
+            self.ws.row_dimensions[k] = self.row_dimensions[v]
         for row in self.rows:
             self.ws.append(row)
 
@@ -529,17 +531,6 @@ def excel_file_to_list(filename):
         ) from exc
 
 
-def excel_workbook_to_list(filename):
-    try:
-        return _excel_workbook_to_list(filename)
-    except Exception as exc:
-        raise ScoValueError(
-            """Le fichier xlsx attendu n'est pas lisible !
-            Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
-            """
-        ) from exc
-
-
 def _open_workbook(filelike, dump_debug=False) -> Workbook:
     """Open document.
     On error, if dump-debug is True, dump data in /tmp for debugging purpose
@@ -559,7 +550,7 @@ def _open_workbook(filelike, dump_debug=False) -> Workbook:
     return workbook
 
 
-def _excel_to_list(filelike):
+def _excel_to_list(filelike) -> tuple[list, list[list]]:
     """returns list of list"""
     workbook = _open_workbook(filelike)
     diag = []  # liste de chaines pour former message d'erreur
@@ -576,7 +567,7 @@ def _excel_to_list(filelike):
     return diag, matrix
 
 
-def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list]:
+def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list[list]]:
     """read a spreadsheet sheet, and returns:
     - diag : a list of strings (error messages aimed at helping the user)
     - a list of lists: the spreadsheet cells
@@ -609,14 +600,21 @@ def _excel_sheet_to_list(sheet: Worksheet, sheet_name: str) -> tuple[list, list]
     return diag, matrix
 
 
-def _excel_workbook_to_list(filelike):
+def excel_workbook_to_list(filelike):
     """Lit un classeur (workbook): chaque feuille est lue
         et est convertie en une liste de listes.
     Returns:
     - diag : a list of strings (error messages aimed at helping the user)
     - a list of lists: the spreadsheet cells
     """
-    workbook = _open_workbook(filelike)
+    try:
+        workbook = _open_workbook(filelike)
+    except Exception as exc:
+        raise ScoValueError(
+            """Le fichier xlsx attendu n'est pas lisible !
+            Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
+            """
+        ) from exc
     diag = []  # liste de chaines pour former message d'erreur
     if len(workbook.sheetnames) < 1:
         diag.append("Aucune feuille trouvée dans le classeur !")
@@ -631,6 +629,7 @@ def _excel_workbook_to_list(filelike):
     return diag, matrix_list
 
 
+# TODO déplacer dans un autre fichier
 def excel_feuille_listeappel(
     sem,
     groupname,
diff --git a/app/scodoc/sco_saisie_excel.py b/app/scodoc/sco_saisie_excel.py
index 620111dc..1ef7e858 100644
--- a/app/scodoc/sco_saisie_excel.py
+++ b/app/scodoc/sco_saisie_excel.py
@@ -23,6 +23,21 @@
 ##############################################################################
 
 """Fichier excel de saisie des notes
+
+
+## Notes d'une évaluation
+
+saisie_notes_tableur (formulaire)
+    -> feuille_saisie_notes (génération de l'excel)
+    -> do_evaluations_upload_xls
+
+## Notes d'un semestre
+
+formsemestre_import_notes (formulaire, import_notes.j2)
+    -> feuille_import_notes (génération de l'excel)
+    -> formsemestre_import_notes
+
+
 """
 from collections import defaultdict
 from typing import AnyStr
@@ -30,13 +45,14 @@ from typing import AnyStr
 from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side
 from openpyxl.styles.numbers import FORMAT_GENERAL
 
-from flask import g, request, url_for
+from flask import g, render_template, request, url_for
 from flask_login import current_user
 
-from app.models import Evaluation, FormSemestre, Identite, Module, ScolarNews
+from app.models import Evaluation, FormSemestre, Identite, ScolarNews
 from app.scodoc.sco_excel import COLORS, ScoExcelSheet
 from app.scodoc import (
     html_sco_header,
+    sco_cache,
     sco_evaluations,
     sco_evaluation_db,
     sco_excel,
@@ -48,6 +64,7 @@ from app.scodoc import (
 from app.scodoc.sco_exceptions import AccessDenied, InvalidNoteValue
 import app.scodoc.sco_utils as scu
 from app.scodoc.TrivialFormulator import TrivialFormulator
+from app.views import ScoData
 
 FONT_NAME = "Arial"
 
@@ -180,7 +197,7 @@ def _insert_line_titles(
     ws.append_row(cells)
 
     # Calcul largeur colonnes (actuellement pour feuille import multi seulement)
-    # Le facteur prend en compte la tailel du font (14)
+    # Le facteur prend en compte la taille du font (14)
     font_size_factor = 1.25
     column_widths = {
         ScoExcelSheet.i2col(idx): (len(str(cell.value)) + 2.0) * font_size_factor
@@ -525,124 +542,89 @@ def generate_excel_import_notes(
     return ws.generate(column_widths=column_widths)
 
 
-def do_evaluation_upload_xls() -> tuple[bool, str]:
+def do_evaluations_upload_xls(
+    notefile,
+    comment: str = "",
+    evaluation: Evaluation | None = None,
+    formsemestre: FormSemestre | None = None,
+) -> tuple[bool, str]:
     """
     Soumission d'un fichier XLS (evaluation_id, notefile)
+    soit dans le formsemestre (import multi-eval)
+    soit dans une seule évaluation
     return:
         ok: bool
-        msg: message diagonistic à affciher
+        msg: message diagnostic à affciher
     """
-    args = scu.get_request_args()
-    comment = args["comment"]
-    evaluation = Evaluation.get_evaluation(args["evaluation_id"])
-
-    # Check access (admin, respformation, responsable_id, ens)
-    if not evaluation.moduleimpl.can_edit_notes(current_user):
-        raise AccessDenied(f"Modification des notes impossible pour {current_user}")
-    #
-    diag, rows = sco_excel.excel_file_to_list(args["notefile"])
+    diag, rows = sco_excel.excel_file_to_list(notefile)
     try:
         if not rows:
             raise InvalidNoteValue()
 
+        # Lecture des évaluations ids
         row_title_idx, evaluations, evaluations_col_idx = _get_sheet_evaluations(
-            rows, evaluation=evaluation, diag=diag
+            rows, evaluation=evaluation, formsemestre=formsemestre, diag=diag
         )
-        # --- get notes -> list (etudid, value)
-        # ignore toutes les lignes ne commençant pas par !
-        notes_by_eval = defaultdict(
-            list
-        )  # { evaluation_id : [ (etudid, note_value), ... ] }
-        ni = row_title_idx + 1
-        for row in rows[row_title_idx + 1 :]:
-            if row:
-                cell0 = row[0].strip()
-                if cell0 and cell0[0] == "!":
-                    etudid = cell0[1:]
-                    # check etud
-                    etud = Identite.get_etud(etudid, accept_none=True)
-                    if not etud:
-                        diag.append(
-                            f"étudiant id invalide en ligne {ni+1}"
-                        )  # ligne excel à partir de 1
-                    else:
-                        _read_notes_evaluations(
-                            row,
-                            etud,
-                            evaluations,
-                            notes_by_eval,
-                            evaluations_col_idx,
-                        )
-            ni += 1
-
-        # -- Check values de chaque évaluation
-        valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval = (
-            _check_notes_evaluations(evaluations, notes_by_eval, diag)
+
+        # Vérification des permissions (admin, resp. formation, responsable_id, ens)
+        for e in evaluations:
+            if not e.moduleimpl.can_edit_notes(current_user):
+                raise AccessDenied(
+                    f"""Modification des notes
+                        dans le module {e.moduleimpl.module.code}
+                        impossible pour {current_user}"""
+                )
+
+        # Lecture des notes
+        notes_by_eval = _read_notes_from_rows(
+            rows, diag, evaluations, evaluations_col_idx, start=row_title_idx + 1
         )
 
         # -- Enregistre les notes de chaque évaluation
-        messages_by_eval: dict[int, str] = {}
-        etudids_with_decisions = set()
-        for evaluation in evaluations:
-            valid_notes = valid_notes_by_eval.get(evaluation.id)
-            if not valid_notes:
-                continue
-            etudids_changed, nb_suppress, etudids_with_decisions_eval, messages = (
-                sco_saisie_notes.notes_add(
-                    current_user,
-                    evaluation.id,
-                    valid_notes_by_eval[evaluation.id],
-                    comment,
-                )
+        with sco_cache.DeferredSemCacheManager():
+            messages_by_eval, etudids_with_decisions = _record_notes_evaluations(
+                evaluations, notes_by_eval, comment, diag, rows=rows
             )
-            etudids_with_decisions |= set(etudids_with_decisions_eval)
-            msg = f"""<div class="diag-evaluation">
-                <ul>
-                <li><div>Module {evaluation.moduleimpl.module.code} :
-                    évaluation {evaluation.description} {evaluation.descr_date()}
-                    </div>
-                    <div>
-                        {len(etudids_changed)} notes changées
-                        ({len(etudids_without_notes_by_eval[evaluation.id])} sans notes,
-                        {len(etudids_absents_by_eval[evaluation.id])} absents,
-                        {nb_suppress} note supprimées)
-                    </div>
-                </li>
-                </ul>
-            """
-            if messages:
-                msg += f"""<div class="warning">Attention&nbsp;:
-                    <ul>
-                        <li>{
-                        '</li><li>'.join(messages)
-                        }
-                        </li>
-                    </ul>
-                </div>"""
-            msg += """</div>"""
-            messages_by_eval[evaluation.id] = msg
 
         # -- News
-        module: Module = evaluation.moduleimpl.module
-        status_url = url_for(
-            "notes.moduleimpl_status",
-            scodoc_dept=g.scodoc_dept,
-            moduleimpl_id=evaluation.moduleimpl_id,
-            _external=True,
-        )
+        if len(evaluations) > 1:
+            modules_str = ", ".join(
+                [evaluation.moduleimpl.module.code for evaluation in evaluations]
+            )
+            status_url = url_for(
+                "notes.formsemestre_status",
+                scodoc_dept=g.scodoc_dept,
+                formsemestre_id=formsemestre.id,
+            )
+            obj_id = formsemestre.id
+        else:
+            modules_str = (
+                evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code
+            )
+            status_url = url_for(
+                "notes.moduleimpl_status",
+                scodoc_dept=g.scodoc_dept,
+                moduleimpl_id=evaluation.moduleimpl_id,
+            )
+            obj_id = evaluation.moduleimpl_id
         ScolarNews.add(
             typ=ScolarNews.NEWS_NOTE,
-            obj=evaluation.moduleimpl_id,
-            text=f"""Chargement notes dans <a href="{status_url}">{
-                        module.titre or module.code}</a>""",
+            obj=obj_id,
+            text=f"""Chargement notes dans <a href="{status_url}">{modules_str}</a>""",
             url=status_url,
             max_frequency=30 * 60,  # 30 minutes
         )
 
+        msg = "<div>" + "\n".join(messages_by_eval.values()) + "</div>"
         if etudids_with_decisions:
-            msg += """<p class="warning"><b>Important:</b> il y avait déjà des décisions de jury
-                enregistrées, qui sont à revoir suite à cette modification !</p>
-                """
+            msg = (
+                """<p class="warning"><b>Important:</b>
+                Il y avait déjà des décisions de jury
+                enregistrées, qui sont à revoir suite à cette modification !
+            </p>
+            """
+                + msg
+            )
         return True, msg
 
     except InvalidNoteValue:
@@ -657,10 +639,101 @@ def do_evaluation_upload_xls() -> tuple[bool, str]:
         return False, msg + "<p>(pas de notes modifiées)</p>"
 
 
+def _read_notes_from_rows(
+    rows: list[list], diag, evaluations, evaluations_col_idx, start=0
+):
+    """--- get notes -> list (etudid, value)
+    ignore toutes les lignes ne commençant pas par '!'
+    """
+    # { evaluation_id : [ (etudid, note_value), ... ] }
+    notes_by_eval = defaultdict(list)
+    ni = start
+    for row in rows[start:]:
+        if row:
+            cell0 = row[0].strip()
+            if cell0 and cell0[0] == "!":
+                etudid = cell0[1:]
+                # check etud
+                etud = Identite.get_etud(etudid, accept_none=True)
+                if not etud:
+                    diag.append(
+                        f"étudiant id invalide en ligne {ni+1}"
+                    )  # ligne excel à partir de 1
+                else:
+                    _read_notes_evaluations(
+                        row,
+                        etud,
+                        evaluations,
+                        notes_by_eval,
+                        evaluations_col_idx,
+                    )
+        ni += 1
+
+    return notes_by_eval
+
+
+def _record_notes_evaluations(
+    evaluations, notes_by_eval, comment, diag, rows: list[list[str]] | None = None
+) -> tuple[dict[int, str], set[int]]:
+    """Enregistre les notes dans les évaluations
+    Return: messages_by_eval, etudids_with_decisions
+    """
+    # -- Check values de chaque évaluation
+    valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval = (
+        _check_notes_evaluations(evaluations, notes_by_eval, diag, rows=rows)
+    )
+
+    messages_by_eval: dict[int, str] = {}
+    etudids_with_decisions = set()
+    for evaluation in evaluations:
+        valid_notes = valid_notes_by_eval.get(evaluation.id)
+        if not valid_notes:
+            continue
+        etudids_changed, nb_suppress, etudids_with_decisions_eval, messages = (
+            sco_saisie_notes.notes_add(
+                current_user, evaluation.id, valid_notes, comment
+            )
+        )
+        etudids_with_decisions |= set(etudids_with_decisions_eval)
+        msg = f"""<div class="diag-evaluation">
+        <ul>
+            <li><div class="{'diag-change' if etudids_changed else 'diag-nochange'}">
+            Module {evaluation.moduleimpl.module.code} :
+                évaluation {evaluation.description} {evaluation.descr_date()}
+        """
+        msg += (
+            f"""
+            </div>
+            <div>
+                {len(etudids_changed)} notes changées
+                ({len(etudids_without_notes_by_eval[evaluation.id])} sans notes,
+                {len(etudids_absents_by_eval[evaluation.id])} absents,
+                {nb_suppress} note supprimées)
+            </div>
+        """
+            if etudids_changed
+            else " : pas de changement</div>"
+        )
+        msg += "</li></ul>"
+        if messages:
+            msg += f"""<div class="warning">Attention&nbsp;:
+                <ul>
+                    <li>{
+                    '</li><li>'.join(messages)
+                    }
+                    </li>
+                </ul>
+            </div>"""
+        msg += """</div>"""
+        messages_by_eval[evaluation.id] = msg
+    return messages_by_eval, etudids_with_decisions
+
+
 def _check_notes_evaluations(
     evaluations: list[Evaluation],
     notes_by_eval: dict[int, list[tuple[int, str]]],
     diag: list[str],
+    rows: list[list[str]] | None = None,
 ) -> tuple[dict[int, list[tuple[int, str]]], list[int], list[int]]:
     """Vérifie que les notes pour ces évaluations sont valides.
     Raise InvalidNoteValue et rempli diag si ce n'est pas le cas.
@@ -678,29 +751,40 @@ def _check_notes_evaluations(
             etudids_non_inscrits,
         ) = sco_saisie_notes.check_notes(notes_by_eval[evaluation.id], evaluation)
         if invalids:
-            diag.append(
-                f"Erreur: la feuille contient {len(invalids)} notes invalides</p>"
-            )
+            diag.append(f"Erreur: la feuille contient {len(invalids)} notes invalides")
+            msg = f"""Notes invalides dans {
+                evaluation.moduleimpl.module.code} {evaluation.description} pour : """
             if len(invalids) < 25:
                 etudsnames = [
                     Identite.get_etud(etudid).nom_prenom() for etudid in invalids
                 ]
-                diag.append("Notes invalides pour: " + ", ".join(etudsnames))
+                msg += ", ".join(etudsnames)
             else:
-                diag.append("Notes invalides pour plus de 25 étudiants")
+                msg += "plus de 25 étudiants"
+            diag.append(msg)
             raise InvalidNoteValue()
         if etudids_non_inscrits:
+            msg = ""
+            if len(etudids_non_inscrits) < 25:
+                # retrouve numéro ligne et données invalides dans fichier
+                for etudid in etudids_non_inscrits:
+                    try:
+                        index = [row[0] for row in rows].index(f"!{etudid}")
+                    except ValueError:
+                        index = None
+                    msg += f"""<li>Ligne {index+1}:
+                                {rows[index][1]} {rows[index][2]} (id={rows[index][0]})
+                            </li>"""
+            else:
+                msg += "<li>sur plus de 25 lignes</li>"
             diag.append(
                 f"""Erreur: la feuille contient {len(etudids_non_inscrits)
-                    } étudiants non inscrits</p>"""
+                    } étudiants inexistants ou non inscrits à l'évaluation
+                    {evaluation.moduleimpl.module.code}
+                    {evaluation.description}
+                <ul>{msg}</ul>
+                """
             )
-            if len(etudids_non_inscrits) < 25:
-                diag.append(
-                    "etudid invalides (inexistants ou non inscrits): "
-                    + ", ".join(str(etudid) for etudid in etudids_non_inscrits)
-                )
-            else:
-                diag.append("etudid invalides sur plus de 25 lignes")
             raise InvalidNoteValue()
     return valid_notes_by_eval, etudids_without_notes_by_eval, etudids_absents_by_eval
 
@@ -724,8 +808,61 @@ def _read_notes_evaluations(
         notes_by_eval[evaluation.id].append((etud.id, val))
 
 
+def _xls_search_sheet_code(
+    rows: list[list[str]], diag: list[str] = None
+) -> tuple[int, int | dict[int, int]]:
+    """Cherche dans la feuille (liste de listes de chaines)
+    la ligne identifiant la ou les évaluations.
+    Si MULTIEVAL (import de plusieurs), renvoie
+    - l'indice de la ligne des TITRES
+    - un dict evaluations_col_idx: { evaluation_id : indice de sa colonne dans la feuille }
+    Si une seule éval (chargement dans une évaluation)
+    - l'indice de la ligne des TITRES
+    - l'evaluation_id indiqué dans la feuille
+    """
+    for i, row in enumerate(rows):
+        if not row:
+            diag.append("Erreur: feuille invalide (ligne vide ?)")
+            raise InvalidNoteValue()
+        eval_code = row[0].strip()
+        # -- eval code: first cell in 1st column beginning by "!"
+        if eval_code.startswith("!"):  # code évaluation trouvé
+            try:
+                sheet_eval_id = int(eval_code[1:])
+            except ValueError as exc:
+                diag.append("Erreur: feuille invalide ! (code évaluation invalide)")
+                raise InvalidNoteValue() from exc
+            return i, sheet_eval_id
+
+        # -- Muti-évaluation: les codes sont au dessus des titres
+        elif eval_code == "MULTIEVAL":  # feuille import multi-eval
+            # cherche les ids des évaluations sur la même ligne
+            try:
+                evaluation_ids = [int(x) for x in row[4:]]
+            except ValueError as exc:
+                diag.append(
+                    f"Erreur: feuille invalide ! (code évaluation invalide sur ligne {i+1})"
+                )
+                raise InvalidNoteValue() from exc
+
+            evaluations_col_idx = {
+                evaluation_id: j
+                for (j, evaluation_id) in enumerate(evaluation_ids, start=4)
+            }
+            return (
+                i + 1,  # i+1 car MULTIEVAL sur la ligne précédent les titres
+                evaluations_col_idx,
+            )
+
+    diag.append("Erreur: feuille invalide ! (pas de ligne code évaluation)")
+    raise InvalidNoteValue()
+
+
 def _get_sheet_evaluations(
-    rows: list[list[str]], evaluation: Evaluation | None = None, diag: list[str] = None
+    rows: list[list[str]],
+    evaluation: Evaluation | None = None,
+    formsemestre: FormSemestre | None = None,
+    diag: list[str] = None,
 ) -> tuple[int, list[Evaluation], dict[int, int]]:
     """
     rows: les valeurs (str) des cellules de la feuille
@@ -735,35 +872,38 @@ def _get_sheet_evaluations(
         formsemestre ou evaluation doivent être indiqués.
 
     Résultat:
-    row_title_idx: l'indice (à partir de 0) de la ligne titre (après laquelle commencent les notes)
+    row_title_idx: l'indice (à partir de 0) de la ligne TITRE (après laquelle commencent les notes)
     evaluations: liste des évaluations à remplir
     evaluations_col_idx: { evaluation_id : indice de sa colonne dans la feuille }
     """
-    # -- search eval code: first cell in 1st column beginning by "!"
-    eval_code = None
-    for i, row in enumerate(rows):
-        if not row:
-            diag.append("Erreur: format invalide (ligne vide ?)")
-            raise InvalidNoteValue()
-        eval_code = row[0].strip()
-        if eval_code.startswith("!"):
-            break
-    if not eval_code:
-        diag.append("Erreur: format invalide ! (pas de ligne evaluation_id)")
-        raise InvalidNoteValue()
 
-    try:
-        sheet_eval_id = int(eval_code[1:])
-    except ValueError:
-        sheet_eval_id = None
-    if sheet_eval_id != evaluation.id:
-        diag.append(
-            f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{
-                sheet_eval_id or ('non trouvé')}' != '{evaluation.id}')"""
-        )
-        raise InvalidNoteValue()
-
-    return i, [evaluation], {evaluation.id: 4}
+    i, r = _xls_search_sheet_code(rows, diag)
+    if isinstance(r, int):  # mono-eval
+        sheet_eval_id = r
+        if sheet_eval_id != evaluation.id:
+            diag.append(
+                f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{
+                            sheet_eval_id or ('non trouvé')}' != '{evaluation.id}')"""
+            )
+            raise InvalidNoteValue()
+        return i, [evaluation], {evaluation.id: 4}
+    if isinstance(r, dict):  # multi-eval
+        evaluations = []
+        evaluations_col_idx = r
+        # Load and check evaluations
+        for evaluation_id in evaluations_col_idx:
+            evaluation = Evaluation.get_evaluation(evaluation_id, accept_none=True)
+            if evaluation is None:
+                diag.append(f"""Erreur: l'évaluation {evaluation_id} n'existe pas""")
+                raise InvalidNoteValue()
+            if evaluation.moduleimpl.formsemestre_id != formsemestre.id:
+                diag.append(
+                    f"""Erreur: l'évaluation {evaluation_id} n'existe pas dans ce semestre"""
+                )
+                raise InvalidNoteValue()
+            evaluations.append(evaluation)
+        return i, evaluations, evaluations_col_idx
+    raise ValueError("_get_sheet_evaluations")
 
 
 def saisie_notes_tableur(evaluation_id: int, group_ids=()):
@@ -840,7 +980,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
     <span class="titredivsaisienote">Étape 2 : chargement d'un fichier de notes</span>"""  # '
     )
 
-    nf = TrivialFormulator(
+    tf = TrivialFormulator(
         request.base_url,
         scu.get_request_args(),
         (
@@ -861,23 +1001,27 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
         formid="notesfile",
         submitlabel="Télécharger",
     )
-    if nf[0] == 0:
+    if tf[0] == 0:
         H.append(
             """<p>Le fichier doit être un fichier tableur obtenu via
         l'étape 1 ci-dessus, puis complété et enregistré au format Excel.
         </p>"""
         )
-        H.append(nf[1])
-    elif nf[0] == -1:
+        H.append(tf[1])
+    elif tf[0] == -1:
         H.append("<p>Annulation</p>")
-    elif nf[0] == 1:
-        updiag = do_evaluation_upload_xls()
-        if updiag[0]:
+    elif tf[0] == 1:
+        args = scu.get_request_args()
+        evaluation = Evaluation.get_evaluation(args["evaluation_id"])
+        ok, diagnostic_msg = do_evaluations_upload_xls(
+            args["notefile"], evaluation=evaluation, comment=args["comment"]
+        )
+        if ok:
             H.append(
                 f"""
             <div class="notes-chargees">
                 <div><b>Notes chargées !</b></div>
-                {updiag[1]}
+                {diagnostic_msg}
             </div>
             <a class="stdlink" href="{
                 url_for("notes.moduleimpl_status",
@@ -898,7 +1042,7 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
             H.append(
                 f"""
             <p class="redboldtext">Notes non chargées !</p>
-            {updiag[1]}
+            {diagnostic_msg}
             <p><a class="stdlink" href="{url_for("notes.saisie_notes_tableur",
                 scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
                 }">
@@ -955,7 +1099,8 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
     Remarques:
     <ul>
     <li>le fichier Excel peut être incomplet: on peut ne saisir que quelques notes
-    et répéter l'opération (en téléchargeant un nouveau fichier) plus tard;
+    et répéter l'opération plus tard (en téléchargeant un nouveau fichier ou en
+    passant par le formulaire de saisie);
     </li>
     <li>seules les valeurs des notes modifiées sont prises en compte;
     </li>
@@ -977,3 +1122,18 @@ def saisie_notes_tableur(evaluation_id: int, group_ids=()):
         )
     H.append(html_sco_header.sco_footer())
     return "\n".join(H)
+
+
+def formsemestre_import_notes(formsemestre: FormSemestre, notefile, comment: str):
+    """Importation de notes dans plusieurs évaluations du semestre"""
+    ok, diagnostic_msg = do_evaluations_upload_xls(
+        notefile, formsemestre=formsemestre, comment=comment
+    )
+    return render_template(
+        "formsemestre/import_notes_after.j2",
+        comment=comment,
+        ok=ok,
+        diagnostic_msg=diagnostic_msg,
+        sco=ScoData(formsemestre=formsemestre),
+        title="Importation des notes",
+    )
diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py
index aad7eb70..cad79c3f 100644
--- a/app/scodoc/sco_saisie_notes.py
+++ b/app/scodoc/sco_saisie_notes.py
@@ -156,7 +156,12 @@ def check_notes(
 
     for etudid, note in notes:
         if etudid not in etudids_inscrits_mod:
-            etudids_non_inscrits.append(etudid)
+            # Si inscrit au formsemestre mais pas au module,
+            # accepte note "NI" uniquement (pour les imports excel multi-éval)
+            if (
+                etudid not in evaluation.moduleimpl.formsemestre.etudids_actifs()[0]
+            ) or note != "NI":
+                etudids_non_inscrits.append(etudid)
             continue
         try:
             etudid = int(etudid)  #
@@ -388,6 +393,27 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
     return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer()
 
 
+def _check_inscription(
+    etudid: int,
+    etudids_inscrits_sem: list[int],
+    etudids_inscrits_mod: set[int],
+    messages: list[str] | None = None,
+) -> str:
+    """Vérifie inscription de etudid au moduleimpl et au semestre, et
+    - si étudiant non inscrit au semestre ou au module: lève NoteProcessError
+    """
+    msg_err = ""
+    if etudid not in etudids_inscrits_sem:
+        msg_err = "non inscrit au semestre"
+    elif etudid not in etudids_inscrits_mod:
+        msg_err = "non inscrit au module"
+    if msg_err:
+        etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
+        msg = f"étudiant {etud.nomprenom if etud else etudid} {msg_err}"
+        log(f"notes_add: {etudid} {msg}: aborting")
+        raise NoteProcessError(msg)
+
+
 def notes_add(
     user: User,
     evaluation_id: int,
@@ -424,22 +450,8 @@ def notes_add(
     for etudid, value in notes:
 
         if check_inscription:
-            msg_err, msg_warn = "", ""
-            if etudid not in etudids_inscrits_sem:
-                msg_err = "non inscrit au semestre"
-            elif etudid not in etudids_inscrits_mod:
-                msg_err = "non inscrit au module"
-            elif etudid not in etudids_actifs:
-                # DEM ou DEF
-                msg_warn = "démissionnaire ou défaillant (note enregistrée)"
-            if msg_err or msg_warn:
-                etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
-                msg = f"étudiant {etud.nomprenom if etud else etudid} {msg_err or msg_warn}"
-            if msg_err:
-                log(f"notes_add: {etudid} non inscrit ou DEM/DEF: aborting")
-                raise NoteProcessError(msg)
-            if msg_warn:
-                messages.append(msg)
+            _check_inscription(etudid, etudids_inscrits_sem, etudids_inscrits_mod)
+
         if (value is not None) and not isinstance(value, float):
             log(f"notes_add: {etudid} valeur de note invalide ({value}): aborting")
             etud = Identite.query.get(etudid) if isinstance(etudid, int) else None
@@ -470,14 +482,26 @@ def notes_add(
                 date=now,
                 do_it=do_it,
             )
+
             if suppressed:
                 nb_suppress += 1
 
             if changed:
                 etudids_changed.append(etudid)
+                # si change sur DEM/DEF ajoute message warning aux messages
+                if etudid not in etudids_actifs:  # DEM ou DEF
+                    etud = (
+                        Identite.query.get(etudid) if isinstance(etudid, int) else None
+                    )
+                    messages.append(
+                        f"""étudiant {etud.nomprenom if etud else etudid
+                        } démissionnaire ou défaillant (note enregistrée)"""
+                    )
+
                 if res.etud_has_decision(etudid, include_rcues=False):
                     etudids_with_decision.append(etudid)
-    except NotImplementedError as exc:  # XXX
+
+    except Exception as exc:
         log("*** exception in notes_add")
         if do_it:
             cnx.rollback()  # abort
@@ -485,10 +509,12 @@ def notes_add(
             sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
             sco_cache.EvaluationCache.delete(evaluation_id)
         raise ScoException from exc
+
     if do_it:
         cnx.commit()
         sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
         sco_cache.EvaluationCache.delete(evaluation_id)
+
     return etudids_changed, nb_suppress, etudids_with_decision, messages
 
 
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 0b4a1af6..015c98ec 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -72,10 +72,23 @@ div.scobox.explanation {
   background-color: var(--sco-color-background);
 }
 
+div.scobox.success div.scobox-title {
+  color: white;
+  background-color: darkgreen;
+}
+
+div.scobox.failure div.scobox-title {
+  color: white;
+  background-color: #c50000;
+}
+
 div.scobox div.scobox-title {
   font-size: 120%;
   font-weight: bold;
   margin-bottom: 8px;
+  padding-left: 8px;
+  padding-top: 4px;
+  padding-bottom: 4px;
 }
 
 div.scobox-buttons {
diff --git a/app/templates/formsemestre/import_notes.j2 b/app/templates/formsemestre/import_notes.j2
index 9ca1c407..8fd75722 100644
--- a/app/templates/formsemestre/import_notes.j2
+++ b/app/templates/formsemestre/import_notes.j2
@@ -4,7 +4,9 @@
 {% block styles %}
 {{super()}}
 <style>
-
+div.vspace {
+    margin-top: 24px;
+}
 </style>
 {% endblock %}
 
@@ -35,7 +37,7 @@ Cette page permet d'importer des notes dans tout ou partie des évaluations du s
     </ul>
 </div>
 
-<div class="help" style="margin-top: 24px;">Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le
+<div class="help vspace">Une fois que le fichier tableur exporté ci-dessus est rempli, téléchargez-le
 ci-dessous.
 Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est jamais montré aux étudiants.
 </div>
@@ -47,4 +49,32 @@ Le texte "commentaire" sera associé à chaque note pour l'historique, il n'est
 
 </div>
 
+<div class="help vspace">
+À l'<b>étape 2</b>, indiquer le fichier Excel
+    <em>téléchargé à l'étape 1</em> et dans lequel on a saisi des notes.
+    <div class="vspace">
+    <b>Remarques :</b>
+        <ul>
+            <li>Le fichier Excel <em>doit impérativement être celui chargé à
+            l'étape 1 pour ce semestre</em>. Il n'est pas possible d'utiliser
+            une liste d'appel ou autre document Excel téléchargé d'une autre page.
+            </li>
+            <li>Ne pas supprimer les lignes et colonnes cachées, qui
+            contiennent des codes. Le fichier exporté contient toutles les
+            évaluations du semestre, vous pouvez au besoin supprimer certaines
+            colonnes d'évaluations.
+            </li>
+
+            <li>Le fichier peut être incomplet: on
+            peut ne saisir que quelques notes et répéter l'opération plus tard (en
+            téléchargeant un nouveau fichier ou en passant par le formulaire de
+            saisie).
+            </li>
+            <li>Seules les valeurs des notes modifiées sont prises en
+            compte.
+            </li>
+        </ul>
+    </div>
+</div>
+
 {% endblock %}
diff --git a/app/templates/formsemestre/import_notes_after.j2 b/app/templates/formsemestre/import_notes_after.j2
new file mode 100644
index 00000000..5f7222c8
--- /dev/null
+++ b/app/templates/formsemestre/import_notes_after.j2
@@ -0,0 +1,54 @@
+{% extends "sco_page.j2" %}
+{% import 'wtf.j2' as wtf %}
+
+{% block styles %}
+{{super()}}
+<style>
+.import-diag ul.tf-msg {
+    padding-top: 8px;
+    padding-bottom: 8px;
+}
+.diag-change {
+    font-weight: bold;
+}
+.diag-nochange {
+    color: gray;
+}
+</style>
+{% endblock %}
+
+{% block app_content %}
+<h2>Import de notes dans les évaluations du semestre</h2>
+
+<div class="scobox {{ 'success' if ok else 'failure' }}">
+    <div class="scobox-title">
+        {% if ok %}
+            Notes importées avec succès
+        {% else %}
+            Erreur: aucune note chargée
+        {% endif %}
+    </div>
+    <div class="import-diag">
+        {{ diagnostic_msg | safe }}
+    </div>
+</div>
+
+<div class="scobox">
+
+    <ul>
+        <li><a class="stdlink"
+            href="{{url_for('notes.formsemestre_recapcomplet',
+            scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id,
+            tabformat='evals')}}">
+            Tableau de <em>toutes</em> les notes du semestre
+            </a>
+        </li>
+        <li><a class="stdlink"
+            href="{{url_for('notes.formsemestre_import_notes',
+            scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id)}}">
+            Importer d'autres notes
+            </a>
+        </li>
+</div>
+
+{% endblock %}
diff --git a/app/views/notes.py b/app/views/notes.py
index 3234f063..95c071a7 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -1870,15 +1870,17 @@ def formsemestre_import_notes(formsemestre_id: int):
         # Handle file upload and form processing
         notefile = form.notefile.data
         comment = form.comment.data
-        # Save the file and process form data here
-        raise ScoValueError("unimplemented")
-        return redirect(url_for("index"))
+        #
+        return sco_saisie_excel.formsemestre_import_notes(
+            formsemestre, notefile, comment
+        )
 
     return render_template(
         "formsemestre/import_notes.j2",
         evaluations=formsemestre.get_evaluations(),
         form=form,
         formsemestre=formsemestre,
+        title="Importation des notes",
         sco=ScoData(formsemestre=formsemestre),
     )
 
diff --git a/sco_version.py b/sco_version.py
index b8822c3e..60714411 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,7 +1,7 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
-SCOVERSION = "9.6.992"
+SCOVERSION = "9.7.0"
 
 SCONAME = "ScoDoc"
 
-- 
GitLab