# -*- 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@gmail.com
#
##############################################################################

"""Gestion des groupes, nouvelle mouture (juin/nov 2009)
"""
import collections
import time

from xml.etree import ElementTree
from xml.etree.ElementTree import Element

import flask
from flask import g, request
from flask import url_for, make_response
from sqlalchemy.sql import text

from app import cache, db, log
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite, Scolog
from app.models import SHORT_STR_LEN
from app.models.groups import GroupDescr, Partition
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.scolog import logdb
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_cursus
from app.scodoc import sco_etud
from app.scodoc.sco_etud import etud_sort_key
import app.scodoc.sco_utils as scu
from app.scodoc import sco_xml
from app.scodoc.sco_exceptions import ScoException, AccessDenied, ScoValueError
from app.scodoc.TrivialFormulator import TrivialFormulator


partitionEditor = ndb.EditableTable(
    "partition",
    "partition_id",
    (
        "partition_id",
        "formsemestre_id",
        "partition_name",
        "compute_ranks",
        "numero",
        "bul_show_rank",
        "show_in_lists",
        "editable",
    ),
    input_formators={
        "bul_show_rank": bool,
        "show_in_lists": bool,
        "editable": bool,
    },
)

groupEditor = ndb.EditableTable(
    "group_descr",
    "group_id",
    ("group_id", "partition_id", "group_name", "numero", "edt_id"),
)

group_list = groupEditor.list


def get_group(group_id: int) -> dict:  # OBSOLETE !
    """Returns group object, with partition"""
    r = ndb.SimpleDictFetch(
        """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.*
        FROM group_descr gd, partition p
        WHERE gd.id=%(group_id)s
        AND p.id = gd.partition_id
        """,
        {"group_id": group_id},
    )
    if not r:
        raise ScoValueError(f"Groupe inexistant ! (id {group_id})")
    return r[0]


def group_delete(group_id: int):
    """Delete a group."""
    # if not group['group_name'] and not force:
    #    raise ValueError('cannot suppress this group')
    # remove memberships:
    ndb.SimpleQuery(
        "DELETE FROM group_membership WHERE group_id=%(group_id)s",
        {"group_id": group_id},
    )
    # delete group:
    ndb.SimpleQuery(
        "DELETE FROM group_descr WHERE id=%(group_id)s", {"group_id": group_id}
    )


def get_partition(partition_id):  # OBSOLETE
    r = ndb.SimpleDictFetch(
        """SELECT p.id AS partition_id, p.*
        FROM partition p
        WHERE p.id = %(partition_id)s
        """,
        {"partition_id": partition_id},
    )
    if not r:
        raise ScoValueError(f"Partition inconnue (déjà supprimée ?) ({partition_id})")
    return r[0]


def get_partitions_list(formsemestre_id, with_default=True) -> list[dict]:
    """Liste des partitions pour ce semestre (list of dicts),
    triées par numéro, avec la partition par défaut en fin de liste.
    OBSOLETE: utiliser FormSemestre.get_partitions_list
    """
    partitions = ndb.SimpleDictFetch(
        """SELECT p.id AS partition_id, p.*
        FROM partition p
        WHERE formsemestre_id=%(formsemestre_id)s
        ORDER BY numero""",
        {"formsemestre_id": formsemestre_id},
    )
    # Move 'all' at end of list (for menus)
    R = [p for p in partitions if p["partition_name"] is not None]
    if with_default:
        R += [p for p in partitions if p["partition_name"] is None]
    return R


def get_default_partition(formsemestre_id):
    """Get partition for 'all' students (this one always exists, with NULL name)"""
    r = ndb.SimpleDictFetch(
        """SELECT p.id AS partition_id, p.* FROM partition p
        WHERE formsemestre_id=%(formsemestre_id)s
        AND partition_name is NULL
        """,
        {"formsemestre_id": formsemestre_id},
    )
    if len(r) != 1:
        raise ScoException(
            "inconsistent partition: %d with NULL name for formsemestre_id=%s"
            % (len(r), formsemestre_id)
        )
    return r[0]


def get_formsemestre_groups(formsemestre_id, with_default=False):
    """Returns  ( partitions, { partition_id : { etudid : group } } )."""
    partitions = get_partitions_list(formsemestre_id, with_default=with_default)
    partitions_etud_groups = {}  # { partition_id : { etudid : group } }
    for partition in partitions:
        pid = partition["partition_id"]
        partitions_etud_groups[pid] = get_etud_groups_in_partition(pid)
    return partitions, partitions_etud_groups


def get_formsemestre_etuds_groups(formsemestre_id: int) -> dict:
    """{ etudid : { partition_id : group_id } }"""
    infos = ndb.SimpleDictFetch(
        """SELECT etudid, p.id AS partition_id, gd.id AS group_id
        FROM group_descr gd, group_membership gm, partition p
        WHERE gd.partition_id = p.id
        AND gm.group_id = gd.id
        AND p.formsemestre_id = %(formsemestre_id)s
        """,
        {"formsemestre_id": formsemestre_id},
    )
    # -> {'etudid': 16483, 'group_id': 5317, 'partition_id': 2264},
    d = collections.defaultdict(lambda: {})
    for i in infos:
        d[i["etudid"]][i["partition_id"]] = i["group_id"]
    return d


def get_partition_groups(partition):  # OBSOLETE !
    """List of groups in this partition (list of dicts).
    Some groups may be empty."""
    return ndb.SimpleDictFetch(
        """SELECT gd.id AS group_id, p.id AS partition_id, gd.*, p.*
        FROM group_descr gd, partition p
        WHERE gd.partition_id=%(partition_id)s
        AND gd.partition_id=p.id
        ORDER BY gd.numero
        """,
        partition,
    )


def get_default_group(formsemestre_id, fix_if_missing=False) -> int:
    """Returns group_id for default ('tous') group
    XXX remplacé par formsemestre.get_default_group
    """
    r = ndb.SimpleDictFetch(
        """SELECT gd.id AS group_id
        FROM group_descr gd, partition p
        WHERE p.formsemestre_id=%(formsemestre_id)s
        AND p.partition_name is NULL
        AND p.id = gd.partition_id
        """,
        {"formsemestre_id": formsemestre_id},
    )
    if len(r) == 0 and fix_if_missing:
        # No default group (problem during sem creation)
        # Try to create it
        log(
            f"""*** Warning: get_default_group(formsemestre_id={formsemestre_id
            }): default group missing, recreating it"""
        )
        try:
            partition_id = get_default_partition(formsemestre_id)["partition_id"]
        except ScoException:
            log(f"creating default partition for {formsemestre_id}")
            partition_id = partition_create(
                formsemestre_id, default=True, redirect=False
            )
        group = create_group(partition_id, default=True)
        return group.id
    # debug check
    if len(r) != 1:
        log(f"invalid group structure for {formsemestre_id}: {len(r)}")
    group_id = r[0]["group_id"]
    return group_id


