# -*- 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 datetime
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 app import db
from app import models
from app.auth.models import User
from app.but import (
    apc_edit_ue,
    bulletin_but_court,  # ne pas enlever: ajoute des routes !
    cursus_but,
    jury_edit_manual,
    jury_but,
    jury_but_pv,
    jury_but_validation_auto,
    jury_but_view,
)
from app.but.forms import jury_but_forms


from app.comp import jury, 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,
    ScolarAutorisationInscription,
    ScolarNews,
    Scolog,
    ScoDocSiteConfig,
    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.pe import pe_view  # ne pas enlever, ajoute des vues
from app.scodoc import sco_bulletins_json, sco_utils as scu
from app import log, send_scodoc_alarm

from app.scodoc.sco_exceptions import (
    AccessDenied,
    ScoValueError,
    ScoInvalidIdType,
)
from app.scodoc import (
    html_sco_header,
    sco_apogee_compare,
    sco_archives_formsemestre,
    sco_assiduites,
    sco_bulletins,
    sco_bulletins_pdf,
    sco_cache,
    sco_cost_formation,
    sco_debouche,
    sco_edit_apc,
    sco_edit_formation,
    sco_edit_matiere,
    sco_edit_module,
    sco_edit_ue,
    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_formations,
    sco_formation_recap,
    sco_formation_versions,
    sco_formsemestre,
    sco_formsemestre_custommenu,
    sco_formsemestre_edit,
    sco_formsemestre_exterieurs,
    sco_formsemestre_inscriptions,
    sco_formsemestre_status,
    sco_formsemestre_validation,
    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_notes,
    sco_semset,
    sco_synchro_etuds,
    sco_tag_module,
    sco_ue_external,
    sco_undo_notes,
    sco_users,
)
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_pv_dict import descr_autorisations
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",
    sco_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",
    sco_edit_formation.formation_create,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/formation_delete",
    sco_edit_formation.formation_delete,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/formation_edit",
    sco_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),
            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",
    sco_edit_ue.ue_create,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/ue_delete",
    sco_edit_ue.ue_delete,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/ue_edit",
    sco_edit_ue.ue_edit,
    Permission.EditFormation,
    methods=["GET", "POST"],
)


@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 sco_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):
    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 sco_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(
    "/edit_ue_set_code_apogee",
    sco_edit_ue.edit_ue_set_code_apogee,
    Permission.EditFormation,
    methods=["POST"],
)
sco_publish(
    "/formsemestre_edit_uecoefs",
    sco_formsemestre_edit.formsemestre_edit_uecoefs,
    Permission.ScoView,
    methods=["GET", "POST"],
)


@bp.route("/formation_table_recap")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formation_table_recap(formation_id, fmt="html"):
    return sco_formation_recap.formation_table_recap(formation_id, fmt=fmt)


