Skip to content
Snippets Groups Projects
Select Git revision
  • e3cacd9ceb17e6ed62e47922df0bdb59eea7791b
  • main default protected
  • master
  • 2024
  • fix/home/scodoc
  • home
6 results

sco_saisie_notes.py

Blame
  • Forked from Jean-Marie Place / R6A06
    Source project has a limited visibility.
    sco_saisie_notes.py 40.70 KiB
    ##############################################################################
    #
    # 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, render_template, 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,
        ScoValueError,
    )
    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
    from app.views import ScoData
    
    
    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.get_evaluation(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
            groups_infos = None
        else:
            group_ids = [int(x) for x in str(group_ids_str).split(",")]
            groups_infos = sco_groups_view.DisplayedGroupsInfos(
                group_ids, formsemestre_id=modimpl.formsemestre.id
            )
            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.form_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 render_template(
                "sco_page.j2",
                content=f"""
                <h2>{diag}</h2>
                <p><a href="{ dest_url }">
                Recommencer</a>
                </p>
                """,
            )
        # 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 <span class="fontred">{value} / {evaluation.note_max:g}</span> ?</h2>
                <p>Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC)
                n'a été rentrée seront affectés.</p>
                <div>
                <b>Groupes: {groups_infos.groups_titles if groups_infos else "tous"},
                dont
                <span class="fontred">
                {len(valid_notes)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""}
                </span>
                par ce changement de note.</b>
                </div>
                """,
                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 render_template(
            "sco_page.j2",
            content=f"""
            <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>
            """,
        )
    
    
    def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False):
        "suppress all notes in this eval"
        evaluation = Evaluation.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 render_template("sco_page.j2", content="\n".join(H))
    
    
    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 = db.session.get(Identite, 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 = db.session.get(Identite, 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 = (
                            db.session.get(Identite, 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: Evaluation, group_ids: list[int] | tuple[int] = ()):
        """Formulaire saisie notes d'une évaluation pour un groupe"""
        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 render_template(
                "sco_page.j2",
                content=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 href="{ moduleimpl_status_url }">Continuer</a>
                </p>
            """,
            )
    
        # 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 = [
            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, submit_on_change=True))
        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>"""
        )
        return render_template(
            "sco_page.j2",
            content="\n".join(H),
            title=page_title,
            javascripts=["js/groups_view.js", "js/saisie_notes.js"],
            sco=ScoData(formsemestre=modimpl.formsemestre),
        )
    
    
    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 = (
                    db.session.get(User, 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
        groups = sco_groups.listgroups(groups_infos.group_ids)
        res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
        etudids = [
            x[0]
            for x in sco_groups.do_evaluation_listeetuds_groups(
                evaluation.id,
                groups=groups,
                getallstudents=groups is None,
                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
    
        
        # Ticket 1018: Import notes from another evaluation
        # Add a select element to import notes from another evaluation
        # On selection change, the notes will be imported
        formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
        evaluations = formsemestre.get_evaluations()
        rows = {}
        # Create a dictionary with the notes of each evaluation for each student
        for e in evaluations:
            notes = sco_evaluation_db.do_evaluation_get_all_notes(e.id)
            for id, note in notes.items():
                if e.id not in rows:
                    rows[e.id] = {}
                rows[e.id][id] = note['value']
    
        if len(evaluations) > 1:
            H.append(
                f'<select data-etudid="{etudid}" class="note_noteImporteur" onchange="set_import_eval(this, {rows})">'
            )
            # Add the default option "Sélectionner une évaluation à importer"
            H.append('<option value="x" disabled selected>Sélectionner une évaluation à importer</option>')
    
            for i in evaluations:
                # Skip the current evaluation
                if i.id == evaluation.id:
                    continue
    
                # Add the others evaluations
                H.append(f'<option value="{i.id}">{"todo titre module"} - {i.description}</option>')
    
            H.append('</select>')
    
            H.append("""<script>
            window.onload = function() {
                // Get the select element
                const selectElement = document.querySelector('.note_noteImporteur');
    
                // Reset the selection to the default option
                if (selectElement) {
                    selectElement.value = ""; // This will select the "Sélectionner une évaluation à importer" option
                }
            }
            </script>
            """)
            H.append(
                f"""<button class="btn btn-primary" onclick="valid_import()" id="import_notes">Importer les notes</button>"""
            )
        else:
            # Span if there is only one evaluation
            H.append('<span class="noteImporteur">')
            envir = "span"
            item = "span"
    
        H.append("</br>")
        H.append("</br>")
    
        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="{
                url_for("notes.do_evaluation_set_missing", scodoc_dept=g.scodoc_dept)
            }" 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"""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)