def get_sem_groups(formsemestre_id):
    """Returns groups for this sem (in all partitions)."""
    return ndb.SimpleDictFetch(
        """SELECT gd.id AS group_id, p.id AS partition_id, gd.*, p.*
        FROM group_descr gd, partition p
        WHERE p.formsemestre_id=%(formsemestre_id)s
        AND p.id = gd.partition_id
        """,
        {"formsemestre_id": formsemestre_id},
    )


def get_group_members(group_id: int, etat=None) -> list[dict]:
    """Liste des etudiants d'un groupe.
    Si etat, filtre selon l'état de l'inscription
    Trié par nom_usuel (ou nom) puis prénom
    Résultat: list de dict avec champs étudiant, adresse, group_membership
    """
    req = """SELECT i.id as etudid, i.*, a.*, gm.*, ins.etat
    FROM identite i, adresse a, group_membership gm,
    group_descr gd, partition p, notes_formsemestre_inscription ins
    WHERE i.id = gm.etudid
    and a.etudid = i.id
    and ins.etudid = i.id
    and ins.formsemestre_id = p.formsemestre_id
    and p.id = gd.partition_id
    and gd.id = gm.group_id
    and gm.group_id=%(group_id)s
    """
    if etat is not None:
        req += " and ins.etat = %(etat)s"

    r = ndb.SimpleDictFetch(req, {"group_id": group_id, "etat": etat})

    for etud in r:
        sco_etud.format_etud_ident(etud)

    # tri selon nom_usuel ou nom, sans accents
    r.sort(key=etud_sort_key)

    if scu.CONFIG.ALLOW_NULL_PRENOM:
        for x in r:
            x["prenom"] = x["prenom"] or ""

    return r


def get_group_infos(group_id, etat: str | None = None):  # was _getlisteetud
    """legacy code: used by group_list and trombino.
    etat: état de l'inscription."""
    from app.scodoc import sco_formsemestre

    cnx = ndb.GetDBConnexion()
    group = get_group(group_id)
    sem = sco_formsemestre.get_formsemestre(group["formsemestre_id"])

    members = get_group_members(group_id, etat=etat)
    # add human readable description of state:
    nbdem = 0
    for t in members:
        if t["etat"] == scu.INSCRIT:
            t["etath"] = ""  # etudiant inscrit, ne l'indique pas dans la liste HTML
        elif t["etat"] == scu.DEMISSION:
            events = sco_etud.scolar_events_list(
                cnx,
                args={
                    "etudid": t["etudid"],
                    "formsemestre_id": group["formsemestre_id"],
                },
            )
            for event in events:
                event_type = event["event_type"]
                if event_type == "DEMISSION":
                    t["date_dem"] = event["event_date"]
                    break
            if "date_dem" in t:
                t["etath"] = "démission le %s" % t["date_dem"]
            else:
                t["etath"] = "(dem.)"
            nbdem += 1
        elif t["etat"] == codes_cursus.DEF:
            t["etath"] = "Défaillant"
        else:
            t["etath"] = t["etat"]
    # Add membership for all partitions, 'partition_id' : group
    for etud in members:  # long: comment eviter ces boucles ?
        etud_add_group_infos(etud, sem["formsemestre_id"])

    if group["partition_name"] is None:
        group_tit = "tous"
    else:
        group_tit = f"""{group["partition_name"]} {group["group_name"]}"""

    return members, group, group_tit, sem, nbdem


def get_group_other_partitions(group):
    """Liste des partitions du même semestre que ce groupe,
    sans celle qui contient ce groupe.
    """
    other_partitions = [
        p
        for p in get_partitions_list(group["formsemestre_id"])
        if p["partition_id"] != group["partition_id"] and p["partition_name"]
    ]
    return other_partitions


def get_etud_groups(etudid: int, formsemestre_id: int, exclude_default=False):
    """Infos sur groupes de l'etudiant dans ce semestre
    [ group + partition_name ]
    """
    req = """SELECT p.id AS partition_id, p.*,
    g.id AS group_id, g.numero as group_numero, g.group_name
    FROM group_descr g, partition p, group_membership gm
    WHERE gm.etudid=%(etudid)s
    and gm.group_id = g.id
    and g.partition_id = p.id
    and p.formsemestre_id = %(formsemestre_id)s
    """
    if exclude_default:
        req += " and p.partition_name is not NULL"
    groups = ndb.SimpleDictFetch(
        req + " ORDER BY p.numero",
        {"etudid": etudid, "formsemestre_id": formsemestre_id},
    )
    return _sortgroups(groups)


def get_etud_main_group(etudid: int, formsemestre_id: int):
    """Return main group (the first one) for etud, or default one if no groups"""
    groups = get_etud_groups(etudid, formsemestre_id, exclude_default=True)
    if groups:
        return groups[0]
    else:
        return get_group(get_default_group(formsemestre_id))


def formsemestre_get_main_partition(formsemestre_id):
    """Return main partition (the first one) for sem, or default one if no groups
    (rappel: default == tous, main == principale (groupes TD habituellement)
    """
    return get_partitions_list(formsemestre_id, with_default=True)[0]


def formsemestre_get_etud_groupnames(formsemestre_id, attr="group_name"):
    """Recupere les groupes de tous les etudiants d'un semestre
    { etudid : { partition_id : group_name  }}  (attr=group_name or group_id)
    """
    infos = ndb.SimpleDictFetch(
        """SELECT
        i.etudid AS etudid,
        p.id AS partition_id,
        gd.group_name,
        gd.id AS group_id
        FROM
        notes_formsemestre_inscription i,
        partition p,
        group_descr gd,
        group_membership gm
        WHERE
        i.formsemestre_id=%(formsemestre_id)s
        and i.formsemestre_id = p.formsemestre_id
        and p.id = gd.partition_id
        and gm.etudid = i.etudid
        and gm.group_id = gd.id
        and p.partition_name is not NULL
        """,
        {"formsemestre_id": formsemestre_id},
    )
    R = {}
    for info in infos:
        if info["etudid"] in R:
            R[info["etudid"]][info["partition_id"]] = info[attr]
        else:
            R[info["etudid"]] = {info["partition_id"]: info[attr]}
    return R


def get_etud_formsemestre_groups(
    etud: Identite, formsemestre: FormSemestre, only_to_show=True
) -> list[GroupDescr]:
    """Liste les groupes auxquels est inscrit.
    Si only_to_show (défaut vrai), ne donne que les groupes "visiables",
    c'est à dire des partitions avec show_in_lists True.
    """
    # Note: je n'ai pas réussi à construire une requete SQLAlechemy avec
    # la Table d'association group_membership
    cursor = db.session.execute(
        text(
            """
    SELECT g.id
    FROM group_descr g, group_membership gm, partition p
    WHERE gm.etudid = :etudid
    AND gm.group_id = g.id
    AND g.partition_id = p.id
    AND p.formsemestre_id = :formsemestre_id
    AND p.partition_name is not NULL
    """
            + (" and (p.show_in_lists is True) " if only_to_show else "")
            + """
    ORDER BY p.numero
    """
        ),
        {"etudid": etud.id, "formsemestre_id": formsemestre.id},
    )
    return [db.session.get(GroupDescr, group_id) for group_id in cursor]


