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

##############################################################################
#
# ScoDoc
#
# 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
#
##############################################################################

"""
Module notes: issu de ScoDoc7 / ZNotes.py

Emmanuel Viennet, 2021
"""

import html
from operator import itemgetter
import time

import flask
from flask import flash, redirect, render_template, url_for
from flask import g, request
from flask_login import current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed
from wtforms.validators import DataRequired, Length
from wtforms import FileField, StringField, SubmitField

from app import db, log, send_scodoc_alarm
from app import models
from app.auth.models import User
from app.but import apc_edit_ue, jury_but_pv


from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import (
    ApcNiveau,
    Assiduite,
    BulAppreciations,
    DispenseUE,
    Evaluation,
    Formation,
    FormSemestre,
    FormSemestreInscription,
    FormSemestreUEComputationExpr,
    Identite,
    Module,
    ModuleImpl,
    Scolog,
    UniteEns,
)
from app.scodoc.sco_exceptions import ScoFormationConflict, ScoPermissionDenied
from app.views import notes_bp as bp

from app.decorators import (
    scodoc,
    scodoc7func,
    permission_required,
    permission_required_compat_scodoc7,
)

from app.formations import (
    edit_formation,
    edit_matiere,
    edit_module,
    edit_ue,
    formation_io,
    formation_versions,
)
from app.scodoc import sco_utils as scu

from app.scodoc.sco_exceptions import (
    AccessDenied,
    ScoValueError,
    ScoInvalidIdType,
)
from app.scodoc import (
    sco_apogee_compare,
    sco_archives_formsemestre,
    sco_assiduites,
    sco_bulletins,
    sco_bulletins_pdf,
    sco_cache,
    sco_cost_formation,
    sco_debouche,
    sco_edit_apc,
    sco_etape_apogee_view,
    sco_etud,
    sco_evaluations,
    sco_evaluation_check_abs,
    sco_evaluation_db,
    sco_evaluation_edit,
    sco_evaluation_recap,
    sco_export_results,
    sco_formsemestre,
    sco_formsemestre_custommenu,
    sco_formsemestre_edit,
    sco_formsemestre_exterieurs,
    sco_formsemestre_inscriptions,
    sco_formsemestre_status,
    sco_groups_view,
    sco_inscr_passage,
    sco_liste_notes,
    sco_lycee,
    sco_moduleimpl,
    sco_moduleimpl_inscriptions,
    sco_moduleimpl_status,
    sco_placement,
    sco_poursuite_dut,
    sco_preferences,
    sco_prepajury,
    sco_pv_forms,
    sco_recapcomplet,
    sco_report,
    sco_report_but,
    sco_saisie_excel,
    sco_saisie_notes,
    sco_semset,
    sco_synchro_etuds,
    sco_tag_module,
    sco_ue_external,
    sco_undo_notes,
    sco_users,
)
from app.formations import formation_recap
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_permissions import Permission
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.views import ScoData


def sco_publish(route, function, permission, methods=("GET",)):
    """Declare a route for a python function,
    protected by permission and called following ScoDoc 7 Zope standards.
    """
    return bp.route(route, methods=methods)(
        scodoc(permission_required(permission)(scodoc7func(function)))
    )


# --------------------------------------------------------------------
#
#    Notes/ methods
#
# --------------------------------------------------------------------

sco_publish(
    "/formsemestre_status",
    sco_formsemestre_status.formsemestre_status,
    Permission.ScoView,
)

sco_publish(
    "/formsemestre_createwithmodules",
    sco_formsemestre_edit.formsemestre_createwithmodules,
    Permission.EditFormSemestre,
    methods=["GET", "POST"],
)

# controle d'acces specifique pour dir. etud:
sco_publish(
    "/formsemestre_editwithmodules",
    sco_formsemestre_edit.formsemestre_editwithmodules,
    Permission.ScoView,
    methods=["GET", "POST"],
)

sco_publish(
    "/formsemestre_clone",
    sco_formsemestre_edit.formsemestre_clone,
    Permission.EditFormSemestre,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_associate_new_version",
    formation_versions.formsemestre_associate_new_version,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_delete",
    sco_formsemestre_edit.formsemestre_delete,
    Permission.EditFormSemestre,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_delete2",
    sco_formsemestre_edit.formsemestre_delete2,
    Permission.EditFormSemestre,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_note_etuds_sans_note",
    sco_formsemestre_status.formsemestre_note_etuds_sans_notes,
    Permission.ScoView,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_recapcomplet",
    sco_recapcomplet.formsemestre_recapcomplet,
    Permission.ScoView,
)
sco_publish(
    "/evaluations_recap",
    sco_evaluation_recap.evaluations_recap,
    Permission.ScoView,
)
sco_publish(
    "/formsemestres_bulletins",
    sco_recapcomplet.formsemestres_bulletins,
    Permission.Observateur,
)
sco_publish(
    "/moduleimpl_status", sco_moduleimpl_status.moduleimpl_status, Permission.ScoView
)
sco_publish(
    "/formsemestre_description",
    sco_formsemestre_status.formsemestre_description,
    Permission.ScoView,
)

sco_publish(
    "/formation_create",
    edit_formation.formation_create,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/formation_delete",
    edit_formation.formation_delete,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/formation_edit",
    edit_formation.formation_edit,
    Permission.EditFormation,
    methods=["GET", "POST"],
)


@bp.route("/formsemestre_bulletinetud")
@scodoc
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def formsemestre_bulletinetud(
    etudid=None,
    formsemestre_id=None,
    fmt=None,
    version="long",
    xml_with_decisions=False,
    force_publishing=False,
    prefer_mail_perso=False,
    code_nip=None,
    code_ine=None,
):
    fmt = fmt or "html"
    if version not in scu.BULLETINS_VERSIONS_BUT:
        raise ScoValueError(
            "formsemestre_bulletinetud: version de bulletin demandée invalide"
        )
    if not isinstance(etudid, int):
        raise ScoInvalidIdType("formsemestre_bulletinetud: etudid must be an integer !")
    if formsemestre_id is not None and not isinstance(formsemestre_id, int):
        raise ScoInvalidIdType(
            "formsemestre_bulletinetud: formsemestre_id must be an integer !"
        )
    formsemestre = FormSemestre.query.filter_by(
        formsemestre_id=formsemestre_id, dept_id=g.scodoc_dept_id
    ).first_or_404()
    if etudid:
        etud = Identite.get_etud(etudid)
    elif code_nip:
        etud = models.Identite.query.filter_by(
            code_nip=str(code_nip), dept_id=formsemestre.dept_id
        ).first_or_404()
    elif code_ine:
        etud = models.Identite.query.filter_by(
            code_ine=str(code_ine), dept_id=formsemestre.dept_id
        ).first_or_404()
    else:
        raise ScoValueError(
            "Paramètre manquant: spécifier etudid, code_nip ou code_ine"
        )
    if version == "butcourt":
        return redirect(
            url_for(
                "notes.bulletin_but_pdf" if fmt == "pdf" else "notes.bulletin_but_html",
                scodoc_dept=g.scodoc_dept,
                etudid=etud.id,
                formsemestre_id=formsemestre_id,
            )
        )
    if fmt == "json":
        return sco_bulletins.get_formsemestre_bulletin_etud_json(
            formsemestre, etud, version=version, force_publishing=force_publishing
        )
    if formsemestre.formation.is_apc() and fmt == "html":
        return render_template(
            "but/bulletin.j2",
            appreciations=BulAppreciations.get_appreciations_list(
                formsemestre.id, etud.id
            ),
            bul_url=url_for(
                "notes.formsemestre_bulletinetud",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                etudid=etud.id,
                fmt="json",
                force_publishing=1,  # pour ScoDoc lui même
                version=version,
            ),
            can_edit_appreciations=formsemestre.est_responsable(current_user)
            or (current_user.has_permission(Permission.EtudInscrit)),
            etud=etud,
            formsemestre=formsemestre,
            inscription_courante=etud.inscription_courante(),
            inscription_str=etud.inscription_descr()["inscription_str"],
            is_apc=formsemestre.formation.is_apc(),
            menu_autres_operations=sco_bulletins.make_menu_autres_operations(
                formsemestre, etud, "notes.formsemestre_bulletinetud", version
            ),
            sco=ScoData(etud=etud, formsemestre=formsemestre),
            scu=scu,
            time=time,
            title=f"Bul. {etud.nom} - BUT",
            version=version,
        )

    if fmt == "oldjson":
        fmt = "json"

    response = sco_bulletins.formsemestre_bulletinetud(
        etud,
        formsemestre_id=formsemestre_id,
        fmt=fmt,
        version=version,
        xml_with_decisions=xml_with_decisions,
        force_publishing=force_publishing,
        prefer_mail_perso=prefer_mail_perso,
    )

    if fmt == "pdfmail":  # ne renvoie rien dans ce cas (mails envoyés)
        return redirect(
            url_for(
                "notes.formsemestre_bulletinetud",
                scodoc_dept=g.scodoc_dept,
                etudid=etud.id,
                formsemestre_id=formsemestre_id,
            )
        )
    return response


sco_publish(
    "/formsemestre_evaluations_cal",
    sco_evaluations.formsemestre_evaluations_cal,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_evaluations_delai_correction",
    sco_evaluations.formsemestre_evaluations_delai_correction,
    Permission.ScoView,
)


@bp.route("/moduleimpl_evaluation_renumber", methods=["GET", "POST"])
@scodoc
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def moduleimpl_evaluation_renumber(moduleimpl_id):
    "Renumérote les évaluations, triant par date"
    modimpl: ModuleImpl = (
        ModuleImpl.query.filter_by(id=moduleimpl_id)
        .join(FormSemestre)
        .filter_by(dept_id=g.scodoc_dept_id)
        .first_or_404()
    )
    if not modimpl.can_edit_evaluation(current_user):
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.moduleimpl_status",
                scodoc_dept=g.scodoc_dept,
                moduleimpl_id=modimpl.id,
            )
        )
    Evaluation.moduleimpl_evaluation_renumber(modimpl)
    # redirect to moduleimpl page:
    return flask.redirect(
        url_for(
            "notes.moduleimpl_status",
            scodoc_dept=g.scodoc_dept,
            moduleimpl_id=moduleimpl_id,
        )
    )