sco_publish(
    "/export_recap_formations_annee_scolaire",
    sco_formation_recap.export_recap_formations_annee_scolaire,
    Permission.ScoView,
)
sco_publish(
    "/formation_add_malus_modules",
    sco_edit_module.formation_add_malus_modules,
    Permission.EditFormation,
)
sco_publish(
    "/matiere_create",
    sco_edit_matiere.matiere_create,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/matiere_delete",
    sco_edit_matiere.matiere_delete,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/matiere_edit",
    sco_edit_matiere.matiere_edit,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/module_create",
    sco_edit_module.module_create,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/module_delete",
    sco_edit_module.module_delete,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/module_edit",
    sco_edit_module.module_edit,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish(
    "/edit_module_set_code_apogee",
    sco_edit_module.edit_module_set_code_apogee,
    Permission.EditFormation,
    methods=["GET", "POST"],
)
sco_publish("/module_list", sco_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.query.filter_by(
        id=formation_id, dept_id=g.scodoc_dept_id
    ).first_or_404()
    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():
    """Set tags on module"""
    module_id = int(request.form.get("module_id"))
    taglist = request.form.get("taglist")
    return sco_tag_module.module_tag_set(module_id, taglist)


@bp.route("/module_clone", methods=["POST"])
@scodoc
@permission_required(Permission.EditFormation)
def module_clone():
    """Clone existing module"""
    module_id = int(request.form.get("module_id"))
    module = Module.query.get_or_404(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")
@scodoc
@permission_required(Permission.ScoView)
def index_html():
    "Page accueil formations"
    fmt = request.args.get("fmt", "html")
    editable = current_user.has_permission(Permission.EditFormation)
    table = sco_formations.formation_list_table()

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

    H = [
        html_sco_header.sco_header(page_title="Formations (programmes)"),
        """<h2>Formations (programmes pédagogiques)</h2>
        """,
        table.html(),
    ]
    if editable:
        H.append(
            f"""
    <p class="help">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>

    <ul>
    <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>
    <h3>Référentiels de compétences</h3>
    <ul>
    <li><a class="stdlink" href="{
        url_for('notes.refcomp_table', scodoc_dept=g.scodoc_dept)
    }">Liste des référentiels chargés</a>
    </li>
    </ul>
    """
        )

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


# --------------------------------------------------------------------
#
#    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 sco_formations.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 f"""
        { html_sco_header.sco_header(page_title="Import d'une formation") }
        <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] }
        { html_sco_header.sco_footer() }
        """
    elif tf[0] == -1:
        return flask.redirect(scu.NotesURL())
    else:
        formation_id, _, _ = sco_formations.formation_import_xml(
            tf[2]["xmlfile"].read()
        )

        return f"""
        { html_sco_header.sco_header(page_title="Import d'une formation") }
        <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>
        { html_sco_header.sco_footer() }
        """


sco_publish("/module_move", sco_edit_formation.module_move, Permission.EditFormation)
sco_publish("/ue_move", sco_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 = int(request.form.get("ue_id"))
    ue = UniteEns.query.get_or_404(ue_id)
    ue2 = 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)
    # --
    header = html_sco_header.html_sem_header(
        f"""Enseignants du <a href="{
            url_for("notes.moduleimpl_status",
            scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
        }">module {modimpl.module.titre or modimpl.module.code}</a>""",
        page_title=f"Enseignants du module {modimpl.module.titre or modimpl.module.code}",
        javascripts=["libjs/AutoSuggest.js"],
        cssstyles=["css/autosuggest_inquisitor.css"],
        bodyOnLoad="init_tf_form('')",
    )
    footer = html_sco_header.sco_footer()

    # 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"""<p class="help">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>
    """

    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 header + "\n".join(H) + tf[1] + F + footer
    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(
                    f"""<p class="help">Enseignant {ens.user_name} déjà dans la liste !</p>"""
                )
            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 header + "\n".join(H) + tf[1] + F + footer


@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 = [
        html_sco_header.html_sem_header(
            f"""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>""",
            javascripts=["libjs/AutoSuggest.js"],
            cssstyles=["css/autosuggest_inquisitor.css"],
            bodyOnLoad="init_tf_form('')",
        )
    ]
    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 "\n".join(H) + tf[1] + help_str + html_sco_header.sco_footer()
    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(
            {
                "nomprenom": etud.nomprenom,
                "just": nb_abs_just,
                "nojust": nb_abs_nj,
                "total": nb_abs,
                "_nomprenom_target": url_for(
                    "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
                ),
            }
        )

    H = [
        html_sco_header.html_sem_header(
            f"""Absences du <a href="{
                url_for("notes.moduleimpl_status",
                    scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id
                )}">module {modimpl.module.titre_str()}</a>""",
            page_title=f"Absences du module {modimpl.module.titre_str()}",
        )
    ]
    if not rows and fmt == "html":
        return (
            "\n".join(H)
            + "<p>Aucune absence signalée</p>"
            + html_sco_header.sco_footer()
        )

    tab = GenTable(
        titles={
            "nomprenom": "Nom",
            "just": "Just.",
            "nojust": "Non Just.",
            "total": "Total",
        },
        columns_ids=("nomprenom", "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(),
    )

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

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


@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()}"
    T = 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=html_sco_header.html_sem_header(
            "Enseignants du semestre", with_page_header=False
        ),
        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),
    )
    return T.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:
            <a href="{
                url_for("notes.formsemestre_validation_suppress_etud",
                scodoc_dept=g.scodoc_dept, etudid=etudid,
                formsemestre_id=formsemestre_id)
            }">supprimer décision jury</a>
            )
            """
        )
    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.EtudInscrit,
    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"""
        {html_sco_header.html_sem_header(tit, with_h2=False)}
        <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 "\n".join(H) + html_sco_header.sco_footer()
    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 "\n".join(H) + tf[1] + html_sco_header.sco_footer()
    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 (
            "\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>"""
            + html_sco_header.sco_footer()
        )


@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", methods=["GET", "POST"])  # API ScoDoc 7 compat
@scodoc
@permission_required_compat_scodoc7(Permission.ScoView)
@scodoc7func
def evaluation_listenotes():
    """Affichage des notes d'une évaluation.
    Si evaluation_id non spécifié, toutes les notes des évaluations de ce modimpl.
    """
    evaluation_id = None
    moduleimpl_id = None
    vals = scu.get_request_args()
    try:
        if "evaluation_id" in vals:
            evaluation_id = int(vals["evaluation_id"])
        if "moduleimpl_id" in vals and vals["moduleimpl_id"]:
            moduleimpl_id = int(vals["moduleimpl_id"])
    except ValueError as exc:
        raise ScoValueError("evaluation_listenotes: id invalides !") from exc

    fmt = vals.get("fmt", "html")
    html_content, page_title = sco_liste_notes.do_evaluation_listenotes(
        evaluation_id=evaluation_id, moduleimpl_id=moduleimpl_id, fmt=fmt
    )
    if fmt == "html":
        H = html_sco_header.sco_header(
            page_title=page_title,
            cssstyles=["css/verticalhisto.css"],
            javascripts=["js/etud_info.js"],
            init_qtip=True,
        )
        F = html_sco_header.sco_footer()
        return H + html_content + F
    else:
        return html_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_notes.saisie_notes_tableur,
    Permission.EnsView,
    methods=["GET", "POST"],
)
sco_publish(
    "/feuille_saisie_notes",
    sco_saisie_notes.feuille_saisie_notes,
    Permission.EnsView,
)
sco_publish("/saisie_notes", sco_saisie_notes.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"],
)