# Ancienne fonction:
def etud_add_group_infos(
    etud, formsemestre_id, sep=" ", only_to_show=False, with_default_partition=True
):
    """Add informations on partitions and group memberships to etud
    (a dict with an etudid)
    If only_to_show, restrict to partions such that show_in_lists is True.
    If with_default_partition, does not discard partition whose name is None
    etud['partitions'] = { partition_id : group + partition_name }
    etud['groupes'] = "TDB, Gr2, TPB1"
    etud['partitionsgroupes'] = "Groupes TD:TDB, Groupes TP:Gr2 (...)"
    """
    etud[
        "partitions"
    ] = collections.OrderedDict()  # partition_id : group + partition_name
    if not formsemestre_id:
        etud["groupes"] = ""
        return etud
    infos = ndb.SimpleDictFetch(
        """SELECT p.partition_name, p.show_in_lists, g.*, g.id AS group_id
        FROM group_descr g, partition p, group_membership gm WHERE gm.etudid=%(etudid)s
        and gm.group_id = g.id
        and g.partition_id = p.id
        and p.formsemestre_id = %(formsemestre_id)s
        """
        + (" and (p.show_in_lists is True) " if only_to_show else "")
        + (" and (p.partition_name is not null) " if not with_default_partition else "")
        + """
        ORDER BY p.numero
        """,
        {"etudid": etud["etudid"], "formsemestre_id": formsemestre_id},
    )

    for info in infos:
        if info["partition_name"]:
            etud["partitions"][info["partition_id"]] = info

    # resume textuel des groupes:
    etud["groupes"] = sep.join(
        [gr["group_name"] for gr in infos if gr["group_name"] is not None]
    )
    etud["partitionsgroupes"] = sep.join(
        [
            (gr["partition_name"] or "") + ":" + gr["group_name"]
            for gr in infos
            if gr["group_name"] is not None
        ]
    )

    return etud


@cache.memoize(timeout=50)  # seconds
def get_etud_groups_in_partition(partition_id):
    """Returns { etudid : group }, with all students in this partition"""
    infos = ndb.SimpleDictFetch(
        """SELECT gd.id AS group_id, gd.*, etudid
        FROM group_descr gd, group_membership gm
        WHERE gd.partition_id = %(partition_id)s
        AND gm.group_id = gd.id
        """,
        {"partition_id": partition_id},
    )
    R = {}
    for i in infos:
        R[i["etudid"]] = i
    return R


def formsemestre_partition_list(formsemestre_id, fmt="xml"):
    """Get partitions and groups in this semestre
    Supported formats: xml, json
    """
    partitions = get_partitions_list(formsemestre_id, with_default=True)
    # Ajoute les groupes
    for p in partitions:
        p["group"] = get_partition_groups(p)
    return scu.sendResult(partitions, name="partition", fmt=fmt)


# Encore utilisé par groupmgr.js
def XMLgetGroupsInPartition(partition_id):  # was XMLgetGroupesTD
    """
    Deprecated: use group_list
    Liste des étudiants dans chaque groupe de cette partition.
    <group partition_id="" partition_name="" group_id="" group_name="" groups_editable="">
    <etud etuid="" sexe="" nom="" prenom="" civilite="" origin=""/>
    </group>
    <group ...>
    ...
    """
    t0 = time.time()
    partition = get_partition(partition_id)
    formsemestre_id = partition["formsemestre_id"]
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    etuds_set = {ins.etudid for ins in formsemestre.inscriptions}

    groups = get_partition_groups(partition)
    # Build XML:
    t1 = time.time()
    doc = Element("ajax-response")
    x_response = Element("response", type="object", id="MyUpdater")
    doc.append(x_response)
    for group in groups:
        x_group = Element(
            "group",
            partition_id=str(partition_id),
            partition_name=partition["partition_name"] or "",
            groups_editable=str(int(partition["groups_editable"])),
            group_id=str(group["group_id"]),
            group_name=group["group_name"] or "",
        )
        x_response.append(x_group)
        for e in get_group_members(group["group_id"]):
            etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0]
            x_group.append(
                Element(
                    "etud",
                    etudid=str(e["etudid"]),
                    civilite=etud["civilite_str"] or "",
                    sexe=etud["civilite_str"] or "",  # compat
                    nom=scu.format_nom(etud["nom"] or ""),
                    prenom=scu.format_prenom(etud["prenom"] or ""),
                    origin=_comp_etud_origin(etud, formsemestre),
                )
            )
            if e["etudid"] in etuds_set:
                etuds_set.remove(e["etudid"])  # etudiant vu dans un groupe

    # Ajoute les etudiants inscrits au semestre mais dans aucun groupe de cette partition:
    if etuds_set:
        x_group = Element(
            "group",
            partition_id=str(partition_id),
            partition_name=partition["partition_name"] or "",
            groups_editable=str(int(partition["groups_editable"])),
            group_id="_none_",
            group_name="",
        )
        doc.append(x_group)
        for etudid in etuds_set:
            etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
            x_group.append(
                Element(
                    "etud",
                    etudid=str(etud["etudid"]),
                    sexe=etud["civilite_str"] or "",
                    nom=scu.format_nom(etud["nom"] or ""),
                    prenom=scu.format_prenom(etud["prenom"] or ""),
                    origin=_comp_etud_origin(etud, formsemestre),
                )
            )
    t2 = time.time()
    log(f"XMLgetGroupsInPartition: {t2-t0} seconds ({t1-t0}+{t2-t1})")
    # XML response:
    data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING)
    response = make_response(data)
    response.headers["Content-Type"] = scu.XML_MIMETYPE
    return response


def _comp_etud_origin(etud: dict, cur_formsemestre: FormSemestre):
    """breve description de l'origine de l'étudiant (sem. precedent)
    (n'indique l'origine que si ce n'est pas le semestre precedent normal)
    """
    # cherche le semestre suivant le sem. courant dans la liste
    cur_sem_idx = None
    for i in range(len(etud["sems"])):
        if etud["sems"][i]["formsemestre_id"] == cur_formsemestre.id:
            cur_sem_idx = i
            break

    if cur_sem_idx is None or (cur_sem_idx + 1) >= (len(etud["sems"]) - 1):
        return ""  # on pourrait indiquer le bac mais en general on ne l'a pas en debut d'annee

    prev_sem = etud["sems"][cur_sem_idx + 1]
    if prev_sem["semestre_id"] != (cur_formsemestre.semestre_id - 1):
        return f" (S{prev_sem['semestre_id']})"
    else:
        return ""  # parcours normal, ne le signale pas