sco_publish(
    "/moduleimpl_evaluation_move",
    sco_evaluation_db.moduleimpl_evaluation_move,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_list_saisies_notes",
    sco_undo_notes.formsemestre_list_saisies_notes,
    Permission.ScoView,
)
sco_publish(
    "/ue_create",
    edit_ue.ue_create,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/ue_delete",
    edit_ue.ue_delete,
    Permission.EditFormation,
    methods=["GET", "POST"],
)


@bp.route("/ue_edit/<int:ue_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EditFormation)
def ue_edit(ue_id: int):
    "Edition de l'UE"
    return edit_ue.ue_edit(ue_id)


@bp.route("/set_ue_niveau_competence", methods=["POST"])
@scodoc
@permission_required(Permission.EditFormation)
def set_ue_niveau_competence():
    """Associe UE et niveau.
    Si le niveau_id est "", désassocie."""
    ue_id = request.form.get("ue_id")
    niveau_id = request.form.get("niveau_id")
    if niveau_id == "":
        niveau_id = None
    ue: UniteEns = UniteEns.query.get_or_404(ue_id)
    niveau = None if niveau_id is None else ApcNiveau.query.get_or_404(niveau_id)
    try:
        ue.set_niveau_competence(niveau)
    except ScoFormationConflict:
        return "", 409  # conflict
    return "", 204


@bp.route("/get_ue_niveaux_options_html")
@scodoc
@permission_required(Permission.ScoView)
def get_ue_niveaux_options_html():
    """fragment html avec les options du menu de sélection du
    niveau de compétences associé à une UE
    """
    ue_id = request.args.get("ue_id")
    if ue_id is None:
        log("WARNING: get_ue_niveaux_options_html missing ue_id arg")
        return "???"
    ue: UniteEns = UniteEns.query.get_or_404(ue_id)
    return apc_edit_ue.get_ue_niveaux_options_html(ue)


@bp.route("/ue_table")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def ue_table(formation_id=None, semestre_idx=1, msg=""):
    return edit_ue.ue_table(
        formation_id=formation_id, semestre_idx=semestre_idx, msg=msg
    )


@bp.route("/ue_infos/<int:ue_id>")
@scodoc
@permission_required(Permission.ScoView)
def ue_infos(ue_id: int):
    ue = UniteEns.query.get_or_404(ue_id)
    return sco_edit_apc.html_ue_infos(ue)


@bp.route("/ue_set_internal", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EditFormation)
@scodoc7func
def ue_set_internal(ue_id):
    """"""
    ue = db.session.get(UniteEns, ue_id)
    if not ue:
        raise ScoValueError("invalid ue_id")
    ue.is_external = False
    db.session.add(ue)
    db.session.commit()
    # Invalide les semestres de cette formation
    ue.formation.invalidate_cached_sems()

    return redirect(
        url_for(
            "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation_id
        )
    )


@bp.route("/ue_sharing_code")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def ue_sharing_code():
    ue_code = request.args.get("ue_code")
    ue_id = request.args.get("ue_id")
    hide_ue_id = request.args.get("hide_ue_id")
    return edit_ue.ue_sharing_code(
        ue_code=ue_code,
        ue_id=None if ((ue_id is None) or ue_id == "") else int(ue_id),
        hide_ue_id=(
            None if ((hide_ue_id is None) or hide_ue_id == "") else int(hide_ue_id)
        ),
    )


sco_publish(
    "/formsemestre_edit_uecoefs",
    sco_formsemestre_edit.formsemestre_edit_uecoefs,
    Permission.ScoView,
    methods=["GET", "POST"],
)


@bp.route("/formation_table_recap/<int:formation_id>")
@scodoc
@permission_required(Permission.ScoView)
def formation_table_recap(formation_id: int):
    "Tableau récap. de la formation"
    formation = Formation.get_formation(formation_id)
    fmt = request.args.get("fmt", "html")
    return formation_recap.formation_table_recap(formation, fmt=fmt)


sco_publish(
    "/export_recap_formations_annee_scolaire",
    formation_recap.export_recap_formations_annee_scolaire,
    Permission.ScoView,
)
sco_publish(
    "/formation_add_malus_modules",
    edit_module.formation_add_malus_modules,
    Permission.EditFormation,
)
sco_publish(
    "/matiere_create",
    edit_matiere.matiere_create,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/matiere_delete",
    edit_matiere.matiere_delete,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/matiere_edit",
    edit_matiere.matiere_edit,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/module_create",
    edit_module.module_create,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/module_delete",
    edit_module.module_delete,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/module_edit",
    edit_module.module_edit,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish("/module_list", edit_module.module_table, Permission.ScoView)
sco_publish("/module_tag_search", sco_tag_module.module_tag_search, Permission.ScoView)


@bp.route("/formation_tag_modules_by_type/<int:formation_id>/<int:semestre_idx>")
@scodoc
@permission_required(Permission.EditFormationTags)
def formation_tag_modules_by_type(formation_id: int, semestre_idx: int):
    """Taggue tous les modules de la formation en fonction de leur type : 'res', 'sae', 'malus'
    Ne taggue pas les modules standards.
    """
    formation = Formation.get_formation(formation_id)
    sco_tag_module.formation_tag_modules_by_type(formation)
    flash("Formation tagguée")
    return flask.redirect(
        url_for(
            "notes.ue_table",
            scodoc_dept=g.scodoc_dept,
            semestre_idx=semestre_idx,
            formation_id=formation.id,
            show_tags=1,
        )
    )


@bp.route("/module_tag_set", methods=["POST"])
@scodoc
@permission_required(Permission.EditFormationTags)
def module_tag_set():  # TODO passer dans l'API
    """Set tags on module"""
    module_id = request.form.get("module_id")
    module: Module = Module.get_instance(module_id)
    taglist = request.form.get("taglist")
    return module.set_tags(taglist)


@bp.route("/module_clone", methods=["POST"])
@scodoc
@permission_required(Permission.EditFormation)
def module_clone():
    """Clone existing module"""
    module_id = request.form.get("module_id")
    module: Module = Module.get_instance(module_id)
    module2 = module.clone()
    db.session.add(module2)
    db.session.commit()
    flash(f"Module {module.code} dupliqué")
    return flask.redirect(
        url_for(
            "notes.ue_table",
            scodoc_dept=g.scodoc_dept,
            semestre_idx=module.semestre_id,
            formation_id=module.formation_id,
        )
    )


#
@bp.route("/")
@bp.route("/index_html", alias=True)
@scodoc
@permission_required(Permission.ScoView)
def index_html():
    "Page accueil formations"
    fmt = request.args.get("fmt", "html")
    detail = scu.to_bool(request.args.get("detail", False))

    editable = current_user.has_permission(Permission.EditFormation)
    table = formation_io.formation_list_table(detail=detail)

    if fmt != "html":
        return table.make_page(fmt=fmt, filename=f"Formations-{g.scodoc_dept}")

    H = [
        f"""<h1>Formations (programmes pédagogiques)</h1>
        <form>
            <input type="checkbox" id="detailCheckbox" name="detail"
                onchange="this.form.submit();"
                {'checked' if detail else ''}>
            <label for="detailCheckbox">Informations détaillées</label>
        </form>
        """,
        table.html(),
    ]
    if editable:
        H.append(
            f"""
    <div class="scobox">
        <div class="help">
            <p>Une "formation" est un programme pédagogique structuré
            en UE, matières et modules.
            Chaque semestre se réfère à une formation.
            La modification d'une formation affecte tous les semestres qui s'y
            réfèrent.
            </p>
        </div>
        <ul class="sco-links">
        <li><a class="stdlink" href="formation_create" id="link-create-formation">Créer une
        formation</a>
        </li>
        <li><a class="stdlink" href="formation_import_xml_form">Importer une formation (xml)</a>
        </li>
        <li><a class="stdlink" href="{
            url_for("notes.export_recap_formations_annee_scolaire",
            scodoc_dept=g.scodoc_dept, annee_scolaire=scu.annee_scolaire()-1)
            }">exporter les formations de l'année scolaire
        {scu.annee_scolaire()-1} - {scu.annee_scolaire()}
        </a>
        </li>
        <li><a class="stdlink" href="{
            url_for("notes.export_recap_formations_annee_scolaire",
            scodoc_dept=g.scodoc_dept, annee_scolaire=scu.annee_scolaire())
            }">exporter les formations de l'année scolaire
        {scu.annee_scolaire()} - {scu.annee_scolaire()+1}
        </a>
        </li>
        </ul>
    </div>

    <div class="scobox">
        <div class="scobox-title">Référentiels de compétences</div>

        <div class="help">
            Les formations par compétences de type BUT doivent être associées à un référentiel
            de compétences définissant leur structure en blocs de compétences.
            Le référentiel doit être chargé avant la définition de la formation s'y référant.
        </div>
        <ul class="sco-links">
        <li><a class="stdlink" href="{
            url_for('notes.refcomp_table', scodoc_dept=g.scodoc_dept)
        }">Liste des référentiels chargés</a>
        </li>
        <li>
            <a class="stdlink" href="{url_for(
            'notes.refcomp_load', scodoc_dept=g.scodoc_dept)
        }">Charger un nouveau référentiel de compétences Orébut</a>
        </li>
        </ul>
    </div>
    """
        )

    return render_template(
        "sco_page_dept.j2",
        content="\n".join(H),
        title="Formations (programmes)",
    )


# --------------------------------------------------------------------
#
#    Notes Methods
#
# --------------------------------------------------------------------

# --- Formations


@bp.route("/formation_export")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formation_export(formation_id, export_ids=False, fmt=None, export_codes_apo=True):
    "Export de la formation au format indiqué (xml ou json)"
    return formation_io.formation_export(
        formation_id,
        export_ids=export_ids,
        fmt=fmt,
        export_codes_apo=export_codes_apo,
    )


@bp.route("/formation_import_xml_form", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EditFormation)
@scodoc7func
def formation_import_xml_form():
    "form import d'une formation en XML"
    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        (("xmlfile", {"input_type": "file", "title": "Fichier XML", "size": 30}),),
        submitlabel="Importer",
        cancelbutton="Annuler",
    )
    if tf[0] == 0:
        return render_template(
            "sco_page_dept.j2",
            title="Import d'une formation",
            content=f"""
        <h2>Import d'une formation</h2>
        <p>Création d'une formation (avec UE, matières, modules)
            à partir un fichier XML (réservé aux utilisateurs avertis).
        </p>
        <p>S'il s'agit d'une formation par compétence (BUT), assurez-vous d'avoir
        chargé le référentiel de compétences AVANT d'importer le fichier formation
        (voir <a class="stdlink" href="{
            url_for("notes.refcomp_table", scodoc_dept=g.scodoc_dept)
        }">page des référentiels</a>).
        </p>
        { tf[1] }
        """,
        )
    elif tf[0] == -1:
        return flask.redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept))
    else:
        formation_id, _, _ = formation_io.formation_import_xml(tf[2]["xmlfile"].read())

        return render_template(
            "sco_page_dept.j2",
            title="Import d'une formation",
            content=f"""
        <h2>Import effectué !</h2>
        <ul>
            <li><a class="stdlink" href="{
            url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation_id
            )}">Voir la formation</a>
            </li>
            <li><a class="stdlink" href="{
            url_for("notes.formation_delete", scodoc_dept=g.scodoc_dept, formation_id=formation_id
            )}">Supprimer cette formation</a>
            (en cas d'erreur, par exemple pour charger auparavant le référentiel de compétences)
            </li>
        </ul>
        """,
        )


