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

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

"""Ajout/Modification/Suppression UE

"""
import re

import sqlalchemy as sa
import flask
from flask import flash, render_template, url_for
from flask import g, request
from flask_login import current_user

from app import db, log
from app.but import apc_edit_ue
from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
from app.models import (
    Formation,
    FormSemestre,
    FormSemestreUEComputationExpr,
    FormSemestreUECoef,
    Matiere,
    UniteEns,
)
from app.models import ApcValidationRCUE, ScolarFormSemestreValidation, ScolarEvent
from app.models import ScolarNews
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import (
    ScoValueError,
    ScoLockedFormError,
    ScoNonEmptyFormationObject,
)

from app.scodoc import html_sco_header
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_apc
from app.scodoc import sco_edit_matiere
from app.scodoc import sco_edit_module
from app.scodoc import sco_groups
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_tag_module

_ueEditor = ndb.EditableTable(
    "notes_ue",
    "ue_id",
    (
        "ue_id",
        "formation_id",
        "acronyme",
        "numero",
        "titre",
        "semestre_idx",
        "type",
        "ue_code",
        "ects",
        "is_external",
        "code_apogee",
        "coefficient",
        "coef_rcue",
        "color",
        "niveau_competence_id",
    ),
    convert_empty_to_nulls=False,  # necessaire pour ue_code == ""
    sortkey="numero",
    input_formators={
        "type": ndb.int_null_is_zero,
        "is_external": scu.to_bool,
        "ects": ndb.float_null_is_null,
    },
    output_formators={
        "numero": ndb.int_null_is_zero,
        "ects": ndb.float_null_is_null,
        "coefficient": ndb.float_null_is_zero,
        "semestre_idx": ndb.int_null_is_null,
    },
)


def ue_list(*args, **kw):
    "list UEs"
    cnx = ndb.GetDBConnexion()
    return _ueEditor.list(cnx, *args, **kw)