# --- 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),
        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,
            },
        )

    # 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 f"""
    {html_sco_header.sco_header()}
    <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>
    {html_sco_header.sco_footer()}
    """


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 = [
        html_sco_header.sco_header(),
        f"""<h2>{action} d'une appréciation sur {etud.nomprenom}</h2>""",
    ]
    F = html_sco_header.sco_footer()
    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 "\n".join(H) + "\n" + tf[1] + F
    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)


# --- FORMULAIRE POUR VALIDATION DES UE ET SEMESTRES


@bp.route("/formsemestre_validation_etud_form")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_validation_etud_form(
    formsemestre_id,
    etudid=None,
    etud_index=None,
    check=0,
    desturl="",
    sortcol=None,
):
    "Formulaire choix jury pour un étudiant"
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    read_only = not formsemestre.can_edit_jury()
    if formsemestre.formation.is_apc():
        return redirect(
            url_for(
                "notes.formsemestre_validation_but",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                etudid=etudid,
            )
        )
    return sco_formsemestre_validation.formsemestre_validation_etud_form(
        formsemestre_id,
        etudid=etudid,
        etud_index=etud_index,
        check=check,
        read_only=read_only,
        dest_url=desturl,
        sortcol=sortcol,
    )


@bp.route("/formsemestre_validation_etud")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_validation_etud(
    formsemestre_id,
    etudid=None,
    codechoice=None,
    desturl="",
    sortcol=None,
):
    "Enregistre choix jury pour un étudiant"
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )

    return sco_formsemestre_validation.formsemestre_validation_etud(
        formsemestre_id,
        etudid=etudid,
        codechoice=codechoice,
        desturl=desturl,
        sortcol=sortcol,
    )


@bp.route("/formsemestre_validation_etud_manu")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_validation_etud_manu(
    formsemestre_id,
    etudid=None,
    code_etat="",
    new_code_prev="",
    devenir="",
    assidu=False,
    desturl="",
    sortcol=None,
):
    "Enregistre choix jury pour un étudiant"
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )

    return sco_formsemestre_validation.formsemestre_validation_etud_manu(
        formsemestre_id,
        etudid=etudid,
        code_etat=code_etat,
        new_code_prev=new_code_prev,
        devenir=devenir,
        assidu=assidu,
        desturl=desturl,
        sortcol=sortcol,
    )