sco_publish("/module_move", edit_formation.module_move, Permission.EditFormation)
sco_publish("/ue_move", edit_formation.ue_move, Permission.EditFormation)


@bp.route("/ue_clone", methods=["POST"])
@scodoc
@permission_required(Permission.EditFormation)
def ue_clone():
    """Clone existing UE"""
    ue_id = request.form.get("ue_id")
    ue = UniteEns.get_ue(ue_id)
    _ = ue.clone()
    db.session.commit()
    flash(f"UE {ue.acronyme} dupliquée")
    return flask.redirect(
        url_for(
            "notes.ue_table",
            scodoc_dept=g.scodoc_dept,
            semestre_idx=ue.semestre_idx,
            formation_id=ue.formation_id,
        )
    )


# --- Semestres de formation


@bp.route(
    "/formsemestre_list", methods=["GET", "POST"]
)  # pour compat anciens clients PHP
@scodoc
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def formsemestre_list(
    fmt="json",
    formsemestre_id=None,
    formation_id=None,
    etape_apo=None,
):
    """List formsemestres in given format.
    kw can specify some conditions: examples:
        formsemestre_list( fmt='json', formation_id='F777')
    """
    log("Warning: calling deprecated view formsemestre_list")
    try:
        formsemestre_id = int(formsemestre_id) if formsemestre_id is not None else None
        formation_id = int(formation_id) if formation_id is not None else None
    except ValueError:
        return scu.json_error(404, "invalid id")
    args = {}
    L = locals()
    for argname in ("formsemestre_id", "formation_id", "etape_apo"):
        if L[argname] is not None:
            args[argname] = L[argname]
    sems = sco_formsemestre.do_formsemestre_list(args=args)
    return scu.sendResult(sems, name="formsemestre", fmt=fmt)


sco_publish(
    "/formsemestre_edit_options",
    sco_formsemestre_edit.formsemestre_edit_options,
    Permission.ScoView,
    methods=["GET", "POST"],
)


@bp.route("/formsemestre_flip_lock", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)  # acces vérifié dans la vue
@scodoc7func
def formsemestre_flip_lock(formsemestre_id, dialog_confirmed=False):
    "Changement de l'état de verrouillage du semestre"
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    dest_url = url_for(
        "notes.formsemestre_status",
        scodoc_dept=g.scodoc_dept,
        formsemestre_id=formsemestre.id,
    )
    if not formsemestre.est_chef_or_diretud():
        raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
    if not dialog_confirmed:
        msg = "verrouillage" if formsemestre.etat else "déverrouillage"
        return scu.confirm_dialog(
            f"<h2>Confirmer le {msg} du semestre ?</h2>",
            help_msg="""Les notes d'un semestre verrouillé ne peuvent plus être modifiées.
            Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment
            (par son responsable ou un administrateur).
            <br>
            Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié.
            """,
            dest_url="",
            cancel_url=dest_url,
            parameters={"formsemestre_id": formsemestre_id},
        )

    formsemestre.flip_lock()
    db.session.commit()

    return flask.redirect(dest_url)


sco_publish(
    "/formsemestre_change_publication_bul",
    sco_formsemestre_edit.formsemestre_change_publication_bul,
    Permission.ScoView,
    methods=["GET", "POST"],
)
sco_publish(
    "/view_formsemestre_by_etape",
    sco_formsemestre.view_formsemestre_by_etape,
    Permission.ScoView,
)