def do_ue_create(args, allow_empty_ue_code=False):
    "create an ue"
    cnx = ndb.GetDBConnexion()
    # check duplicates
    ues = ue_list({"formation_id": args["formation_id"], "acronyme": args["acronyme"]})
    if ues:
        raise ScoValueError(
            f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
                (chaque UE doit avoir un acronyme unique dans la formation)"""
        )
    if "ue_code" not in args or args["ue_code"] is None or not args["ue_code"].strip():
        if allow_empty_ue_code:
            args["ue_code"] = ""
        else:
            # évite les conflits: génère nouveau ue_code
            while True:
                cursor = db.session.execute(sa.text("select notes_newid_ucod();"))
                code = cursor.fetchone()[0]
                if UniteEns.query.filter_by(ue_code=code).count() == 0:
                    break
            args["ue_code"] = code

    # last checks
    if not args.get("acronyme"):
        raise ScoValueError("acronyme vide")
    args["coefficient"] = args.get("coefficient", None)
    if args["coefficient"] == "":
        args["coefficient"] = None

    # create
    # XXX TODO utiliser UniteEns.create_from_dict
    ue_id = _ueEditor.create(cnx, args)
    log(f"do_ue_create: created {ue_id} with {args}")

    formation: Formation = db.session.get(Formation, args["formation_id"])
    formation.invalidate_module_coefs()
    # news
    formation = db.session.get(Formation, args["formation_id"])
    ScolarNews.add(
        typ=ScolarNews.NEWS_FORM,
        obj=args["formation_id"],
        text=f"Modification de la formation {formation.acronyme}",
    )
    formation.invalidate_cached_sems()
    return ue_id


def do_ue_delete(ue: UniteEns, delete_validations=False, force=False):
    """delete UE and attached matieres (but not modules).
    Si force, pas de confirmation dialog et pas de redirect
    """
    formation: Formation = ue.formation
    semestre_idx = ue.semestre_idx
    if not ue.can_be_deleted():
        raise ScoNonEmptyFormationObject(
            f"UE (id={ue.id}, dud)",
            msg=f"{ue.titre or ''} ({ue.acronyme})",
            dest_url=url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=formation.id,
                semestre_idx=semestre_idx,
            ),
        )

    log(f"do_ue_delete: ue_id={ue.id}, delete_validations={delete_validations}")

    # Il y a-t-il des etudiants ayant validé cette UE ?
    # si oui, propose de supprimer les validations
    validations_ue = ScolarFormSemestreValidation.query.filter_by(ue_id=ue.id).all()
    validations_rcue = ApcValidationRCUE.query.filter(
        (ApcValidationRCUE.ue1_id == ue.id) | (ApcValidationRCUE.ue2_id == ue.id)
    ).all()
    if (
        (len(validations_ue) > 0 or len(validations_rcue) > 0)
        and not delete_validations
        and not force
    ):
        return scu.confirm_dialog(
            f"""<p>Des étudiants ont une décision de jury sur l'UE {ue.acronyme} ({ue.titre})</p>
            <p>Si vous supprimez cette UE, ces décisions vont être supprimées !</p>""",
            dest_url="",
            target_variable="delete_validations",
            cancel_url=url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=formation.id,
                semestre_idx=semestre_idx,
            ),
            parameters={"ue_id": ue.id, "dialog_confirmed": 1},
        )
    if delete_validations:
        log(f"deleting all validations of UE {ue.id}")
        for v in validations_ue:
            db.session.delete(v)
        for v in validations_rcue:
            db.session.delete(v)

    # delete old formulas
    formulas = FormSemestreUEComputationExpr.query.filter_by(ue_id=ue.id).all()
    for formula in formulas:
        db.session.delete(formula)

    # delete all matieres in this UE
    for mat in Matiere.query.filter_by(ue_id=ue.id):
        db.session.delete(mat)

    # delete uecoefs
    for uecoef in FormSemestreUECoef.query.filter_by(ue_id=ue.id):
        db.session.delete(uecoef)
    # delete events
    for event in ScolarEvent.query.filter_by(ue_id=ue.id):
        db.session.delete(event)
    db.session.flush()

    db.session.delete(ue)
    db.session.commit()

    # cas compliqué, mais rarement utilisé: acceptable de tout invalider
    formation.invalidate_module_coefs()
    # -> invalide aussi les formsemestres
    # news
    ScolarNews.add(
        typ=ScolarNews.NEWS_FORM,
        obj=formation.id,
        text=f"Modification de la formation {formation.acronyme}",
    )
    #
    if not force:
        return flask.redirect(
            url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=formation.id,
                semestre_idx=semestre_idx,
            )
        )
    return None


def ue_create(formation_id=None, default_semestre_idx=None):
    """Formulaire création d'une UE"""
    return ue_edit(
        create=True,
        formation_id=formation_id,
        default_semestre_idx=default_semestre_idx,
    )


def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=None):
    """Formulaire modification ou création d'une UE"""
    create = int(create)
    if not create:
        ue: UniteEns = UniteEns.query.get_or_404(ue_id)
        ue_dict = ue.to_dict()
        formation_id = ue.formation_id
        title = f"Modification de l'UE {ue.acronyme} {ue.titre}"
        initvalues = ue_dict
        submitlabel = "Modifier les valeurs"
        can_change_semestre_id = (
            (ue.modules.count() == 0) or (ue.semestre_idx is None)
        ) and ue.niveau_competence is None
    else:
        ue = None
        title = "Création d'une UE"

        exp = re.compile(r"UCOD(\d+)$")
        matches = {exp.match(u.ue_code) for u in UniteEns.query if exp.match(u.ue_code)}
        max_code = (
            max(int(match.group(1)) for match in matches if match) if matches else 0
        )
        proposed_code = f"UCOD{max_code+1}"

        initvalues = {
            "semestre_idx": default_semestre_idx,
            "color": ue_guess_color_default(formation_id, default_semestre_idx),
            "coef_rcue": 1.0,
            "ue_code": proposed_code,
        }
        submitlabel = "Créer cette UE"
        can_change_semestre_id = True
    formation = db.session.get(Formation, formation_id)
    if not formation:
        raise ScoValueError(f"Formation inexistante ! (id={formation_id})")
    cursus = formation.get_cursus()
    is_apc = cursus.APC_SAE
    semestres_indices = list(range(1, cursus.NB_SEM + 1))
    H = [
        html_sco_header.sco_header(page_title=title, javascripts=["js/edit_ue.js"]),
        "<h2>" + title,
        f" (formation {formation.acronyme}, version {formation.version})</h2>",
        """
    <p class="help">Les UE sont des groupes de modules dans une formation donnée,
    utilisés pour la validation (on calcule des moyennes par UE et applique des
    seuils ("barres")).
    </p>

    <p class="help">Note: sauf exception, l'UE n'a pas de coefficient associé.
    Seuls les <em>modules</em> ont des coefficients.
    </p>""",
        (
            f"""
    <h4>UE du semestre S{ue.semestre_idx}</h4>
    """
            if is_apc and ue
            else ""
        ),
    ]

    ue_types = cursus.ALLOWED_UE_TYPES
    ue_types.sort()
    ue_types_names = [codes_cursus.UE_TYPE_NAME[k] for k in ue_types]
    ue_types = [str(x) for x in ue_types]

    form_descr = [
        ("ue_id", {"input_type": "hidden"}),
        ("create", {"input_type": "hidden", "default": create}),
        ("formation_id", {"input_type": "hidden", "default": formation_id}),
        ("titre", {"size": 48, "explanation": "nom de l'UE"}),
        ("acronyme", {"size": 12, "explanation": "abbréviation", "allow_null": False}),
        (
            "numero",
            {
                "size": 4,
                "explanation": "numéro (1,2,3,4) de l'UE pour l'ordre d'affichage",
                "type": "int",
            },
        ),
    ]
    if can_change_semestre_id:
        form_descr += [
            (
                "semestre_idx",
                {
                    "input_type": "menu",
                    "type": "int",
                    "allow_null": False,
                    "title": cursus.SESSION_NAME.capitalize(),
                    "explanation": f"{cursus.SESSION_NAME} de l'UE dans la formation",
                    "labels": ["non spécifié"] + [str(x) for x in semestres_indices],
                    "allowed_values": [""] + semestres_indices,
                },
            ),
        ]
    else:
        form_descr += [
            ("semestre_idx", {"default": ue.semestre_idx, "input_type": "hidden"}),
        ]
    form_descr += [
        (
            "type",
            {
                "explanation": "type d'UE",
                "input_type": "menu",
                "allowed_values": ue_types,
                "labels": ue_types_names,
            },
        ),
        (
            "ects",
            {
                "size": 4,
                "type": "float",
                "min_value": 0,
                "max_value": 1000,
                "title": "ECTS",
                "explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)"
                + (
                    ". (si les ECTS dépendent du parcours, voir plus bas.)"
                    if is_apc
                    else ""
                ),
                "allow_null": not is_apc,  # ects requis en APC
            },
        ),
    ]
    if is_apc:  # coef pour la moyenne RCUE
        form_descr.append(
            (
                "coef_rcue",
                {
                    "size": 4,
                    "type": "float",
                    "min_value": 0,
                    "title": "Coef. RCUE",
                    "explanation": """pondération utilisée pour le calcul de la moyenne du RCUE. Laisser à 1, sauf si votre établissement a explicitement décidé de pondérations.
                    """,
                    "defaut": 1.0,
                    "allow_null": False,
                    "enabled": is_apc,
                },
            )
        )
    else:  # non APC, coef d'UE
        form_descr.append(
            (
                "coefficient",
                {
                    "size": 4,
                    "type": "float",
                    "min_value": 0,
                    "title": "Coefficient",
                    "explanation": """les coefficients d'UE ne sont utilisés que
                    lorsque l'option <em>Utiliser les coefficients d'UE pour calculer
                    la moyenne générale</em> est activée. Par défaut, le coefficient
                    d'une UE est simplement la somme des coefficients des modules dans
                    lesquels l'étudiant a des notes.
                    Jamais utilisé en BUT.
                    """,
                    "enabled": not is_apc,
                },
            )
        )
    form_descr += [
        (
            "ue_code",
            {
                "size": 12,
                "title": "Code UE",
                "max_length": SHORT_STR_LEN,
                "explanation": """code interne (non vide). Toutes les UE partageant le même code
                (et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE).
                Voir liste ci-dessous.""",
                "allow_null": False,
            },
        ),
        (
            "code_apogee",
            {
                "title": "Code Apogée",
                "size": 25,
                "explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules",
                "max_length": APO_CODE_STR_LEN,
            },
        ),
        (
            "is_external",
            {
                "input_type": "boolcheckbox",
                "title": "UE externe",
                "readonly": not create,  # ne permet pas de transformer une UE existante en externe
                "explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement",
            },
        ),
        (
            "color",
            {
                "input_type": "color",
                "title": "Couleur",
                "explanation": "pour affichages",
            },
        ),
    ]
    if create and not cursus.UE_IS_MODULE and not is_apc:
        form_descr.append(
            (
                "create_matiere",
                {
                    "input_type": "boolcheckbox",
                    "default": True,
                    "title": "Créer matière identique",
                    "explanation": "créer immédiatement une matière dans cette UE (utile si on n'utilise pas de matières)",
                },
            )
        )
    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        form_descr,
        initvalues=initvalues,
        submitlabel=submitlabel,
        cancelbutton="Revenir à la formation",
    )
    if tf[0] == 0:
        ue_parcours_div = ""
        if ue and is_apc:
            ue_parcours_div = apc_edit_ue.form_ue_choix_parcours(ue)
        if ue and ue.modules.count() and ue.semestre_idx is not None:
            modules_div = f"""<div id="ue_list_modules">
            <div><b>{ue.modules.count()} modules sont rattachés
            à cette UE</b> du semestre S{ue.semestre_idx},
            elle ne peut donc pas être changée de semestre.</div>
            <ul>"""
            for m in ue.modules:
                modules_div += f"""<li><a class="stdlink" href="{url_for(
                    "notes.module_edit",
                    scodoc_dept=g.scodoc_dept, module_id=m.id)
                }">{m.code} {m.titre or "sans titre"}</a></li>"""
            modules_div += """</ul></div>"""
        else:
            modules_div = ""
        if ue:
            clone_form = f"""
            <form action="ue_clone" class="clone_form" method="post">
            <input type="hidden" name="ue_id" value="{ue.id}">
            <button type="submit">Créer une copie de cette UE</button>
            </form>
            """
        else:
            clone_form = ""
        bonus_div = """<div id="bonus_description"></div>"""
        ue_div = """<div id="ue_list_code" class="sco_box sco_green_bg"></div>"""
        return (
            "\n".join(H)
            + tf[1]
            + clone_form
            + ue_parcours_div
            + modules_div
            + bonus_div
            + ue_div
            + html_sco_header.sco_footer()
        )
    elif tf[0] == 1:
        if create:
            if not tf[2]["ue_code"]:
                del tf[2]["ue_code"]
            if not tf[2]["numero"]:
                # numero regroupant par semestre ou année:
                tf[2]["numero"] = next_ue_numero(
                    formation_id, int(tf[2]["semestre_idx"])
                )
            ue_id = do_ue_create(tf[2])
            if is_apc or cursus.UE_IS_MODULE or tf[2]["create_matiere"]:
                # rappel: en APC, toutes les UE ont une matière, créée ici
                # (inutilisée mais à laquelle les modules sont rattachés)
                matiere_id = sco_edit_matiere.do_matiere_create(
                    {"ue_id": ue_id, "titre": tf[2]["titre"], "numero": 1},
                )
            if cursus.UE_IS_MODULE:
                # dans ce mode, crée un (unique) module dans l'UE:
                _ = sco_edit_module.do_module_create(
                    {
                        "titre": tf[2]["titre"],
                        "code": tf[2]["acronyme"],
                        # tous les modules auront coef 1, et on utilisera les ECTS:
                        "coefficient": 1.0,
                        "ue_id": ue_id,
                        "matiere_id": matiere_id,
                        "formation_id": formation_id,
                        "semestre_id": tf[2]["semestre_idx"],
                    },
                )
            ue = db.session.get(UniteEns, ue_id)
            flash(f"UE créée (code {ue.ue_code})")
        else:
            if not tf[2]["numero"]:
                tf[2]["numero"] = 0
            do_ue_edit(tf[2])
            flash("UE modifiée")

    if tf[2]:
        dest_semestre_idx = tf[2]["semestre_idx"]
    elif ue:
        dest_semestre_idx = ue.semestre_idx
    elif default_semestre_idx:
        dest_semestre_idx = default_semestre_idx
    elif "semestre_idx" in request.form:
        dest_semestre_idx = request.form["semestre_idx"]
    else:
        dest_semestre_idx = 1

    return flask.redirect(
        url_for(
            "notes.ue_table",
            scodoc_dept=g.scodoc_dept,
            formation_id=formation_id,
            semestre_idx=dest_semestre_idx,
        )
    )