def set_group(etudid: int, group_id: int) -> bool:  # OBSOLETE !
    """Inscrit l'étudiant au groupe.
    Return True if ok, False si deja inscrit.
    Warning:
     - don't check if group_id exists (the caller should check).
     - don't check if group's partition is editable
    """
    cnx = ndb.GetDBConnexion()
    cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
    args = {"etudid": etudid, "group_id": group_id}
    # déjà inscrit ?
    r = ndb.SimpleDictFetch(
        "SELECT * FROM group_membership gm WHERE etudid=%(etudid)s and group_id=%(group_id)s",
        args,
        cursor=cursor,
    )
    if len(r):
        return False
    # inscrit
    ndb.SimpleQuery(
        "INSERT INTO group_membership (etudid, group_id) VALUES (%(etudid)s, %(group_id)s)",
        args,
        cursor=cursor,
    )
    return True


def change_etud_group_in_partition(etudid: int, group: GroupDescr) -> bool:
    """Inscrit etud au groupe
    (et le désinscrit d'autres groupes de cette partition)
    Return True si changement, False s'il était déjà dans ce groupe.
    """
    etud: Identite = Identite.query.get_or_404(etudid)
    if not group.partition.set_etud_group(etud, group):
        return  # pas de changement

    # - log
    formsemestre: FormSemestre = group.partition.formsemestre
    log(f"change_etud_group_in_partition: etudid={etudid} group={group}")
    Scolog.logdb(
        method="changeGroup",
        etudid=etudid,
        msg=f"""formsemestre_id={formsemestre.id}, partition_name={
            group.partition.partition_name or ""}, group_name={group.group_name or ""}""",
        commit=True,
    )

    # - Update parcours
    if group.partition.partition_name == scu.PARTITION_PARCOURS:
        formsemestre.update_inscriptions_parcours_from_groups(etudid=etudid)

    # - invalidate cache
    sco_cache.invalidate_formsemestre(
        formsemestre_id=formsemestre.id
    )  # > change etud group


def setGroups(
    partition_id,
    groupsLists="",  # members of each existing group
    groupsToCreate="",  # name and members of new groups
    groupsToDelete="",  # groups to delete
):
    """Affect groups (Ajax POST request): renvoie du XML
    groupsLists: lignes de la forme "group_id;etudid;...\n"
    groupsToCreate: lignes "group_name;etudid;...\n"
    groupsToDelete: group_id;group_id;...

    Ne peux pas modifier les groupes des partitions non éditables.
    """

    def xml_error(msg, code=404):
        data = (
            f'<?xml version="1.0" encoding="utf-8"?><response>Error: {msg}</response>'
        )
        response = make_response(data, code)
        response.headers["Content-Type"] = scu.XML_MIMETYPE
        return response

    partition: Partition = db.session.get(Partition, partition_id)
    if not partition.groups_editable and (groupsToCreate or groupsToDelete):
        msg = "setGroups: partition non editable"
        log(msg)
        return xml_error(msg, code=403)

    if not partition.formsemestre.etat:
        raise AccessDenied("Modification impossible: semestre verrouillé")
    if not partition.formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
    log(
        f"""***setGroups: partition={partition}
groupsLists={groupsLists}
groupsToCreate={groupsToCreate}
groupsToDelete={groupsToDelete}
"""
    )

    groupsToDelete = [g for g in groupsToDelete.split(";") if g]
    etud_groups = formsemestre_get_etud_groupnames(
        partition.formsemestre.id, attr="group_id"
    )
    for line in groupsLists.split("\n"):  # for each group_id (one per line)
        fs = line.split(";")
        group_id = fs[0].strip()
        if not group_id:
            continue
        try:
            group_id = int(group_id)
        except ValueError:
            log(f"setGroups: ignoring invalid group_id={group_id}")
            continue
        group: GroupDescr = GroupDescr.query.get_or_404(group_id)
        # Anciens membres du groupe:
        old_members_set = {etud.id for etud in group.etuds}
        # Place dans ce groupe les etudiants indiqués:
        for etudid_str in fs[1:-1]:
            etudid = int(etudid_str)
            if etudid in old_members_set:
                # était dans ce groupe, l'enlever
                old_members_set.remove(etudid)
            if (etudid not in etud_groups) or (
                group_id != etud_groups[etudid].get(partition_id, "")
            ):  # pas le meme groupe qu'actuel
                change_etud_group_in_partition(etudid, group)
        # Retire les anciens membres:
        cnx = ndb.GetDBConnexion()
        cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
        for etudid in old_members_set:
            ndb.SimpleQuery(
                "DELETE FROM group_membership WHERE etudid=%(etudid)s and group_id=%(group_id)s",
                {"etudid": etudid, "group_id": group_id},
                cursor=cursor,
            )
            logdb(
                cnx,
                method="removeFromGroup",
                etudid=etudid,
                msg=f"""formsemestre_id={partition.formsemestre.id},partition_name={
                    partition.partition_name}, group_name={group.group_name}""",
            )

    # Supprime les groupes indiqués comme supprimés:
    for group_id in groupsToDelete:
        delete_group(group_id, partition_id=partition_id)

    # Crée les nouveaux groupes
    for line in groupsToCreate.split("\n"):  # for each group_name (one per line)
        fs = line.split(";")
        group_name = fs[0].strip()
        if not group_name:
            continue
        try:
            group = create_group(partition_id, group_name)
        except ScoValueError as exc:
            msg = exc.args[0] if len(exc.args) > 0 else "erreur inconnue"
            return xml_error(msg, code=404)
        # Place dans ce groupe les etudiants indiqués:
        for etudid in fs[1:-1]:
            change_etud_group_in_partition(etudid, group)

    # Update parcours
    partition.formsemestre.update_inscriptions_parcours_from_groups()

    data = (
        '<?xml version="1.0" encoding="utf-8"?><response>Groupes enregistrés</response>'
    )
    response = make_response(data)
    response.headers["Content-Type"] = scu.XML_MIMETYPE
    return response


def create_group(partition_id, group_name="", default=False) -> GroupDescr:
    """Create a new group in this partition.
    If default, create default partition (with no name)
    Obsolete: utiliser Partition.create_group
    """
    partition = Partition.query.get_or_404(partition_id)
    if not partition.formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
    #
    if group_name:
        group_name = group_name.strip()
    if not group_name and not default:
        raise ValueError("invalid group name: ()")

    if not GroupDescr.check_name(partition, group_name, default=default):
        raise ScoValueError(f"Le groupe {group_name} existe déjà dans cette partition")

    numeros = [g.numero if g.numero is not None else 0 for g in partition.groups]
    if len(numeros) > 0:
        new_numero = max(numeros) + 1
    else:
        new_numero = 0
    group = GroupDescr(partition=partition, group_name=group_name, numero=new_numero)
    db.session.add(group)
    db.session.commit()
    log(f"create_group: created group_id={group.id}")
    #
    return group