@bp.route("/formsemestre_custommenu_edit", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_custommenu_edit(formsemestre_id):
    "Dialogue modif menu"
    # accessible à tous !
    return sco_formsemestre_custommenu.formsemestre_custommenu_edit(formsemestre_id)


# --- dialogue modif enseignants/moduleimpl
@bp.route("/edit_enseignants_form", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def edit_enseignants_form(moduleimpl_id):
    "modif liste enseignants/moduleimpl"
    modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
    modimpl.can_change_ens(raise_exc=True)
    #
    page_title = f"Enseignants du module {modimpl.module.titre or modimpl.module.code}"
    title = f"""<h2>Enseignants du <a class="stdlink" href="{
            url_for("notes.moduleimpl_status",
            scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
        }">module {modimpl.module.titre or modimpl.module.code}</a></h2>"""
    # Liste des enseignants avec forme pour affichage / saisie avec suggestion
    userlist = sco_users.get_user_list()
    uid2display = {}  # uid : forme pour affichage = "NOM Prenom (login)"(login)"
    for u in userlist:
        uid2display[u.id] = u.get_nomplogin()
        allowed_user_names = list(uid2display.values())

    H = [
        f"""<ul><li><b>{
            uid2display.get(modimpl.responsable_id, modimpl.responsable_id)
        }</b> (responsable)</li>"""
    ]
    u: User
    for u in modimpl.enseignants:
        H.append(
            f"""
            <li>{u.get_nomcomplet()} (<a class="stdlink" href="{
                url_for('notes.edit_enseignants_form_delete',
                scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id,
                ens_id=u.id)
                }">supprimer</a>)
            </li>"""
        )
    H.append("</ul>")
    F = f"""<div class="help space-before-18">
    <p>Les enseignants d'un module ont le droit de
    saisir et modifier toutes les notes des évaluations de ce module.
    </p>
    <p class="help">Pour changer le responsable du module, passez par la
    page "<a class="stdlink" href="{
        url_for("notes.formsemestre_editwithmodules", scodoc_dept=g.scodoc_dept,
            formsemestre_id=modimpl.formsemestre_id)
        }">Modification du semestre</a>",
    accessible uniquement au responsable de la formation (chef de département)
    </p>
    </div>
    """

    modform = [
        ("moduleimpl_id", {"input_type": "hidden"}),
        (
            "ens_id",
            {
                "input_type": "text_suggest",
                "size": 50,
                "title": "Ajouter un enseignant",
                "allowed_values": allowed_user_names,
                "allow_null": False,
                "text_suggest_options": {
                    "script": url_for(
                        "users.get_user_list_xml", scodoc_dept=g.scodoc_dept
                    )
                    + "?",
                    "varname": "start",
                    "json": False,
                    "noresults": "Valeur invalide !",
                    "timeout": 60000,
                },
            },
        ),
    ]
    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        modform,
        submitlabel="Ajouter enseignant",
        cancelbutton="Annuler",
    )
    if tf[0] == 0:
        return render_template(
            "sco_page.j2",
            title=page_title,
            content=title + "\n".join(H) + tf[1] + F,
            javascripts=["libjs/AutoSuggest.js"],
            cssstyles=["css/autosuggest_inquisitor.css"],
        )
    elif tf[0] == -1:
        return flask.redirect(
            url_for(
                "notes.moduleimpl_status",
                scodoc_dept=g.scodoc_dept,
                moduleimpl_id=moduleimpl_id,
            )
        )
    else:
        ens = User.get_user_from_nomplogin(tf[2]["ens_id"])
        if ens is None:
            H.append(
                '<p class="help">Pour ajouter un enseignant, choisissez un nom dans le menu</p>'
            )
        else:
            # et qu'il n'est pas deja:
            if (
                ens.id in (x.id for x in modimpl.enseignants)
                or ens.id == modimpl.responsable_id
            ):
                H.append(
                    scu.html_flash_message(
                        f"Enseignant {ens.user_name} déjà dans la liste !"
                    )
                )
            else:
                modimpl.enseignants.append(ens)
                db.session.add(modimpl)
                db.session.commit()
                return flask.redirect(
                    url_for(
                        "notes.edit_enseignants_form",
                        scodoc_dept=g.scodoc_dept,
                        moduleimpl_id=moduleimpl_id,
                    )
                )
        return render_template(
            "sco_page.j2",
            title=page_title,
            content=title + "\n".join(H) + tf[1] + F,
            javascripts=["libjs/AutoSuggest.js"],
            cssstyles=["css/autosuggest_inquisitor.css"],
        )


@bp.route("/edit_moduleimpl_resp", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def edit_moduleimpl_resp(moduleimpl_id: int):
    """Changement d'un enseignant responsable de module
    Accessible par Admin et dir des etud si flag resp_can_change_ens
    """
    modimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
    modimpl.can_change_responsable(current_user, raise_exc=True)  # access control
    H = [
        f"""<h2 class="formsemestre">Modification du responsable du
         <a class="stdlink" href="{
                url_for("notes.moduleimpl_status",
                    scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
            }">module {modimpl.module.titre or ""}</a></h2>"""
    ]

    help_str = """<p class="help">Taper le début du nom de l'enseignant.</p>"""
    # Liste des enseignants avec forme pour affichage / saisie avec suggestion
    userlist = [sco_users.user_info(user=u) for u in sco_users.get_user_list()]
    uid2display = {}  # uid : forme pour affichage = "NOM Prenom (login)"
    for u in userlist:
        uid2display[u["id"]] = u["nomplogin"]
    allowed_user_names = list(uid2display.values())

    initvalues = modimpl.to_dict(with_module=False)
    initvalues["responsable_id"] = uid2display.get(
        modimpl.responsable_id, modimpl.responsable_id
    )
    form = [
        ("moduleimpl_id", {"input_type": "hidden"}),
        (
            "responsable_id",
            {
                "input_type": "text_suggest",
                "size": 50,
                "title": "Responsable du module",
                "allowed_values": allowed_user_names,
                "allow_null": False,
                "text_suggest_options": {
                    "script": url_for(
                        "users.get_user_list_xml", scodoc_dept=g.scodoc_dept
                    )
                    + "?",
                    "varname": "start",
                    "json": False,
                    "noresults": "Valeur invalide !",
                    "timeout": 60000,
                },
            },
        ),
    ]
    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        form,
        submitlabel="Changer responsable",
        cancelbutton="Annuler",
        initvalues=initvalues,
    )
    if tf[0] == 0:
        return render_template(
            "sco_page.j2",
            content="\n".join(H) + tf[1] + help_str,
            title="Modification responsable module",
            javascripts=["libjs/AutoSuggest.js"],
            cssstyles=["css/autosuggest_inquisitor.css"],
        )
    elif tf[0] == -1:
        return flask.redirect(
            url_for(
                "notes.moduleimpl_status",
                scodoc_dept=g.scodoc_dept,
                moduleimpl_id=moduleimpl_id,
            )
        )
    else:
        responsable = User.get_user_from_nomplogin(tf[2]["responsable_id"])
        if not responsable:
            # presque impossible: tf verifie les valeurs (mais qui peuvent changer entre temps)
            return flask.redirect(
                url_for(
                    "notes.moduleimpl_status",
                    scodoc_dept=g.scodoc_dept,
                    moduleimpl_id=moduleimpl_id,
                )
            )

        modimpl.responsable = responsable
        db.session.add(modimpl)
        db.session.commit()
        flash("Responsable modifié")
        return flask.redirect(
            url_for(
                "notes.moduleimpl_status",
                scodoc_dept=g.scodoc_dept,
                moduleimpl_id=moduleimpl_id,
            )
        )


@bp.route("/view_module_abs")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def view_module_abs(moduleimpl_id, fmt="html"):
    """Visualisation des absences a un module"""
    modimpl: ModuleImpl = (
        ModuleImpl.query.filter_by(id=moduleimpl_id)
        .join(FormSemestre)
        .filter_by(dept_id=g.scodoc_dept_id)
    ).first_or_404()

    inscrits: list[Identite] = sorted(
        [i.etud for i in modimpl.inscriptions], key=lambda e: e.sort_key
    )

    rows = []
    for etud in inscrits:
        (
            nb_abs_nj,
            nb_abs_just,
            nb_abs,
        ) = sco_assiduites.formsemestre_get_assiduites_count(
            etud.id, modimpl.formsemestre, moduleimpl_id=modimpl.id
        )
        rows.append(
            {
                "civilite": etud.civilite_str,
                "nom": etud.nom_disp(),
                "prenom": etud.prenom_str,
                "just": nb_abs_just,
                "nojust": nb_abs_nj,
                "total": nb_abs,
                "_nom_target": url_for(
                    "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
                ),
            }
        )

    content = f"""
        <h2>Absences du <a href="{
            url_for("notes.moduleimpl_status",
                scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id
            )}">module {modimpl.module.titre_str()}</a></h2>"""
    if not rows and fmt == "html":
        content += "<p>Aucune absence signalée</p>"

    tab = GenTable(
        titles={
            "civilite": "Civ.",
            "nom": "Nom",
            "prenom": "Prénom",
            "just": "Just.",
            "nojust": "Non Just.",
            "total": "Total",
        },
        columns_ids=("civilite", "nom", "prenom", "just", "nojust", "total"),
        rows=rows,
        html_class="table_leftalign",
        base_url=f"{request.base_url}?moduleimpl_id={moduleimpl_id}",
        filename="absmodule_" + scu.make_filename(modimpl.module.titre_str()),
        caption=f"Absences dans le module {modimpl.module.titre_str()}",
        preferences=sco_preferences.SemPreferences(),
        table_id="view_module_abs",
    )

    if fmt != "html":
        return tab.make_page(fmt=fmt)

    if not tab.is_empty():
        content += tab.html()

    return render_template(
        "sco_page.j2",
        content=content,
        title=f"Absences du module {modimpl.module.titre_str()}",
    )


@bp.route("/delete_ue_expr/<int:formsemestre_id>/<int:ue_id>", methods=["GET", "POST"])
@scodoc
def delete_ue_expr(formsemestre_id: int, ue_id: int):
    """Efface une expression de calcul d'UE"""
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    if not formsemestre.can_be_edited_by(current_user):
        raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération")
    expr = FormSemestreUEComputationExpr.query.filter_by(
        formsemestre_id=formsemestre_id, ue_id=ue_id
    ).first()
    if expr is not None:
        db.session.delete(expr)
        db.session.commit()
        flash("formule supprimée")
    return flask.redirect(
        url_for(
            "notes.formsemestre_status",
            scodoc_dept=g.scodoc_dept,
            formsemestre_id=formsemestre_id,
        )
    )


@bp.route("/formsemestre_enseignants_list")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_enseignants_list(formsemestre_id, fmt="html"):
    """Liste les enseignants intervenants dans le semestre (resp. modules et chargés de TD)
    et indique les absences saisies par chacun.
    """
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    # resp. de modules et charges de TD
    # uid : { "mods" : liste des modimpls, ... }
    sem_ens: dict[int, list[ModuleImpl]] = {}
    modimpls = formsemestre.modimpls_sorted
    for modimpl in modimpls:
        if not modimpl.responsable_id in sem_ens:
            sem_ens[modimpl.responsable_id] = {"mods": [modimpl]}
        else:
            sem_ens[modimpl.responsable_id]["mods"].append(modimpl)

        for enseignant in modimpl.enseignants:
            if not enseignant.id in sem_ens:
                sem_ens[enseignant.id] = {"mods": [modimpl]}
            else:
                sem_ens[enseignant.id]["mods"].append(modimpl)
    # compte les absences ajoutées par chacun dans tout le semestre
    for uid, info in sem_ens.items():
        # Note : avant 9.6, on utilisait Scolog pour compter les opérations AddAbsence
        # ici on compte directement les assiduités
        info["nbabsadded"] = (
            Assiduite.query.filter_by(user_id=uid, etat=scu.EtatAssiduite.ABSENT)
            .filter(
                Assiduite.date_debut >= formsemestre.date_debut,
                Assiduite.date_debut <= formsemestre.date_fin,
            )
            .join(Identite)
            .join(FormSemestreInscription)
            .filter_by(formsemestre_id=formsemestre.id)
            .count()
        )
    # description textuelle des modules
    for uid, info in sem_ens.items():
        info["descr_mods"] = ", ".join(
            [modimpl.module.code for modimpl in sem_ens[uid]["mods"]]
        )

    # ajoute infos sur enseignant:
    for uid, info in sem_ens.items():
        user: User = db.session.get(User, uid)
        if user:
            if user.email:
                info["email"] = user.email
                info["_email_target"] = f"mailto:{user.email}"
            info["nom_fmt"] = user.get_nom_fmt()
            info["prenom_fmt"] = user.get_prenom_fmt()
            info["sort_key"] = user.sort_key()

    sem_ens_list = list(sem_ens.values())
    sem_ens_list.sort(key=itemgetter("sort_key"))

    # --- Generate page with table
    title = f"Enseignants de {formsemestre.titre_mois()}"
    table = GenTable(
        columns_ids=["nom_fmt", "prenom_fmt", "descr_mods", "nbabsadded", "email"],
        titles={
            "nom_fmt": "Nom",
            "prenom_fmt": "Prénom",
            "email": "Mail",
            "descr_mods": "Modules",
            "nbabsadded": "Saisies Abs.",
        },
        rows=sem_ens_list,
        html_sortable=True,
        html_class="table_leftalign formsemestre_enseignants_list",
        html_with_td_classes=True,
        filename=scu.make_filename(f"Enseignants-{formsemestre.titre_annee()}"),
        html_title="""<h2 class="formsemestre">Enseignants du semestre</h2>""",
        base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
        caption="""Tous les enseignants (responsables ou associés aux modules de
        ce semestre) apparaissent. Le nombre de saisies d'absences est indicatif.""",
        preferences=sco_preferences.SemPreferences(formsemestre_id),
        table_id="formsemestre_enseignants_list",
    )
    return table.make_page(page_title=title, title=title, fmt=fmt)


@bp.route("/edit_enseignants_form_delete", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def edit_enseignants_form_delete(moduleimpl_id, ens_id: int):
    """remove ens from this modueimpl

    ens_id:  user.id
    """
    modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
    modimpl.can_change_ens(raise_exc=True)
    # search ens_id
    ens: User | None = None
    for ens in modimpl.enseignants:
        if ens.id == ens_id:
            break
    if ens is None:
        raise ScoValueError(f"invalid ens_id ({ens_id})")
    modimpl.enseignants.remove(ens)
    db.session.commit()
    return flask.redirect(
        url_for(
            "notes.edit_enseignants_form",
            scodoc_dept=g.scodoc_dept,
            moduleimpl_id=moduleimpl_id,
        )
    )


# --- Gestion des inscriptions aux semestres

sco_publish(
    "/do_formsemestre_inscription_list",
    sco_formsemestre_inscriptions.do_formsemestre_inscription_list,
    Permission.ScoView,
)


@bp.route("/do_formsemestre_inscription_listinscrits")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def do_formsemestre_inscription_listinscrits(formsemestre_id, fmt=None):
    """Liste les inscrits (état I) à ce semestre et cache le résultat"""
    r = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits(
        formsemestre_id
    )
    return scu.sendResult(r, fmt=fmt, name="inscrits")


@bp.route("/formsemestre_desinscription", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EditFormSemestre)
@scodoc7func
def formsemestre_desinscription(etudid, formsemestre_id, dialog_confirmed=False):
    """désinscrit l'etudiant de ce semestre (et donc de tous les modules).
    A n'utiliser qu'en cas d'erreur de saisie.
    S'il s'agit d'un semestre extérieur et qu'il n'y a plus d'inscrit,
    le semestre sera supprimé.
    """
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    sem = formsemestre.to_dict()  # compat
    # -- check lock
    if not formsemestre.etat:
        raise ScoValueError("desinscription impossible: semestre verrouille")

    # -- Si décisions de jury, désinscription interdite
    nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
    if nt.etud_has_decision(etudid):
        raise ScoValueError(
            f"""Désinscription impossible: l'étudiant a une décision de jury
            (la supprimer avant si nécessaire avec
            <a class="stdlink" href="{
                url_for("notes.formsemestre_validation_suppress_etud",
                scodoc_dept=g.scodoc_dept, etudid=etudid,
                formsemestre_id=formsemestre_id)
            }">supprimer décision jury</a>)
            """,
            safe=True,
        )
    if not dialog_confirmed:
        etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
        if formsemestre.modalite != "EXT":
            msg_ext = """
            <p>%s sera désinscrit de tous les modules du semestre %s (%s - %s).</p>
            <p>Cette opération ne doit être utilisée que pour corriger une <b>erreur</b> !
            Un étudiant réellement inscrit doit le rester, le faire éventuellement <b>démissionner<b>.
            </p>
            """ % (
                etud["nomprenom"],
                sem["titre_num"],
                sem["date_debut"],
                sem["date_fin"],
            )
        else:  # semestre extérieur
            msg_ext = """
            <p>%s sera désinscrit du semestre extérieur %s (%s - %s).</p>
            """ % (
                etud["nomprenom"],
                sem["titre_num"],
                sem["date_debut"],
                sem["date_fin"],
            )
            inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
                args={"formsemestre_id": formsemestre_id}
            )
            nbinscrits = len(inscrits)
            if nbinscrits <= 1:
                msg_ext = """<p class="warning">Attention: le semestre extérieur
                sera supprimé car il n'a pas d'autre étudiant inscrit.
                </p>
                """
        return scu.confirm_dialog(
            """<h2>Confirmer la demande de désinscription ?</h2>""" + msg_ext,
            dest_url="",
            cancel_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            ),
            parameters={"etudid": etudid, "formsemestre_id": formsemestre_id},
        )

    sco_formsemestre_inscriptions.do_formsemestre_desinscription(
        etudid, formsemestre_id
    )

    flash("Étudiant désinscrit")
    return redirect(
        url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
    )