# --- Jurys BUT
@bp.route(
    "/formsemestre_validation_but/<int:formsemestre_id>/<etudid>",
    methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_validation_but(
    formsemestre_id: int,
    etudid: int,
):
    "Form. saisie décision jury semestre BUT"
    formsemestre: FormSemestre = FormSemestre.query.filter_by(
        id=formsemestre_id, dept_id=g.scodoc_dept_id
    ).first_or_404()
    # la route ne donne pas le type d'etudid pour pouvoir construire des URLs
    # provisoires avec NEXT et PREV
    try:
        etudid = int(etudid)
    except ValueError as exc:
        raise ScoValueError("adresse invalide") from exc
    etud = Identite.get_etud(etudid)
    nb_etuds = formsemestre.etuds.count()
    read_only = not formsemestre.can_edit_jury()

    # --- Navigation
    prev_lnk = (
        f"""{scu.EMO_PREV_ARROW}&nbsp;<a href="{url_for(
                "notes.formsemestre_validation_but", scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id, etudid="PREV"
            )}" class="stdlink"">précédent</a>
    """
        if nb_etuds > 1
        else ""
    )
    next_lnk = (
        f"""<a href="{url_for(
                "notes.formsemestre_validation_but", scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id, etudid="NEXT"
            )}" class="stdlink"">suivant</a>&nbsp;{scu.EMO_NEXT_ARROW}
    """
        if nb_etuds > 1
        else ""
    )
    navigation_div = f"""
    <div class="but_navigation">
        <div class="prev">
           {prev_lnk}
        </div>
        <div class="back_list">
            <a href="{
                url_for(
                    "notes.formsemestre_recapcomplet",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                    mode_jury=1,
                    selected_etudid=etud.id
            )}" class="stdlink">retour à la liste</a>
        </div>
        <div class="next">
            {next_lnk}
        </div>
    </div>
    """

    H = [
        html_sco_header.sco_header(
            page_title=f"Validation BUT S{formsemestre.semestre_id}",
            formsemestre_id=formsemestre_id,
            etudid=etudid,
            cssstyles=[
                "css/jury_but.css",
                "css/cursus_but.css",
            ],
            javascripts=("js/jury_but.js",),
        ),
        """<div class="jury_but">
        """,
    ]

    if formsemestre.etuds_inscriptions[etudid].etat != scu.INSCRIT:
        return (
            "\n".join(H)
            + f"""
            <div>
                <div class="bull_head">
                <div>
                    <div class="titre_parcours">Jury BUT</div>
                    <div class="nom_etud">{etud.html_link_fiche()}</div>
                </div>
                <div class="bull_photo"><a href="{
                    etud.url_fiche()
                    }">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
                </div>
                </div>
                <div class="warning">Impossible de statuer sur cet étudiant:
                        il est démissionnaire ou défaillant (voir <a class="stdlink" href="{
                    etud.url_fiche()}">sa fiche</a>)
                </div>
            </div>
            {navigation_div}
            </div>
        """
            + html_sco_header.sco_footer()
        )

    deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
    has_notes_en_attente = deca.has_notes_en_attente()
    evaluations_a_debloquer = Evaluation.get_evaluations_blocked_for_etud(
        formsemestre, etud
    )
    if has_notes_en_attente or evaluations_a_debloquer:
        read_only = True
    if request.method == "POST":
        if not read_only:
            deca.record_form(request.form)
            ScolarNews.add(
                typ=ScolarNews.NEWS_JURY,
                obj=formsemestre.id,
                text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
                url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre.id,
                ),
            )
            flash("codes enregistrés")
        return flask.redirect(
            url_for(
                "notes.formsemestre_validation_but",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                etudid=etudid,
            )
        )

    warning = ""
    if len(deca.niveaux_competences) != len(deca.decisions_rcue_by_niveau):
        warning += f"""<div class="warning">Attention: {len(deca.niveaux_competences)}
                niveaux mais {len(deca.decisions_rcue_by_niveau)} regroupements RCUE.</div>"""
    if (deca.parcour is None) and len(formsemestre.parcours) > 0:
        warning += (
            """<div class="warning">L'étudiant n'est pas inscrit à un parcours.</div>"""
        )
    if formsemestre.date_fin - datetime.date.today() > datetime.timedelta(days=12):
        # encore loin de la fin du semestre de départ de ce jury ?
        warning += f"""<div class="warning">Le semestre S{formsemestre.semestre_id}
            terminera le {formsemestre.date_fin.strftime("%d/%m/%Y")}&nbsp;:
            êtes-vous certain de vouloir enregistrer une décision de jury&nbsp;?
            </div>"""

    if deca.formsemestre_impair:
        inscription = deca.formsemestre_impair.etuds_inscriptions.get(etud.id)
        if (not inscription) or inscription.etat != scu.INSCRIT:
            etat_ins = scu.ETATS_INSCRIPTION.get(inscription.etat, "inconnu?")
            warning += f"""<div class="warning">{etat_ins}
                en S{deca.formsemestre_impair.semestre_id}</div>"""

    if deca.formsemestre_pair:
        inscription = deca.formsemestre_pair.etuds_inscriptions.get(etud.id)
        if (not inscription) or inscription.etat != scu.INSCRIT:
            etat_ins = scu.ETATS_INSCRIPTION.get(inscription.etat, "inconnu?")
            warning += f"""<div class="warning">{etat_ins}
                en S{deca.formsemestre_pair.semestre_id}</div>"""

    if has_notes_en_attente:
        warning += f"""<div class="warning-bloquant">{etud.html_link_fiche()
            } a des notes en ATTente dans les modules suivants.
            Vous devez régler cela avant de statuer en jury !
            <ul class="modimpls_att">
            """
        for modimpl in deca.get_modimpls_attente():
            warning += f"""<li><a href="{
                    url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
                }" class="stdlink">{modimpl.module.code} {modimpl.module.titre_str()}</a></li>"""
        warning += "</ul></div>"
    if evaluations_a_debloquer:
        links_evals = [
            f"""<a class="stdlink" href="{url_for(
                    'notes.evaluation_listenotes', scodoc_dept=g.scodoc_dept, evaluation_id=e.id
                )}">{e.description} en {e.moduleimpl.module.code}</a>"""
            for e in evaluations_a_debloquer
        ]
        warning += f"""<div class="warning-bloquant">Impossible de statuer sur cet étudiant:
                il a des notes dans des évaluations qui seront débloquées plus tard:
                voir {", ".join(links_evals)}
                """

    if warning:
        warning = f"""<div class="jury_but_warning jury_but_box">{warning}</div>"""
    H.append(
        f"""
    <div>
        <div class="bull_head">
        <div>
            <div class="titre_parcours">Jury BUT{deca.annee_but}
            - Parcours {(deca.parcour.libelle if deca.parcour else False) or "non spécifié"}
            - {deca.annee_scolaire_str()}</div>
            <div class="nom_etud">{etud.html_link_fiche()}</div>
        </div>
        <div class="bull_photo"><a href="{
            etud.url_fiche()}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
        </div>
        </div>
        {warning}
    </div>

    <form method="post" class="jury_but_box" id="jury_but">
    """
    )

    H.append(jury_but_view.show_etud(deca, read_only=read_only))

    autorisations_idx = deca.get_autorisations_passage()
    div_autorisations_passage = (
        f"""
        <div class="but_autorisations_passage">
            <span>Autorisé à passer en&nbsp;:</span>
            { ", ".join( ["S" + str(i) for i in autorisations_idx ] )}
        </div>
    """
        if autorisations_idx
        else """<div class="but_autorisations_passage but_explanation">
            pas d'autorisations de passage enregistrées.
            </div>
            """
    )
    H.append(div_autorisations_passage)

    if read_only:
        H.append(
            f"""
            <div class="but_explanation">
            {"Vous n'avez pas la permission de modifier ces décisions."
            if formsemestre.etat
            else "Semestre verrouillé."}
            Les champs entourés en vert sont enregistrés.
            </div>"""
        )
    else:
        erase_span = f"""
            <a style="margin-left: 16px;" class="stdlink"
            href="{
                url_for("notes.erase_decisions_annee_formation",
                scodoc_dept=g.scodoc_dept, formation_id=deca.formsemestre.formation.id,
                etudid=deca.etud.id, annee=deca.annee_but, formsemestre_id=formsemestre_id)}"
            >effacer des décisions de jury</a>

            <a style="margin-left: 16px;" class="stdlink"
            href="{
                url_for("notes.formsemestre_validate_previous_ue",
                scodoc_dept=g.scodoc_dept,
                etudid=deca.etud.id, formsemestre_id=formsemestre_id)}"
            >enregistrer des UEs antérieures</a>
            """
        H.append(
            f"""<div class="but_settings">
            <input type="checkbox" onchange="enable_manual_codes(this)">
                <em>permettre la saisie manuelles des codes
                {"d'année et " if deca.jury_annuel else ""}
                de niveaux.
                Dans ce cas, assurez-vous de la cohérence entre les codes d'UE/RCUE/Année !
                </em>
            </input>
            </div>

            <div class="but_buttons">
                <span><input type="submit" value="Enregistrer ces décisions"></span>
                <span>{erase_span}</span>
            </div>
            """
        )
    H.append(navigation_div)
    H.append("</form>")

    # Affichage cursus BUT
    but_cursus = cursus_but.EtudCursusBUT(etud, deca.formsemestre.formation)
    H += [
        """<div class="jury_but_box">
        <div class="jury_but_box_title"><b>Niveaux de compétences enregistrés :</b></div>
        """,
        render_template(
            "but/cursus_etud.j2",
            cursus=but_cursus,
            scu=scu,
        ),
        "</div>",
    ]
    H.append(
        render_template(
            "but/documentation_codes_jury.j2",
            nom_univ=f"""Export {sco_preferences.get_preference("InstituteName")
            or sco_preferences.get_preference("UnivName")
            or "Apogée"}""",
            codes=ScoDocSiteConfig.get_codes_apo_dict(),
        )
    )
    H.append(
        f"""<div class="but_doc_codes but_warning_rcue_cap">
    {scu.EMO_WARNING} Rappel: pour les redoublants, seules les UE <b>capitalisées</b> (note > 10)
    lors d'une année précédente peuvent être prise en compte pour former
    un RCUE (associé à un niveau de compétence du BUT).
    </div>
    """
    )
    return "\n".join(H) + html_sco_header.sco_footer()