def _add_ue_semestre_id(ues: list[dict], is_apc):
    """ajoute semestre_id dans les ue, en regardant
    semestre_idx ou à défaut, pour les formations non APC, le premier module
    de chacune.
    Les UE sans modules se voient attribuer  le numero UE_SEM_DEFAULT (1000000),
    qui les place à la fin de la liste.
    """
    for ue in ues:
        if ue["semestre_idx"] is not None:
            ue["semestre_id"] = ue["semestre_idx"]
        elif is_apc:
            ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT
        else:
            # était le comportement ScoDoc7
            modules = sco_edit_module.module_list(args={"ue_id": ue["ue_id"]})
            if modules:
                ue["semestre_id"] = modules[0]["semestre_id"]
            else:
                ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT


def next_ue_numero(formation_id, semestre_id=None):
    """Numero d'une nouvelle UE dans cette formation.
    Si le semestre est specifie, cherche les UE ayant des modules de ce semestre
    """
    formation = db.session.get(Formation, formation_id)
    ues = ue_list(args={"formation_id": formation_id})
    if not ues:
        return 0
    if semestre_id is None:
        return ues[-1]["numero"] + 1000
    else:
        # Avec semestre: (prend le semestre du 1er module de l'UE)
        _add_ue_semestre_id(ues, formation.get_cursus().APC_SAE)
        ue_list_semestre = [ue for ue in ues if ue["semestre_id"] == semestre_id]
        if ue_list_semestre:
            return ue_list_semestre[-1]["numero"] + 10
        else:
            return ues[-1]["numero"] + 1000