sco_publish(
    "/do_formsemestre_desinscription",
    sco_formsemestre_inscriptions.do_formsemestre_desinscription,
    Permission.EtudInscrit,
    methods=["GET", "POST"],
)


@bp.route(
    "/etud_desinscrit_ue/<int:etudid>/<int:formsemestre_id>/<int:ue_id>",
    methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.EtudInscrit)
def etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
    """
    - En classique: désinscrit l'etudiant de tous les modules de cette UE dans ce semestre.
    - En APC: dispense de l'UE indiquée.
    """
    etud = Identite.get_etud(etudid)
    ue = UniteEns.query.get_or_404(ue_id)
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    if ue.formation.is_apc():
        if (
            DispenseUE.query.filter_by(
                formsemestre_id=formsemestre_id, etudid=etudid, ue_id=ue_id
            ).count()
            == 0
        ):
            disp = DispenseUE(
                formsemestre_id=formsemestre_id, ue_id=ue_id, etudid=etudid
            )
            db.session.add(disp)
            db.session.commit()
            log(f"etud_desinscrit_ue {etud} {ue}")
            Scolog.logdb(
                method="etud_desinscrit_ue",
                etudid=etud.id,
                msg=f"Désinscription de l'UE {ue.acronyme} de {formsemestre.titre_annee()}",
                commit=True,
            )
        sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
    else:
        sco_moduleimpl_inscriptions.do_etud_desinscrit_ue_classic(
            etudid, formsemestre_id, ue_id
        )
    flash(f"{etud.nomprenom} déinscrit de {ue.acronyme}")
    return flask.redirect(
        url_for(
            "notes.moduleimpl_inscriptions_stats",
            scodoc_dept=g.scodoc_dept,
            formsemestre_id=formsemestre_id,
        )
    )


@bp.route(
    "/etud_inscrit_ue/<int:etudid>/<int:formsemestre_id>/<int:ue_id>",
    methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.EtudInscrit)
def etud_inscrit_ue(etudid, formsemestre_id, ue_id):
    """
    En classic: inscrit l'étudiant à tous les modules de cette UE dans ce semestre.
    En APC: enlève la dispense de cette UE s'il y en a une.
    """
    formsemestre = FormSemestre.query.filter_by(
        id=formsemestre_id, dept_id=g.scodoc_dept_id
    ).first_or_404()
    etud = Identite.get_etud(etudid)
    ue = UniteEns.query.get_or_404(ue_id)
    if ue.formation.is_apc():
        for disp in DispenseUE.query.filter_by(
            formsemestre_id=formsemestre_id, etudid=etud.id, ue_id=ue_id
        ):
            db.session.delete(disp)
            log(f"etud_inscrit_ue {etud} {ue}")
            Scolog.logdb(
                method="etud_inscrit_ue",
                etudid=etud.id,
                msg=f"Inscription à l'UE {ue.acronyme} de {formsemestre.titre_annee()}",
                commit=True,
            )
        db.session.commit()
        sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
    else:
        # Formations classiques: joue sur les inscriptions aux modules
        sco_moduleimpl_inscriptions.do_etud_inscrit_ue(etud.id, formsemestre_id, ue_id)
    return flask.redirect(
        url_for(
            "notes.moduleimpl_inscriptions_stats",
            scodoc_dept=g.scodoc_dept,
            formsemestre_id=formsemestre_id,
        )
    )


# --- Inscriptions
sco_publish(
    "/formsemestre_inscription_with_modules_form",
    sco_formsemestre_inscriptions.formsemestre_inscription_with_modules_form,
    Permission.EtudInscrit,
)
sco_publish(
    "/formsemestre_inscription_with_modules_etud",
    sco_formsemestre_inscriptions.formsemestre_inscription_with_modules_etud,
    Permission.EtudInscrit,
)
sco_publish(
    "/formsemestre_inscription_with_modules",
    sco_formsemestre_inscriptions.formsemestre_inscription_with_modules,
    Permission.EtudInscrit,
)
sco_publish(
    "/formsemestre_inscription_option",
    sco_formsemestre_inscriptions.formsemestre_inscription_option,
    Permission.EtudInscrit,
    methods=["GET", "POST"],
)
sco_publish(
    "/do_moduleimpl_incription_options",
    sco_formsemestre_inscriptions.do_moduleimpl_incription_options,
    Permission.EtudInscrit,
)
sco_publish(
    "/formsemestre_inscrits_ailleurs",
    sco_formsemestre_inscriptions.formsemestre_inscrits_ailleurs,
    Permission.ScoView,
)
sco_publish(
    "/moduleimpl_inscriptions_edit",
    sco_moduleimpl_inscriptions.moduleimpl_inscriptions_edit,
    Permission.ScoView,
    methods=["GET", "POST"],
)
sco_publish(
    "/moduleimpl_inscriptions_stats",
    sco_moduleimpl_inscriptions.moduleimpl_inscriptions_stats,
    Permission.ScoView,
)


# --- Evaluations


@bp.route("/evaluation_delete", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EnsView)
@scodoc7func
def evaluation_delete(evaluation_id):
    """Form delete evaluation"""
    evaluation: Evaluation = (
        Evaluation.query.filter_by(id=evaluation_id)
        .join(ModuleImpl)
        .join(FormSemestre)
        .filter_by(dept_id=g.scodoc_dept_id)
        .first_or_404()
    )

    tit = f"""Suppression de l'évaluation {evaluation.description} ({evaluation.descr_date()})"""
    etat = sco_evaluations.do_evaluation_etat(evaluation.id)
    H = [
        f"""
        <h2 class="formsemestre">Module <tt>{evaluation.moduleimpl.module.code}</tt>
            {evaluation.moduleimpl.module.titre_str()}</h2>
            <h3>{tit}</h3>
                <p class="help">Opération <span class="redboldtext">irréversible</span>.
                Si vous supprimez l'évaluation, vous ne pourrez pas retrouver les notes associées.
                </p>
        """,
    ]
    warning = False
    if etat["nb_notes_total"]:
        warning = True
        nb_desinscrits = etat["nb_notes_total"] - etat["nb_notes"]
        H.append(
            f"""<div class="ue_warning"><span>Il y a {etat["nb_notes_total"]} notes"""
        )
        if nb_desinscrits:
            H.append(
                """ (dont {nb_desinscrits} d'étudiants qui ne sont plus inscrits)"""
            )
        H.append(""" dans l'évaluation</span>""")
        if etat["nb_notes"] == 0:
            H.append(
                """<p>Vous pouvez quand même supprimer l'évaluation, les notes des étudiants désincrits seront effacées.</p>"""
            )

    if etat["nb_notes"]:
        H.append(
            f"""<p>Suppression impossible (effacer les notes d'abord)</p>
            <p><a class="stdlink" href="{
                url_for("notes.moduleimpl_status",
                        scodoc_dept=g.scodoc_dept, moduleimpl_id=evaluation.moduleimpl_id)
                }">retour au tableau de bord du module</a>
            </p>
            </div>"""
        )
        return render_template("sco_page.j2", title=tit, content="\n".join(H))
    if warning:
        H.append("""</div>""")

    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        (("evaluation_id", {"input_type": "hidden"}),),
        initvalues={"evaluation_id": evaluation.id},
        submitlabel="Confirmer la suppression",
        cancelbutton="Annuler",
    )
    if tf[0] == 0:
        return render_template("sco_page.j2", title=tit, content="\n".join(H) + tf[1])
    elif tf[0] == -1:
        return flask.redirect(
            url_for(
                "notes.moduleimpl_status",
                scodoc_dept=g.scodoc_dept,
                moduleimpl_id=evaluation.moduleimpl_id,
            )
        )
    else:
        evaluation.delete()
        return render_template(
            "sco_page.j2",
            title=tit,
            content="\n".join(H)
            + f"""<p>OK, évaluation supprimée.</p>
        <p><a class="stdlink" href="{
            url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept,
            moduleimpl_id=evaluation.moduleimpl_id)
            }">Continuer</a></p>""",
        )