@bp.route(
    "/formsemestre_validation_auto_but/<int:formsemestre_id>", methods=["GET", "POST"]
)
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_validation_auto_but(formsemestre_id: int = None):
    "Saisie automatique des décisions de jury BUT"
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )
    if not formsemestre.formation.is_apc():
        raise ScoValueError(
            "formsemestre_validation_auto_but est réservé aux formations APC"
        )

    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    form = jury_but_forms.FormSemestreValidationAutoBUTForm()
    if request.method == "POST":
        if not form.cancel.data:
            nb_etud_modif, _ = (
                jury_but_validation_auto.formsemestre_validation_auto_but(formsemestre)
            )
            flash(f"Décisions enregistrées ({nb_etud_modif} étudiants modifiés)")
        return redirect(
            url_for(
                "notes.formsemestre_recapcomplet",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                mode_jury=1,
            )
        )
    # Avertissement si formsemestre impair
    formsemestres_suspects = {}
    if formsemestre.semestre_id % 2:
        _, decas = jury_but_validation_auto.formsemestre_validation_auto_but(
            formsemestre, dry_run=True
        )
        # regarde si il y a des semestres pairs postérieurs qui ne soient pas bloqués
        formsemestres_suspects = {
            deca.formsemestre_pair.id: deca.formsemestre_pair
            for deca in decas
            if deca.formsemestre_pair
            and deca.formsemestre_pair.date_debut > formsemestre.date_debut
            and not deca.formsemestre_pair.block_moyennes
        }

    return render_template(
        "but/formsemestre_validation_auto_but.j2",
        form=form,
        formsemestres_suspects=formsemestres_suspects,
        sco=ScoData(formsemestre=formsemestre),
        title="Calcul automatique jury BUT",
    )


@bp.route(
    "/formsemestre_validate_previous_ue/<int:formsemestre_id>/<int:etudid>",
    methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_validate_previous_ue(formsemestre_id, etudid=None):
    "Form. saisie UE validée hors ScoDoc"
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )
    etud: Identite = (
        Identite.query.filter_by(id=etudid)
        .join(FormSemestreInscription)
        .filter_by(formsemestre_id=formsemestre_id)
        .first_or_404()
    )

    return sco_formsemestre_validation.formsemestre_validate_previous_ue(
        formsemestre, etud
    )


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