def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
    """Delete an UE"""
    ue = UniteEns.query.get_or_404(ue_id)
    if ue.modules.all():
        raise ScoValueError(
            f"""Suppression de l'UE {ue.titre} impossible car
        des modules (ou SAÉ ou ressources) lui sont rattachés.""",
            dest_url=url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=ue.formation.id,
                semestre_idx=ue.semestre_idx,
            ),
        )
    if not ue.can_be_deleted():
        raise ScoNonEmptyFormationObject(
            "UE",
            msg=f"{ue.titre or ''} ({ue.acronyme})",
            dest_url=url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=ue.formation_id,
                semestre_idx=ue.semestre_idx,
            ),
        )

    if not dialog_confirmed:
        return scu.confirm_dialog(
            f"<h2>Suppression de l'UE {ue.titre or ''} ({ue.acronyme})</h2>",
            dest_url="",
            parameters={"ue_id": ue.id},
            cancel_url=url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=ue.formation_id,
                semestre_idx=ue.semestre_idx,
            ),
        )

    return do_ue_delete(ue, delete_validations=delete_validations)


def ue_table(formation_id=None, semestre_idx=1, msg=""):  # was ue_list
    """Liste des matières et modules d'une formation, avec liens pour
    éditer (si non verrouillée).
    """
    from app.scodoc import sco_formsemestre_validation

    formation: Formation = db.session.get(Formation, formation_id)
    if not formation:
        raise ScoValueError("invalid formation_id")
    parcours = formation.get_cursus()
    is_apc = parcours.APC_SAE
    if semestre_idx == "all" or semestre_idx == "":
        semestre_idx = None
    else:
        semestre_idx = int(semestre_idx)
    show_tags = scu.to_bool(request.args.get("show_tags", 0))
    locked = formation.has_locked_sems(semestre_idx)
    semestre_ids = range(1, parcours.NB_SEM + 1)
    # transition: on requete ici via l'ORM mais on utilise les fonctions ScoDoc7
    # basées sur des dicts
    ues_obj = UniteEns.query.filter_by(
        formation_id=formation_id, is_external=False
    ).order_by(UniteEns.semestre_idx, UniteEns.numero)
    # safety check: renumérote les ue s'il en manque ou s'il y a des ex-aequo.
    # cela facilite le travail de la passerelle !
    numeros = {ue.numero for ue in ues_obj}
    if (None in numeros) or len(numeros) < ues_obj.count():
        scu.objects_renumber(db, ues_obj)

    ues_externes_obj = UniteEns.query.filter_by(
        formation_id=formation_id, is_external=True
    )
    # liste ordonnée des formsemestres de cette formation:
    formsemestres = sorted(
        FormSemestre.query.filter_by(formation_id=formation_id).all(),
        key=lambda s: s.sort_key(),
        reverse=True,
    )

    if is_apc:
        # Pour faciliter la transition des anciens programmes non APC
        for ue in ues_obj:
            ue.guess_semestre_idx()
            # vérifie qu'on a bien au moins une matière dans chaque UE
            if ue.matieres.count() < 1:
                mat = Matiere(ue_id=ue.id)
                db.session.add(mat)
        # donne des couleurs aux UEs crées avant
        colorie_anciennes_ues(ues_obj)
        db.session.commit()
    ues = [ue.to_dict() for ue in ues_obj]
    ues_externes = [ue.to_dict() for ue in ues_externes_obj]

    # tri par semestre et numero:
    _add_ue_semestre_id(ues, is_apc)
    _add_ue_semestre_id(ues_externes, is_apc)
    ues.sort(key=lambda u: (u["semestre_id"], u["numero"]))
    ues_externes.sort(key=lambda u: (u["semestre_id"], u["numero"]))
    # Codes dupliqués (pour aider l'utilisateur)
    seen = set()
    duplicated_codes = {
        ue["ue_code"] for ue in ues if ue["ue_code"] in seen or seen.add(ue["ue_code"])
    }
    ues_with_duplicated_code = [ue for ue in ues if ue["ue_code"] in duplicated_codes]

    has_perm_change = current_user.has_permission(Permission.EditFormation)
    # editable = (not locked) and has_perm_change
    # On autorise maintenant la modification des formations qui ont
    # des semestres verrouillés, sauf si cela affect les notes passées
    # (verrouillées):
    #   - pas de modif des modules utilisés dans des semestres verrouillés
    #   - pas de changement des codes d'UE utilisés dans des semestres verrouillés
    editable = has_perm_change
    tag_editable = (
        current_user.has_permission(Permission.EditFormationTags) or has_perm_change
    )
    if locked:
        lockicon = scu.icontag("lock32_img", title="verrouillé")
    else:
        lockicon = ""

    arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags()
    delete_icon = scu.icontag(
        "delete_small_img", title="Supprimer (module inutilisé)", alt="supprimer"
    )
    delete_disabled_icon = scu.icontag(
        "delete_small_dis_img", title="Suppression impossible (module utilisé)"
    )
    H = [
        html_sco_header.sco_header(
            cssstyles=html_sco_header.BOOTSTRAP_MULTISELECT_CSS
            + ["libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/ue_table.css"],
            javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
            + [
                "libjs/jinplace-1.2.1.min.js",
                "js/ue_list.js",
                "js/edit_ue.js",
                "libjs/jQuery-tagEditor/jquery.tag-editor.min.js",
                "libjs/jQuery-tagEditor/jquery.caret.min.js",
                "js/module_tag_editor.js",
            ],
            page_title=f"Formation {formation.acronyme} v{formation.version}",
        ),
        f"""<h2>{formation.html()} {lockicon}
        </h2>
        """,
    ]
    if locked:
        H.append(
            """<p class="help">Cette formation est verrouillée car
des semestres verrouillés s'y réferent.
Si vous souhaitez modifier cette formation (par exemple pour y ajouter un module),
vous devez:
</p>
<ul class="help">
<li>soit créer une nouvelle version de cette formation pour pouvoir l'éditer
librement (vous pouvez passer par la fonction "Associer à une nouvelle version
du programme" (menu "Semestre") si vous avez un semestre en cours);
</li>
<li>soit déverrouiller le ou les semestres qui s'y réfèrent (attention, en
 principe ces semestres sont archivés et ne devraient pas être modifiés).
</li>
</ul>"""
        )
    if msg:
        H.append('<p class="msg">' + msg + "</p>")

    if ues_with_duplicated_code:
        H.append(
            f"""<div class="ue_warning"><span>Attention: plusieurs UEs de cette
            formation ont le même code : <tt>{
                ', '.join([
                '<a class="stdlink" href="' + url_for( "notes.ue_edit",
                    scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"] )
                + '">' + ue["acronyme"] + " (code " + ue["ue_code"] + ")</a>"
                for ue in ues_with_duplicated_code ])
            }</tt>.
            Il faut corriger cela, sinon les capitalisations et ECTS seront
            erronés !</span></div>"""
        )

    # Description de la formation
    H.append(
        render_template(
            "pn/form_descr.j2",
            formation=formation,
            parcours=parcours,
            editable=editable,
        )
    )

    # Formation APC (BUT) ?
    if is_apc:
        lock_info = (
            """<span class="lock_info">verrouillé (voir liste des semestres utilisateurs
            en bas de page)</span>
        """
            if locked
            else ""
        )
        H.append(
            f"""<div class="formation_apc_infos">
        <div class="ue_list_tit">Formation par compétences (BUT)
        - {_html_select_semestre_idx(formation_id, semestre_ids, semestre_idx)}
        </form>
        {lock_info}
        </div>
        """
        )
        if formation.referentiel_competence is None:
            descr_refcomp = ""
            msg_refcomp = "associer à un référentiel de compétences"
        else:
            descr_refcomp = f"""Référentiel de compétences:
            <a href="{url_for('notes.refcomp_show',
            scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}"
            class="stdlink">
            {formation.referentiel_competence.type_titre}
            {formation.referentiel_competence.specialite_long}
            </a>&nbsp;"""
            msg_refcomp = "changer"
        H.append(f"""<ul><li>{descr_refcomp}""")
        if current_user.has_permission(Permission.EditFormation):
            if (
                formation.referentiel_competence is None
                or formation.formsemestres.count() == 0
            ):
                H.append(
                    f"""<a class="stdlink" href="{url_for('notes.refcomp_assoc_formation',
            scodoc_dept=g.scodoc_dept, formation_id=formation_id)
            }">{msg_refcomp}</a>"""
                )
            elif formation.referentiel_competence is not None:
                H.append("""(non modifiable car utilisé par des semestres)""")
        H.append("</li>")
        if formation.referentiel_competence is not None:
            H.append(
                """<li>Parcours, compétences et UEs&nbsp;:
            <div class="formation_parcs">
            """
            )
            for parc in formation.referentiel_competence.parcours:
                H.append(
                    f"""<div><a href="{url_for("notes.parcour_formation",
                    scodoc_dept=g.scodoc_dept, formation_id=formation.id, parcour_id=parc.id )
                }">{parc.code}</a></div>"""
                )
            H.append("""</div></li>""")

        H.append(
            f"""
        <li> <a class="stdlink" href="{
            url_for('notes.edit_modules_ue_coefs',
                scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=semestre_idx)
        }">{'Visualiser' if locked else 'Éditer'} les coefficients des ressources et SAÉs</a>
        </li>
        </ul>
        """
        )
    # Description des UE/matières/modules
    H.append(
        f"""
    <div class="formation_ue_list">
    <div class="ue_list_tit">Formation (programme pédagogique):</div>
    <form>
    <input type="checkbox" class="sco_tag_checkbox"
        {'checked' if show_tags else ''}
    > Montrer les tags des modules voire en ajouter
    <i>(ceux correspondant aux titres des compétences étant ajoutés par défaut)</i>
    </input>
    </form>
    """
    )
    if is_apc:
        H.append(
            sco_edit_apc.html_edit_formation_apc(
                formation,
                semestre_idx=semestre_idx,
                editable=editable,
                tag_editable=tag_editable,
            )
        )
    else:
        H.append('<div class="formation_classic_infos">')
        H.append(
            _ue_table_ues(
                parcours,
                ues,
                editable,
                tag_editable,
                has_perm_change,
                arrow_up,
                arrow_down,
                arrow_none,
                delete_icon,
                delete_disabled_icon,
            )
        )
        if editable:
            H.append(
                f"""<ul>
                <li><a class="stdlink" href="{
                    url_for('notes.ue_create', scodoc_dept=g.scodoc_dept, formation_id=formation_id)
                    }">Ajouter une UE</a>
                </li>
                <li><a href="{
                    url_for('notes.formation_add_malus_modules',
                    scodoc_dept=g.scodoc_dept, formation_id=formation_id)
                }" class="stdlink">Ajouter des modules de malus dans chaque UE</a>
                </li>
                </ul>
                """
            )
        H.append("</div>")
    H.append("</div>")  # formation_ue_list

    if ues_externes:
        H.append(
            f"""
        <div class="formation_ue_list formation_ue_list_externes">
            <div class="ue_list_tit">UE externes déclarées (pour information):
            </div>
            {_ue_table_ues(
                parcours,
                ues_externes,
                editable,
                tag_editable,
                has_perm_change,
                arrow_up,
                arrow_down,
                arrow_none,
                delete_icon,
                delete_disabled_icon,
            )}
        </div>
        """
        )
    H.append("<p><ul>")
    if has_perm_change:
        H.append(
            f"""
            <li><a class="stdlink" href="{
            url_for('notes.formsemestre_associate_new_version',
            scodoc_dept=g.scodoc_dept, formation_id=formation_id
            )
            }">Créer une nouvelle version de la formation</a> (copie non verrouillée)
            </li>

        """
        )
        if not len(formsemestres):
            H.append(
                f"""
            <li><a class="stdlink" href="{
            url_for('notes.formation_delete',
            scodoc_dept=g.scodoc_dept, formation_id=formation_id
            )
            }">Supprimer cette formation</a> (pas encore utilisée par des semestres)
            </li>
            """
            )
    H.append(
        f"""
        <li><a class="stdlink" href="{
            url_for('notes.formation_table_recap', scodoc_dept=g.scodoc_dept,
                formation_id=formation_id)
            }">Table récapitulative de la formation</a>
        </li>
        <li><a class="stdlink" href="{
            url_for('notes.formation_tag_modules_by_type', scodoc_dept=g.scodoc_dept,
                formation_id=formation_id,
                semestre_idx=1 if semestre_idx is None else semestre_idx
                )
            }">Tagguer tous les modules par leur type</a> (tag <tt>res</tt>, <tt>sae</tt>).
        </li>
        <li><a class="stdlink" href="{
            url_for('notes.formation_export', scodoc_dept=g.scodoc_dept,
                formation_id=formation_id, fmt='xml')
            }">Export XML de la formation</a> ou
            <a class="stdlink" href="{
            url_for('notes.formation_export', scodoc_dept=g.scodoc_dept,
                formation_id=formation_id, fmt='xml', export_codes_apo=0)
            }">sans codes Apogée</a>
            (permet de l'enregistrer pour l'échanger avec un autre site)
        </li>

        <li><a class="stdlink" href="{
            url_for('notes.formation_export', scodoc_dept=g.scodoc_dept,
            formation_id=formation_id, fmt='json')
            }">Export JSON de la formation</a>
        </li>

        <li><a class="stdlink" href="{
            url_for('notes.module_table', scodoc_dept=g.scodoc_dept,
            formation_id=formation_id)
            }">Liste détaillée des modules de la formation</a> (debug)
        </li>
        </ul>
        </p>"""
    )
    if has_perm_change:
        H.append(
            """
        <h3> <a name="sems">Semestres ou sessions de cette formation</a></h3>
        <p><ul>"""
        )
        for formsemestre in formsemestres:
            H.append(f"""<li>{formsemestre.html_link_status()}""")
            if not formsemestre.etat:
                H.append(" [verrouillé]")
            else:
                H.append(
                    f""" &nbsp;<a class="stdlink" href="{url_for("notes.formsemestre_editwithmodules",
                        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id
                        )}">Modifier</a>"""
                )
            H.append("</li>")
        H.append("</ul>")

    if current_user.has_permission(Permission.EditFormSemestre):
        H.append(
            f"""<ul>
        <li><a class="stdlink" href="{
            url_for('notes.formsemestre_createwithmodules', scodoc_dept=g.scodoc_dept,
            formation_id=formation_id, semestre_id=1)
        }">Mettre en place un nouveau semestre de formation {formation.acronyme}</a>
        </li>
        </ul>"""
        )
    #   <li>(debug) <a class="stdlink" href="check_form_integrity?formation_id=%(formation_id)s">Vérifier cohérence</a></li>

    warn, _ = sco_formsemestre_validation.check_formation_ues(formation)
    H.append(warn)

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


