# -*- mode: python -*-
# -*- coding: utf-8 -*-

##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
#   Emmanuel Viennet      emmanuel.viennet@viennet.net
#
##############################################################################

"""Saisie des notes

   Formulaire revu en juillet 2016
"""
import time
import psycopg2

import flask
from flask import g, url_for, request
from flask_login import current_user

from app.auth.models import User
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Evaluation, FormSemestre
from app.models import ModuleImpl, ScolarNews
from app.models.etudiants import Identite
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
import app.scodoc.notesdb as ndb
from app import log
from app.scodoc.sco_exceptions import (
    AccessDenied,
    InvalidNoteValue,
    NoteProcessError,
    ScoGenError,
    ScoInvalidParamError,
    ScoValueError,
)
from app.scodoc.TrivialFormulator import TrivialFormulator, TF
from app.scodoc import html_sco_header, sco_users
from app.scodoc import htmlutils
from app.scodoc import sco_abs
from app.scodoc import sco_cache
from app.scodoc import sco_edit_module
from app.scodoc import sco_evaluations
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_excel
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_permissions_check
from app.scodoc import sco_undo_notes
from app.scodoc import sco_etud


def convert_note_from_string(
    note: str,
    note_max,
    note_min=scu.NOTES_MIN,
    etudid: int = None,
    absents: list[int] = None,
    tosuppress: list[int] = None,
    invalids: list[int] = None,
):
    """converti une valeur (chaine saisie) vers une note numérique (float)
    Les listes absents, tosuppress et invalids sont modifiées
    """
    invalid = False
    note_value = None
    note = note.replace(",", ".")
    if note[:3] == "ABS":
        note_value = None
        absents.append(etudid)
    elif note[:3] == "NEU" or note[:3] == "EXC":
        note_value = scu.NOTES_NEUTRALISE
    elif note[:3] == "ATT":
        note_value = scu.NOTES_ATTENTE
    elif note[:3] == "SUP":
        note_value = scu.NOTES_SUPPRESS
        tosuppress.append(etudid)
    else:
        try:
            note_value = float(note)
            if (note_value < note_min) or (note_value > note_max):
                raise ValueError
        except ValueError:
            invalids.append(etudid)
            invalid = True

    return note_value, invalid


def _displayNote(val):
    """Convert note from DB to viewable string.
    Utilisé seulement pour I/O vers formulaires (sans perte de precision)
    (Utiliser fmt_note pour les affichages)
    """
    if val is None:
        val = "ABS"
    elif val == scu.NOTES_NEUTRALISE:
        val = "EXC"  # excuse, note neutralise
    elif val == scu.NOTES_ATTENTE:
        val = "ATT"  # attente, note neutralise
    elif val == scu.NOTES_SUPPRESS:
        val = "SUPR"
    else:
        val = "%g" % val
    return val


def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict):
    # XXX typehint : float or str
    """notes is a list of tuples (etudid, value)
    mod is the module (used to ckeck type, for malus)
    returns list of valid notes (etudid, float value)
    and 4 lists of etudid: invalids, withoutnotes, absents, tosuppress, existingjury
    """
    note_max = evaluation["note_max"]
    if mod["module_type"] in (
        scu.ModuleType.STANDARD,
        scu.ModuleType.RESSOURCE,
        scu.ModuleType.SAE,
    ):
        note_min = scu.NOTES_MIN
    elif mod["module_type"] == ModuleType.MALUS:
        note_min = -20.0
    else:
        raise ValueError("Invalid module type")  # bug
    L = []  # liste (etudid, note) des notes ok (ou absent)
    invalids = []  # etudid avec notes invalides
    withoutnotes = []  # etudid sans notes (champs vides)
    absents = []  # etudid absents
    tosuppress = []  # etudids avec ancienne note à supprimer

    for (etudid, note) in notes:
        note = str(note).strip().upper()
        try:
            etudid = int(etudid)  #
        except ValueError as exc:
            raise ScoValueError(f"Code étudiant ({etudid}) invalide") from exc
        if note[:3] == "DEM":
            continue  # skip !
        if note:
            value, invalid = convert_note_from_string(
                note,
                note_max,
                note_min=note_min,
                etudid=etudid,
                absents=absents,
                tosuppress=tosuppress,
                invalids=invalids,
            )
            if not invalid:
                L.append((etudid, value))
        else:
            withoutnotes.append(etudid)
    return L, invalids, withoutnotes, absents, tosuppress