@bp.route("/formsemestre_ext_edit_ue_validations", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid=None):
    "Form. edition UE semestre extérieur"
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )

    return sco_formsemestre_exterieurs.formsemestre_ext_edit_ue_validations(
        formsemestre_id, etudid
    )


@bp.route("/formsemestre_validation_auto")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_validation_auto(formsemestre_id):
    "Formulaire saisie automatisee des decisions d'un semestre"
    formsemestre: FormSemestre = FormSemestre.query.filter_by(
        id=formsemestre_id, dept_id=g.scodoc_dept_id
    ).first_or_404()
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )

    if formsemestre.formation.is_apc():
        return redirect(
            url_for(
                "notes.formsemestre_validation_auto_but",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre.id,
            )
        )
    return sco_formsemestre_validation.formsemestre_validation_auto(formsemestre_id)


@bp.route("/do_formsemestre_validation_auto")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def do_formsemestre_validation_auto(formsemestre_id):
    "Formulaire saisie automatisee des decisions d'un semestre"
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )

    return sco_formsemestre_validation.do_formsemestre_validation_auto(formsemestre_id)


@bp.route("/formsemestre_validation_suppress_etud", methods=["GET", "POST"])
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_validation_suppress_etud(
    formsemestre_id, etudid, dialog_confirmed=False
):
    """Suppression des décisions de jury pour un étudiant."""
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )
    etud = Identite.get_etud(etudid)
    if formsemestre.formation.is_apc():
        next_url = url_for(
            "scolar.fiche_etud",
            scodoc_dept=g.scodoc_dept,
            etudid=etudid,
        )
    else:
        next_url = url_for(
            "notes.formsemestre_validation_etud_form",
            scodoc_dept=g.scodoc_dept,
            formsemestre_id=formsemestre_id,
            etudid=etudid,
        )
    if not dialog_confirmed:
        d = sco_bulletins_json.dict_decision_jury(
            etud, formsemestre, with_decisions=True
        )

        descr_ues = [f"{u['acronyme']}: {u['code']}" for u in d.get("decision_ue", [])]
        dec_annee = d.get("decision_annee")
        if dec_annee:
            descr_annee = dec_annee.get("code", "-")
        else:
            descr_annee = "-"

        existing = f"""
        <ul>
        <li>Semestre : {d.get("decision", {"code":"-"})['code'] or "-"}</li>
        <li>Année BUT: {descr_annee}</li>
        <li>UEs : {", ".join(descr_ues)}</li>
        <li>RCUEs: {len(d.get("decision_rcue", []))} décisions</li>
        <li>Autorisations: {descr_autorisations(ScolarAutorisationInscription.query.filter_by(origin_formsemestre_id=formsemestre_id,
            etudid=etudid))}
        </ul>
        """
        return scu.confirm_dialog(
            f"""<h2>Confirmer la suppression des décisions du semestre
            {formsemestre.titre_mois()} pour {etud.nomprenom}
            </h2>
            <p>Cette opération est irréversible.</p>
            <div>
            {existing}
            </div>
            """,
            OK="Supprimer",
            dest_url="",
            cancel_url=next_url,
            parameters={"etudid": etudid, "formsemestre_id": formsemestre_id},
        )

    sco_formsemestre_validation.formsemestre_validation_suppress_etud(
        formsemestre_id, etudid
    )
    flash("Décisions supprimées")
    return flask.redirect(next_url)


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


@bp.route("/formsemestre_saisie_jury")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None):
    """Page de saisie: liste des étudiants et lien vers page jury
    sinon, redirect vers page recap en mode jury
    """
    return redirect(
        url_for(
            "notes.formsemestre_recapcomplet",
            scodoc_dept=g.scodoc_dept,
            formsemestre_id=formsemestre_id,
            mode_jury=1,
        )
    )


@bp.route(
    "/formsemestre_jury_but_erase/<int:formsemestre_id>",
    methods=["GET", "POST"],
    defaults={"etudid": None},
)
@bp.route(
    "/formsemestre_jury_but_erase/<int:formsemestre_id>/<int:etudid>",
    methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.ScoView)