@bp.route("/evaluation_edit", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EnsView)
@scodoc7func
def evaluation_edit(evaluation_id):
    "form edit evaluation"
    return sco_evaluation_edit.evaluation_create_form(
        evaluation_id=evaluation_id, edit=True
    )


@bp.route("/evaluation_create", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EnsView)
@scodoc7func
def evaluation_create(moduleimpl_id):
    "form create evaluation"
    modimpl = db.session.get(ModuleImpl, moduleimpl_id)
    if modimpl is None:
        raise ScoValueError("Ce module n'existe pas ou plus !")
    return sco_evaluation_edit.evaluation_create_form(
        moduleimpl_id=moduleimpl_id, edit=False
    )


@bp.route("/evaluation_listenotes")
@scodoc
@permission_required_compat_scodoc7(Permission.ScoView)
def evaluation_listenotes():
    """Affichage des notes d'une évaluation.
    Args:
    - evaluation_id (une seule éval)
    - ou moduleimpl_id (toutes les évals du module)
    - group_ids: groupes à lister
    - fmt : html, xls, pdf, json
    """
    # Arguments
    evaluation_id = request.args.get("evaluation_id")
    moduleimpl_id = request.args.get("moduleimpl_id")
    try:
        if evaluation_id is not None:
            evaluation_id = int(evaluation_id)
        if moduleimpl_id is not None:
            moduleimpl_id = int(moduleimpl_id)
    except ValueError as exc:
        raise ScoValueError("evaluation_listenotes: id invalides !") from exc
    fmt = request.args.get("fmt", "html")
    #
    content, page_title = sco_liste_notes.do_evaluation_listenotes(
        evaluation_id=evaluation_id, moduleimpl_id=moduleimpl_id, fmt=fmt
    )
    if fmt == "html":
        return render_template(
            "sco_page.j2",
            content=content,
            title=page_title,
            cssstyles=["css/verticalhisto.css"],
            javascripts=["js/groups_view.js"],
        )
    return content


sco_publish(
    "/evaluation_list_operations",
    sco_undo_notes.evaluation_list_operations,
    Permission.ScoView,
)


@bp.route("/evaluation_check_absences_html/<int:evaluation_id>")
@scodoc
@permission_required(Permission.ScoView)
def evaluation_check_absences_html(evaluation_id: int):
    "Check absences sur une évaluation"
    evaluation: Evaluation = (
        Evaluation.query.filter_by(id=evaluation_id)
        .join(ModuleImpl)
        .join(FormSemestre)
        .filter_by(dept_id=g.scodoc_dept_id)
        .first_or_404()
    )
    return sco_evaluation_check_abs.evaluation_check_absences_html(evaluation)


sco_publish(
    "/formsemestre_check_absences_html",
    sco_evaluation_check_abs.formsemestre_check_absences_html,
    Permission.ScoView,
)

# --- Placement des étudiants pour l'évaluation
sco_publish(
    "/placement_eval_selectetuds",
    sco_placement.placement_eval_selectetuds,
    Permission.EnsView,
    methods=["GET", "POST"],
)

# --- Saisie des notes
sco_publish(
    "/saisie_notes_tableur",
    sco_saisie_excel.saisie_notes_tableur,
    Permission.EnsView,
    methods=["GET", "POST"],
)
sco_publish(
    "/feuille_saisie_notes",
    sco_saisie_excel.feuille_saisie_notes,
    Permission.EnsView,
)
sco_publish(
    "/do_evaluation_set_missing",
    sco_saisie_notes.do_evaluation_set_missing,
    Permission.EnsView,
    methods=["GET", "POST"],
)
sco_publish(
    "/evaluation_suppress_alln",
    sco_saisie_notes.evaluation_suppress_alln,
    Permission.ScoView,
    methods=["GET", "POST"],
)


@bp.route("/form_saisie_notes/<int:evaluation_id>")
@scodoc
@permission_required(Permission.EnsView)  # + controle contextuel
def form_saisie_notes(evaluation_id: int):
    "Formulaire de saisie des notes d'une évaluation"
    evaluation = Evaluation.get_evaluation(evaluation_id)
    group_ids = request.args.getlist("group_ids")
    try:
        group_ids = [int(gid) for gid in group_ids]
    except ValueError as exc:
        raise ScoValueError("group_ids invalide") from exc
    return sco_saisie_notes.saisie_notes(evaluation, group_ids)


@bp.route("/formsemestre_import_notes/<int:formsemestre_id>", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)  # controle contextuel
def formsemestre_import_notes(formsemestre_id: int | None = None):
    "Import via excel des notes de toutes les évals d'un semestre."
    return _formsemestre_or_modimpl_import_notes(formsemestre_id=formsemestre_id)


@bp.route(
    "/moduleimpl_import_notes/<int:moduleimpl_id>",
    methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.ScoView)  # controle contextuel
def moduleimpl_import_notes(moduleimpl_id: int | None = None):
    "Import via excel des notes de toutes les évals d'un module."
    return _formsemestre_or_modimpl_import_notes(moduleimpl_id=moduleimpl_id)


def _formsemestre_or_modimpl_import_notes(
    formsemestre_id: int | None = None, moduleimpl_id: int | None = None
):
    """Import via excel des notes de toutes les évals d'un semestre.
    Ou, si moduleimpl_import_notes, toutes les évals de ce module.
    """
    formsemestre = (
        FormSemestre.get_formsemestre(formsemestre_id)
        if formsemestre_id is not None
        else None
    )
    modimpl = (
        ModuleImpl.get_modimpl(moduleimpl_id) if moduleimpl_id is not None else None
    )
    if not (formsemestre or modimpl):
        raise ScoValueError("paramètre manquant")
    dest_url = (
        url_for(
            "notes.moduleimpl_status",
            scodoc_dept=g.scodoc_dept,
            moduleimpl_id=modimpl.id,
        )
        if modimpl
        else url_for(
            "notes.formsemestre_status",
            scodoc_dept=g.scodoc_dept,
            formsemestre_id=formsemestre.id,
        )
    )
    if formsemestre and not formsemestre.est_chef_or_diretud():
        raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
    if modimpl and not modimpl.can_edit_notes(current_user):
        raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)

    class ImportForm(FlaskForm):
        notefile = FileField(
            "Fichier d'import",
            validators=[
                DataRequired(),
                FileAllowed(["xlsx"], "Fichier xlsx seulement !"),
            ],
        )
        comment = StringField("Commentaire", validators=[Length(max=256)])
        submit = SubmitField("Télécharger")

    form = ImportForm()
    if form.validate_on_submit():
        # Handle file upload and form processing
        notefile = form.notefile.data
        comment = form.comment.data
        #
        return sco_saisie_excel.formsemestre_import_notes(
            formsemestre=formsemestre,
            modimpl=modimpl,
            notefile=notefile,
            comment=comment,
        )

    return render_template(
        "formsemestre/import_notes.j2",
        evaluations=(
            formsemestre.get_evaluations()
            if formsemestre
            else modimpl.evaluations.all()
        ),
        form=form,
        formsemestre=formsemestre,
        modimpl=modimpl,
        title="Importation des notes",
        sco=ScoData(formsemestre=formsemestre),
    )


@bp.route("/formsemestre_feuille_import_notes/<int:formsemestre_id>")
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_feuille_import_notes(formsemestre_id: int):
    """Feuille excel pour importer les notes de toutes les évaluations du semestre"""
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    xls = sco_saisie_excel.excel_feuille_import(formsemestre=formsemestre)
    filename = scu.sanitize_filename(formsemestre.titre_annee())
    return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)


@bp.route("/moduleimpl_feuille_import_notes/<int:moduleimpl_id>")
@scodoc
@permission_required(Permission.ScoView)
def moduleimpl_feuille_import_notes(moduleimpl_id: int):
    """Feuille excel pour importer les notes de toutes les évaluations du modimpl"""
    modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
    xls = sco_saisie_excel.excel_feuille_import(modimpl=modimpl)
    filename = scu.sanitize_filename(
        f"{modimpl.module.code} {modimpl.formsemestre.annee_scolaire_str()}"
    )
    return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)


# --- Bulletins
@bp.route("/formsemestre_bulletins_pdf")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_bulletins_pdf(
    formsemestre_id,
    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
    version="selectedevals",
):
    "Publie les bulletins dans un classeur PDF"
    # Informations sur les groupes à utiliser:
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
    )
    pdfdoc, filename = sco_bulletins_pdf.get_formsemestre_bulletins_pdf(
        formsemestre_id, groups_infos=groups_infos, version=version
    )
    return scu.sendPDFFile(pdfdoc, filename)


_EXPL_BULL = """Versions des bulletins:
<ul>
<li><bf>courte</bf>: moyennes des modules (en BUT: seulement les moyennes d'UE)</li>
<li><bf>intermédiaire</bf>: moyennes des modules et notes des évaluations sélectionnées</li>
<li><bf>complète</bf>: toutes les notes</li>
</ul>"""


@bp.route("/formsemestre_bulletins_pdf_choice")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_bulletins_pdf_choice(
    formsemestre_id,
    version=None,
    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
):
    """Choix version puis envoi classeur bulletins pdf"""
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    # Informations sur les groupes à utiliser:
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
    )
    if version:
        pdfdoc, filename = sco_bulletins_pdf.get_formsemestre_bulletins_pdf(
            formsemestre_id, groups_infos=groups_infos, version=version
        )
        return scu.sendPDFFile(pdfdoc, filename)
    return _formsemestre_bulletins_choice(
        formsemestre,
        explanation=_EXPL_BULL,
        groups_infos=groups_infos,
        title="Choisir la version des bulletins à générer",
    )