def delete_group(group_id, partition_id=None):
    """form suppression d'un groupe.
    (ne desinscrit pas les etudiants, change juste leur
    affectation aux groupes)
    partition_id est optionnel et ne sert que pour verifier que le groupe
    est bien dans cette partition.
    S'il s'agit d'un groupe de parcours, affecte l'inscription des étudiants aux parcours.
    """
    group = GroupDescr.query.get_or_404(group_id)
    if partition_id:
        if partition_id != group.partition_id:
            raise ValueError("inconsistent partition/group")
    if not group.partition.formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
    log(f"delete_group: group={group} partition={group.partition}")
    formsemestre = group.partition.formsemestre
    group_delete(group.id)
    formsemestre.update_inscriptions_parcours_from_groups()


def partition_create(
    formsemestre_id,
    partition_name="",
    default=False,
    numero=None,
    redirect=True,
):
    """Create a new partition"""
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    if not formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
    if partition_name:
        partition_name = str(partition_name).strip()
    if default:
        partition_name = None
    if not partition_name and not default:
        raise ScoValueError("Nom de partition invalide (vide)")
    redirect = int(redirect)
    # checkGroupName(partition_name)
    if partition_name in [
        p["partition_name"] for p in get_partitions_list(formsemestre_id)
    ]:
        raise ScoValueError(
            "Il existe déjà une partition %s dans ce semestre" % partition_name
        )

    cnx = ndb.GetDBConnexion()
    if numero is None:
        numero = (
            ndb.SimpleQuery(
                "SELECT MAX(id) FROM partition WHERE formsemestre_id=%(formsemestre_id)s",
                {"formsemestre_id": formsemestre_id},
            ).fetchone()[0]
            or 0
        )
    partition_id = partitionEditor.create(
        cnx,
        {
            "formsemestre_id": formsemestre_id,
            "partition_name": partition_name,
            "numero": numero,
        },
    )
    log("createPartition: created partition_id=%s" % partition_id)
    #
    if redirect:
        return flask.redirect(
            url_for(
                "scolar.edit_partition_form",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
            )
        )
    else:
        return partition_id


def get_arrow_icons_tags():
    """returns html tags for arrows"""
    #
    arrow_up = scu.icontag("arrow_up", title="remonter")
    arrow_down = scu.icontag("arrow_down", title="descendre")
    arrow_none = scu.icontag("arrow_none", title="")

    return arrow_up, arrow_down, arrow_none


def edit_partition_form(formsemestre_id=None):
    """Form to create/suppress partitions"""
    # ad-hoc form
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    if not formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")

    partitions = get_partitions_list(formsemestre_id)
    arrow_up, arrow_down, arrow_none = get_arrow_icons_tags()
    suppricon = scu.icontag(
        "delete_small_img", border="0", alt="supprimer", title="Supprimer"
    )
    #
    H = [
        html_sco_header.sco_header(
            page_title="Partitions...",
            javascripts=["js/edit_partition_form.js"],
        ),
        # limite à SHORT_STR_LEN
        r"""<script type="text/javascript">
          function checkname() {
 var val = document.editpart.partition_name.value.replace(/^\s+/, "").replace(/\s+$/, "");
 if ((val.length > 0)&&(val.length < 32)) {
   document.editpart.ok.disabled = false;
 } else {
   document.editpart.ok.disabled = true;
 }
}
          </script>
          """,
        f"""<h2>Partitions du semestre</h2>
          <p class="help">
          👉💡 vous pourriez essayer <a href="{
              url_for("scolar.partition_editor",
                scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
          }" class="stdlink">le nouvel éditeur</a>
          </p>

          <form name="editpart" id="editpart" method="POST" action="partition_create">
          <div id="epmsg"></div>
          <table>
          <tr class="eptit">
          <th></th><th></th><th></th><th>Partition</th><th>Groupes</th><th></th><th></th><th></th>
          </tr>
    """,
    ]
    i = 0
    for p in partitions:
        if p["partition_name"] is not None:
            H.append(
                f"""<tr><td class="epnav"><a class="stdlink"
                href="{url_for("scolar.partition_delete",
                scodoc_dept=g.scodoc_dept, partition_id=p["partition_id"])
                }">{suppricon}</a>&nbsp;</td><td class="epnav">"""
            )
            if i != 0:
                H.append(
                    f"""<a href="{url_for("scolar.partition_move",
                    scodoc_dept=g.scodoc_dept, partition_id=p["partition_id"], after=0)
                    }">{arrow_up}</a>"""
                )
            H.append('</td><td class="epnav">')
            if i < len(partitions) - 2:
                H.append(
                    f"""<a href="{url_for("scolar.partition_move",
                    scodoc_dept=g.scodoc_dept, partition_id=p["partition_id"], after=1)
                    }">{arrow_down}</a>"""
                )
            i += 1
            H.append(
                f"""</td>
            <td>{p["partition_name"] or ""}</td>
            <td>"""
            )
            lg = [
                f"""{group["group_name"]} ({len(get_group_members(group["group_id"]))})"""
                for group in get_partition_groups(p)
            ]
            H.append(", ".join(lg))
            H.append("""</td><td>""")
            H.append(
                f"""<a class="stdlink" href="{
                    url_for("scolar.affect_groups",
                    scodoc_dept=g.scodoc_dept,
                    partition_id=p["partition_id"])
                }">répartir</a></td>
                """
            )
            H.append("""</td>""")
            if p["groups_editable"]:
                H.append(
                    f"""<td><a class="stdlink" href="{
                    url_for("scolar.partition_rename",
                    scodoc_dept=g.scodoc_dept, partition_id=p["partition_id"])
                    }">renommer</a></td>"""
                )
            else:
                H.append("""<td>non éditable</td>""")
            # classement:
            H.append('<td width="250px">')
            if p["bul_show_rank"]:
                checked = 'checked="1"'
            else:
                checked = ""
            H.append(
                '<div><input type="checkbox" class="rkbox" data-partition_id="%s" %s onchange="update_rk(this);"/>afficher rang sur bulletins</div>'
                % (p["partition_id"], checked)
            )
            if p["show_in_lists"]:
                checked = 'checked="1"'
            else:
                checked = ""
            H.append(
                f"""<div><input type="checkbox" class="rkbox" data-partition_id="{p['partition_id']
                }" {checked} onchange="update_show_in_list(this);"/>Afficher ces groupes sur les tableaux et bulletins</div>"""
            )
            H.append("</td>")
            #
            H.append("</tr>")
    H.append("</table>")
    H.append(
        f"""<div class="form_rename_partition">
        <input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
        <input type="hidden" name="redirect" value="1"/>
        <input type="text" name="partition_name" size="12" onkeyup="checkname();"/>
        <input type="submit" name="ok" disabled="1" value="Nouvelle partition"/>
    """
    )
    if formsemestre.formation.is_apc() and scu.PARTITION_PARCOURS not in (
        p["partition_name"] for p in partitions
    ):
        # propose création partition "Parcours"
        H.append(
            f"""
        <div style="margin-top: 10px"><a class="stdlink" href="{
            url_for("scolar.create_partition_parcours", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
        }">Créer une partition avec un groupe par parcours (BUT)</a>
        </div>
        """
        )
    H.append(
        """
    </div>
    </form>
    """
    )
    H.append(
        """<div class="help">
    <p>Les partitions sont des découpages de l'ensemble des étudiants.
    Par exemple, les "groupes de TD" sont une partition.
    On peut créer autant de partitions que nécessaire.
    </p>
    <ul>
    <li>Dans chaque partition, un nombre de groupes quelconque peuvent
    être créés (suivre le lien "répartir").
    <li>On peut faire afficher le classement de l'étudiant dans son
    groupe d'une partition en cochant "afficher rang sur bulletins"
    (ainsi, on peut afficher le classement en groupes de TD mais pas en
    groupe de TP, si ce sont deux partitions).
    </li>
    <li>Décocher "Afficher ces groupes sur les tableaux et bulletins" pour ne pas que cette partition
    apparaisse dans les noms de groupes
    </li>
    </ul>
    </div>
    """
    )
    return "\n".join(H) + html_sco_header.sco_footer()