def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx):
    htm = """<form method="get">Semestre:
    <select onchange="this.form.submit()" name="semestre_idx" id="semestre_idx" >
    """
    for i in list(semestre_ids) + ["all"]:
        if i == "all":
            label = "tous"
        else:
            label = f"S{i}"
        htm += f"""<option value="{i}" {
            'selected'
            if (semestre_idx == i)
            or (i == "all" and semestre_idx is None)
            else ''
            }>{label}</option>
        """

    htm += f"""
    </select>
    <input type="hidden" name="formation_id" value="{formation_id}"></input>
    <input type="hidden" name="show_tags" value="0"></input>
    </form>"""
    return htm


def _ue_table_ues(
    parcours,
    ues: list[dict],
    editable,
    tag_editable,
    has_perm_change,
    arrow_up,
    arrow_down,
    arrow_none,
    delete_icon,
    delete_disabled_icon,
) -> str:
    """Édition de programme: liste des UEs (avec leurs matières et modules).
    Pour les formations classiques (non APC/BUT)
    """
    H = []
    cur_ue_semestre_id = None
    iue = 0
    for ue in ues:
        if ue["ects"] is None:
            ue["ects_str"] = ""
        else:
            ue["ects_str"] = ", %g ECTS" % ue["ects"]
        if editable:
            klass = "span_apo_edit"
        else:
            klass = ""
        ue["code_apogee_str"] = (
            """, Apo: <span class="%s" data-url="edit_ue_set_code_apogee" id="%s" data-placeholder="%s">"""
            % (klass, ue["ue_id"], scu.APO_MISSING_CODE_STR)
            + (ue["code_apogee"] or "")
            + "</span>"
        )

        if cur_ue_semestre_id != ue["semestre_id"]:
            cur_ue_semestre_id = ue["semestre_id"]
            if ue["semestre_id"] == codes_cursus.UE_SEM_DEFAULT:
                lab = "Pas d'indication de semestre:"
            else:
                lab = f"""Semestre {ue["semestre_id"]}:"""
            H.append(
                f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>'
            )
            H.append('<ul class="notes_ue_list">')
        H.append('<li class="notes_ue_list">')
        if iue != 0 and editable:
            H.append(
                '<a href="ue_move?ue_id=%s&after=0" class="aud">%s</a>'
                % (ue["ue_id"], arrow_up)
            )
        else:
            H.append(arrow_none)
        if iue < len(ues) - 1 and editable:
            H.append(
                '<a href="ue_move?ue_id=%s&after=1" class="aud">%s</a>'
                % (ue["ue_id"], arrow_down)
            )
        else:
            H.append(arrow_none)
        ue["acro_titre"] = str(ue["acronyme"])
        if ue["titre"] != ue["acronyme"]:
            ue["acro_titre"] += " " + str(ue["titre"])
        H.append(
            """%(acro_titre)s <span class="ue_code">(code %(ue_code)s%(ects_str)s, coef. %(coefficient)3.2f%(code_apogee_str)s)</span>
            <span class="ue_coef"></span>
            """
            % ue
        )
        if ue["type"] != codes_cursus.UE_STANDARD:
            H.append(
                '<span class="ue_type">%s</span>'
                % codes_cursus.UE_TYPE_NAME[ue["type"]]
            )
        if ue["is_external"]:
            # Cas spécial: si l'UE externe a plus d'un module, c'est peut être une UE
            # qui a été déclarée externe par erreur (ou suite à un bug d'import/export xml)
            # Dans ce cas, propose de changer le type (même si verrouillée)
            if len(sco_moduleimpl.moduleimpls_in_external_ue(ue["ue_id"])) > 1:
                H.append('<span class="ue_is_external">')
                if has_perm_change:
                    H.append(
                        f"""<a class="stdlink" href="{
                            url_for("notes.ue_set_internal", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"])
                            }">transformer en UE ordinaire</a>&nbsp;"""
                    )
                H.append("</span>")
        ue_editable = editable and not ue_is_locked(ue["ue_id"])
        if ue_editable:
            H.append(
                '<a class="stdlink" href="ue_edit?ue_id=%(ue_id)s">modifier</a>' % ue
            )
        else:
            H.append('<span class="locked">[verrouillé]</span>')
        H.append(
            _ue_table_matieres(
                parcours,
                ue,
                editable,
                tag_editable,
                arrow_up,
                arrow_down,
                arrow_none,
                delete_icon,
                delete_disabled_icon,
            )
        )
        if (iue >= len(ues) - 1) or ue["semestre_id"] != ues[iue + 1]["semestre_id"]:
            H.append(
                f"""</ul><ul><li><a href="{url_for('notes.ue_create', scodoc_dept=g.scodoc_dept,
                formation_id=ue['formation_id'], semestre_idx=ue['semestre_id'])
            }">Ajouter une UE dans le semestre {ue['semestre_id'] or ''}</a></li></ul>
            </div>
            """
            )
        iue += 1

    return "\n".join(H)