def do_evaluation_upload_xls():
    """
    Soumission d'un fichier XLS (evaluation_id, notefile)
    """
    authuser = current_user
    vals = scu.get_request_args()
    evaluation_id = int(vals["evaluation_id"])
    comment = vals["comment"]
    E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
    M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0]
    # Check access
    # (admin, respformation, and responsable_id)
    if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]):
        raise AccessDenied("Modification des notes impossible pour %s" % authuser)
    #
    diag, lines = sco_excel.excel_file_to_list(vals["notefile"])
    try:
        if not lines:
            raise InvalidNoteValue()
        # -- search eval code
        n = len(lines)
        i = 0
        while i < n:
            if not lines[i]:
                diag.append("Erreur: format invalide (ligne vide ?)")
                raise InvalidNoteValue()
            f0 = lines[i][0].strip()
            if f0 and f0[0] == "!":
                break
            i = i + 1
        if i == n:
            diag.append("Erreur: format invalide ! (pas de ligne evaluation_id)")
            raise InvalidNoteValue()

        eval_id_str = lines[i][0].strip()[1:]
        try:
            eval_id = int(eval_id_str)
        except ValueError:
            eval_id = None
        if eval_id != evaluation_id:
            diag.append(
                f"""Erreur: fichier invalide: le code d'évaluation de correspond pas ! ('{
                    eval_id_str}' != '{evaluation_id}')"""
            )
            raise InvalidNoteValue()
        # --- get notes -> list (etudid, value)
        # ignore toutes les lignes ne commençant pas par !
        notes = []
        ni = i + 1
        try:
            for line in lines[i + 1 :]:
                if line:
                    cell0 = line[0].strip()
                    if cell0 and cell0[0] == "!":
                        etudid = cell0[1:]
                        if len(line) > 4:
                            val = line[4].strip()
                        else:
                            val = ""  # ligne courte: cellule vide
                        if etudid:
                            notes.append((etudid, val))
                ni += 1
        except:
            diag.append(
                f"""Erreur: Ligne invalide ! (erreur ligne {ni})<br>{lines[ni]}"""
            )
            raise InvalidNoteValue()
        # -- check values
        L, invalids, withoutnotes, absents, _ = _check_notes(notes, E, M["module"])
        if len(invalids):
            diag.append(
                f"Erreur: la feuille contient {len(invalids)} notes invalides</p>"
            )
            if len(invalids) < 25:
                etudsnames = [
                    sco_etud.get_etud_info(etudid=etudid, filled=True)[0]["nomprenom"]
                    for etudid in invalids
                ]
                diag.append("Notes invalides pour: " + ", ".join(etudsnames))
            raise InvalidNoteValue()
        else:
            nb_changed, nb_suppress, existing_decisions = notes_add(
                authuser, evaluation_id, L, comment
            )
            # news
            E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[
                0
            ]
            M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
            mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
            mod["moduleimpl_id"] = M["moduleimpl_id"]
            mod["url"] = url_for(
                "notes.moduleimpl_status",
                scodoc_dept=g.scodoc_dept,
                moduleimpl_id=mod["moduleimpl_id"],
                _external=True,
            )
            ScolarNews.add(
                typ=ScolarNews.NEWS_NOTE,
                obj=M["moduleimpl_id"],
                text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % mod,
                url=mod["url"],
                max_frequency=30 * 60,  # 30 minutes
            )

            msg = (
                "<p>%d notes changées (%d sans notes, %d absents, %d note supprimées)</p>"
                % (nb_changed, len(withoutnotes), len(absents), nb_suppress)
            )
            if existing_decisions:
                msg += """<p class="warning">Important: il y avait déjà des décisions de jury enregistrées, qui sont potentiellement à revoir suite à cette modification !</p>"""
            # msg += '<p>' + str(notes) # debug
            return 1, msg

    except InvalidNoteValue:
        if diag:
            msg = (
                '<ul class="tf-msg"><li class="tf_msg">'
                + '</li><li class="tf_msg">'.join(diag)
                + "</li></ul>"
            )
        else:
            msg = '<ul class="tf-msg"><li class="tf_msg">Une erreur est survenue</li></ul>'
        return 0, msg + "<p>(pas de notes modifiées)</p>"


def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) -> bool:
    """Enregistre la note d'un seul étudiant
    value: valeur externe (float ou str)
    """
    if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl.id):
        raise AccessDenied(f"Modification des notes impossible pour {current_user}")
    # Convert and check value
    L, invalids, _, _, _ = _check_notes(
        [(etud.id, value)], evaluation.to_dict(), evaluation.moduleimpl.module.to_dict()
    )
    if len(invalids) == 0:
        nb_changed, _, _ = notes_add(
            current_user, evaluation.id, L, "Initialisation notes"
        )
        if nb_changed == 1:
            return True
    return False  # error


def do_evaluation_set_missing(
    evaluation_id, value, dialog_confirmed=False, group_ids_str: str = ""
):
    """Initialisation des notes manquantes"""
    evaluation = Evaluation.query.get_or_404(evaluation_id)
    modimpl = evaluation.moduleimpl
    # Check access
    # (admin, respformation, and responsable_id)
    if not sco_permissions_check.can_edit_notes(current_user, modimpl.id):
        raise AccessDenied(f"Modification des notes impossible pour {current_user}")
    #
    notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
    if not group_ids_str:
        groups = None
    else:
        group_ids = [int(x) for x in str(group_ids_str).split(",")]
        groups = sco_groups.listgroups(group_ids)

    etudid_etats = sco_groups.do_evaluation_listeetuds_groups(
        evaluation_id,
        getallstudents=groups is None,
        groups=groups,
        include_demdef=False,
    )

    notes = []
    for etudid, _ in etudid_etats:  # pour tous les inscrits
        if etudid not in notes_db:  # pas de note
            notes.append((etudid, value))
    # Convert and check values
    L, invalids, _, _, _ = _check_notes(
        notes, evaluation.to_dict(), modimpl.module.to_dict()
    )
    dest_url = url_for(
        "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id
    )
    diag = ""
    if len(invalids) > 0:
        diag = f"Valeur {value} invalide ou hors barème"
    if diag:
        return f"""
            {html_sco_header.sco_header()}
            <h2>{diag}</h2>
            <p><a href="{ dest_url }">
            Recommencer</a>
            </p>
            {html_sco_header.sco_footer()}
            """
    # Confirm action
    if not dialog_confirmed:
        plural = len(L) > 1
        return scu.confirm_dialog(
            f"""<h2>Mettre toutes les notes manquantes de l'évaluation
            à la valeur {value} ?</h2>
            <p>Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC)
            n'a été rentrée seront affectés.</p>
            <p><b>{len(L)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""}
            par ce changement de note.</b>
            </p>
            """,
            dest_url="",
            cancel_url=dest_url,
            parameters={
                "evaluation_id": evaluation_id,
                "value": value,
                "group_ids_str": group_ids_str,
            },
        )
    # ok
    comment = "Initialisation notes manquantes"
    nb_changed, _, _ = notes_add(current_user, evaluation_id, L, comment)
    # news
    url = url_for(
        "notes.moduleimpl_status",
        scodoc_dept=g.scodoc_dept,
        moduleimpl_id=evaluation.moduleimpl_id,
    )
    ScolarNews.add(
        typ=ScolarNews.NEWS_NOTE,
        obj=evaluation.moduleimpl_id,
        text=f"""Initialisation notes dans <a href="{url}">{modimpl.module.titre or ""}</a>""",
        url=url,
        max_frequency=30 * 60,
    )
    return f"""
        { html_sco_header.sco_header() }
        <h2>{nb_changed} notes changées</h2>
        <ul>
        <li><a class="stdlink" href="{dest_url}">
        Revenir au formulaire de saisie des notes</a>
        </li>
        <li><a class="stdlink" href="{
            url_for(
                "notes.moduleimpl_status",
                scodoc_dept=g.scodoc_dept,
                moduleimpl_id=evaluation.moduleimpl_id,
            )}">Tableau de bord du module</a>
        </li>
        </ul>
        { html_sco_header.sco_footer() }
        """