def formsemestre_jury_but_erase(formsemestre_id: int, etudid: int = None):
    """Supprime la décision de jury BUT pour cette année.
    Si l'étudiant n'est pas spécifié, efface les décisions de tous les inscrits.
    Si only_one_sem, n'efface que pour le formsemestre indiqué, pas les deux de l'année.
    """
    only_one_sem = int(request.args.get("only_one_sem") or False)
    formsemestre: FormSemestre = FormSemestre.query.filter_by(
        id=formsemestre_id, dept_id=g.scodoc_dept_id
    ).first_or_404()
    if not formsemestre.can_edit_jury():
        raise ScoPermissionDenied(
            dest_url=url_for(
                "notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )
    if not formsemestre.formation.is_apc():
        raise ScoValueError("semestre non BUT")
    if etudid is None:
        etud = None
        etuds = formsemestre.get_inscrits(include_demdef=True)
        dest_url = url_for(
            "notes.formsemestre_recapcomplet",
            scodoc_dept=g.scodoc_dept,
            formsemestre_id=formsemestre_id,
            mode_jury=1,
        )
    else:
        etud = Identite.get_etud(etudid)
        etuds = [etud]
        dest_url = url_for(
            "notes.formsemestre_validation_but",
            scodoc_dept=g.scodoc_dept,
            formsemestre_id=formsemestre_id,
            etudid=etudid,
        )
    if request.method == "POST":
        with sco_cache.DeferredSemCacheManager():
            for etud in etuds:
                deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
                deca.erase(only_one_sem=only_one_sem)
                log(f"formsemestre_jury_but_erase({formsemestre_id}, {etudid})")
        flash(
            (
                "décisions de jury du semestre effacées"
                if only_one_sem
                else "décisions de jury des semestres de l'année BUT effacées"
            )
            + f" pour {len(etuds)} étudiant{'s' if len(etuds) > 1 else ''}"
        )
        return redirect(dest_url)

    return render_template(
        "confirm_dialog.j2",
        title=f"""Effacer les validations de jury {
            ("de " + etud.nomprenom)
            if etud
            else ("des " + str(len(etuds)) + " étudiants inscrits dans ce semestre")
            } ?""",
        explanation=(
            f"""Les validations d'UE et autorisations de passage
            du semestre S{formsemestre.semestre_id} seront effacées."""
            if only_one_sem
            else """Les validations de toutes les UE, RCUE (compétences) et année
        issues de cette année scolaire seront effacées.
        """
        )
        + """
        <p>Les décisions des années scolaires précédentes ne seront pas modifiées.</p>
        <p>Efface aussi toutes les validations concernant l'année BUT de ce semestre,
        même si elles ont été acquises ailleurs.
        </p>
        <div class="warning">Cette opération est irréversible !
        A n'utiliser que dans des cas exceptionnels, vérifiez bien tous les étudiants ensuite.
        </div>
        """,
        cancel_url=dest_url,
    )


@bp.route(
    "/erase_decisions_annee_formation/<int:etudid>/<int:formation_id>/<int:annee>",
    methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.EtudInscrit)
def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int):
    """Efface toute les décisions d'une année pour cet étudiant"""
    etud: Identite = Identite.query.get_or_404(etudid)
    formation: Formation = Formation.query.filter_by(
        id=formation_id, dept_id=g.scodoc_dept_id
    ).first_or_404()
    if request.method == "POST":
        jury.erase_decisions_annee_formation(etud, formation, annee, delete=True)
        flash("Décisions de jury effacées")
        return redirect(
            url_for(
                "scolar.fiche_etud",
                scodoc_dept=g.scodoc_dept,
                etudid=etud.id,
            )
        )
    validations = jury.erase_decisions_annee_formation(etud, formation, annee)
    formsemestre_origine_id = request.args.get("formsemestre_id")
    formsemestre_origine = (
        FormSemestre.query.get_or_404(formsemestre_origine_id)
        if formsemestre_origine_id
        else None
    )
    return render_template(
        "jury/erase_decisions_annee_formation.j2",
        annee=annee,
        cancel_url=url_for(
            "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
        ),
        etud=etud,
        formation=formation,
        formsemestre_origine=formsemestre_origine,
        validations=validations,
        sco=ScoData(),
        title=f"Effacer décisions de jury {etud.nom} - année {annee}",
    )


@bp.route(
    "/jury_delete_manual/<int:etudid>",
    methods=["GET", "POST"],
)
@scodoc
@permission_required(Permission.EtudInscrit)
def jury_delete_manual(etudid: int):
    """Efface toute les décisions d'une année pour cet étudiant"""
    etud: Identite = Identite.query.get_or_404(etudid)
    return jury_edit_manual.jury_delete_manual(etud)


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,
)


@bp.route("/formsemestre_set_apo_etapes", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def formsemestre_set_apo_etapes():
    """Change les codes étapes du semestre indiqué.
    Args: oid=formsemestre_id, value=chaine "V1RT, V1RT2", codes séparés par des virgules
    """
    formsemestre_id = int(request.form.get("oid"))
    etapes_apo_str = request.form.get("value")
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
    current_etapes = {e.etape_apo for e in formsemestre.etapes}
    new_etapes = {s.strip() for s in etapes_apo_str.split(",")}

    if new_etapes != current_etapes:
        formsemestre.etapes = []
        for etape_apo in new_etapes:
            etape = models.FormSemestreEtape(
                formsemestre_id=formsemestre_id, etape_apo=etape_apo
            )
            formsemestre.etapes.append(etape)
        db.session.add(formsemestre)
        db.session.commit()
        ScolarNews.add(
            typ=ScolarNews.NEWS_APO,
            text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})",
        )
    return ("", 204)