def _ue_table_matieres(
    parcours,
    ue,
    editable,
    tag_editable,
    arrow_up,
    arrow_down,
    arrow_none,
    delete_icon,
    delete_disabled_icon,
):
    """Édition de programme: liste des matières (et leurs modules) d'une UE."""
    H = []
    if not parcours.UE_IS_MODULE:
        H.append('<ul class="notes_matiere_list">')
    matieres = sco_edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]})
    for mat in matieres:
        if not parcours.UE_IS_MODULE:
            H.append('<li class="notes_matiere_list">')
            if editable and not sco_edit_matiere.matiere_is_locked(mat["matiere_id"]):
                H.append(
                    f"""<a class="stdlink" href="{
                        url_for("notes.matiere_edit",
                        scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])
                        }">
                    """
                )
            H.append("%(titre)s" % mat)
            if editable and not sco_edit_matiere.matiere_is_locked(mat["matiere_id"]):
                H.append("</a>")

        modules = sco_edit_module.module_list(args={"matiere_id": mat["matiere_id"]})
        H.append(
            _ue_table_modules(
                parcours,
                ue,
                mat,
                modules,
                editable,
                tag_editable,
                arrow_up,
                arrow_down,
                arrow_none,
                delete_icon,
                delete_disabled_icon,
            )
        )
        if not parcours.UE_IS_MODULE:
            H.append("</li>")
    if not matieres:
        H.append("<li>Aucune matière dans cette UE ! ")
        if editable:
            H.append(
                """<a class="stdlink" href="ue_delete?ue_id=%(ue_id)s">supprimer l'UE</a>"""
                % ue
            )
        H.append("</li>")
    if editable and not parcours.UE_IS_MODULE:
        H.append(
            '<li><a class="stdlink" href="matiere_create?ue_id=%(ue_id)s">créer une matière</a> </li>'
            % ue
        )
    if not parcours.UE_IS_MODULE:
        H.append("</ul>")
    return "\n".join(H)