def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
    "suppress all notes in this eval"
    evaluation = Evaluation.query.get_or_404(evaluation_id)

    if sco_permissions_check.can_edit_notes(
        current_user, evaluation.moduleimpl_id, allow_ens=False
    ):
        # On a le droit de modifier toutes les notes
        # recupere les etuds ayant une note
        notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
    elif sco_permissions_check.can_edit_notes(
        current_user, evaluation.moduleimpl_id, allow_ens=True
    ):
        # Enseignant associé au module: ne peut supprimer que les notes qu'il a saisi
        notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
            evaluation_id, by_uid=current_user.id
        )
    else:
        raise AccessDenied(f"Modification des notes impossible pour {current_user}")

    notes = [(etudid, scu.NOTES_SUPPRESS) for etudid in notes_db.keys()]

    status_url = url_for(
        "notes.moduleimpl_status",
        scodoc_dept=g.scodoc_dept,
        moduleimpl_id=evaluation.moduleimpl_id,
    )

    if not dialog_confirmed:
        nb_changed, nb_suppress, existing_decisions = notes_add(
            current_user, evaluation_id, notes, do_it=False, check_inscription=False
        )
        msg = f"""<p>Confirmer la suppression des {nb_suppress} notes ?
        <em>(peut affecter plusieurs groupes)</em>
        </p>
        """

        if existing_decisions:
            msg += """<p class="warning">Important: il y a déjà des décisions de
             jury enregistrées, qui seront potentiellement à revoir suite à
             cette modification !</p>"""
        return scu.confirm_dialog(
            msg,
            dest_url="",
            OK="Supprimer les notes",
            cancel_url=status_url,
            parameters={"evaluation_id": evaluation_id},
        )

    # modif
    nb_changed, nb_suppress, existing_decisions = notes_add(
        current_user,
        evaluation_id,
        notes,
        comment="effacer tout",
        check_inscription=False,
    )
    assert nb_changed == nb_suppress
    H = [f"""<p>{nb_suppress} notes supprimées</p>"""]
    if existing_decisions:
        H.append(
            """<p class="warning">Important: il y avait déjà des décisions
            de jury enregistrées, qui sont potentiellement à revoir suite
            à cette modification !
            </p>"""
        )
    H += [
        f"""<p><a class="stdlink" href="{status_url}">continuer</a>
        """
    ]
    # news
    ScolarNews.add(
        typ=ScolarNews.NEWS_NOTE,
        obj=evaluation.moduleimpl.id,
        text=f"""Suppression des notes d'une évaluation dans
        <a class="stdlink" href="{status_url}"
        >{evaluation.moduleimpl.module.titre or 'module sans titre'}</a>
        """,
        url=status_url,
    )

    return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer()