@bp.route("/etud_bulletins_pdf")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def etud_bulletins_pdf(etudid, version="selectedevals"):
    "Publie tous les bulletins d'un etudiants dans un classeur PDF"
    if version not in scu.BULLETINS_VERSIONS:
        raise ScoValueError("etud_bulletins_pdf: version de bulletin demandée invalide")
    pdfdoc, filename = sco_bulletins_pdf.get_etud_bulletins_pdf(etudid, version=version)
    return scu.sendPDFFile(pdfdoc, filename)


@bp.route("/formsemestre_bulletins_mailetuds_choice")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_bulletins_mailetuds_choice(
    formsemestre_id,
    version=None,
    dialog_confirmed=False,
    prefer_mail_perso=0,
    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
):
    """Choix version puis envoi classeur bulletins pdf"""
    # Informations sur les groupes à utiliser:
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
    )
    if version:
        return flask.redirect(
            url_for(
                "notes.formsemestre_bulletins_mailetuds",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                version=version,
                dialog_confirmed=int(dialog_confirmed),
                prefer_mail_perso=prefer_mail_perso,
                group_ids=groups_infos.group_ids,
            )
        )
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    expl_bull = """Versions des bulletins:
        <ul>
            <li><b>courte</b>: moyennes des modules</li>
            <li><b>intermédiaire</b>: moyennes des modules et notes des évaluations sélectionnées</li>
            <li><b>complète</b>: toutes les notes</li>
        """
    if formsemestre.formation.is_apc():
        expl_bull += """
            <li><b>courte spéciale BUT</b>: un résumé en une page pour les BUTs</li>
        """
    expl_bull += "</ul>"
    return _formsemestre_bulletins_choice(
        formsemestre,
        title="Choisir la version des bulletins à envoyer par mail",
        explanation="""Chaque étudiant (non démissionnaire ni défaillant)
            ayant une adresse mail connue de ScoDoc
            recevra une copie PDF de son bulletin de notes, dans la version choisie.
            </p><p>"""
        + expl_bull,
        choose_mail=True,
        groups_infos=groups_infos,
    )


# not published
def _formsemestre_bulletins_choice(
    formsemestre: FormSemestre,
    title="",
    explanation="",
    choose_mail=False,
    groups_infos=None,
):
    """Choix d'une version de bulletin
    (pour envois mail ou génération classeur pdf)
    """
    versions_bulletins = (
        scu.BULLETINS_VERSIONS_BUT
        if formsemestre.formation.is_apc()
        else scu.BULLETINS_VERSIONS
    )

    return render_template(
        "formsemestre/bulletins_choice.j2",
        explanation=explanation,
        choose_mail=choose_mail,
        formsemestre=formsemestre,
        menu_groups_choice=sco_groups_view.menu_groups_choice(
            groups_infos, submit_on_change=True
        ),
        sco=ScoData(formsemestre=formsemestre),
        sco_groups_view=sco_groups_view,
        title=title,
        versions_bulletins=versions_bulletins,
    )


@bp.route("/formsemestre_bulletins_mailetuds", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_bulletins_mailetuds(
    formsemestre_id,
    version="long",
    dialog_confirmed=False,
    prefer_mail_perso=0,
    group_ids: list[int] = None,  # si indiqué, ne prend que ces groupes
):
    """Envoie à chaque etudiant son bulletin
    (inscrit non démissionnaire ni défaillant et ayant un mail renseigné dans ScoDoc)
    """
    groups_infos = sco_groups_view.DisplayedGroupsInfos(
        group_ids,
        formsemestre_id=formsemestre_id,
        select_all_when_unspecified=True,
    )
    etudids = {m["etudid"] for m in groups_infos.members}
    prefer_mail_perso = int(prefer_mail_perso)
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    inscriptions = [
        inscription
        for inscription in formsemestre.inscriptions
        if inscription.etat == scu.INSCRIT and inscription.etudid in etudids
    ]
    #
    if not sco_bulletins.can_send_bulletin_by_mail(formsemestre_id):
        raise AccessDenied("vous n'avez pas le droit d'envoyer les bulletins")
    # Confirmation dialog
    if not dialog_confirmed:
        return scu.confirm_dialog(
            f"<h2>Envoyer les {len(inscriptions)} bulletins par e-mail aux étudiants inscrits sélectionnés ?",
            dest_url="",
            cancel_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            ),
            parameters={
                "version": version,
                "formsemestre_id": formsemestre_id,
                "prefer_mail_perso": prefer_mail_perso,
                "group_ids": group_ids,
            },
        )
    # Make each bulletin
    nb_sent = 0
    for inscription in inscriptions:
        sent, _ = sco_bulletins.do_formsemestre_bulletinetud(
            formsemestre,
            inscription.etud,
            version=version,
            prefer_mail_perso=prefer_mail_perso,
            fmt="pdfmail",
        )
        if sent:
            nb_sent += 1
    #
    return render_template(
        "sco_page.j2",
        title="Mailing bulletins",
        content=f"""
    <p>{nb_sent} bulletins sur {len(inscriptions)} envoyés par mail !</p>
    <p><a class="stdlink" href="{url_for('notes.formsemestre_status',
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id)
        }">continuer</a></p>
    """,
    )


sco_publish(
    "/external_ue_create_form",
    sco_ue_external.external_ue_create_form,
    Permission.ScoView,
    methods=["GET", "POST"],
)


@bp.route("/appreciation_add_form", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.EnsView)
@scodoc7func
def appreciation_add_form(
    etudid=None,
    formsemestre_id=None,
    appreciation_id=None,  # si id, edit
    suppress=False,  # si true, supress id
):
    "form ajout ou edition d'une appreciation"
    if appreciation_id:  # edit mode
        appreciation = db.session.get(BulAppreciations, appreciation_id)
        if appreciation is None:
            raise ScoValueError("id d'appreciation invalide !")
        formsemestre_id = appreciation.formsemestre_id
        etudid = appreciation.etudid
    etud: Identite = Identite.query.filter_by(
        id=etudid, dept_id=g.scodoc_dept_id
    ).first_or_404()
    vals = scu.get_request_args()
    if "edit" in vals:
        edit = int(vals["edit"])
    elif appreciation_id:
        edit = 1
    else:
        edit = 0
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    # check custom access permission
    can_edit_app = formsemestre.est_responsable(current_user) or (
        current_user.has_permission(Permission.EtudInscrit)
    )
    if not can_edit_app:
        raise AccessDenied("vous n'avez pas le droit d'ajouter une appreciation")
    #
    bul_url = url_for(
        "notes.formsemestre_bulletinetud",
        scodoc_dept=g.scodoc_dept,
        formsemestre_id=formsemestre_id,
        etudid=etudid,
    )

    if suppress:
        db.session.delete(appreciation)
        Scolog.logdb(
            method="appreciation_suppress",
            etudid=etudid,
        )
        db.session.commit()
        flash("appréciation supprimée")
        return flask.redirect(bul_url)
    #
    if appreciation_id:
        action = "Édition"
    else:
        action = "Ajout"
    H = [
        f"""<h2>{action} d'une appréciation sur {etud.nomprenom}</h2>""",
    ]
    descr = [
        ("edit", {"input_type": "hidden", "default": edit}),
        ("etudid", {"input_type": "hidden"}),
        ("formsemestre_id", {"input_type": "hidden"}),
        ("appreciation_id", {"input_type": "hidden"}),
        ("comment", {"title": "", "input_type": "textarea", "rows": 4, "cols": 60}),
    ]
    if appreciation_id:
        initvalues = {
            "etudid": etudid,
            "formsemestre_id": formsemestre_id,
            "comment": appreciation.comment,
        }
    else:
        initvalues = {}
    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        descr,
        initvalues=initvalues,
        cancelbutton="Annuler",
        submitlabel="Ajouter appréciation",
    )
    if tf[0] == 0:
        return render_template("sco_page.j2", content="\n".join(H) + "\n" + tf[1])
    elif tf[0] == -1:
        return flask.redirect(bul_url)
    else:
        if edit:
            appreciation.author = (current_user.user_name,)
            appreciation.comment = tf[2]["comment"].strip()
            flash("appréciation modifiée")
        else:  # nouvelle
            appreciation = BulAppreciations(
                etudid=etudid,
                formsemestre_id=formsemestre_id,
                author=current_user.user_name,
                comment=tf[2]["comment"].strip(),
            )
            flash("appréciation ajoutée")
        db.session.add(appreciation)
        # log
        Scolog.logdb(
            method="appreciation_add",
            etudid=etudid,
            msg=appreciation.comment_safe(),
        )
        db.session.commit()
        # ennuyeux mais necessaire (pour le PDF seulement)
        sco_cache.invalidate_formsemestre(
            pdfonly=True, formsemestre_id=formsemestre_id
        )  # > appreciation_add
        return flask.redirect(bul_url)


sco_publish(
    "/formsemestre_ext_create_form",
    sco_formsemestre_exterieurs.formsemestre_ext_create_form,
    Permission.ScoView,
    methods=["GET", "POST"],
)


# ------------- PV de JURY et archives
sco_publish(
    "/formsemestre_pvjury", sco_pv_forms.formsemestre_pvjury, Permission.ScoView
)

sco_publish("/pvjury_page_but", jury_but_pv.pvjury_page_but, Permission.ScoView)

