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

##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2023 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 modules
(portage from DTML)
"""
import flask
from flask import flash, url_for, render_template
from flask import g, request
from flask_login import current_user

from app import db, log
from app import models
from app.models import APO_CODE_STR_LEN
from app.models import Formation, Matiere, Module, UniteEns
from app.models import FormSemestre, ModuleImpl
from app.models import ScolarNews
from app.models.but_refcomp import ApcAppCritique, ApcParcours

import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import (
    ScoValueError,
    ScoLockedFormError,
    ScoGenError,
    ScoNonEmptyFormationObject,
)
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_matiere
from app.scodoc import sco_moduleimpl

_moduleEditor = ndb.EditableTable(
    "notes_modules",
    "module_id",
    (
        "module_id",
        "titre",
        "code",
        "abbrev",
        "heures_cours",
        "heures_td",
        "heures_tp",
        "coefficient",
        "ue_id",
        "matiere_id",
        "formation_id",
        "semestre_id",
        "numero",
        "code_apogee",
        "module_type"
        #'ects'
    ),
    sortkey="numero, code, titre",
    output_formators={
        "heures_cours": ndb.float_null_is_zero,
        "heures_td": ndb.float_null_is_zero,
        "heures_tp": ndb.float_null_is_zero,
        "numero": ndb.int_null_is_zero,
        "coefficient": ndb.float_null_is_zero,
        "module_type": ndb.int_null_is_zero
        #'ects' : ndb.float_null_is_null
    },
)


def module_list(*args, **kw):
    "list modules"
    cnx = ndb.GetDBConnexion()
    return _moduleEditor.list(cnx, *args, **kw)


def do_module_create(args) -> int:
    "Create a module. Returns id of new object."
    formation = Formation.query.get(args["formation_id"])
    # refuse de créer un module APC avec semestres incohérents:
    if formation.is_apc():
        ue = UniteEns.query.get(args["ue_id"])
        if int(args.get("semestre_id", 0)) != ue.semestre_idx:
            raise ScoValueError("Formation incompatible: contacter le support ScoDoc")
    # create
    cnx = ndb.GetDBConnexion()
    module_id = _moduleEditor.create(cnx, args)
    log(f"do_module_create: created {module_id} with {args}")

    # news
    ScolarNews.add(
        typ=ScolarNews.NEWS_FORM,
        obj=formation.id,
        text=f"Modification de la formation {formation.acronyme}",
        max_frequency=10 * 60,
    )
    formation.invalidate_cached_sems()
    return module_id


def module_create(
    matiere_id=None, module_type=None, semestre_id=None, formation_id=None
):
    """Formulaire de création d'un module
    Si matiere_id est spécifié, le module sera créé dans cette matière (cas normal).
    Sinon, donne le choix de l'UE de rattachement et utilise la première
    matière de cette UE (si elle n'existe pas, la crée).
    """
    return module_edit(
        create=True,
        matiere_id=matiere_id,
        module_type=module_type,
        semestre_id=semestre_id,
        formation_id=formation_id,
    )


def can_delete_module(module):
    "True si le module n'est pas utilisée dans des formsemestre"
    return len(module.modimpls.all()) == 0


def do_module_delete(oid):
    "delete module"
    module = Module.query.get_or_404(oid)
    mod = module_list({"module_id": oid})[0]  # sco7
    if module_is_locked(module.id):
        raise ScoLockedFormError()
    if not can_delete_module(module):
        raise ScoNonEmptyFormationObject(
            "Module",
            msg=module.titre,
            dest_url=url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=module.formation_id,
                semestre_idx=module.ue.semestre_idx,
            ),
        )

    # S'il y a des moduleimpls, on ne peut pas detruire le module !
    mods = sco_moduleimpl.moduleimpl_list(module_id=oid)
    if mods:
        err_page = f"""
        <h3>Destruction du module impossible car il est utilisé dans des
         semestres existants !</h3>
        <p class="help">Il faut d'abord supprimer le semestre (ou en retirer
        ce module).
        Mais il est peut être préférable de laisser ce programme intact et
        d'en créer une nouvelle version pour la modifier sans affecter
        les semestres déjà en place.
        </p>
        <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, 
            formation_id=mod["formation_id"])}">reprendre</a>
        """
        raise ScoGenError(err_page)
    # delete
    cnx = ndb.GetDBConnexion()
    _moduleEditor.delete(cnx, oid)

    # news
    formation = module.formation
    ScolarNews.add(
        typ=ScolarNews.NEWS_FORM,
        obj=mod["formation_id"],
        text=f"Modification de la formation {formation.acronyme}",
        max_frequency=10 * 60,
    )
    formation.invalidate_cached_sems()


def module_delete(module_id=None):
    """Delete a module"""
    module = Module.query.get_or_404(module_id)
    mod = module_list(args={"module_id": module_id})[0]  # sco7

    if not can_delete_module(module):
        raise ScoNonEmptyFormationObject(
            "Module",
            msg=module.titre,
            dest_url=url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=module.formation_id,
                semestre_idx=module.ue.semestre_idx,
            ),
        )

    H = [
        html_sco_header.sco_header(page_title="Suppression d'un module"),
        f"""<h2>Suppression du module {module.titre or "<em>sans titre</em>"} ({module.code})</h2>""",
    ]

    dest_url = url_for(
        "notes.ue_table",
        scodoc_dept=g.scodoc_dept,
        formation_id=module.formation_id,
        semestre_idx=module.ue.semestre_idx,
    )
    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        (("module_id", {"input_type": "hidden"}),),
        initvalues=mod,
        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(dest_url)
    else:
        do_module_delete(module_id)
        return flask.redirect(dest_url)


def do_module_edit(vals: dict) -> None:
    "edit a module"
    # check
    mod = module_list({"module_id": vals["module_id"]})[0]
    if module_is_locked(mod["module_id"]):
        # formation verrouillée: empeche de modifier certains champs:
        vals = vals.copy()
        protected_fields = ("coefficient", "ue_id", "matiere_id", "semestre_id")
        for f in protected_fields:
            if f in vals:
                del vals[f]
    # edit
    cnx = ndb.GetDBConnexion()
    _moduleEditor.edit(cnx, vals)
    Formation.query.get(mod["formation_id"]).invalidate_cached_sems()


def check_module_code_unicity(code, field, formation_id, module_id=None):
    "true si code module unique dans la formation"
    modules = module_list(args={"code": code, "formation_id": formation_id})
    if module_id:  # edition: supprime le module en cours
        modules = [m for m in modules if m["module_id"] != module_id]

    return len(modules) == 0


def module_edit(
    module_id=None,
    create=False,
    matiere_id=None,
    module_type=None,
    semestre_id=None,
    formation_id=None,
):
    """Formulaire édition ou création module.
    Si create, création nouveau module.
        Si matiere_id est spécifié, le module sera créé dans cette matière (cas normal).
        Sinon, donne le choix de l'UE de rattachement et utilise la première matière
        de cette UE (si elle n'existe pas, la crée).
    """
    from app.scodoc import sco_tag_module

    # --- Détermination de la formation
    orig_semestre_idx = semestre_id
    ue = None
    if create:
        if matiere_id:
            matiere = Matiere.query.get_or_404(matiere_id)
            ue = matiere.ue
            formation = ue.formation
            orig_semestre_idx = ue.semestre_idx if semestre_id is None else semestre_id
        else:
            formation = Formation.query.get_or_404(formation_id)
        module = None
        unlocked = True
    else:
        if not module_id:
            raise ValueError("missing module_id !")
        module = models.Module.query.get_or_404(module_id)
        ue = module.ue
        module_dict = module.to_dict()
        formation = module.formation
        unlocked = not module_is_locked(module_id)

    parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
    is_apc = parcours.APC_SAE  # BUT
    if not create:
        orig_semestre_idx = module.ue.semestre_idx if is_apc else module.semestre_id
    if orig_semestre_idx is None:
        orig_semestre_idx = 1
    # il y a-t-il des modimpls ?
    in_use = (module is not None) and (len(module.modimpls.all()) > 0)
    matieres = Matiere.query.filter(
        Matiere.ue_id == UniteEns.id, UniteEns.formation_id == formation.id
    ).order_by(UniteEns.semestre_idx, UniteEns.numero, Matiere.numero)
    if in_use:
        # restreint aux matières du même semestre
        matieres = matieres.filter(UniteEns.semestre_idx == module.ue.semestre_idx)

    if is_apc:
        # ne conserve que la 1ere matière de chaque UE,
        # et celle à laquelle ce module est rattaché
        matieres = [
            mat
            for mat in matieres
            if (module and module.matiere and (module.matiere.id == mat.id))
            or (mat.id == mat.ue.matieres.first().id)
        ]
        mat_names = [
            "S%s / %s" % (mat.ue.semestre_idx, mat.ue.acronyme) for mat in matieres
        ]
    else:
        mat_names = ["%s / %s" % (mat.ue.acronyme, mat.titre or "") for mat in matieres]

    if module:  # edition
        ue_mat_ids = ["%s!%s" % (mat.ue.id, mat.id) for mat in matieres]
        module_dict["ue_matiere_id"] = "%s!%s" % (
            module_dict["ue_id"],
            module_dict["matiere_id"],
        )

    semestres_indices = list(range(1, parcours.NB_SEM + 1))
    # Toutes les UEs de la formation (tout parcours):
    ues = formation.ues.order_by(
        UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme
    )
    if is_apc and create and semestre_id is not None:
        ues = ues.filter_by(semestre_idx=semestre_id)
    # L'UE de rattachement par défaut: 1ere du semestre
    ue_default = (
        formation.ues.filter_by(semestre_idx=orig_semestre_idx)
        .order_by(UniteEns.numero, UniteEns.acronyme)
        .first()
    )

    # --- Titre de la page
    if create:
        if is_apc and module_type is not None:
            object_name = scu.MODULE_TYPE_NAMES[module_type]
        else:
            object_name = "Module"
        page_title = f"Création {object_name}"
        if matiere_id:
            title = f"""Création {object_name} dans la matière
            {matiere.titre},
            (UE {ue.acronyme}), semestre {ue.semestre_idx}
            """
        else:
            title = f"""Création {object_name} dans la formation
            {formation.acronyme}"""
    else:
        page_title = f"Modification du module {module.code or module.titre or ''}"
        title = f"""Modification du module {module.code or ''} {module.titre or ''}
        (formation {formation.acronyme}, version {formation.version})
        """

    H = [
        html_sco_header.sco_header(
            page_title=page_title,
            cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"],
            javascripts=[
                "libjs/jQuery-tagEditor/jquery.tag-editor.min.js",
                "libjs/jQuery-tagEditor/jquery.caret.min.js",
                "js/module_tag_editor.js",
                "js/module_edit.js",
            ],
        ),
        f"""<h2>{title}</h2>""",
        render_template(
            "scodoc/help/modules.j2",
            is_apc=is_apc,
            semestre_id=semestre_id,
            formsemestres=FormSemestre.query.filter(
                ModuleImpl.formsemestre_id == FormSemestre.id,
                ModuleImpl.module_id == module_id,
            )
            .order_by(FormSemestre.date_debut)
            .all()
            if not create
            else None,
            create=create,
        ),
    ]
    if not unlocked:
        H.append(
            """<div class="ue_warning"><span>Formation verrouillée, seuls
            certains éléments peuvent être modifiés</span></div>"""
        )
    if is_apc:
        module_types = scu.ModuleType  # tous les types
    else:
        # ne propose pas SAE et Ressources, sauf si déjà de ce type...
        module_types = set(scu.ModuleType) - {
            scu.ModuleType.RESSOURCE,
            scu.ModuleType.SAE,
        }
        if module:
            module_types |= {
                scu.ModuleType(module.module_type)
                if module.module_type
                else scu.ModuleType.STANDARD
            }
    # Numéro du module
    # cherche le numero adéquat (pour placer le module en fin de liste)
    if module:
        default_num = module.numero
    else:
        modules = formation.modules.all()
        if modules:
            default_num = max([m.numero or 0 for m in modules]) + 10
        else:
            default_num = 10

    descr = [
        (
            "code",
            {
                "size": 10,
                "explanation": """code du module (issu du programme, exemple M1203,
                R2.01, ou SAÉ 3.4. Doit être unique dans la formation)""",
                "allow_null": False,
                "validator": lambda val, field, formation_id=formation.id: check_module_code_unicity(
                    val, field, formation_id, module_id=module.id if module else None
                ),
            },
        ),
        (
            "titre",
            {
                "size": 30,
                "explanation": """nom du module. Exemple:
                <em>Introduction à la démarche ergonomique</em>""",
            },
        ),
        (
            "abbrev",
            {
                "size": 20,
                "explanation": """nom abrégé (pour bulletins).
                Exemple: <em>Intro. à l'ergonomie</em>""",
            },
        ),
        (
            "module_type",
            {
                "input_type": "menu",
                "title": "Type",
                "explanation": "",
                "labels": [x.name.capitalize() for x in module_types],
                "allowed_values": [str(int(x)) for x in module_types],
                "enabled": unlocked,
            },
        ),
        (
            "heures_cours",
            {
                "title": "Heures cours :",
                "size": 4,
                "type": "float",
                "min_value": 0,
                "explanation": "nombre d'heures de cours (optionnel)",
            },
        ),
        (
            "heures_td",
            {
                "title": "Heures de TD :",
                "size": 4,
                "type": "float",
                "min_value": 0,
                "explanation": "nombre d'heures de Travaux Dirigés (optionnel)",
            },
        ),
        (
            "heures_tp",
            {
                "title": "Heures de TP :",
                "size": 4,
                "type": "float",
                "min_value": 0,
                "explanation": "nombre d'heures de Travaux Pratiques (optionnel)",
            },
        ),
    ]
    if is_apc:
        if module:
            coefs_lst = module.ue_coefs_list()
            if coefs_lst:
                coefs_descr_txt = ", ".join(
                    [f"{ue.acronyme}: {c}" for (ue, c) in coefs_lst]
                )
            else:
                coefs_descr_txt = """<span class="missing_value">non définis</span>"""
            descr += [
                (
                    "ue_coefs",
                    {
                        "readonly": True,
                        "title": "Coefficients vers les UE ",
                        "default": coefs_descr_txt,
                        "explanation": """ <br>(passer par la page d'édition de la
                        formation pour modifier les coefficients)""",
                    },
                )
            ]
        else:
            descr += [
                (
                    "sep_ue_coefs",
                    {
                        "input_type": "separator",
                        "title": """
                    <div>(<em>les coefficients vers les UE se fixent sur la page dédiée</em>)
                    </div>""",
                    },
                ),
            ]
    else:  # Module classique avec coef scalaire:
        descr += [
            (
                "coefficient",
                {
                    "size": 4,
                    "type": "float",
                    "explanation": "coefficient dans la formation (PPN)",
                    "allow_null": False,
                    "enabled": unlocked,
                },
            ),
        ]
    descr += [
        (
            "formation_id",
            {
                "input_type": "hidden",
                "default": formation.id,
            },
        ),
        (
            "semestre_id",
            {
                "input_type": "hidden",
                "default": orig_semestre_idx,
            },
        ),
    ]
    if module:
        descr += [
            ("ue_id", {"input_type": "hidden"}),
            ("module_id", {"input_type": "hidden"}),
            (
                "ue_matiere_id",
                {
                    "input_type": "menu",
                    "title": "Rattachement :" if is_apc else "Matière :",
                    "explanation": (
                        "UE de rattachement, utilisée notamment pour les malus"
                        + (
                            " (module utilisé, ne peut pas être changé de semestre)"
                            if in_use
                            else ""
                        )
                    )
                    if is_apc
                    else "un module appartient à une seule matière.",
                    "labels": mat_names,
                    "allowed_values": ue_mat_ids,
                    "enabled": unlocked,
                },
            ),
        ]
    else:  # Création
        if matiere_id:
            descr += [
                ("ue_id", {"default": ue.id, "input_type": "hidden"}),
                ("matiere_id", {"default": matiere_id, "input_type": "hidden"}),
            ]
        else:
            # choix de l'UE de rattachement
            descr += [
                (
                    "ue_id",
                    {
                        "input_type": "menu",
                        "type": "int",
                        "title": "UE de rattachement",
                        "explanation": "utilisée notamment pour les malus",
                        "labels": [
                            f"S{u.semestre_idx if u.semestre_idx is not None else '.'} / {u.acronyme} {u.titre}"
                            for u in ues
                        ],
                        "allowed_values": [u.id for u in ues],
                        "default": ue_default.id if ue_default is not None else "",
                    },
                ),
            ]

    if is_apc:
        # le semestre du module est toujours celui de son UE
        descr += [
            (
                "semestre_id",
                {
                    "input_type": "hidden",
                    "type": "int",
                    "readonly": True,
                },
            )
        ]
    else:
        descr += [
            (
                "semestre_id",
                {
                    "input_type": "menu",
                    "type": "int",
                    "title": parcours.SESSION_NAME.capitalize(),
                    "explanation": f"{parcours.SESSION_NAME} de début du module dans la formation standard",
                    "labels": [str(x) for x in semestres_indices],
                    "allowed_values": semestres_indices,
                    "enabled": unlocked,
                },
            )
        ]
    descr += [
        (
            "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 (ce code est propre à chaque établissement, se rapprocher 
                du référent Apogée).
                """,
                "validator": lambda val, _: len(val) < APO_CODE_STR_LEN,
            },
        ),
        (
            "numero",
            {
                "title": "Numéro",
                "size": 4,
                "explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage",
                "type": "int",
                "default": default_num,
            },
        ),
    ]

    if is_apc:
        # Choix des parcours
        ref_comp = formation.referentiel_competence
        if ref_comp:
            descr += [
                (
                    "parcours",
                    {
                        "input_type": "checkbox",
                        "vertical": True,
                        "dom_id": "tf_module_parcours",
                        "labels": [parcour.libelle for parcour in ref_comp.parcours]
                        + ["Tous (tronc commun)"],
                        "allowed_values": [
                            str(parcour.id) for parcour in ref_comp.parcours
                        ]
                        + ["-1"],
                        "explanation": """Parcours dans lesquels est utilisé ce module.<br>
                        Attention: si le module ne doit pas avoir les mêmes coefficients suivant le parcours, 
                        il faut en créer plusieurs versions, car dans ScoDoc chaque module a ses coefficients.""",
                    },
                )
            ]
            if module:
                module_dict["parcours"] = [
                    str(parcour.id) for parcour in module.parcours
                ]
                module_dict["app_critiques"] = [
                    str(app_crit.id) for app_crit in module.app_critiques
                ]
            # Choix des Apprentissages Critiques
            if ue is not None:
                annee = f"BUT{(orig_semestre_idx+1)//2}"
                app_critiques = ApcAppCritique.app_critiques_ref_comp(ref_comp, annee)
                if ue.niveau_competence is not None:
                    descr += [
                        (
                            "app_critiques",
                            {
                                "title": "Apprentissages Critiques",
                                "input_type": "checkbox",
                                "vertical": True,
                                "dom_id": "tf_module_app_critiques",
                                "labels": [
                                    f"{app_crit.code}&nbsp; {app_crit.libelle}"
                                    for app_crit in app_critiques
                                ],
                                "allowed_values": [
                                    str(app_crit.id) for app_crit in app_critiques
                                ],
                                "html_data": [],
                                "explanation": """Apprentissages Critiques liés à ce module.
                                    (si vous changez le semestre, revenez ensuite sur cette page
                                    pour associer les AC.)
                                    """,
                            },
                        )
                    ]
                else:
                    if module.ue.type == codes_cursus.UE_STANDARD:
                        descr += [
                            (
                                "app_critiques",
                                {
                                    "input_type": "separator",
                                    "title": f"""<span class="fontred">{scu.EMO_WARNING }
                                    L'UE <a class="stdlink" href="{
                                        url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue.id)
                                        }">{ue.acronyme} {ue.titre}</a>
                                    n'est pas associée à un niveau de compétences
                                    </span>""",
                                },
                            )
                        ]
        else:
            descr += [
                (
                    "parcours",
                    {
                        "input_type": "separator",
                        "title": f"""<span class="fontred">{scu.EMO_WARNING }
                        Pas de parcours:
                         <a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation', 
                         scodoc_dept=g.scodoc_dept, formation_id=formation.id)
                         }">associer un référentiel de compétence</a>
                         </span>""",
                    },
                )
            ]
    # force module semestre_idx to its UE
    if module:
        if (not module.ue) or (module.ue.semestre_idx is None):
            # Filet de sécurité si jamais l'UE n'a pas non plus de semestre:
            module_dict["semestre_id"] = 1
        else:
            module_dict["semestre_id"] = module.ue.semestre_idx

    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        descr,
        html_foot_markup=f"""<div class="sco_tag_module_edit"><span
        class="sco_tag_edit"><textarea data-module_id="{module_id}" class="module_tag_editor"
        >{','.join(sco_tag_module.module_tag_list(module_id))}</textarea></span></div>
        """
        if not create
        else "",
        initvalues=module_dict if module else {},
        submitlabel="Modifier ce module" if module else "Créer ce module",
        cancelbutton="Annuler",
    )
    #
    if tf[0] == 0:
        return (
            "\n".join(H)
            + tf[1]
            + (
                f"""
        <form action="module_clone" class="clone_form" method="post">
        <input type="hidden" name="module_id" value="{module_id}">
        <button type="submit">Créer une copie de ce module</button>
        </form>
        """
                if not create
                else ""
            )
            + html_sco_header.sco_footer()
        )
    elif tf[0] == -1:
        return flask.redirect(
            url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=formation.id,
                semestre_idx=orig_semestre_idx,
            )
        )
    else:
        if create:
            if not matiere_id:
                # formulaire avec choix UE de rattachement
                ue = UniteEns.query.get(tf[2]["ue_id"])
                if ue is None:
                    raise ValueError("UE invalide")
                matiere = ue.matieres.first()
                if matiere:
                    tf[2]["matiere_id"] = matiere.id
                else:
                    matiere_id = sco_edit_matiere.do_matiere_create(
                        {"ue_id": ue.id, "titre": ue.titre, "numero": 1},
                    )
                    tf[2]["matiere_id"] = matiere_id

            tf[2]["semestre_id"] = ue.semestre_idx
            module_id = do_module_create(tf[2])
            module = Module.query.get(module_id)
        else:  # EDITION MODULE
            # l'UE de rattachement peut changer
            tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!")
            x, y = tf[2]["ue_matiere_id"].split("!")
            tf[2]["ue_id"] = int(x)
            tf[2]["matiere_id"] = int(y)
            old_ue_id = module.ue.id
            new_ue_id = tf[2]["ue_id"]
            if (old_ue_id != new_ue_id) and in_use:
                new_ue = UniteEns.query.get_or_404(new_ue_id)
                if new_ue.semestre_idx != module.ue.semestre_idx:
                    # pas changer de semestre un module utilisé !
                    raise ScoValueError(
                        "Module utilisé: il ne peut pas être changé de semestre !"
                    )
            # En APC, force le semestre égal à celui de l'UE
            if is_apc:
                selected_ue = UniteEns.query.get(tf[2]["ue_id"])
                if selected_ue is None:
                    raise ValueError("UE invalide")
                tf[2]["semestre_id"] = selected_ue.semestre_idx
            if not tf[2].get("code"):
                raise ScoValueError("Le code du module doit être spécifié.")
            # Check unicité code module dans la formation
            # ??? TODO
            #
            do_module_edit(tf[2])
        # Modifie les parcours
        if ("parcours" in tf[2]) and formation.referentiel_competence:
            if "-1" in tf[2]["parcours"]:  # "tous"
                module.parcours = formation.referentiel_competence.parcours.all()
            else:
                module.parcours = [
                    ApcParcours.query.get(int(parcour_id_str))
                    for parcour_id_str in tf[2]["parcours"]
                ]
        # Modifie les AC
        if "app_critiques" in tf[2]:
            module.app_critiques = [
                ApcAppCritique.query.get(int(ac_id_str))
                for ac_id_str in tf[2]["app_critiques"]
            ]
        db.session.add(module)
        db.session.commit()
        return flask.redirect(
            url_for(
                "notes.ue_table",
                scodoc_dept=g.scodoc_dept,
                formation_id=formation.id,
                semestre_idx=tf[2]["semestre_id"] if is_apc else 1,
            )
        )


# Edition en ligne du code Apogee
def edit_module_set_code_apogee(id=None, value=None):
    "Set UE code apogee"
    module_id = id
    value = str(value).strip("-_ \t")
    log("edit_module_set_code_apogee: module_id=%s code_apogee=%s" % (module_id, value))

    modules = module_list(args={"module_id": module_id})
    if not modules:
        return "module invalide"  # should not occur

    do_module_edit({"module_id": module_id, "code_apogee": value})
    if not value:
        value = scu.APO_MISSING_CODE_STR
    return value


def module_table(formation_id):
    """Liste des modules de la formation
    (XXX inutile ou a revoir)
    """
    from app.scodoc import sco_formations

    if not formation_id:
        raise ScoValueError("invalid formation !")
    formation: Formation = Formation.query.get_or_404(formation_id)
    H = [
        html_sco_header.sco_header(
            page_title=f"Liste des modules de {formation.titre}"
        ),
        f"""<h2>Listes des modules dans la formation {formation.titre} ({formation.acronyme}</h2>
        <ul class="notes_module_list">
        """,
    ]
    editable = current_user.has_permission(Permission.ScoChangeFormation)

    for module_dict in module_list(args={"formation_id": formation_id}):
        H.append('<li class="notes_module_list">%s' % module_dict)
        if editable:
            H.append(
                '<a href="module_edit?module_id=%(module_id)s">modifier</a>'
                % module_dict
            )
            H.append(
                '<a href="module_delete?module_id=%(module_id)s">supprimer</a>'
                % module_dict
            )
        H.append("</li>")
    H.append("</ul>")
    H.append(html_sco_header.sco_footer())
    return "\n".join(H)


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


def module_count_moduleimpls(module_id):
    "Number of moduleimpls using this module"
    mods = sco_moduleimpl.moduleimpl_list(module_id=module_id)
    return len(mods)


def formation_add_malus_modules(
    formation_id: int, semestre_id: int = None, titre=None, redirect=True
):
    """Création d'un module de "malus" dans chaque UE d'une formation"""

    formation = Formation.query.get_or_404(formation_id)

    nb = 0
    ues = formation.ues
    if semestre_id is not None:
        ues = ues.filter_by(semestre_idx=semestre_id)
    for ue in ues:
        if ue.type == codes_cursus.UE_STANDARD:
            if ue_add_malus_module(ue, titre=titre) != None:
                nb += 1

    flash(f"Modules de malus ajoutés dans {nb} UEs du S{semestre_id}")

    formation.invalidate_cached_sems()

    if redirect:
        return flask.redirect(
            url_for(
                "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation_id
            )
        )


def ue_add_malus_module(ue: UniteEns, titre=None, code=None) -> int:
    """Add a malus module in this ue.
    If already exists, do nothing.
    Returns id of malus module.
    """
    modules_malus = [m for m in ue.modules if m.module_type == scu.ModuleType.MALUS]
    if len(modules_malus) > 0:
        return None  # déjà existant

    titre = titre or f"Malus {ue.acronyme}"
    code = code or f"MALUS{ue.numero}"

    # Tout module doit avoir un semestre_id (indice 1, 2, ...)
    if ue.semestre_idx is None:
        semestre_ids = sorted(list(set([m.semestre_id for m in ue.modules])))
        if len(semestre_ids) > 0:
            semestre_id = semestre_ids[0]
        else:
            # c'est ennuyeux: dans ce cas, on pourrait demander à indiquer explicitement
            # le semestre ? ou affecter le malus au semestre 1 ???
            raise ScoValueError(
                "Impossible d'ajouter un malus si l'UE n'a pas de numéro de semestre et ne comporte pas d'autres modules"
            )
    else:
        semestre_id = ue.semestre_idx

    # Matiere pour placer le module malus
    titre_matiere_malus = "Malus"

    matieres_malus = [mat for mat in ue.matieres if mat.titre == titre_matiere_malus]
    if len(matieres_malus) > 0:
        # matière Malus déjà existante, l'utilise
        matiere = matieres_malus[0]
    else:
        if ue.matieres.count() > 0:
            numero = max([(mat.numero or 0) for mat in ue.matieres]) + 10
        else:
            numero = 0
        matiere = Matiere(ue_id=ue.id, titre=titre_matiere_malus, numero=numero)
        db.session.add(matiere)

    module = Module(
        titre=titre,
        code=code,
        coefficient=0.0,
        ue=ue,
        matiere=matiere,
        formation=ue.formation,
        semestre_id=semestre_id,
        module_type=scu.ModuleType.MALUS,
    )
    db.session.add(module)
    db.session.commit()

    return module.id