def notes_add(
    user: User,
    evaluation_id: int,
    notes: list,
    comment=None,
    do_it=True,
    check_inscription=True,
) -> tuple:
    """
    Insert or update notes
    notes is a list of tuples (etudid,value)
    If do_it is False, simulate the process and returns the number of values that
    WOULD be changed or suppressed.
    Nota:
    - si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log)
    Return tuple (nb_changed, nb_suppress, existing_decisions)
    """
    now = psycopg2.Timestamp(
        *time.localtime()[:6]
    )  # datetime.datetime.now().isoformat()
    # Verifie inscription et valeur note
    inscrits = {
        x[0]
        for x in sco_groups.do_evaluation_listeetuds_groups(
            evaluation_id, getallstudents=True, include_demdef=True
        )
    }
    for (etudid, value) in notes:
        if check_inscription and (etudid not in inscrits):
            raise NoteProcessError(f"etudiant {etudid} non inscrit dans ce module")
        if (value is not None) and not isinstance(value, float):
            raise NoteProcessError(
                f"etudiant {etudid}: valeur de note invalide ({value})"
            )
    # Recherche notes existantes
    notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id)
    # Met a jour la base
    cnx = ndb.GetDBConnexion()
    cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
    nb_changed = 0
    nb_suppress = 0
    E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
    M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
    existing_decisions = (
        []
    )  # etudids pour lesquels il y a une decision de jury et que la note change
    try:
        for (etudid, value) in notes:
            changed = False
            if etudid not in notes_db:
                # nouvelle note
                if value != scu.NOTES_SUPPRESS:
                    if do_it:
                        aa = {
                            "etudid": etudid,
                            "evaluation_id": evaluation_id,
                            "value": value,
                            "comment": comment,
                            "uid": user.id,
                            "date": now,
                        }
                        ndb.quote_dict(aa)
                        cursor.execute(
                            """INSERT INTO notes_notes
                            (etudid, evaluation_id, value, comment, date, uid)
                            VALUES (%(etudid)s,%(evaluation_id)s,%(value)s,%(comment)s,%(date)s,%(uid)s)
                            """,
                            aa,
                        )
                    changed = True
            else:
                # il y a deja une note
                oldval = notes_db[etudid]["value"]
                if type(value) != type(oldval):
                    changed = True
                elif type(value) == float and (
                    abs(value - oldval) > scu.NOTES_PRECISION
                ):
                    changed = True
                elif value != oldval:
                    changed = True
                if changed:
                    # recopie l'ancienne note dans notes_notes_log, puis update
                    if do_it:
                        cursor.execute(
                            """INSERT INTO notes_notes_log
                                (etudid,evaluation_id,value,comment,date,uid)
                            SELECT etudid, evaluation_id, value, comment, date, uid
                            FROM notes_notes
                            WHERE etudid=%(etudid)s
                            and evaluation_id=%(evaluation_id)s
                            """,
                            {"etudid": etudid, "evaluation_id": evaluation_id},
                        )
                        aa = {
                            "etudid": etudid,
                            "evaluation_id": evaluation_id,
                            "value": value,
                            "date": now,
                            "comment": comment,
                            "uid": user.id,
                        }
                        ndb.quote_dict(aa)
                    if value != scu.NOTES_SUPPRESS:
                        if do_it:
                            cursor.execute(
                                """UPDATE notes_notes
                                SET value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
                                WHERE etudid = %(etudid)s
                                and evaluation_id = %(evaluation_id)s
                                """,
                                aa,
                            )
                    else:  # suppression ancienne note
                        if do_it:
                            log(
                                "notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s"
                                % (evaluation_id, etudid, oldval)
                            )
                            cursor.execute(
                                """DELETE FROM notes_notes
                                WHERE etudid = %(etudid)s 
                                AND evaluation_id = %(evaluation_id)s
                                """,
                                aa,
                            )
                            # garde trace de la suppression dans l'historique:
                            aa["value"] = scu.NOTES_SUPPRESS
                            cursor.execute(
                                """INSERT INTO notes_notes_log (etudid,evaluation_id,value,comment,date,uid)
                                VALUES (%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s)
                                """,
                                aa,
                            )
                        nb_suppress += 1
            if changed:
                nb_changed += 1
                if has_existing_decision(M, E, etudid):
                    existing_decisions.append(etudid)
    except Exception as exc:
        log("*** exception in notes_add")
        if do_it:
            cnx.rollback()  # abort
            # inval cache
            sco_cache.invalidate_formsemestre(
                formsemestre_id=M["formsemestre_id"]
            )  # > modif notes (exception)
            sco_cache.EvaluationCache.delete(evaluation_id)
        raise ScoGenError("Erreur enregistrement note: merci de ré-essayer") from exc
    if do_it:
        cnx.commit()
        sco_cache.invalidate_formsemestre(
            formsemestre_id=M["formsemestre_id"]
        )  # > modif notes
        sco_cache.EvaluationCache.delete(evaluation_id)
    return nb_changed, nb_suppress, existing_decisions