def partition_set_attr(partition_id, attr, value):
    """Set partition attribute: bul_show_rank or show_in_lists"""
    if attr not in {"bul_show_rank", "show_in_lists"}:
        raise ValueError(f"invalid partition attribute: {attr}")

    partition = Partition.query.get_or_404(partition_id)
    if not partition.formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")

    log(f"partition_set_attr({partition_id}, {attr}, {value})")
    value = int(value)
    if getattr(partition, attr, None) != value:
        setattr(partition, attr, value)
        db.session.add(partition)
        db.session.commit()
        # invalid bulletin cache
        sco_cache.invalidate_formsemestre(formsemestre_id=partition.formsemestre.id)
    return "enregistré"


def partition_delete(partition_id, force=False, redirect=1, dialog_confirmed=False):
    """Suppress a partition (and all groups within).
    The default partition cannot be suppressed (unless force).
    Si la partition de parcours est supprimée, les étudiants sont désinscrits des parcours.
    """
    partition = get_partition(partition_id)
    formsemestre_id = partition["formsemestre_id"]
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    if not formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")

    if not partition["partition_name"] and not force:
        raise ValueError("cannot suppress this partition")
    redirect = int(redirect)
    cnx = ndb.GetDBConnexion()
    groups = get_partition_groups(partition)

    if not dialog_confirmed:
        if groups:
            grnames = "(" + ", ".join([g["group_name"] or "" for g in groups]) + ")"
        else:
            grnames = ""
        return scu.confirm_dialog(
            """<h2>Supprimer la partition "%s" ?</h2>
                <p>Les groupes %s de cette partition seront supprimés</p>
                """
            % (partition["partition_name"], grnames),
            dest_url="",
            cancel_url="edit_partition_form?formsemestre_id=%s" % formsemestre_id,
            parameters={"redirect": redirect, "partition_id": partition_id},
        )

    log("partition_delete: partition_id=%s" % partition_id)
    # 1- groups
    for group in groups:
        group_delete(group["group_id"])
    # 2- partition
    partitionEditor.delete(cnx, partition_id)

    formsemestre.update_inscriptions_parcours_from_groups()

    # redirect to partition edit page:
    if redirect:
        return flask.redirect(
            "edit_partition_form?formsemestre_id=" + str(formsemestre_id)
        )


def partition_move(partition_id, after=0, redirect=1):
    """Move before/after previous one (decrement/increment numero)"""
    partition = get_partition(partition_id)
    formsemestre_id = partition["formsemestre_id"]
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    if not formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
    #
    redirect = int(redirect)
    after = int(after)  # 0: deplace avant, 1 deplace apres
    if after not in (0, 1):
        raise ValueError('invalid value for "after"')
    others = get_partitions_list(formsemestre_id)

    objs = (
        Partition.query.filter_by(formsemestre_id=formsemestre_id)
        .order_by(Partition.numero, Partition.partition_name)
        .all()
    )
    if len({o.numero for o in objs}) != len(objs):
        # il y a des numeros identiques !
        scu.objects_renumber(db, objs)

    if len(others) > 1:
        pidx = [p["partition_id"] for p in others].index(partition_id)
        # log("partition_move: after=%s pidx=%s" % (after, pidx))
        neigh = None  # partition to swap with
        if after == 0 and pidx > 0:
            neigh = others[pidx - 1]
        elif after == 1 and pidx < len(others) - 1:
            neigh = others[pidx + 1]
        if neigh:  #
            # swap numero between partition and its neighbor
            # log("moving partition %s" % partition_id)
            cnx = ndb.GetDBConnexion()
            # Si aucun numéro n'a été affecté, le met au minimum
            min_numero = (
                ndb.SimpleQuery(
                    "SELECT MIN(numero) FROM partition WHERE formsemestre_id=%(formsemestre_id)s",
                    {"formsemestre_id": formsemestre_id},
                ).fetchone()[0]
                or 0
            )
            if neigh["numero"] is None:
                neigh["numero"] = min_numero - 1
            if partition["numero"] is None:
                partition["numero"] = min_numero - 1 - after
            partition["numero"], neigh["numero"] = neigh["numero"], partition["numero"]
            partitionEditor.edit(cnx, partition)
            partitionEditor.edit(cnx, neigh)
            sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id)

    # redirect to partition edit page:
    if redirect:
        return flask.redirect(
            "edit_partition_form?formsemestre_id=" + str(formsemestre_id)
        )


def partition_rename(partition_id):
    """Form to rename a partition"""
    partition = get_partition(partition_id)
    formsemestre_id = partition["formsemestre_id"]
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    if not formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
    H = ["<h2>Renommer une partition</h2>"]
    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        (
            ("partition_id", {"default": partition_id, "input_type": "hidden"}),
            (
                "partition_name",
                {
                    "title": "Nouveau nom",
                    "default": partition["partition_name"],
                    "allow_null": False,
                    "size": 12,
                    "validator": lambda val, _: (len(val) < SHORT_STR_LEN)
                    and (val != scu.PARTITION_PARCOURS),
                },
            ),
        ),
        submitlabel="Renommer",
        cancelbutton="Annuler",
    )
    if tf[0] == 0:
        return (
            html_sco_header.sco_header()
            + "\n".join(H)
            + "\n"
            + tf[1]
            + html_sco_header.sco_footer()
        )
    elif tf[0] == -1:
        return flask.redirect(
            "edit_partition_form?formsemestre_id=" + str(formsemestre_id)
        )
    else:
        # form submission
        return partition_set_name(partition_id, tf[2]["partition_name"])