def _ue_table_modules(
    parcours,
    ue,
    mat,
    modules,
    editable,
    tag_editable,
    arrow_up,
    arrow_down,
    arrow_none,
    delete_icon,
    delete_disabled_icon,
    unit_name="matière",
    add_suppress_link=True,  # lien "supprimer cette matière"
    empty_list_msg="Aucun élément dans cette matière",
    create_element_msg="créer un module",
):
    """Édition de programme: liste des modules d'une matière d'une UE"""
    H = ['<ul class="notes_module_list">']
    im = 0
    for mod in modules:
        mod["nb_moduleimpls"] = sco_edit_module.module_count_moduleimpls(
            mod["module_id"]
        )
        klass = "notes_module_list"
        if mod["module_type"] == ModuleType.MALUS:
            klass += " module_malus"
        H.append('<li class="%s">' % klass)

        H.append('<span class="notes_module_list_buts">')
        if im != 0 and editable:
            H.append(
                '<a href="module_move?module_id=%s&after=0" class="aud">%s</a>'
                % (mod["module_id"], arrow_up)
            )
        else:
            H.append(arrow_none)
        if im < len(modules) - 1 and editable:
            H.append(
                '<a href="module_move?module_id=%s&after=1" class="aud">%s</a>'
                % (mod["module_id"], arrow_down)
            )
        else:
            H.append(arrow_none)
        im += 1
        if mod["nb_moduleimpls"] == 0 and editable:
            icon = delete_icon
        else:
            icon = delete_disabled_icon
        H.append(
            '<a class="smallbutton" href="module_delete?module_id=%s">%s</a>'
            % (mod["module_id"], icon)
        )

        H.append("</span>")

        mod_editable = (
            editable  # and not sco_edit_module.module_is_locked( Mod['module_id'])
        )
        if mod_editable:
            H.append(
                '<a class="discretelink" title="Modifier le module numéro %(numero)s, utilisé par %(nb_moduleimpls)d sessions" href="module_edit?module_id=%(module_id)s">'
                % mod
            )
        if mod["module_type"] not in (scu.ModuleType.STANDARD, scu.ModuleType.MALUS):
            H.append(
                f"""<span class="invalid-module-type">{scu.EMO_WARNING} type incompatible </span>"""
            )
        H.append(
            '<span class="formation_module_tit">%s</span>'
            % scu.join_words(mod["code"], mod["titre"])
        )
        if mod_editable:
            H.append("</a>")
        heurescoef = (
            "%(heures_cours)s/%(heures_td)s/%(heures_tp)s, coef. %(coefficient)s" % mod
        )
        if mod_editable:
            klass = "span_apo_edit"
        else:
            klass = ""
        heurescoef += (
            ', Apo: <span class="%s" data-url="edit_module_set_code_apogee" id="%s" data-placeholder="%s">'
            % (klass, mod["module_id"], scu.APO_MISSING_CODE_STR)
            + (mod["code_apogee"] or "")
            + "</span>"
        )
        if tag_editable:
            tag_cls = "module_tag_editor"
        else:
            tag_cls = "module_tag_editor_ro"
        tag_mk = """<span class="sco_tag_edit"><form><textarea data-module_id="{}" class="{}">{}</textarea></form></span>"""
        tag_edit = tag_mk.format(
            mod["module_id"],
            tag_cls,
            ",".join(sco_tag_module.module_tag_list(mod["module_id"])),
        )
        if ue["semestre_idx"] is not None and mod["semestre_id"] != ue["semestre_idx"]:
            warning_semestre = ' <span class="red">incohérent ?</span>'
        else:
            warning_semestre = ""
        H.append(
            " %s %s%s" % (parcours.SESSION_NAME, mod["semestre_id"], warning_semestre)
            + " (%s)" % heurescoef
            + tag_edit
        )
        H.append("</li>")
    if not modules:
        H.append(f"<li>{empty_list_msg} ! ")
        if editable and add_suppress_link:
            H.append(
                f"""<a class="stdlink" href="{
                    url_for("notes.matiere_delete",
                    scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])}"
                >la supprimer</a>
                """
            )
        H.append("</li>")
    if editable:  # and ((not parcours.UE_IS_MODULE) or len(Modlist) == 0):
        H.append(
            f"""<li> <a class="stdlink" href="{
                    url_for("notes.module_create",
                    scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])}"
            >{create_element_msg}</a></li>
            """
        )
    H.append("</ul>")
    return "\n".join(H)