def saisie_notes_tableur(evaluation_id, group_ids=()):
    """Saisie des notes via un fichier Excel"""
    evaluation = Evaluation.query.get_or_404(evaluation_id)
    moduleimpl_id = evaluation.moduleimpl.id
    formsemestre_id = evaluation.moduleimpl.formsemestre_id
    if not sco_permissions_check.can_edit_notes(current_user, moduleimpl_id):
        return (
            html_sco_header.sco_header()
            + f"""
                <h2>Modification des notes impossible pour {current_user.user_name}</h2>
                <p>(vérifiez que le semestre n'est pas verrouillé et que vous
                avez l'autorisation d'effectuer cette opération)
                </p>
                <p><a class="stdlink" href="{
                url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, 
                    moduleimpl_id=moduleimpl_id)
                }">Continuer</a></p>
               """
            + html_sco_header.sco_footer()
        )

    page_title = "Saisie des notes" + (
        f"""de {evaluation.description}""" if evaluation.description else ""
    )

    # Informations sur les groupes à afficher:
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids=group_ids,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
        etat=None,
    )

    H = [
        html_sco_header.sco_header(
            page_title=page_title,
            javascripts=sco_groups_view.JAVASCRIPTS,
            cssstyles=sco_groups_view.CSSSTYLES,
            init_qtip=True,
        ),
        sco_evaluations.evaluation_describe(evaluation_id=evaluation_id),
        """<span class="eval_title">Saisie des notes par fichier</span>""",
    ]

    # Menu choix groupe:
    H.append("""<div id="group-tabs"><table><tr><td>""")
    H.append(sco_groups_view.form_groups_choice(groups_infos))
    H.append("</td></tr></table></div>")

    H.append(
        f"""<div class="saisienote_etape1">
        <span class="titredivsaisienote">Etape 1 : </span>
        <ul>
        <li><a class="stdlink" href="feuille_saisie_notes?evaluation_id={evaluation_id}&{
            groups_infos.groups_query_args}"
            id="lnk_feuille_saisie">obtenir le fichier tableur à remplir</a>
        </li>
        <li>ou <a class="stdlink" href="{url_for("notes.saisie_notes",
            scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id)
        }">aller au formulaire de saisie</a></li>
        </ul>
        </div>
        <form>
            <input type="hidden" name="evaluation_id" id="formnotes_evaluation_id" 
                value="{evaluation_id}"/>
        </form>
        """
    )

    H.append(
        """<div class="saisienote_etape2">
    <span class="titredivsaisienote">Etape 2 : chargement d'un fichier de notes</span>"""  # '
    )

    nf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        (
            ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}),
            (
                "notefile",
                {"input_type": "file", "title": "Fichier de note (.xls)", "size": 44},
            ),
            (
                "comment",
                {
                    "size": 44,
                    "title": "Commentaire",
                    "explanation": "(la colonne remarque du fichier excel est ignorée)",
                },
            ),
        ),
        formid="notesfile",
        submitlabel="Télécharger",
    )
    if nf[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("<p>Annulation</p>")
    elif nf[0] == 1:
        updiag = do_evaluation_upload_xls()
        if updiag[0]:
            H.append(updiag[1])
            H.append(
                f"""<p>Notes chargées.&nbsp;&nbsp;&nbsp;
            <a class="stdlink" href="{
                url_for("notes.moduleimpl_status",
                    scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
            }">
            Revenir au tableau de bord du module</a>
            &nbsp;&nbsp;&nbsp;
            <a class="stdlink" href="{url_for("notes.saisie_notes",
                scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
            }">Charger d'autres notes dans cette évaluation</a>
            </p>"""
            )
        else:
            H.append(
                f"""
            <p class="redboldtext">Notes non chargées !</p>
            {updiag[1]}
            <p><a class="stdlink" href="{url_for("notes.saisie_notes_tableur",
                scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
                }">
                Reprendre</a>
            </p>
            """
            )

    #
    H.append("""</div><h3>Autres opérations</h3><ul>""")
    if sco_permissions_check.can_edit_notes(
        current_user, moduleimpl_id, allow_ens=False
    ):
        H.append(
            f"""
        <li>
        <form action="do_evaluation_set_missing" method="POST">
        Mettre toutes les notes manquantes à <input type="text" size="5" name="value"/>
        <input type="submit" value="OK"/>
        <input type="hidden" name="evaluation_id" value="{evaluation_id}"/>
        <em>ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente"</em>
        </form>
        </li>
        <li><a class="stdlink" href="{url_for("notes.evaluation_suppress_alln",
            scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id)
        }">Effacer toutes les notes de cette évaluation</a>
        (ceci permet ensuite de supprimer l'évaluation si besoin)
        </li>
        <li><a class="stdlink" href="{url_for("notes.moduleimpl_status",
            scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
        }">Revenir au module</a>
        </li>
        <li><a class="stdlink" href="{url_for("notes.saisie_notes",
            scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
        }">Revenir au formulaire de saisie</a>
        </li>
        </ul>

        <h3>Explications</h3>
<ol>
<li>Etape 1:
<ol><li>choisir le ou les groupes d'étudiants;</li>
    <li>télécharger le fichier Excel à remplir.</li>
</ol>
</li>
<li>Etape 2 (cadre vert): Indiquer le fichier Excel
    <em>téléchargé à l'étape 1</em> et dans lequel on a saisi des notes.
    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;
    </li>
    <li>seules les valeurs des notes modifiées sont prises en compte;
    </li>
    <li>seules les notes sont extraites du fichier Excel;
    </li>
    <li>on peut optionnellement ajouter un commentaire (type "copies corrigées
    par Dupont", ou "Modif. suite à contestation") dans la case "Commentaire".
    </li>
    <li>le fichier Excel <em>doit impérativement être celui chargé à l'étape 1
    pour cette évaluation</em>. Il n'est pas possible d'utiliser une liste d'appel
    ou autre document Excel téléchargé d'une autre page.
    </li>
    </ul>
</li>
</ol>
"""
        )
    H.append(html_sco_header.sco_footer())
    return "\n".join(H)


def feuille_saisie_notes(evaluation_id, group_ids=[]):
    """Document Excel pour saisie notes dans l'évaluation et les groupes indiqués"""
    evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})
    if not evals:
        raise ScoValueError("invalid evaluation_id")
    eval_dict = evals[0]
    M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=eval_dict["moduleimpl_id"])[0]
    formsemestre_id = M["formsemestre_id"]
    Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
    sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"])
    mod_responsable = sco_users.user_info(M["responsable_id"])
    if eval_dict["jour"]:
        indication_date = ndb.DateDMYtoISO(eval_dict["jour"])
    else:
        indication_date = scu.sanitize_filename(eval_dict["description"])[:12]
    eval_name = "%s-%s" % (Mod["code"], indication_date)

    if eval_dict["description"]:
        evaltitre = "%s du %s" % (eval_dict["description"], eval_dict["jour"])
    else:
        evaltitre = "évaluation du %s" % eval_dict["jour"]
    description = "%s en %s (%s) resp. %s" % (
        evaltitre,
        Mod["abbrev"] or "",
        Mod["code"] or "",
        mod_responsable["prenomnom"],
    )

    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids=group_ids,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
        etat=None,
    )
    groups = sco_groups.listgroups(groups_infos.group_ids)
    gr_title_filename = sco_groups.listgroups_filename(groups)
    # gr_title = sco_groups.listgroups_abbrev(groups)
    if None in [g["group_name"] for g in groups]:  # tous les etudiants
        getallstudents = True
        # gr_title = "tous"
        gr_title_filename = "tous"
    else:
        getallstudents = False
    etudids = [
        x[0]
        for x in sco_groups.do_evaluation_listeetuds_groups(
            evaluation_id, groups, getallstudents=getallstudents, include_demdef=True
        )
    ]

    # une liste de liste de chaines: lignes de la feuille de calcul
    L = []

    etuds = _get_sorted_etuds(eval_dict, etudids, formsemestre_id)
    for e in etuds:
        etudid = e["etudid"]
        groups = sco_groups.get_etud_groups(etudid, formsemestre_id)
        grc = sco_groups.listgroups_abbrev(groups)

        L.append(
            [
                "%s" % etudid,
                e["nom"].upper(),
                e["prenom"].lower().capitalize(),
                e["inscr"]["etat"],
                grc,
                e["val"],
                e["explanation"],
            ]
        )

    filename = "notes_%s_%s" % (eval_name, gr_title_filename)
    xls = sco_excel.excel_feuille_saisie(
        eval_dict, sem["titreannee"], description, lines=L
    )
    return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
    # return sco_excel.send_excel_file(xls, filename)