def partition_set_name(partition_id, partition_name, redirect=1):
    """Set partition name"""
    partition_name = str(partition_name).strip()
    if not partition_name:
        raise ValueError("partition name must be non empty")
    partition = get_partition(partition_id)
    if partition["partition_name"] is None:
        raise ValueError("can't set a name to default partition")
    if partition_name == scu.PARTITION_PARCOURS:
        raise ScoValueError(f"nom de partition {scu.PARTITION_PARCOURS} réservé.")
    formsemestre_id = partition["formsemestre_id"]
    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
    if not formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
    # check unicity
    r = ndb.SimpleDictFetch(
        """SELECT p.* FROM partition p
        WHERE p.partition_name = %(partition_name)s
        AND formsemestre_id = %(formsemestre_id)s
        """,
        {"partition_name": partition_name, "formsemestre_id": formsemestre_id},
    )
    if len(r) > 1 or (len(r) == 1 and r[0]["id"] != partition_id):
        raise ScoValueError(
            f"Partition {partition_name} déjà existante dans ce semestre !"
        )
    redirect = int(redirect)
    cnx = ndb.GetDBConnexion()
    partitionEditor.edit(
        cnx, {"partition_id": partition_id, "partition_name": partition_name}
    )
    sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id)

    # redirect to partition edit page:
    if redirect:
        return flask.redirect(
            "edit_partition_form?formsemestre_id=" + str(formsemestre_id)
        )


def groups_auto_repartition(partition: Partition):
    """Réparti les etudiants dans des groupes dans une partition, en respectant le niveau
    et la mixité.
    """
    if not partition.groups_editable:
        raise AccessDenied("Partition non éditable")
    formsemestre = partition.formsemestre
    # renvoie sur page édition partitions et groupes
    dest_url = url_for(
        "scolar.partition_editor",
        scodoc_dept=g.scodoc_dept,
        formsemestre_id=formsemestre.id,
    )
    if not formsemestre.can_change_groups():
        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")

    descr = [
        ("partition_id", {"input_type": "hidden"}),
        (
            "groupNames",
            {
                "size": 40,
                "title": "Groupes à créer",
                "allow_null": False,
                "explanation": "noms des groupes à former, séparés par des virgules (les groupes existants seront effacés)",
            },
        ),
    ]

    H = [
        html_sco_header.sco_header(
            page_title="Répartition des groupes", formsemestre_id=formsemestre.id
        ),
        f"""<h2>Répartition des groupes de {partition.partition_name}</h2>
        <p>Semestre {formsemestre.titre_annee()}</p>
        <p class="help">Les groupes existants seront <b>effacés</b> et remplacés par
          ceux créés ici. La répartition aléatoire tente d'uniformiser le niveau
          des groupes (en utilisant la dernière moyenne générale disponible pour
          chaque étudiant) et de maximiser la mixité de chaque groupe.
        </p>
        """,
    ]

    tf = TrivialFormulator(
        request.base_url,
        scu.get_request_args(),
        descr,
        {},
        cancelbutton="Annuler",
        submitlabel="Créer et peupler les groupes",
        name="tf",
    )
    if tf[0] == 0:
        return "\n".join(H) + "\n" + tf[1] + html_sco_header.sco_footer()
    elif tf[0] == -1:
        return flask.redirect(dest_url)
    else:
        # form submission
        log(f"groups_auto_repartition({partition})")
        group_names = tf[2]["groupNames"]
        group_names = sorted({x.strip() for x in group_names.split(",")})
        # Détruit les groupes existant de cette partition
        for group in partition.groups:
            db.session.delete(group)
        db.session.commit()
        # Crée les nouveaux groupes
        groups = []
        for group_name in group_names:
            if group_name.strip():
                groups.append(partition.create_group(group_name))
        #
        nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
        identdict = nt.identdict
        # build:  { civilite : liste etudids trie par niveau croissant }
        civilites = {x["civilite"] for x in identdict.values()}
        listes = {}
        for civilite in civilites:
            listes[civilite] = [
                (_get_prev_moy(x["etudid"], formsemestre.id), x["etudid"])
                for x in identdict.values()
                if x["civilite"] == civilite
            ]
            listes[civilite].sort()
            log("listes[%s] = %s" % (civilite, listes[civilite]))
        # affect aux groupes:
        n = len(identdict)
        igroup = 0
        nbgroups = len(groups)
        while n > 0:
            log(f"n={n}")
            for civilite in civilites:
                log(f"civilite={civilite}")
                if len(listes[civilite]):
                    n -= 1
                    etudid = listes[civilite].pop()[1]
                    group = groups[igroup]
                    igroup = (igroup + 1) % nbgroups
                    log(f"in {etudid} in group {group.id}")
                    change_etud_group_in_partition(etudid, group)
                    log(f"{etudid} in group {group.id}")
        return flask.redirect(dest_url)


def _get_prev_moy(etudid, formsemestre_id):
    """Donne la derniere moyenne generale calculee pour cette étudiant,
    ou 0 si on n'en trouve pas (nouvel inscrit,...).
    """
    info = sco_etud.get_etud_info(etudid=etudid, filled=True)
    if not info:
        raise ScoValueError("etudiant invalide: etudid=%s" % etudid)
    etud = info[0]
    Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
    if Se.prev:
        prev_sem = db.session.get(FormSemestre, Se.prev["formsemestre_id"])
        nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_sem)
        return nt.get_etud_moy_gen(etudid)
    else:
        return 0.0


def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):
    """Crée une partition "apo_etapes" avec un groupe par étape Apogée.
    Cette partition n'est crée que si plusieurs étapes différentes existent dans ce
    semestre.
    Si la partition existe déjà, ses groupes sont mis à jour (les groupes devenant
     vides ne sont pas supprimés).
    """
    # A RE-ECRIRE pour utiliser les modèles.
    from app.scodoc import sco_formsemestre_inscriptions

    partition_name = str(partition_name)
    log(f"create_etapes_partition({formsemestre_id})")
    ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
        args={"formsemestre_id": formsemestre_id}
    )
    etapes = {i["etape"] for i in ins if i["etape"]}
    partitions = get_partitions_list(formsemestre_id, with_default=False)
    partition = None
    for p in partitions:
        if p["partition_name"] == partition_name:
            partition = p
            break
    if len(etapes) < 2 and not partition:
        return  # moins de deux étapes, pas de création
    if partition:
        pid = partition["partition_id"]
    else:
        pid = partition_create(
            formsemestre_id, partition_name=partition_name, redirect=False
        )
    partition: Partition = db.session.get(Partition, pid)
    groups = partition.groups
    groups_by_names = {g.group_name: g for g in groups}
    for etape in etapes:
        if etape not in groups_by_names:
            new_group = create_group(pid, etape)
            groups_by_names[etape] = new_group
    # Place les etudiants dans les groupes
    for i in ins:
        if i["etape"]:
            change_etud_group_in_partition(i["etudid"], groups_by_names[i["etape"]])


