##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 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 html
import time


import flask
from flask import g, url_for
from flask_login import current_user
from flask_sqlalchemy.query import Query
import psycopg2

from app import db, log
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,
    Module,
    ModuleImpl,
    ScolarNews,
    Assiduite,
)
from app.models.etudiants import Identite

from app.scodoc.sco_exceptions import (
    AccessDenied,
    NoteProcessError,
    ScoException,
    ScoInvalidParamError,
    ScoValueError,
)
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_cache
from app.scodoc import sco_etud
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_evaluations
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_undo_notes
import app.scodoc.notesdb as ndb
from app.scodoc.TrivialFormulator import TF
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import json_error
from app.scodoc.sco_utils import ModuleType


def convert_note_from_string(
    note: str,
    note_max: float,
    note_min: float = scu.NOTES_MIN,
    etudid: int = None,
    absents: list[int] = None,
    invalids: list[int] = None,
) -> tuple[float, bool]:
    """converti une valeur (chaine saisie) vers une note numérique (float)
    Les listes absents et invalids sont modifiées.
    Return:
        note_value: float (valeur de la note ou code EXC, ATT, ...)
        invalid: True si note invalide (eg hors barème)
    """
    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
    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 check_notes(
    notes: list[(int, float | str)], evaluation: Evaluation
) -> tuple[list[tuple[int, float]], list[int], list[int], list[int], list[int]]:
    """Vérifie et converti les valeurs des notes pour une évaluation.

    notes: list of tuples (etudid, value)
    evaluation: target

    Returns
        valid_notes: list of valid notes (etudid, float value)
    and 4 lists of etudid:
        etudids_invalids     : etudid avec notes invalides
        etudids_without_notes: etudid sans notes (champs vides)
        etudids_absents      : etudid avec note ABS
        etudids_non_inscrits : etudid non inscrits à ce module
                                (ne considère pas l'inscr. au semestre)
    """
    note_max = evaluation.note_max or 0.0
    module: Module = evaluation.moduleimpl.module
    if module.module_type in (
        scu.ModuleType.STANDARD,
        scu.ModuleType.RESSOURCE,
        scu.ModuleType.SAE,
    ):
        if evaluation.evaluation_type == Evaluation.EVALUATION_BONUS:
            note_min, note_max = -20, 20
        else:
            note_min = scu.NOTES_MIN
    elif module.module_type == ModuleType.MALUS:
        note_min = -20.0
    else:
        raise ValueError("Invalid module type")  # bug
    # Vérifie inscription au module (même DEM/DEF)
    etudids_inscrits_mod = {
        i.etudid for i in evaluation.moduleimpl.query_inscriptions()
    }
    valid_notes = []
    etudids_invalids = []
    etudids_without_notes = []
    etudids_absents = []
    etudids_non_inscrits = []

    for etudid, note in notes:
        if etudid not in etudids_inscrits_mod:
            # 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)  #
        except ValueError as exc:
            raise ScoValueError(f"Code étudiant ({etudid}) invalide") from exc
        note = str(note).strip().upper()
        if note[:3] == "DEM":
            continue  # skip !
        if note:
            value, invalid = convert_note_from_string(
                note,
                note_max,
                note_min=note_min,
                etudid=etudid,
                absents=etudids_absents,
                invalids=etudids_invalids,
            )
            if not invalid:
                valid_notes.append((etudid, value))
        else:
            etudids_without_notes.append(etudid)
    return (
        valid_notes,
        etudids_invalids,
        etudids_without_notes,
        etudids_absents,
        etudids_non_inscrits,
    )


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 evaluation.moduleimpl.can_edit_notes(current_user):
        raise AccessDenied(f"Modification des notes impossible pour {current_user}")
    # Convert and check value
    notes, invalids, _, _, _ = check_notes([(etud.id, value)], evaluation)
    if len(invalids) == 0:
        etudids_changed, _, _, _ = notes_add(
            current_user, evaluation.id, notes, "Initialisation notes"
        )
        if len(etudids_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 modimpl.can_edit_notes(current_user):
        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
    valid_notes, invalids, _, _, _ = check_notes(notes, evaluation)
    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(valid_notes) > 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(valid_notes)} é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"
    etudids_changed, _, _, _ = notes_add(
        current_user, evaluation_id, valid_notes, 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>{len(etudids_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 evaluation.moduleimpl.can_edit_notes(current_user, 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 evaluation.moduleimpl.can_edit_notes(current_user, 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:
        etudids_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
    etudids_changed, nb_suppress, existing_decisions, _ = notes_add(
        current_user,
        evaluation_id,
        notes,
        comment="effacer tout",
        check_inscription=False,
    )
    assert len(etudids_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
    if nb_suppress:
        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 _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,
    notes: list,
    comment=None,
    do_it=True,
    check_inscription=True,
) -> tuple[list[int], int, list[int], list[str]]:
    """
    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)

    Raise NoteProcessError si note invalide ou étudiant non inscrit.

    Return: tuple (etudids_changed, nb_suppress, etudids_with_decision, messages)

    messages = list de messages d'avertissement/information pour l'utilisateur
    """
    evaluation = Evaluation.get_evaluation(evaluation_id)
    now = psycopg2.Timestamp(*time.localtime()[:6])
    messages = []
    # Vérifie inscription au module (même DEM/DEF)
    etudids_inscrits_mod = {
        i.etudid for i in evaluation.moduleimpl.query_inscriptions()
    }
    # Les étudiants inscrits au semestre et ceux "actifs" (ni DEM ni DEF)
    etudids_inscrits_sem, etudids_actifs = (
        evaluation.moduleimpl.formsemestre.etudids_actifs()
    )
    for etudid, value in notes:

        if check_inscription:
            _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
            raise NoteProcessError(
                f"etudiant {etud.nomprenom if etud else 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)
    etudids_changed = []
    nb_suppress = 0
    formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre
    res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
    # etudids pour lesquels il y a une decision de jury et que la note change:
    etudids_with_decision = []
    try:
        for etudid, value in notes:
            changed, suppressed = _record_note(
                cursor,
                notes_db,
                etudid,
                evaluation_id,
                value,
                comment=comment,
                user=user,
                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 Exception as exc:
        log("*** exception in notes_add")
        if do_it:
            cnx.rollback()  # abort
            # inval cache
            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


def _record_note(
    cursor,
    notes_db,
    etudid: int,
    evaluation_id: int,
    value: float,
    comment: str = "",
    user: User = None,
    date=None,
    do_it=False,
):
    "Enregistrement de la note en base"
    changed = False
    suppressed = False
    args = {
        "etudid": etudid,
        "evaluation_id": evaluation_id,
        "value": value,
        # convention scodoc7 quote comment:
        "comment": (html.escape(comment) if isinstance(comment, str) else comment),
        "uid": user.id,
        "date": date,
    }
    if etudid not in notes_db:
        # nouvelle note
        if value != scu.NOTES_SUPPRESS:
            if do_it:
                # Note: le conflit ci-dessous peut arriver si un autre thread
                # a modifié la base après qu'on ait lu notes_db
                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)
                    ON CONFLICT ON CONSTRAINT notes_notes_etudid_evaluation_id_key
                    DO UPDATE SET etudid=%(etudid)s, evaluation_id=%(evaluation_id)s,
                        value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s
                    """,
                    args,
                )
            changed = True
    else:
        # il y a deja une note
        oldval = notes_db[etudid]["value"]
        changed = (
            (not isinstance(value, type(oldval)))
            or (
                isinstance(value, float) and (abs(value - oldval) > scu.NOTES_PRECISION)
            )
            or value != oldval
        )
        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
                    """,
                    args,
                )
            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
                        """,
                        args,
                    )
            else:  # suppression ancienne note
                if do_it:
                    log(
                        f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={
                            etudid}, oldval={oldval}"""
                    )
                    cursor.execute(
                        """DELETE FROM notes_notes
                        WHERE etudid = %(etudid)s
                        AND evaluation_id = %(evaluation_id)s
                        """,
                        args,
                    )
                    # garde trace de la suppression dans l'historique:
                    args["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)
                        """,
                        args,
                    )
                suppressed = True
    return changed, suppressed


# 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 [])]
    evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
    if evaluation is None:
        raise ScoValueError("évaluation inexistante")
    modimpl = evaluation.moduleimpl
    moduleimpl_status_url = url_for(
        "notes.moduleimpl_status",
        scodoc_dept=g.scodoc_dept,
        moduleimpl_id=evaluation.moduleimpl_id,
    )
    # Check access
    # (admin, respformation, and responsable_id)
    if not evaluation.moduleimpl.can_edit_notes(current_user):
        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=modimpl.formsemestre_id,
        select_all_when_unspecified=True,
        etat=None,
    )

    page_title = (
        f'Saisie "{evaluation.description}"'
        if evaluation.description
        else "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": "Saisir par fichier tableur",
                    "id": "menu_saisie_tableur",
                    "endpoint": "notes.saisie_notes_tableur",
                    "args": {
                        "evaluation_id": evaluation.id,
                        "group_ids": groups_infos.group_ids,
                    },
                },
                {
                    "title": "Voir toutes les notes du module",
                    "endpoint": "notes.evaluation_listenotes",
                    "args": {"moduleimpl_id": evaluation.moduleimpl_id},
                },
                {
                    "title": "Effacer toutes les notes de cette évaluation",
                    "endpoint": "notes.evaluation_suppress_alln",
                    "args": {"evaluation_id": 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(
        evaluation, modimpl, 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.
    Vous pouvez aussi copier/coller depuis un tableur ou autre logiciel.
    </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_notes(
    evaluation: Evaluation, etudids: list, formsemestre_id: int
) -> list[dict]:
    """Liste d'infos sur les notes existantes pour les étudiants indiqués"""
    notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
    cnx = ndb.GetDBConnexion()
    etuds = []
    for etudid in etudids:
        # infos identite etudiant
        e = sco_etud.etudident_list(cnx, {"etudid": etudid})[0]
        etud = Identite.get_etud(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
        warn_abs_lst: str = ""
        if evaluation.date_debut is not None and evaluation.date_fin is not None:
            assiduites_etud: Query = etud.assiduites.filter(
                Assiduite.etat == scu.EtatAssiduite.ABSENT,
                Assiduite.date_debut <= evaluation.date_fin,
                Assiduite.date_fin >= evaluation.date_debut,
            )
            premiere_assi: Assiduite = assiduites_etud.first()
            if premiere_assi is not None:
                warn_abs_lst: str = (
                    f"absent {'justifié' if premiere_assi.est_just else ''}"
                )

        e["absinfo"] = '<span class="sn_abs">' + warn_abs_lst + "</span>  "

        # Note actuelle de l'étudiant:
        if etudid in notes_db:
            e["val"] = scu.fmt_note(
                notes_db[etudid]["value"], fixed_precision_str=False
            )
            user = (
                User.query.get(notes_db[etudid]["uid"])
                if notes_db[etudid]["uid"]
                else None
            )
            e["explanation"] = (
                f"""{
                notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M")
            } par {user.get_nomplogin() if user else '?'
            } {(' : ' + notes_db[etudid]["comment"]) if notes_db[etudid]["comment"] else ''}
            """
            )
        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(
    evaluation: Evaluation, modimpl: ModuleImpl, groups_infos, destination=""
):
    """Formulaire HTML saisie des notes  dans l'évaluation du moduleimpl
    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.
    """
    formsemestre_id = modimpl.formsemestre_id
    formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre
    res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
    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>'

    # Décisions de jury existantes ?
    # en BUT on ne considère pas les RCUEs car ils peuvenut avoir été validés depuis
    # d'autres semestres (les validations de RCUE n'indiquent pas si elles sont "externes")
    decisions_jury = {
        etudid: res.etud_has_decision(etudid, include_rcues=False) for etudid in etudids
    }

    # Nb de décisions de jury (pour les inscrits à l'évaluation):
    nb_decisions = sum(decisions_jury.values())

    etuds = get_sorted_etuds_notes(evaluation, 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 modimpl.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": f"&nbsp;/ {evaluation.note_max:g}",
                },
            )
        )
    elif modimpl.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(f"invalid module type ({modimpl.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 = f'disabled="{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 = f"""<span class="{classdem}">{e["civilite_str"]} {
            scu.format_nomprenom(e, reverse=True)}</span>"""

        # Historique des saisies de notes:
        explanation = (
            ""
            if disabled
            else f"""<span id="hist_{etudid}">{
            get_note_history_menu(evaluation.id, etudid)
            }</span>"""
        )
        explanation = e["absinfo"] + explanation

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

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

        # Element de formulaire:
        descr.append(
            (
                "note_" + str(etudid),
                {
                    "size": 5,
                    "title": label_link,
                    "explanation": explanation,
                    "return_focus_next": True,
                    "attributes": [
                        f'class="note{classdem}"',
                        disabled_attr,
                        f'''data-last-saved-value="{e['val']}"''',
                        f'''data-orig-value="{e["val"]}"''',
                        f'data-etudid="{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=modimpl.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_notes(
    evaluation: Evaluation, notes: list[tuple[(int, float)]], comment: str = ""
) -> dict:
    """Enregistre une liste de notes.
    Vérifie que les étudiants sont bien inscrits à ce module, et que l'on a le droit.
    Result: dict avec
    """
    log(f"save_note: evaluation_id={evaluation.id} uid={current_user} notes={notes}")
    status_url = url_for(
        "notes.moduleimpl_status",
        scodoc_dept=g.scodoc_dept,
        moduleimpl_id=evaluation.moduleimpl_id,
        _external=True,
    )
    # Check access: admin, respformation, or responsable_id
    if not evaluation.moduleimpl.can_edit_notes(current_user):
        return json_error(403, "modification notes non autorisee pour cet utilisateur")
    #
    valid_notes, _, _, _, _ = check_notes(notes, evaluation)
    if valid_notes:
        etudids_changed, _, etudids_with_decision, messages = notes_add(
            current_user, evaluation.id, valid_notes, comment=comment, do_it=True
        )
        ScolarNews.add(
            typ=ScolarNews.NEWS_NOTE,
            obj=evaluation.moduleimpl_id,
            text=f"""Chargement notes dans <a href="{status_url}">{
                    evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code}</a>""",
            url=status_url,
            max_frequency=30 * 60,  # 30 minutes
        )
        result = {
            "etudids_with_decision": etudids_with_decision,
            "etudids_changed": etudids_changed,
            "history_menu": {
                etudid: get_note_history_menu(evaluation.id, etudid)
                for etudid in etudids_changed
            },
            "messages": messages,
        }
    else:
        result = {
            "etudids_changed": [],
            "etudids_with_decision": [],
            "history_menu": [],
            "messages": [],
        }

    return result


def get_note_history_menu(evaluation_id: int, etudid: int) -> str:
    """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(
            f'<select data-etudid="{etudid}" class="note_history" onchange="change_history(this);">'
        )
        envir = "select"
        item = "option"
    else:
        # pas de menu
        H.append('<span class="history">')
        envir = "span"
        item = "span"

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

    H.append(f"</{envir}>")
    return "\n".join(H)