def has_existing_decision(M, E, etudid):
    """Verifie s'il y a une validation pour cet etudiant dans ce semestre ou UE
    Si oui, return True
    """
    formsemestre_id = M["formsemestre_id"]
    formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
    nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
    if nt.get_etud_decision_sem(etudid):
        return True
    dec_ues = nt.get_etud_decisions_ue(etudid)
    if dec_ues:
        mod = sco_edit_module.module_list({"module_id": M["module_id"]})[0]
        ue_id = mod["ue_id"]
        if ue_id in dec_ues:
            return True  # decision pour l'UE a laquelle appartient cette evaluation

    return False  # pas de decision de jury affectee par cette note


# -----------------------------
# Nouveau formulaire saisie notes (2016)


def saisie_notes(evaluation_id: int, group_ids: list = None):
    """Formulaire saisie notes d'une évaluation pour un groupe"""
    if not isinstance(evaluation_id, int):
        raise ScoInvalidParamError()
    group_ids = [int(group_id) for group_id in (group_ids or [])]
    evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})
    if not evals:
        raise ScoValueError("évaluation inexistante")
    E = evals[0]
    M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0]
    formsemestre_id = M["formsemestre_id"]
    moduleimpl_status_url = url_for(
        "notes.moduleimpl_status",
        scodoc_dept=g.scodoc_dept,
        moduleimpl_id=E["moduleimpl_id"],
    )
    # Check access
    # (admin, respformation, and responsable_id)
    if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]):
        return f"""
            {html_sco_header.sco_header()}
            <h2>Modification des notes impossible pour {current_user.user_name}</h2>

            <p>(vérifiez que le semestre n'est pas verrouillé et que vous
               avez l'autorisation d'effectuer cette opération)</p>
               <p><a href="{ moduleimpl_status_url }">Continuer</a>
            </p>
            {html_sco_header.sco_footer()}
            """

    # Informations sur les groupes à afficher:
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids=group_ids,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
        etat=None,
    )

    if E["description"]:
        page_title = 'Saisie "%s"' % E["description"]
    else:
        page_title = "Saisie des notes"

    # HTML page:
    H = [
        html_sco_header.sco_header(
            page_title=page_title,
            javascripts=sco_groups_view.JAVASCRIPTS + ["js/saisie_notes.js"],
            cssstyles=sco_groups_view.CSSSTYLES,
            init_qtip=True,
        ),
        sco_evaluations.evaluation_describe(
            evaluation_id=evaluation_id, link_saisie=False
        ),
        '<div id="saisie_notes"><span class="eval_title">Saisie des notes</span>',
    ]
    H.append("""<div id="group-tabs"><table><tr><td>""")
    H.append(sco_groups_view.form_groups_choice(groups_infos))
    H.append('</td><td style="padding-left: 35px;">')
    H.append(
        htmlutils.make_menu(
            "Autres opérations",
            [
                {
                    "title": "Saisie par fichier tableur",
                    "id": "menu_saisie_tableur",
                    "endpoint": "notes.saisie_notes_tableur",
                    "args": {
                        "evaluation_id": E["evaluation_id"],
                        "group_ids": groups_infos.group_ids,
                    },
                },
                {
                    "title": "Voir toutes les notes du module",
                    "endpoint": "notes.evaluation_listenotes",
                    "args": {"moduleimpl_id": E["moduleimpl_id"]},
                },
                {
                    "title": "Effacer toutes les notes de cette évaluation",
                    "endpoint": "notes.evaluation_suppress_alln",
                    "args": {"evaluation_id": E["evaluation_id"]},
                },
            ],
            alone=True,
        )
    )
    H.append(
        """
        </td>
        <td style="padding-left: 35px;"><button class="btn_masquer_DEM">Masquer les DEM</button></td>
        </tr>
        </table>
        </div>
        <style>
		.btn_masquer_DEM{
			font-size: 12px;
		}
		body.masquer_DEM .btn_masquer_DEM{
			background: #009688;
			color: #fff;
		}
		body.masquer_DEM .etud_dem{
			display: none !important;
		}
	    </style>
        """
    )

    # Le formulaire de saisie des notes:
    form = _form_saisie_notes(E, M, groups_infos, destination=moduleimpl_status_url)
    if form is None:
        return flask.redirect(moduleimpl_status_url)
    H.append(form)
    #
    H.append("</div>")  # /saisie_notes

    H.append(
        """<div class="sco_help">
    <p>Les modifications sont enregistrées au fur et à mesure.</p>
    <h4>Codes spéciaux:</h4>
    <ul>
    <li>ABS: absent (compte comme un zéro)</li>
    <li>EXC: excusé (note neutralisée)</li>
    <li>SUPR: pour supprimer une note existante</li>
    <li>ATT: note en attente (permet de publier une évaluation avec des notes manquantes)</li>
    </ul>
    </div>"""
    )

    H.append(html_sco_header.sco_footer())
    return "\n".join(H)