def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None):
    """HTML list of UE sharing this code
    Either ue_code or ue_id may be specified.
    hide_ue_id spécifie un id à retirer de la liste.
    """
    if ue_id is not None:
        ue = UniteEns.query.get_or_404(ue_id)
        if not ue_code:
            ue_code = ue.ue_code
        formation_code = ue.formation.formation_code
        # UE du même code, code formation et departement:
        q_ues = (
            UniteEns.query.filter_by(ue_code=ue_code)
            .join(UniteEns.formation)
            .filter_by(dept_id=g.scodoc_dept_id, formation_code=formation_code)
        )
    else:
        # Toutes les UE du departement avec ce code:
        q_ues = (
            UniteEns.query.filter_by(ue_code=ue_code)
            .join(UniteEns.formation)
            .filter_by(dept_id=g.scodoc_dept_id)
        )

    if hide_ue_id is not None:  # enlève l'ue de depart
        q_ues = q_ues.filter(UniteEns.id != hide_ue_id)

    ues = q_ues.all()
    msg = " dans les formations du département "
    if not ues:
        if ue_id is not None:
            return f"""<span class="ue_share">Seule UE avec code {
                ue_code if ue_code is not None else '-'}{msg}</span>"""
        else:
            return f"""<span class="ue_share">Aucune UE avec code {
                ue_code if ue_code is not None else '-'}{msg}</span>"""
    H = []
    if ue_id:
        H.append(
            f"""<span class="ue_share">Pour information, autres UEs avec le code {
                ue_code if ue_code is not None else '-'}{msg}:</span>"""
        )
    else:
        H.append(
            f"""<span class="ue_share">UE avec le code {
                ue_code if ue_code is not None else '-'}{msg}:</span>"""
        )
    H.append("<ul>")
    for ue in ues:
        H.append(
            f"""<li>{ue.acronyme} ({ue.titre or ''}) dans
            <a class="stdlink" href="{
                url_for("notes.ue_table",
                scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"
            >{ue.formation.acronyme} ({ue.formation.titre})</a>, version {ue.formation.version}
            </li>
            """
        )
    H.append("</ul>")
    return "\n".join(H)


def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False):
    "edit an UE"
    # check
    ue_id = args["ue_id"]
    ue = ue_list({"ue_id": ue_id})[0]
    if (not bypass_lock) and ue_is_locked(ue["ue_id"]):
        raise ScoLockedFormError()
    # check: acronyme unique dans cette formation
    if "acronyme" in args:
        new_acro = args["acronyme"]
        ues = ue_list({"formation_id": ue["formation_id"], "acronyme": new_acro})
        if ues and ues[0]["ue_id"] != ue_id:
            raise ScoValueError(
                f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
                (chaque UE doit avoir un acronyme unique dans la formation.)"""
            )
    # On ne peut pas supprimer le code UE:
    if "ue_code" in args and not args["ue_code"]:
        del args["ue_code"]

    cnx = ndb.GetDBConnexion()
    _ueEditor.edit(cnx, args)

    formation = db.session.get(Formation, ue["formation_id"])
    if not dont_invalidate_cache:
        # Invalide les semestres utilisant cette formation
        # ainsi que les poids et coefs
        formation.invalidate_module_coefs()


# essai edition en ligne:
def edit_ue_set_code_apogee(id=None, value=None):
    "set UE code apogee"
    ue_id = id
    value = value.strip("-_ \t")[:APO_CODE_STR_LEN]  # tronque

    log("edit_ue_set_code_apogee: ue_id=%s code_apogee=%s" % (ue_id, value))

    ues = ue_list(args={"ue_id": ue_id})
    if not ues:
        return "ue invalide"

    do_ue_edit(
        {"ue_id": ue_id, "code_apogee": value},
        bypass_lock=True,
        dont_invalidate_cache=False,
    )
    if not value:
        value = scu.APO_MISSING_CODE_STR
    return value


def ue_is_locked(ue_id):
    """True if UE should not be modified
    (contains modules used in a locked formsemestre)
    """
    r = ndb.SimpleDictFetch(
        """SELECT ue.id
        FROM notes_ue ue, notes_modules mod, notes_formsemestre sem, notes_moduleimpl mi
        WHERE ue.id = mod.ue_id
        AND mi.module_id = mod.id AND mi.formsemestre_id = sem.id
        AND ue.id = %(ue_id)s AND sem.etat = false
        """,
        {"ue_id": ue_id},
    )
    return len(r) > 0


UE_PALETTE = [
    "#B80004",  # rouge
    "#F97B3D",  # Orange Crayola
    "#FEB40B",  # Honey Yellow
    "#80CB3F",  # Yellow Green
    "#05162E",  # Oxford Blue
    "#548687",  # Steel Teal
    "#444054",  # Independence
    "#889696",  # Spanish Gray
    "#0CA4A5",  # Viridian Green
]


def colorie_anciennes_ues(ues: list[UniteEns]) -> None:
    """Avant ScoDoc 9.2, les ue n'avaient pas de couleurs
    Met des défauts raisonnables
    """
    nb_colors = len(UE_PALETTE)
    index = 0
    last_sem_idx = 0
    for ue in ues:
        if ue.semestre_idx != last_sem_idx:
            index = 0
        last_sem_idx = ue.semestre_idx
        if ue.color is None:
            ue.color = UE_PALETTE[index % nb_colors]
            index += 1
            db.session.add(ue)


def ue_guess_color_default(formation_id: int, default_semestre_idx: int) -> str:
    """Un code couleur pour une nouvelle UE dans ce semestre"""
    nb_colors = len(UE_PALETTE)
    # UE existantes dans ce semestre:
    nb_ues = UniteEns.query.filter(
        UniteEns.formation_id == formation_id,
        UniteEns.semestre_idx == default_semestre_idx,
    ).count()
    index = nb_ues
    return UE_PALETTE[index % nb_colors]