sco_publish(
    "/formsemestre_lettres_individuelles",
    sco_pv_forms.formsemestre_lettres_individuelles,
    Permission.ScoView,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_pvjury_pdf",
    sco_pv_forms.formsemestre_pvjury_pdf,
    Permission.ScoView,
    methods=["GET", "POST"],
)
sco_publish(
    "/feuille_preparation_jury",
    sco_prepajury.feuille_preparation_jury,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_archive",
    sco_archives_formsemestre.formsemestre_archive,
    Permission.ScoView,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_delete_archive",
    sco_archives_formsemestre.formsemestre_delete_archive,
    Permission.ScoView,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_list_archives",
    sco_archives_formsemestre.formsemestre_list_archives,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_get_archived_file",
    sco_archives_formsemestre.formsemestre_get_archived_file,
    Permission.ScoView,
)
sco_publish("/view_apo_csv", sco_etape_apogee_view.view_apo_csv, Permission.EditApogee)
sco_publish(
    "/view_apo_csv_store",
    sco_etape_apogee_view.view_apo_csv_store,
    Permission.EditApogee,
    methods=["GET", "POST"],
)
sco_publish(
    "/view_apo_csv_download_and_store",
    sco_etape_apogee_view.view_apo_csv_download_and_store,
    Permission.EditApogee,
    methods=["GET", "POST"],
)
sco_publish(
    "/view_apo_csv_delete",
    sco_etape_apogee_view.view_apo_csv_delete,
    Permission.EditApogee,
    methods=["GET", "POST"],
)
sco_publish(
    "/view_scodoc_etuds", sco_etape_apogee_view.view_scodoc_etuds, Permission.EditApogee
)
sco_publish(
    "/view_apo_etuds", sco_etape_apogee_view.view_apo_etuds, Permission.EditApogee
)
sco_publish(
    "/apo_semset_maq_status",
    sco_etape_apogee_view.apo_semset_maq_status,
    Permission.EditApogee,
)
sco_publish(
    "/apo_csv_export_results",
    sco_etape_apogee_view.apo_csv_export_results,
    Permission.EditApogee,
)


# sco_semset
sco_publish("/semset_page", sco_semset.semset_page, Permission.EditApogee)
sco_publish(
    "/do_semset_create",
    sco_semset.do_semset_create,
    Permission.EditApogee,
    methods=["GET", "POST"],
)
sco_publish(
    "/do_semset_delete",
    sco_semset.do_semset_delete,
    Permission.EditApogee,
    methods=["GET", "POST"],
)
sco_publish(
    "/edit_semset_set_title",
    sco_semset.edit_semset_set_title,
    Permission.EditApogee,
    methods=["GET", "POST"],
)
sco_publish(
    "/do_semset_add_sem",
    sco_semset.do_semset_add_sem,
    Permission.EditApogee,
    methods=["GET", "POST"],
)
sco_publish(
    "/do_semset_remove_sem",
    sco_semset.do_semset_remove_sem,
    Permission.EditApogee,
    methods=["GET", "POST"],
)

# sco_export_result
sco_publish(
    "/scodoc_table_results",
    sco_export_results.scodoc_table_results,
    Permission.EditApogee,
)


@bp.route("/apo_compare_csv_form")
@scodoc
@permission_required(Permission.ScoView)
def apo_compare_csv_form():
    "Choix de fichiers Apogée à comparer"
    return render_template(
        "apogee/apo_compare_form.j2", title="Comparaison de fichiers Apogée"
    )


@bp.route("/apo_compare_csv", methods=["POST"])
@scodoc
@permission_required(Permission.ScoView)
def apo_compare_csv():
    "Page comparaison 2 fichiers CSV"
    try:
        file_a = request.files["file_a"]
        file_b = request.files["file_b"]
        autodetect = request.form.get("autodetect", False)
    except KeyError as exc:
        raise ScoValueError("invalid arguments") from exc
    return sco_apogee_compare.apo_compare_csv(file_a, file_b, autodetect=autodetect)


# ------------- INSCRIPTIONS: PASSAGE D'UN SEMESTRE A UN AUTRE
sco_publish(
    "/formsemestre_inscr_passage",
    sco_inscr_passage.formsemestre_inscr_passage,
    Permission.EtudInscrit,
    methods=["GET", "POST"],
)
sco_publish(
    "/formsemestre_synchro_etuds",
    sco_synchro_etuds.formsemestre_synchro_etuds,
    Permission.ScoView,
    methods=["GET", "POST"],
)

# ------------- RAPPORTS STATISTIQUES
sco_publish(
    "/formsemestre_report_counts",
    sco_report.formsemestre_report_counts,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_suivi_cohorte",
    sco_report.formsemestre_suivi_cohorte,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_suivi_cursus",
    sco_report.formsemestre_suivi_cursus,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_etuds_lycees",
    sco_lycee.formsemestre_etuds_lycees,
    Permission.ViewEtudData,
)
sco_publish(
    "/scodoc_table_etuds_lycees",
    sco_lycee.scodoc_table_etuds_lycees,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_graph_cursus",
    sco_report.formsemestre_graph_cursus,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_but_indicateurs",
    sco_report_but.formsemestre_but_indicateurs,
    Permission.ScoView,
)
sco_publish(
    "/formsemestre_poursuite_report",
    sco_poursuite_dut.formsemestre_poursuite_report,
    Permission.ScoView,
)
sco_publish(
    "/report_debouche_date", sco_debouche.report_debouche_date, Permission.ScoView
)
sco_publish(
    "/formsemestre_estim_cost",
    sco_cost_formation.formsemestre_estim_cost,
    Permission.ScoView,
)

# --------------------------------------------------------------------
# DEBUG


@bp.route("/check_sem_integrity")
@scodoc
@permission_required(Permission.EditFormSemestre)
@scodoc7func
def check_sem_integrity(formsemestre_id, fix=False):
    """Debug.
    Check that ue and module formations are consistents
    """
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
    bad_ue = []
    bad_sem = []
    formations_set = set()  # les formations mentionnées dans les UE et modules
    for modimpl in modimpls:
        mod = Module.get_instance(modimpl["module_id"])
        formations_set.add(mod.formation_id)
        ue = mod.ue
        ue_dict = ue.to_dict()
        formations_set.add(ue_dict["formation_id"])
        if ue_dict["formation_id"] != mod.formation_id:
            modimpl["mod"] = mod.to_dict()
            modimpl["ue"] = ue_dict
            bad_ue.append(modimpl)
        if formsemestre.formation_id != mod.formation_id:
            bad_sem.append(modimpl)
            modimpl["mod"] = mod.to_dict()

    H = [
        f"""<p>formation_id={formsemestre.formation_id}""",
    ]
    if bad_ue:
        H += [
            "<h2>Modules d'une autre formation que leur UE:</h2>",
            "<br>".join([str(x) for x in bad_ue]),
        ]
    if bad_sem:
        H += [
            "<h2>Module du semestre dans une autre formation:</h2>",
            "<br>".join([str(x) for x in bad_sem]),
        ]
    if not bad_ue and not bad_sem:
        H.append("<p>Aucun problème à signaler !</p>")
    else:
        log(f"check_sem_integrity: problem detected: formations_set={formations_set}")
        if formsemestre.formation_id in formations_set:
            formations_set.remove(formsemestre.formation_id)
        if len(formations_set) == 1:
            if fix:
                log(f"check_sem_integrity: trying to fix {formsemestre_id}")
                formation_id = formations_set.pop()
                if formsemestre.formation_id != formation_id:
                    formsemestre.formation_id = formation_id
                    db.session.add(formsemestre)
                    db.session.commit()
                    sco_cache.invalidate_formsemestre(formsemestre.id)
                H.append("""<p class="alert">Problème réparé: vérifiez</p>""")
            else:
                H.append(
                    f"""
                <p class="alert">Problème détecté réparable:
                <a href="{url_for( "notes.check_sem_integrity", scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id, fix=1)}">réparer maintenant</a></p>
                """
                )
        else:
            H.append("""<p class="alert">Problème détecté !</p>""")

    return render_template("sco_page.j2", content="\n".join(H))


@bp.route("/check_form_integrity")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def check_form_integrity(formation_id, fix=False):
    "debug (obsolete)"
    log(f"check_form_integrity: formation_id={formation_id}  fix={fix}")
    formation: Formation = Formation.query.filter_by(
        dept_id=g.scodoc_dept_id, formation_id=formation_id
    ).first_or_404()
    bad = []
    for ue in formation.ues:
        for matiere in ue.matieres:
            for mod in matiere.modules:
                if mod.ue_id != ue.id:
                    if fix:
                        # fix mod.ue_id
                        log(f"fix: mod.ue_id = {ue.id} (was {mod.ue_id})")
                        mod.ue_id = ue.id
                        db.session.add(mod)
                    bad.append(mod)
                if mod.formation_id != formation_id:
                    bad.append(mod)
    if bad:
        txth = "<br>".join([html.escape(str(x)) for x in bad])
        txt = "\n".join([str(x) for x in bad])
        log(f"check_form_integrity: formation_id={formation_id}\ninconsistencies:")
        log(txt)
        # Notify by e-mail
        send_scodoc_alarm("Notes: formation incoherente !", txt)
    else:
        txth = "OK"
        log("ok")
    return render_template("sco_page.j2", content=txth)


@bp.route("/check_formsemestre_integrity")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def check_formsemestre_integrity(formsemestre_id):
    "debug"
    log(f"check_formsemestre_integrity: formsemestre_id={formsemestre_id}")
    # verifie que tous les moduleimpl d'un formsemestre
    # se réfèrent à un module dont l'UE appartient a la même formation
    # Ancien bug: les ue_id étaient mal copiés lors des création de versions
    # de formations
    diag = []
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    for modimpl in formsemestre.modimpls:
        if modimpl.module.ue_id != modimpl.module.matiere.ue_id:
            diag.append(
                f"""moduleimpl {modimpl.id}: module.ue_id={modimpl.module.ue_id
                    } != matiere.ue_id={modimpl.module.matiere.ue_id}"""
            )
        if modimpl.module.ue.formation_id != modimpl.module.formation_id:
            diag.append(
                f"""moduleimpl {modimpl.id}: ue.formation_id={
                    modimpl.module.ue.formation_id} != mod.formation_id={
                    modimpl.module.formation_id}"""
            )
    if diag:
        send_scodoc_alarm(
            f"Notes: formation incoherente dans semestre {formsemestre_id} !",
            "\n".join(diag),
        )
        log(f"check_formsemestre_integrity: formsemestre_id={formsemestre_id}")
        log("inconsistencies:\n" + "\n".join(diag))
    else:
        diag = ["OK"]
        log("ok")
    return render_template("sco_page.j2", content="<br>".join(diag))