def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int):
    notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
        eval_dict["evaluation_id"]
    )  # Notes existantes
    cnx = ndb.GetDBConnexion()
    etuds = []
    for etudid in etudids:
        # infos identite etudiant
        e = sco_etud.etudident_list(cnx, {"etudid": etudid})[0]
        etud: Identite = Identite.query.get(etudid)
        # TODO: refactor et eliminer etudident_list.
        e["etud"] = etud  # utilisé seulement pour le tri -- a refactorer
        sco_etud.format_etud_ident(e)
        etuds.append(e)
        # infos inscription dans ce semestre
        e["inscr"] = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
            {"etudid": etudid, "formsemestre_id": formsemestre_id}
        )[0]
        # Groupes auxquels appartient cet étudiant:
        e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id)

        # Information sur absence (tenant compte de la demi-journée)
        jour_iso = ndb.DateDMYtoISO(eval_dict["jour"])
        warn_abs_lst = []
        if eval_dict["matin"]:
            nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=1)
            nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=1)
            if nbabs:
                if nbabsjust:
                    warn_abs_lst.append("absent justifié le matin !")
                else:
                    warn_abs_lst.append("absent le matin !")
        if eval_dict["apresmidi"]:
            nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=0)
            nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=0)
            if nbabs:
                if nbabsjust:
                    warn_abs_lst.append("absent justifié l'après-midi !")
                else:
                    warn_abs_lst.append("absent l'après-midi !")

        e["absinfo"] = '<span class="sn_abs">' + " ".join(warn_abs_lst) + "</span>  "

        # Note actuelle de l'étudiant:
        if etudid in notes_db:
            e["val"] = _displayNote(notes_db[etudid]["value"])
            comment = notes_db[etudid]["comment"]
            if comment is None:
                comment = ""
            e["explanation"] = "%s (%s) %s" % (
                notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M"),
                notes_db[etudid]["uid"],
                comment,
            )
        else:
            e["val"] = ""
            e["explanation"] = ""
        # Démission ?
        if e["inscr"]["etat"] == "D":
            # if not e['val']:
            e["val"] = "DEM"
            e["explanation"] = "Démission"

    etuds.sort(key=lambda x: x["etud"].sort_key)

    return etuds


def _form_saisie_notes(E, M, groups_infos, destination=""):
    """Formulaire HTML saisie des notes  dans l'évaluation E du moduleimpl M
    pour les groupes indiqués.

    On charge tous les étudiants, ne seront montrés que ceux
    des groupes sélectionnés grace a un filtre en javascript.
    """
    evaluation_id = E["evaluation_id"]
    formsemestre_id = M["formsemestre_id"]

    etudids = [
        x[0]
        for x in sco_groups.do_evaluation_listeetuds_groups(
            evaluation_id, getallstudents=True, include_demdef=True
        )
    ]
    if not etudids:
        return '<div class="ue_warning"><span>Aucun étudiant sélectionné !</span></div>'

    # Decisions de jury existantes ?
    decisions_jury = {etudid: has_existing_decision(M, E, etudid) for etudid in etudids}
    # Nb de decisions de jury (pour les inscrits à l'évaluation):
    nb_decisions = sum(decisions_jury.values())

    etuds = _get_sorted_etuds(E, etudids, formsemestre_id)

    # Build form:
    descr = [
        ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}),
        ("formsemestre_id", {"default": formsemestre_id, "input_type": "hidden"}),
        (
            "group_ids",
            {"default": groups_infos.group_ids, "input_type": "hidden", "type": "list"},
        ),
        # ('note_method', { 'default' : note_method, 'input_type' : 'hidden'}),
        ("comment", {"size": 44, "title": "Commentaire", "return_focus_next": True}),
        ("changed", {"default": "0", "input_type": "hidden"}),  # changed in JS
    ]
    if M["module"]["module_type"] in (
        ModuleType.STANDARD,
        ModuleType.RESSOURCE,
        ModuleType.SAE,
    ):
        descr.append(
            (
                "s3",
                {
                    "input_type": "text",  # affiche le barème
                    "title": "Notes ",
                    "cssclass": "formnote_bareme",
                    "readonly": True,
                    "default": "&nbsp;/ %g" % E["note_max"],
                },
            )
        )
    elif M["module"]["module_type"] == ModuleType.MALUS:
        descr.append(
            (
                "s3",
                {
                    "input_type": "text",  # affiche le barème
                    "title": "",
                    "cssclass": "formnote_bareme",
                    "readonly": True,
                    "default": "Points de malus (soustraits à la moyenne de l'UE, entre -20 et 20)",
                },
            )
        )
    else:
        raise ValueError("invalid module type (%s)" % M["module"]["module_type"])  # bug

    initvalues = {}
    for e in etuds:
        etudid = e["etudid"]
        disabled = e["val"] == "DEM"
        etud_classes = []
        if disabled:
            classdem = " etud_dem"
            etud_classes.append("etud_dem")
            disabled_attr = 'disabled="%d"' % disabled
        else:
            classdem = ""
            disabled_attr = ""
        # attribue a chaque element une classe css par groupe:
        for group_info in e["groups"]:
            etud_classes.append("group-" + str(group_info["group_id"]))

        label = (
            '<span class="%s">' % classdem
            + e["civilite_str"]
            + " "
            + sco_etud.format_nomprenom(e, reverse=True)
            + "</span>"
        )

        # Historique des saisies de notes:
        if not disabled:
            explanation = (
                '<span id="hist_%s">' % etudid
                + get_note_history_menu(evaluation_id, etudid)
                + "</span>"
            )
        else:
            explanation = ""
        explanation = e["absinfo"] + explanation

        # Lien modif decision de jury:
        explanation += '<span id="jurylink_%s" class="jurylink"></span>' % etudid

        # Valeur actuelle du champ:
        initvalues["note_" + str(etudid)] = e["val"]
        label_link = '<a class="etudinfo" id="%s">%s</a>' % (etudid, label)

        # Element de formulaire:
        descr.append(
            (
                "note_" + str(etudid),
                {
                    "size": 5,
                    "title": label_link,
                    "explanation": explanation,
                    "return_focus_next": True,
                    "attributes": [
                        'class="note%s"' % classdem,
                        disabled_attr,
                        'data-last-saved-value="%s"' % e["val"],
                        'data-orig-value="%s"' % e["val"],
                        'data-etudid="%s"' % etudid,
                    ],
                    "template": """<tr%(item_dom_attr)s class="etud_elem """
                    + " ".join(etud_classes)
                    + """"><td class="tf-fieldlabel">%(label)s</td>
                    <td class="tf-field">%(elem)s</td></tr>
                    """,
                },
            )
        )
    #
    H = []
    if nb_decisions > 0:
        H.append(
            f"""<div class="saisie_warn">
        <ul class="tf-msg">
        <li class="tf-msg">Attention: il y a déjà des <b>décisions de jury</b> enregistrées pour
        {nb_decisions} étudiants. Après changement des notes, vérifiez la situation !</li>
        </ul>
        </div>"""
        )

    tf = TF(
        destination,
        scu.get_request_args(),
        descr,
        initvalues=initvalues,
        submitbutton=False,
        formid="formnotes",
        method="GET",
    )
    H.append(tf.getform())  # check and init
    H.append(
        f"""<a href="{url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, 
        moduleimpl_id=M["moduleimpl_id"])
        }" class="btn btn-primary">Terminer</a>
        """
    )
    if tf.canceled():
        return None
    elif (not tf.submitted()) or not tf.result:
        # ajout formulaire saisie notes manquantes
        H.append(
            f"""
        <div>
        <form id="do_evaluation_set_missing" action="do_evaluation_set_missing" method="POST">
        Mettre les notes manquantes à
        <input type="text" size="5" name="value"/>
        <input type="submit" value="OK"/>
        <input type="hidden" name="evaluation_id" value="{evaluation_id}"/>
        <input class="group_ids_str" type="hidden" name="group_ids_str" value="{
            ",".join([str(x) for x in groups_infos.group_ids])
        }"/>
        <em>ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente"</em>
        </form>
        </div>
        """
        )
        # affiche formulaire
        return "\n".join(H)
    else:
        # form submission
        # rien à faire
        return None