def do_evaluation_listeetuds_groups(
    evaluation_id: int,
    groups=None,
    getallstudents: bool = False,
    include_demdef: bool = False,
) -> list[tuple[int, str]]:
    """Donne la liste non triée des etudids inscrits à cette évaluation dans les
    groupes indiqués.
    Si getallstudents==True, donne tous les étudiants inscrits à cette
    evaluation.
    Si include_demdef, compte aussi les etudiants démissionnaires et défaillants
    (sinon, par défaut, seulement les 'I')

    Résultat: [ (etudid, etat) ], où etat='I', 'D', 'DEF'
    """
    # nb: pour notes_table / do_evaluation_etat, getallstudents est vrai et
    #  include_demdef faux
    fromtables = [
        "notes_moduleimpl_inscription Im",
        "notes_formsemestre_inscription Isem",
        "notes_moduleimpl M",
        "notes_evaluation E",
    ]
    # construit condition sur les groupes
    if not getallstudents:
        if not groups:
            return []  # no groups, so no students
        rg = ["gm.group_id = '%(group_id)s'" % g for g in groups]
        rq = """and Isem.etudid = gm.etudid
        and gd.partition_id = p.id
        and p.formsemestre_id = Isem.formsemestre_id
        """
        r = rq + " AND (" + " or ".join(rg) + " )"
        fromtables += ["group_membership gm", "group_descr gd", "partition p"]
    else:
        r = ""

    # requete complete
    req = (
        "SELECT distinct Im.etudid, Isem.etat FROM "
        + ", ".join(fromtables)
        + """ WHERE Isem.etudid = Im.etudid
        and Im.moduleimpl_id = M.id
        and Isem.formsemestre_id = M.formsemestre_id
        and E.moduleimpl_id = M.id
        and E.id = %(evaluation_id)s
        """
    )
    if not include_demdef:
        req += " and Isem.etat='I'"
    req += r
    cnx = ndb.GetDBConnexion()
    cursor = cnx.cursor()
    cursor.execute(req, {"evaluation_id": evaluation_id})
    return cursor.fetchall()


def do_evaluation_listegroupes(evaluation_id, include_default=False):
    """Donne la liste des groupes dans lesquels figurent des etudiants inscrits
    au module/semestre auquel appartient cette evaluation.
    Si include_default, inclue aussi le groupe par defaut ('tous')
    [ group ]
    """
    if include_default:
        c = ""
    else:
        c = " AND p.partition_name is not NULL"
    cnx = ndb.GetDBConnexion()
    cursor = cnx.cursor()
    cursor.execute(
        """SELECT DISTINCT gd.id AS group_id
        FROM group_descr gd, group_membership gm, partition p,
             notes_moduleimpl m, notes_evaluation e
        WHERE gm.group_id = gd.id
        and gd.partition_id = p.id
        and p.formsemestre_id = m.formsemestre_id
        and m.id = e.moduleimpl_id
        and e.id = %(evaluation_id)s
        """
        + c,
        {"evaluation_id": evaluation_id},
    )
    group_ids = [x[0] for x in cursor]
    return listgroups(group_ids)


def listgroups(group_ids):
    cnx = ndb.GetDBConnexion()
    cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
    groups = []
    for group_id in group_ids:
        cursor.execute(
            """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.*
            FROM group_descr gd, partition p
            WHERE p.id = gd.partition_id
            AND gd.id = %(group_id)s
            """,
            {"group_id": group_id},
        )
        r = cursor.dictfetchall()
        if r:
            groups.append(r[0])
    return _sortgroups(groups)


def _sortgroups(groups):
    # Tri: place 'all' en tête, puis groupe par partition / nom de groupe
    R = [g for g in groups if g["partition_name"] is None]
    o = [g for g in groups if g["partition_name"] != None]
    o.sort(key=lambda x: (x["numero"] or 0, x["group_name"]))

    return R + o


def listgroups_filename(groups):
    """Build a filename representing groups"""
    return "gr" + "+".join([g["group_name"] or "tous" for g in groups])


def listgroups_abbrev(groups):
    """Human readable abbreviation descring groups (eg "A / AB / B3")
    Ne retient que les partitions avec show_in_lists
    """
    return " / ".join(
        [g["group_name"] for g in groups if g["group_name"] and g["show_in_lists"]]
    )


# form_group_choice replaces formChoixGroupe
def form_group_choice(
    formsemestre_id,
    allow_none=True,  #  offre un choix vide dans chaque partition
    select_default=True,  # Le groupe par defaut est mentionné (hidden).
    display_sem_title=False,
):
    """Partie de formulaire pour le choix d'un ou plusieurs groupes.
    Variable : group_ids
    """
    from app.scodoc import sco_formsemestre

    sem = sco_formsemestre.get_formsemestre(formsemestre_id)
    if display_sem_title:
        sem_title = "%s: " % sem["titremois"]
    else:
        sem_title = ""
    #
    H = ["""<table>"""]
    for p in get_partitions_list(formsemestre_id):
        if p["partition_name"] is None:
            if select_default:
                H.append(
                    '<input type="hidden" name="group_ids:list" value="%s"/>'
                    % get_partition_groups(p)[0]["group_id"]
                )
        else:
            H.append("<tr><td>Groupe de %(partition_name)s</td><td>" % p)
            H.append('<select name="group_ids:list">')
            if allow_none:
                H.append('<option value="">aucun</option>')
            for group in get_partition_groups(p):
                H.append(
                    '<option value="%s">%s %s</option>'
                    % (group["group_id"], sem_title, group["group_name"])
                )
            H.append("</select></td></tr>")
    H.append("""</table>""")
    return "\n".join(H)


def make_query_groups(group_ids):
    if group_ids:
        return "&".join(["group_ids%3Alist=" + str(group_id) for group_id in group_ids])
    else:
        return ""


class GroupIdInferer(object):
    """Sert à retrouver l'id d'un groupe dans un semestre donné
    à partir de son nom.
    Attention: il peut y avoir plusieurs groupes de même nom
    dans des partitions différentes. Dans ce cas, prend le dernier listé.
    On peut indiquer la partition en écrivant
    partition_name:group_name
    """

    def __init__(self, formsemestre_id):
        groups = get_sem_groups(formsemestre_id)
        self.name2group_id = {}
        self.partitionname2group_id = {}
        for group in groups:
            self.name2group_id[group["group_name"]] = group["group_id"]
            self.partitionname2group_id[
                (group["partition_name"], group["group_name"])
            ] = group["group_id"]

    def __getitem__(self, name):
        """Get group_id from group_name, or None is nonexistent.
        The group name can be prefixed by the partition's name, using
        syntax partition_name:group_name
        """
        l = name.split(":", 1)
        if len(l) > 1:
            partition_name, group_name = l
        else:
            partition_name = None
            group_name = name
        if partition_name is None:
            group_id = self.name2group_id.get(group_name, None)
            if group_id is None and name[-2:] == ".0":
                # si nom groupe numerique, excel ajoute parfois ".0" !
                group_name = group_name[:-2]
                group_id = self.name2group_id.get(group_name, None)
        else:
            group_id = self.partitionname2group_id.get(
                (partition_name, group_name), None
            )
        return group_id