@bp.route("/formsemestre_set_elt_annee_apo", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def formsemestre_set_elt_annee_apo():
    """Change les codes étapes du semestre indiqué.
    Args: oid=formsemestre_id, value=chaine "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules
    """
    oid = int(request.form.get("oid"))
    value = (request.form.get("value") or "").strip()
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(oid)
    if value != formsemestre.elt_annee_apo:
        formsemestre.elt_annee_apo = value
        db.session.add(formsemestre)
        db.session.commit()
        ScolarNews.add(
            typ=ScolarNews.NEWS_APO,
            text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})",
        )
    return ("", 204)


@bp.route("/formsemestre_set_elt_sem_apo", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def formsemestre_set_elt_sem_apo():
    """Change les codes étapes du semestre indiqué.
    Args: oid=formsemestre_id, value=chaine "V3ONM, V3ONM1, V3ONM2", codes séparés par des virgules
    """
    try:
        oid = int(request.form.get("oid"))
    except (TypeError, ValueError) as exc:
        raise ScoValueError("paramètre invalide") from exc
    value = (request.form.get("value") or "").strip()
    formsemestre: FormSemestre = FormSemestre.query.get_or_404(oid)
    if value != formsemestre.elt_sem_apo:
        formsemestre.elt_sem_apo = value
        db.session.add(formsemestre)
        db.session.commit()
        ScolarNews.add(
            typ=ScolarNews.NEWS_APO,
            text=f"Modification code Apogée du semestre {formsemestre.titre_annee()})",
        )
    return ("", 204)


@bp.route("/ue_set_apo", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def ue_set_apo():
    """Change le code APO de l'UE
    Args: oid=ue_id, value=chaine "VRTU12" (1 seul code / UE)
    """
    ue_id = int(request.form.get("oid"))
    code_apo = (request.form.get("value") or "").strip()
    ue = UniteEns.query.get_or_404(ue_id)
    if code_apo != ue.code_apogee:
        ue.code_apogee = code_apo
        db.session.add(ue)
        db.session.commit()
        ScolarNews.add(
            typ=ScolarNews.NEWS_FORM,
            text=f"Modification code Apogée d'UE dans la formation {ue.formation.titre} ({ue.formation.acronyme})",
        )
    return ("", 204)


@bp.route("/module_set_apo", methods=["POST"])
@scodoc
@permission_required(Permission.EditApogee)
def module_set_apo():
    """Change le code APO du module
    Args: oid=ue_id, value=chaine "VRTU12" (1 seul code / UE)
    """
    oid = int(request.form.get("oid"))
    code_apo = (request.form.get("value") or "").strip()
    mod = Module.query.get_or_404(oid)
    if code_apo != mod.code_apogee:
        mod.code_apogee = code_apo
        db.session.add(mod)
        db.session.commit()
        ScolarNews.add(
            typ=ScolarNews.NEWS_FORM,
            text=f"""Modification code Apogée d'UE dans la formation {
                mod.formation.titre} ({mod.formation.acronyme})""",
        )
    return ("", 204)


# 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,
)

sco_publish(
    "/apo_compare_csv_form",
    sco_apogee_compare.apo_compare_csv_form,
    Permission.ScoView,
    methods=["GET", "POST"],
)
sco_publish(
    "/apo_compare_csv",
    sco_apogee_compare.apo_compare_csv,
    Permission.ScoView,
    methods=["GET", "POST"],
)

# ------------- 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
    """
    sem = sco_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 = sco_edit_module.module_list({"module_id": modimpl["module_id"]})[0]
        formations_set.add(mod["formation_id"])
        ue = UniteEns.query.get_or_404(mod["ue_id"])
        ue_dict = ue.to_dict()
        formations_set.add(ue_dict["formation_id"])
        if ue_dict["formation_id"] != mod["formation_id"]:
            modimpl["mod"] = mod
            modimpl["ue"] = ue_dict
            bad_ue.append(modimpl)
        if sem["formation_id"] != mod["formation_id"]:
            bad_sem.append(modimpl)
            modimpl["mod"] = mod

    H = [
        html_sco_header.sco_header(),
        "<p>formation_id=%s" % sem["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("check_sem_integrity: problem detected: formations_set=%s" % formations_set)
        if sem["formation_id"] in formations_set:
            formations_set.remove(sem["formation_id"])
        if len(formations_set) == 1:
            if fix:
                log("check_sem_integrity: trying to fix %s" % formsemestre_id)
                formation_id = formations_set.pop()
                if sem["formation_id"] != formation_id:
                    sem["formation_id"] = formation_id
                    sco_formsemestre.do_formsemestre_edit(sem)
                H.append("""<p class="alert">Problème réparé: vérifiez</p>""")
            else:
                H.append(
                    """
                <p class="alert">Problème détecté réparable:
                <a href="check_sem_integrity?formsemestre_id=%s&fix=1">réparer maintenant</a></p>
                """
                    % (formsemestre_id,)
                )
        else:
            H.append("""<p class="alert">Problème détecté !</p>""")

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


@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 html_sco_header.sco_header() + txth + html_sco_header.sco_footer()


@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 (
        html_sco_header.sco_header() + "<br>".join(diag) + html_sco_header.sco_footer()
    )