def save_note(etudid=None, evaluation_id=None, value=None, comment=""):
    """Enregistre une note (ajax)"""
    authuser = current_user
    log(
        "save_note: evaluation_id=%s etudid=%s uid=%s value=%s"
        % (evaluation_id, etudid, authuser, value)
    )
    E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0]
    M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
    Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
    Mod["url"] = url_for(
        "notes.moduleimpl_status",
        scodoc_dept=g.scodoc_dept,
        moduleimpl_id=M["moduleimpl_id"],
        _external=True,
    )
    result = {"nbchanged": 0}  # JSON
    # Check access: admin, respformation, or responsable_id
    if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]):
        result["status"] = "unauthorized"
    else:
        L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod)
        if L:
            nbchanged, _, existing_decisions = notes_add(
                authuser, evaluation_id, L, comment=comment, do_it=True
            )
            ScolarNews.add(
                typ=ScolarNews.NEWS_NOTE,
                obj=M["moduleimpl_id"],
                text='Chargement notes dans <a href="%(url)s">%(titre)s</a>' % Mod,
                url=Mod["url"],
                max_frequency=30 * 60,  # 30 minutes
            )
            result["nbchanged"] = nbchanged
            result["existing_decisions"] = existing_decisions
            if nbchanged > 0:
                result["history_menu"] = get_note_history_menu(evaluation_id, etudid)
            else:
                result["history_menu"] = ""  # no update needed
        result["status"] = "ok"
    return scu.sendJSON(result)


def get_note_history_menu(evaluation_id, etudid):
    """Menu HTML historique de la note"""
    history = sco_undo_notes.get_note_history(evaluation_id, etudid)
    if not history:
        return ""

    H = []
    if len(history) > 1:
        H.append(
            '<select data-etudid="%s" class="note_history" onchange="change_history(this);">'
            % etudid
        )
        envir = "select"
        item = "option"
    else:
        # pas de menu
        H.append('<span class="history">')
        envir = "span"
        item = "span"

    first = True
    for i in history:
        jt = i["date"].strftime("le %d/%m/%Y à %H:%M") + " (%s)" % i["user_name"]
        dispnote = _displayNote(i["value"])
        if first:
            nv = ""  # ne repete pas la valeur de la note courante
        else:
            # ancienne valeur
            nv = ": %s" % dispnote
        first = False
        if i["comment"]:
            comment = ' <span class="histcomment">%s</span>' % i["comment"]
        else:
            comment = ""
        H.append(
            '<%s data-note="%s">%s %s%s</%s>' % (item, dispnote, jt, nv, comment, item)
        )

    H.append("</%s>" % envir)
    return "\n".join(H)