##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
# See LICENSE
##############################################################################

"""Résultats semestre: méthodes communes aux formations classiques et APC
"""

from collections import Counter
from collections.abc import Generator
from functools import cached_property
import numpy as np
import pandas as pd

from flask import g, url_for

from app.auth.models import User
from app.comp import res_sem
from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre
from app.comp.moy_mod import ModuleImplResults
from app.models import FormSemestre, FormSemestreUECoef
from app.models import Identite
from app.models import ModuleImpl, ModuleImplInscription
from app.models.ues import UniteEns
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM
from app.scodoc import sco_evaluation_db
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_groups
from app.scodoc import sco_utils as scu
from app.scodoc import table_builder as tb

# Il faut bien distinguer
#  - ce qui est caché de façon persistente (via redis):
#      ce sont les attributs listés dans `_cached_attrs`
#      le stockage et l'invalidation sont gérés dans sco_cache.py
#
#  - les valeurs cachées durant le temps d'une requête
#      (durée de vie de l'instance de ResultatsSemestre)
#      qui sont notamment les attributs décorés par `@cached_property``
#
class ResultatsSemestre(ResultatsCache):
    """Les résultats (notes, ...) d'un formsemestre
    Classe commune à toutes les formations (classiques, BUT)
    """

    _cached_attrs = (
        "bonus",
        "bonus_ues",
        "dispense_ues",
        "etud_coef_ue_df",
        "etud_moy_gen_ranks",
        "etud_moy_gen",
        "etud_moy_ue",
        "modimpl_inscr_df",
        "modimpls_results",
        "moyennes_matieres",
    )

    def __init__(self, formsemestre: FormSemestre):
        super().__init__(formsemestre, ResultatsSemestreCache)
        # BUT ou standard ? (apc == "approche par compétences")
        self.is_apc = formsemestre.formation.is_apc()
        # Attributs "virtuels", définis dans les sous-classes
        self.bonus: pd.Series = None  # virtuel
        "Bonus sur moy. gen. Series de float, index etudid"
        self.bonus_ues: pd.DataFrame = None  # virtuel
        "DataFrame de float, index etudid, columns: ue.id"
        self.dispense_ues: set[tuple[int, int]] = set()
        """set des dispenses d'UE: (etudid, ue_id), en APC seulement."""
        #  ResultatsSemestreBUT ou ResultatsSemestreClassic
        self.etud_moy_ue = {}
        "etud_moy_ue: DataFrame columns UE, rows etudid"
        self.etud_moy_gen: pd.Series = None
        self.etud_moy_gen_ranks = {}
        self.etud_moy_gen_ranks_int = {}
        self.moy_gen_rangs_by_group = None  # virtual
        self.modimpl_inscr_df: pd.DataFrame = None
        "Inscriptions: row etudid, col modimlpl_id"
        self.modimpls_results: ModuleImplResults = None
        "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }"
        self.etud_coef_ue_df = None
        """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)"""
        self.modimpl_coefs_df: pd.DataFrame = None
        """Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef."""

        self.validations = None
        self.moyennes_matieres = {}
        """Moyennes de matières, si calculées. { matiere_id : Series, index etudid }"""

    def __repr__(self):
        return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>"

    def compute(self):
        "Charge les notes et inscriptions et calcule toutes les moyennes"
        # voir ce qui est chargé / calculé ici et dans les sous-classes
        raise NotImplementedError()

    def get_inscriptions_counts(self) -> Counter:
        """Nombre d'inscrits, défaillants, démissionnaires.

        Exemple: res.get_inscriptions_counts()[scu.INSCRIT]

        Result: a collections.Counter instance
        """
        return Counter(ins.etat for ins in self.formsemestre.inscriptions)

    @cached_property
    def etuds(self) -> list[Identite]:
        "Liste des inscrits au semestre, avec les démissionnaires et les défaillants"
        # nb: si la liste des inscrits change, ResultatsSemestre devient invalide
        return self.formsemestre.get_inscrits(include_demdef=True)

    @cached_property
    def etud_index(self) -> dict[int, int]:
        "dict { etudid : indice dans les inscrits }"
        return {e.id: idx for idx, e in enumerate(self.etuds)}

    def etud_ues_ids(self, etudid: int) -> list[int]:
        """Liste des UE auxquelles l'etudiant est inscrit, sans bonus
        (surchargée en BUT pour prendre en compte les parcours)
        """
        # Pour les formations classiques, etudid n'est pas utilisé
        # car tous les étudiants sont inscrits à toutes les UE
        return [ue.id for ue in self.ues if ue.type != UE_SPORT]

    def etud_ues(self, etudid: int) -> Generator[UniteEns]:
        """Liste des UE auxquelles l'étudiant est inscrit."""
        return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))

    def etud_ects_tot_sem(self, etudid: int) -> float:
        """Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)"""
        etud_ues = self.etud_ues(etudid)
        return sum([ue.ects or 0 for ue in etud_ues]) if etud_ues else 0.0

    def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray:
        """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue.
        Utile pour stats bottom tableau recap.
        Résultat: 1d array of float
        """
        # différent en BUT et classique: virtuelle
        raise NotImplementedError

    @cached_property
    def etuds_dict(self) -> dict[int, Identite]:
        """dict { etudid : Identite } inscrits au semestre,
        avec les démissionnaires et defs."""
        return {etud.id: etud for etud in self.etuds}

    @cached_property
    def ues(self) -> list[UniteEns]:
        """Liste des UEs du semestre (avec les UE bonus sport)
        (indices des DataFrames).
        Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs.
        """
        return self.formsemestre.query_ues(with_sport=True).all()

    @cached_property
    def ressources(self):
        "Liste des ressources du semestre, triées par numéro de module"
        return [
            m
            for m in self.formsemestre.modimpls_sorted
            if m.module.module_type == scu.ModuleType.RESSOURCE
        ]

    @cached_property
    def saes(self):
        "Liste des SAÉs du semestre, triées par numéro de module"
        return [
            m
            for m in self.formsemestre.modimpls_sorted
            if m.module.module_type == scu.ModuleType.SAE
        ]

    # --- JURY...
    def load_validations(self) -> ValidationsSemestre:
        """Load validations, set attribute and return value"""
        if not self.validations:
            self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
        return self.validations

    def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]:
        """Liste des UEs du semestre qui doivent être validées

        Rappel: l'étudiant est inscrit à des modimpls et non à des UEs.

        - En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules
        du parcours.

        - En classique: toutes les UEs des modimpls auxquels l'étudiant est inscrit sont
        susceptibles d'être validées.

        Les UE "bonus" (sport) ne sont jamais "validables".
        """
        if self.is_apc:
            return list(self.etud_ues(etudid))
        # Formations classiques:
        # restreint aux UE auxquelles l'étudiant est inscrit (dans l'un des modimpls)
        ues = {
            modimpl.module.ue
            for modimpl in self.formsemestre.modimpls_sorted
            if self.modimpl_inscr_df[modimpl.id][etudid]
        }
        ues = sorted(list(ues), key=lambda x: x.numero or 0)
        return ues

    def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]:
        """Liste des modimpl de cette UE auxquels l'étudiant est inscrit.
        Utile en formations classiques, surchargée pour le BUT.
        Inclus modules bonus le cas échéant.
        """
        # Utilisée pour l'affichage ou non de l'UE sur le bulletin
        # Méthode surchargée en BUT
        modimpls = [
            modimpl
            for modimpl in self.formsemestre.modimpls_sorted
            if modimpl.module.ue.id == ue.id
            and self.modimpl_inscr_df[modimpl.id][etudid]
        ]
        if not with_bonus:
            return [
                modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT
            ]
        return modimpls

    @cached_property
    def ue_au_dessus(self, seuil=10.0) -> pd.DataFrame:
        """DataFrame columns UE, rows etudid, valeurs: bool
        Par exemple, pour avoir le nombre d'UE au dessus de 10 pour l'étudiant etudid
        nb_ues_ok = sum(res.ue_au_dessus().loc[etudid])
        """
        return self.etud_moy_ue > (seuil - scu.NOTES_TOLERANCE)

    def apply_capitalisation(self):
        """Recalcule la moyenne générale pour prendre en compte d'éventuelles
        UE capitalisées.
        """
        # Supposant qu'il y a peu d'UE capitalisées,
        # on recalcule les moyennes gen des etuds ayant des UE capitalisée.
        self.load_validations()
        ue_capitalisees = self.validations.ue_capitalisees
        for etudid in ue_capitalisees.index:
            recompute_mg = False
            # ue_codes = set(ue_capitalisees.loc[etudid]["ue_code"])
            # for ue_code in ue_codes:
            #     ue = ue_by_code.get(ue_code)
            #     if ue is None:
            #         ue = self.formsemestre.query_ues.filter_by(ue_code=ue_code)
            #         ue_by_code[ue_code] = ue

            # Quand il y a une capitalisation, vérifie toutes les UEs
            sum_notes_ue = 0.0
            sum_coefs_ue = 0.0
            for ue in self.formsemestre.query_ues():
                ue_cap = self.get_etud_ue_status(etudid, ue.id)
                if ue_cap is None:
                    continue
                if ue_cap["is_capitalized"]:
                    recompute_mg = True
                coef = ue_cap["coef_ue"]
                if not np.isnan(ue_cap["moy"]) and coef:
                    sum_notes_ue += ue_cap["moy"] * coef
                    sum_coefs_ue += coef

            if recompute_mg and sum_coefs_ue > 0.0:
                # On doit prendre en compte une ou plusieurs UE capitalisées
                # et donc recalculer la moyenne générale
                self.etud_moy_gen[etudid] = sum_notes_ue / sum_coefs_ue
                # Ajoute le bonus sport
                if self.bonus is not None and self.bonus[etudid]:
                    self.etud_moy_gen[etudid] += self.bonus[etudid]
                    self.etud_moy_gen[etudid] = max(
                        0.0, min(self.etud_moy_gen[etudid], 20.0)
                    )

    def get_etud_etat(self, etudid: int) -> str:
        "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)"
        ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
        if ins is None:
            return ""
        return ins.etat

    def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict:
        """Donne les informations sur la capitalisation de l'UE ue pour cet étudiant.
        Résultat:
            Si pas capitalisée: None
            Si capitalisée: un dict, avec les colonnes de validation.
        """
        capitalisations = self.validations.ue_capitalisees.loc[etudid]
        if isinstance(capitalisations, pd.DataFrame):
            ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code]
            if ue_cap.empty:
                return None
            if isinstance(ue_cap, pd.DataFrame):
                # si plusieurs fois capitalisée, prend le max
                cap_idx = ue_cap["moy_ue"].values.argmax()
                ue_cap = ue_cap.iloc[cap_idx]
        else:
            if capitalisations["ue_code"] == ue.ue_code:
                ue_cap = capitalisations
            else:
                return None
        # converti la Series en dict, afin que les np.int64 reviennent en int
        # et remplace les NaN (venant des NULL en base) par des None
        ue_cap_dict = ue_cap.to_dict()
        if ue_cap_dict["formsemestre_id"] is not None and np.isnan(
            ue_cap_dict["formsemestre_id"]
        ):
            ue_cap_dict["formsemestre_id"] = None
        if ue_cap_dict["compense_formsemestre_id"] is not None and np.isnan(
            ue_cap_dict["compense_formsemestre_id"]
        ):
            ue_cap_dict["compense_formsemestre_id"] = None
        return ue_cap_dict

    def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict:
        """L'état de l'UE pour cet étudiant.
        Result: dict, ou None si l'UE n'est pas dans ce semestre.
        """
        ue = UniteEns.query.get(ue_id)
        if ue.type == UE_SPORT:
            return {
                "is_capitalized": False,
                "was_capitalized": False,
                "is_external": False,
                "coef_ue": 0.0,
                "cur_moy_ue": 0.0,
                "moy": 0.0,
                "event_date": None,
                "ue": ue.to_dict(),
                "formsemestre_id": None,
                "capitalized_ue_id": None,
                "ects_pot": 0.0,
                "ects": 0.0,
                "ects_ue": ue.ects,
            }
        if not ue_id in self.etud_moy_ue:
            return None
        if not self.validations:
            self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
        cur_moy_ue = self.etud_moy_ue[ue_id][etudid]
        moy_ue = cur_moy_ue
        is_capitalized = False  # si l'UE prise en compte est une UE capitalisée
        # s'il y a precedemment une UE capitalisée (pas forcement meilleure):
        was_capitalized = False
        if etudid in self.validations.ue_capitalisees.index:
            ue_cap = self._get_etud_ue_cap(etudid, ue)
            if ue_cap and not np.isnan(ue_cap["moy_ue"]):
                was_capitalized = True
                if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue):
                    moy_ue = ue_cap["moy_ue"]
                    is_capitalized = True

        # Coef l'UE dans le semestre courant:
        if self.is_apc:
            # utilise les ECTS comme coef.
            coef_ue = ue.ects
        else:
            # formations classiques
            coef_ue = self.etud_coef_ue_df[ue_id][etudid]
        if (not coef_ue) and is_capitalized:  # étudiant non inscrit dans l'UE courante
            if self.is_apc:
                # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS
                ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
                coef_ue = ue_capitalized.ects
                if coef_ue is None:
                    orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"])
                    raise ScoValueError(
                        f"""L'UE capitalisée {ue_capitalized.acronyme}
                    du semestre {orig_sem.titre_annee()}
                    n'a pas d'indication d'ECTS.
                    Corrigez ou faite corriger le programme
                    <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
                    formation_id=ue_capitalized.formation_id)}">via cette page</a>.
                    """
                    )
            else:
                # Coefs de l'UE capitalisée en formation classique:
                # va chercher le coef dans le semestre d'origine
                coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue(
                    ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"]
                )

        return {
            "is_capitalized": is_capitalized,
            "was_capitalized": was_capitalized,
            "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
            "coef_ue": coef_ue,
            "ects_pot": ue.ects or 0.0,
            "ects": self.validations.decisions_jury_ues.get(etudid, {})
            .get(ue.id, {})
            .get("ects", 0.0)
            if self.validations.decisions_jury_ues
            else 0.0,
            "ects_ue": ue.ects,
            "cur_moy_ue": cur_moy_ue,
            "moy": moy_ue,
            "event_date": ue_cap["event_date"] if is_capitalized else None,
            "ue": ue.to_dict(),
            "formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None,
            "capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None,
        }

    def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float:
        "Détermine le coefficient de l'UE pour cet étudiant."
        # calcul différent en classique et BUT
        raise NotImplementedError()

    def get_etud_ue_cap_coef(self, etudid, ue, ue_cap):  # UNUSED in ScoDoc 9
        """Calcule le coefficient d'une UE capitalisée, pour cet étudiant,
        injectée dans le semestre courant.

        ue : ue du semestre courant

        ue_cap = resultat de formsemestre_get_etud_capitalisation
        { 'ue_id' (dans le semestre source),
          'ue_code', 'moy', 'event_date','formsemestre_id' }
        """
        # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ?
        ue_coef_db = FormSemestreUECoef.query.filter_by(
            formsemestre_id=self.formsemestre.id, ue_id=ue.id
        ).first()
        if ue_coef_db is not None:
            return ue_coef_db.coefficient

        # En APC: somme des coefs des modules vers cette UE
        # En classique:  Capitalisation UE externe: quel coef appliquer ?
        # En ScoDoc 7, calculait la somme des coefs dans l'UE du semestre d'origine
        # ici si l'étudiant est inscrit dans le semestre courant,
        # somme des coefs des modules de l'UE auxquels il est inscrit
        return self.compute_etud_ue_coef(etudid, ue)

    # --- TABLEAU RECAP

    def get_table_recap(
        self,
        convert_values=False,
        include_evaluations=False,
        mode_jury=False,
        allow_html=True,
    ) -> tb.Table:
        """Table récap. des résultats.
        allow_html: si vrai, peut mettre du HTML dans les valeurs

        Result: XXX tuple avec
        - rows: liste de dicts { column_id : value }
        - titles: { column_id : title }
        - columns_ids: (liste des id de colonnes)

        Si convert_values, transforme les notes en chaines ("12.34").
        Les colonnes générées sont:
            etudid
            rang : rang indicatif (basé sur moy gen)
            moy_gen : moy gen indicative
            moy_ue_<ue_id>, ...,  les moyennes d'UE
            moy_res_<modimpl_id>_<ue_id>, ... les moyennes de ressources dans l'UE
            moy_sae_<modimpl_id>_<ue_id>, ... les moyennes de SAE dans l'UE

        On ajoute aussi des attributs: XXX
        - pour les lignes:
            _css_row_class (inutilisé pour le monent)
            _<column_id>_class classe css:
                - la moyenne générale a la classe col_moy_gen
                - les colonnes SAE ont la classe col_sae
                - les colonnes Resources ont la classe col_res
                - les colonnes d'UE ont la classe col_ue
                - les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_<ue_id>
            _<column_id>_order : clé de tri
        """
        if convert_values:
            fmt_note = scu.fmt_note
        else:
            fmt_note = lambda x: x

        parcours = self.formsemestre.formation.get_parcours()
        barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE
        barre_valid_ue = parcours.NOTES_BARRE_VALID_UE
        barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
        NO_NOTE = "-"  # contenu des cellules sans notes
        rows = []
        dict_nom_res = {}  # cache uid : nomcomplet

        # def add_cell(
        #     row: dict,
        #     col_id: str,
        #     title: str,
        #     content: str,
        #     classes: str = "",
        #     idx: int = 100,
        # ):
        #     "Add a cell to our table. classes is a list of css class names"
        #     row[col_id] = content
        #     if classes:
        #         row[f"_{col_id}_class"] = classes + f" c{idx}"
        #     if not col_id in titles:
        #         titles[col_id] = title
        #         titles[f"_{col_id}_col_order"] = idx
        #         if classes:
        #             titles[f"_{col_id}_class"] = classes
        #     return idx + 1

        etuds_inscriptions = self.formsemestre.etuds_inscriptions
        ues = self.formsemestre.query_ues(with_sport=True)  # avec bonus
        ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
        modimpl_ids = set()  # modimpl effectivement présents dans la table
        table = tb.Table()
        for etudid in etuds_inscriptions:
            idx = 0  # index de la colonne
            etud = Identite.query.get(etudid)
            row = tb.Row(table, etudid)
            table.add_row(row)
            # --- Codes (seront cachés, mais exportés en excel)
            row.add_cell("etudid", "etudid", etudid, "codes")
            row.add_cell(
                "code_nip",
                "code_nip",
                etud.code_nip or "",
                "codes",
            )

            # --- Rang
            if not self.formsemestre.block_moyenne_generale:
                row.add_cell(
                    "rang",
                    "Rg",
                    self.etud_moy_gen_ranks[etudid],
                    "rang",
                    data={"order": f"{self.etud_moy_gen_ranks_int[etudid]:05d}"},
                )
            # --- Identité étudiant
            url_bulletin = url_for(
                "notes.formsemestre_bulletinetud",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=self.formsemestre.id,
                etudid=etudid,
            )
            row.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
            row.add_cell(
                "nom_disp",
                "Nom",
                etud.nom_disp(),
                "identite_detail",
                data={"order": etud.sort_key},
                target=url_bulletin,
                target_attrs=f'class="etudinfo" id="{etudid}"',
            )
            row.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
            row.add_cell(
                "nom_short",
                "Nom",
                etud.nom_short,
                "identite_court",
                data={
                    "order": etud.sort_key,
                    "etudid": etud.id,
                    "nomprenom": etud.nomprenom,
                },
                target=url_bulletin,
                target_attrs=f'class="etudinfo" id="{etudid}"',
            )

            # --- Moyenne générale
            if not self.formsemestre.block_moyenne_generale:
                moy_gen = self.etud_moy_gen.get(etudid, False)
                note_class = ""
                if moy_gen is False:
                    moy_gen = NO_NOTE
                elif isinstance(moy_gen, float) and moy_gen < barre_moy:
                    note_class = "moy_ue_warning"  # en rouge
                row.add_cell(
                    "moy_gen",
                    "Moy",
                    fmt_note(moy_gen),
                    "col_moy_gen",
                    classes=[note_class],
                )
            # Ajoute bulle sur titre du pied de table:
            table.foot_title_row.cells["moy_gen"].target_attrs = (
                'title="moyenne indicative"' if self.is_apc else ""
            )
            # --- Moyenne d'UE
            nb_ues_validables, nb_ues_warning = 0, 0
            for ue in ues_sans_bonus:
                ue_status = self.get_etud_ue_status(etudid, ue.id)
                if ue_status is not None:
                    col_id = f"moy_ue_{ue.id}"
                    val = ue_status["moy"]
                    note_class = ""
                    if isinstance(val, float):
                        if val < barre_moy:
                            note_class = " moy_inf"
                        elif val >= barre_valid_ue:
                            note_class = " moy_ue_valid"
                            nb_ues_validables += 1
                        if val < barre_warning_ue:
                            note_class = " moy_ue_warning"  # notes très basses
                            nb_ues_warning += 1
                    row.add_cell(
                        col_id,
                        ue.acronyme,
                        fmt_note(val),
                        group=f"col_ue_{ue.id}",
                        classes=["col_ue", note_class],
                    )
                    table.foot_title_row.cells[
                        col_id
                    ].target_attrs = (
                        f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
                    )
                    if mode_jury:
                        # pas d'autre colonnes de résultats
                        continue
                    # Bonus (sport) dans cette UE ?
                    # Le bonus sport appliqué sur cette UE
                    if (self.bonus_ues is not None) and (ue.id in self.bonus_ues):
                        val = self.bonus_ues[ue.id][etud.id] or ""
                        val_fmt = val_fmt_html = fmt_note(val)
                        if val:
                            val_fmt_html = f"""<span class="green-arrow-up"></span><span class="sp2l">{
                                val_fmt
                            }</span>"""
                        row.add_cell(
                            f"bonus_ue_{ue.id}",
                            f"Bonus {ue.acronyme}",
                            val_fmt_html if allow_html else val_fmt,
                            group=f"col_ue_{ue.id}",
                            classes=["col_ue_bonus"],
                            raw_content=val_fmt,
                        )
                    # Les moyennes des modules (ou ressources et SAÉs) dans cette UE
                    for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
                        if ue_status["is_capitalized"]:
                            val = "-c-"
                        else:
                            modimpl_results = self.modimpls_results.get(modimpl.id)
                            if modimpl_results:  # pas bonus
                                if self.is_apc:  # BUT
                                    moys_vers_ue = modimpl_results.etuds_moy_module.get(
                                        ue.id
                                    )
                                    val = (
                                        moys_vers_ue.get(etudid, "?")
                                        if moys_vers_ue is not None
                                        else ""
                                    )
                                else:  # classique: Series indépendante de l'UE
                                    val = modimpl_results.etuds_moy_module.get(
                                        etudid, "?"
                                    )
                            else:
                                val = ""

                        col_id = (
                            f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
                        )
                        val_fmt = val_fmt_html = fmt_note(val)
                        if convert_values and (
                            modimpl.module.module_type == scu.ModuleType.MALUS
                        ):
                            val_fmt_html = (
                                (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
                            )
                        cell = row.add_cell(
                            col_id,
                            modimpl.module.code,
                            val_fmt_html,
                            group=f"col_ue_{ue.id}_modules",
                            classes=[
                                f"col_{modimpl.module.type_abbrv()}",
                                f"mod_ue_{ue.id}",
                            ],
                            raw_content=val_fmt,
                        )
                        if modimpl.module.module_type == scu.ModuleType.MALUS:
                            # positionne la colonne à droite de l'UE
                            cell.group = f"col_ue_{ue.id}_malus"
                            table.insert_group(cell.group, after=f"col_ue_{ue.id}")

                        table.foot_title_row.cells[col_id].target = url_for(
                            "notes.moduleimpl_status",
                            scodoc_dept=g.scodoc_dept,
                            moduleimpl_id=modimpl.id,
                        )

                        nom_resp = dict_nom_res.get(modimpl.responsable_id)
                        if nom_resp is None:
                            user = User.query.get(modimpl.responsable_id)
                            nom_resp = user.get_nomcomplet() if user else ""
                            dict_nom_res[modimpl.responsable_id] = nom_resp
                        table.foot_title_row.cells[
                            col_id
                        ].target_attrs = (
                            f""" title="{modimpl.module.titre} ({nom_resp})" """
                        )
                        modimpl_ids.add(modimpl.id)
            nb_ues_etud_parcours = len(self.etud_ues_ids(etudid))
            ue_valid_txt = (
                ue_valid_txt_html
            ) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
            if nb_ues_warning:
                ue_valid_txt_html += " " + scu.EMO_WARNING
            # place juste avant moy. gen.
            table.insert_group("col_ues_validables", before="moy_gen")
            classes = ["col_ue"]
            if nb_ues_warning:
                classes.append("moy_ue_warning")
            elif nb_ues_validables < len(ues_sans_bonus):
                classes.append("moy_inf")
            row.add_cell(
                "ues_validables",
                "UEs",
                ue_valid_txt_html,
                "col_ues_validables",
                classes=classes,
                raw_content=ue_valid_txt,
                data={"order": nb_ues_validables},  #  tri
            )

            if mode_jury and self.validations:
                if self.is_apc:
                    # formations BUT: pas de code semestre, concatene ceux des UEs
                    dec_ues = self.validations.decisions_jury_ues.get(etudid)
                    if dec_ues:
                        jury_code_sem = ",".join(
                            [dec_ues[ue_id].get("code", "") for ue_id in dec_ues]
                        )
                    else:
                        jury_code_sem = ""
                else:
                    # formations classiques: code semestre
                    dec_sem = self.validations.decisions_jury.get(etudid)
                    jury_code_sem = dec_sem["code"] if dec_sem else ""
                row.add_cell("jury_code_sem", "Jury", jury_code_sem, "jury_code_sem")
                row.add_cell(
                    "jury_link",
                    "",
                    f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
                    scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid
                    )
                    }">{("saisir" if not jury_code_sem else "modifier")
                        if self.formsemestre.etat else "voir"} décisions</a>""",
                    "col_jury_link",
                )

        self.recap_add_partitions(table)
        self.recap_add_cursus(table)
        self._recap_add_admissions(table)

        # tri par rang croissant
        if not self.formsemestre.block_moyenne_generale:
            table.sort_rows(key=lambda e: e["_rang_order"])
        else:
            table.sort_rows(key=lambda e: e["_ues_validables_order"], reverse=True)

        # INFOS POUR FOOTER
        bottom_infos = self._recap_bottom_infos(
            table, ues_sans_bonus, modimpl_ids, fmt_note
        )
        if include_evaluations:
            self._recap_add_evaluations(table)

        # Ajoute style "col_empty" aux colonnes de modules vides
        row_moy = table.get_row_by_id("moy")
        for col_id in table.column_ids:
            if "col_empty" in row_moy.cells[col_id]:
                table.column_classes[col_id].append("col_empty")

        # Ligne avec la classe de chaque colonne
        # récupère le type à partir des classes css (hack...)
        row_type = tb.BottomRow(
            table,
            "type_col",
            title="Type col.",
            left_title_col_ids=["prenom", "nom_short"],
            category="bottom_infos",
            classes=["bottom_info"],
        )
        for col_id in table.column_ids:
            group_name = table.column_group.get(col_id, "")
            if group_name.startswith("col_"):
                group_name = group_name[4:]
            row_type.add_cell(col_id, None, group_name)

        # Titres
        table.add_head_row(self.head_title_row)
        table.add_foot_row(self.foot_title_row)
        return table

    def _recap_bottom_infos(
        self, table: tb.Table, ues, modimpl_ids: set, fmt_note
    ) -> dict:
        """Les informations à mettre en bas de la table: min, max, moy, ECTS, Apo"""
        # Ordre des lignes: Min, Max, Moy, Coef, ECTS, Apo
        row_min = tb.BottomRow(
            table,
            "min",
            title="Min.",
            left_title_col_ids=["prenom", "nom_short"],
            category="bottom_infos",
            classes=["bottom_info"],
        )
        row_max = tb.BottomRow(
            table,
            "max",
            title="Max.",
            left_title_col_ids=["prenom", "nom_short"],
            category="bottom_infos",
            classes=["bottom_info"],
        )
        row_moy = tb.BottomRow(
            table,
            "moy",
            title="Moy.",
            left_title_col_ids=["prenom", "nom_short"],
            category="bottom_infos",
            classes=["bottom_info"],
        )
        row_coef = tb.BottomRow(
            table,
            "coef",
            title="Coef.",
            left_title_col_ids=["prenom", "nom_short"],
            category="bottom_infos",
            classes=["bottom_info"],
        )
        row_ects = tb.BottomRow(
            table,
            "ects",
            title="ECTS",
            left_title_col_ids=["prenom", "nom_short"],
            category="bottom_infos",
            classes=["bottom_info"],
        )
        row_apo = tb.BottomRow(
            table,
            "apo",
            title="Code Apogée",
            left_title_col_ids=["prenom", "nom_short"],
            category="bottom_infos",
            classes=["bottom_info"],
        )

        # --- ECTS
        # titre (à gauche) sur 2 colonnes pour s'adapter à l'affichage des noms/prenoms
        for ue in ues:
            col_id = f"moy_ue_{ue.id}"
            row_ects.add_cell(col_id, None, ue.ects, "col_ue")
            # ajoute cell UE vides sur ligne coef pour borders verticales
            # XXX TODO classes dans table sur colonne ajoutées à tous les TD
            row_coef.add_cell(col_id, None, "", "col_ue")
        row_ects.add_cell(
            "moy_gen",
            None,
            sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]),
            "col_moy_gen",
        )
        # --- MIN, MAX, MOY, APO
        row_min.add_cell("moy_gen", None, fmt_note(self.etud_moy_gen.min()))
        row_max.add_cell("moy_gen", None, fmt_note(self.etud_moy_gen.max()))
        row_moy.add_cell("moy_gen", None, fmt_note(self.etud_moy_gen.mean()))

        for ue in ues:
            col_id = f"moy_ue_{ue.id}"
            row_min.add_cell(col_id, None, fmt_note(self.etud_moy_ue[ue.id].min()))
            row_max.add_cell(col_id, None, fmt_note(self.etud_moy_ue[ue.id].max()))
            row_moy.add_cell(col_id, None, fmt_note(self.etud_moy_ue[ue.id].mean()))
            row_apo.add_cell(col_id, None, ue.code_apogee or "")

            for modimpl in self.formsemestre.modimpls_sorted:
                if modimpl.id in modimpl_ids:
                    col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
                    if self.is_apc:
                        coef = self.modimpl_coefs_df[modimpl.id][ue.id]
                    else:
                        coef = modimpl.module.coefficient or 0
                    row_coef.add_cell(col_id, None, fmt_note(coef))
                    notes = self.modimpl_notes(modimpl.id, ue.id)
                    if np.isnan(notes).all():
                        # aucune note valide
                        row_min.add_cell(col_id, None, np.nan)
                        row_max.add_cell(col_id, None, np.nan)
                        moy = np.nan
                    else:
                        row_min.add_cell(col_id, None, fmt_note(np.nanmin(notes)))
                        row_max.add_cell(col_id, None, fmt_note(np.nanmax(notes)))
                        moy = np.nanmean(notes)
                    row_moy.add_cell(
                        col_id,
                        None,
                        fmt_note(moy),
                        # aucune note dans ce module ?
                        classes=["col_empty" if np.isnan(moy) else ""],
                    )
                    row_apo.add_cell(col_id, None, modimpl.module.code_apogee or "")

        return {  # { key : row } avec key = min, max, moy, coef, ...
            "min": row_min,
            "max": row_max,
            "moy": row_moy,
            "coef": row_coef,
            "ects": row_ects,
            "apo": row_apo,
        }

    def _recap_etud_groups_infos(
        self, etudid: int, row: dict, titles: dict
    ):  # XXX non utilisé
        """Table recap: ajoute à row les colonnes sur les groupes pour cet etud"""
        # dec = self.get_etud_decision_sem(etudid)
        # if dec:
        #    codes_nb[dec["code"]] += 1
        row_class = ""
        etud_etat = self.get_etud_etat(etudid)
        if etud_etat == DEM:
            gr_name = "Dém."
            row_class = "dem"
        elif etud_etat == DEF:
            gr_name = "Déf."
            row_class = "def"
        else:
            # XXX probablement à revoir pour utiliser données cachées,
            # via get_etud_groups_in_partition ou autre
            group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id)
            gr_name = group["group_name"] or ""
        row["group"] = gr_name
        row["_group_class"] = "group"
        if row_class:
            row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class])
        titles["group"] = "Gr"

    def _recap_add_admissions(self, table: tb.Table):
        """Ajoute les colonnes "admission"
        Les colonnes ont la classe css "admission"
        """
        fields = {
            "bac": "Bac",
            "specialite": "Spécialité",
            "type_admission": "Type Adm.",
            "classement": "Rg. Adm.",
        }
        first = True
        for cid, title in fields.items():
            cell_head, cell_foot = table.add_title(cid, title)
            cell_head.classes.append("admission")
            cell_foot.classes.append("admission")
            if first:
                cell_head.classes.append("admission_first")
                cell_foot.classes.append("admission_first")
                first = False

        for row in table.rows:
            etud = Identite.query.get(row.id)  # TODO XXX
            admission = etud.admission.first()
            first = True
            for cid, title in fields.items():
                cell = row.add_cell(
                    cid,
                    title,
                    getattr(admission, cid) or "",
                    "admission",
                )
                if first:
                    cell.classes.append("admission_first")
                    first = False

    def recap_add_cursus(self, table: tb.Table):
        """Ajoute colonne avec code cursus, eg 'S1 S2 S1'"""
        table.insert_group("cursus", before="col_ues_validables")
        cid = "code_cursus"
        formation_code = self.formsemestre.formation.formation_code
        for row in table.rows:
            etud = Identite.query.get(row.id)  # TODO XXX à optimiser: etud dans row
            row.add_cell(
                cid,
                "Cursus",
                " ".join(
                    [
                        f"S{ins.formsemestre.semestre_id}"
                        for ins in reversed(etud.inscriptions())
                        if ins.formsemestre.formation.formation_code == formation_code
                    ]
                ),
                "cursus",
            )

    def recap_add_partitions(self, table: tb.Table):
        """Ajoute les colonnes indiquant les groupes
        La table contient des rows avec la clé etudid.

        Les colonnes ont la classe css "partition"
        """
        table.insert_group("partition", after="parcours")
        partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
            self.formsemestre.id
        )
        first_partition = True
        for partition in partitions:
            col_classes = ["partition"]
            if not first_partition:
                col_classes.append("partition_aux")
            first_partition = False
            cid = f"part_{partition['partition_id']}"
            cell_head, cell_foot = table.add_title(cid, partition["partition_name"])
            cell_head.classes += col_classes
            cell_foot.classes += col_classes

            if partition["bul_show_rank"]:
                rg_cid = cid + "_rg"  # rang dans la partition
                cell_head, cell_foot = table.add_title(
                    cid, f"Rg {partition['partition_name']}"
                )
                cell_head.classes.append("partition_rangs")
                cell_foot.classes.append("partition_rangs")

            partition_etud_groups = partitions_etud_groups[partition["partition_id"]]
            for row in table.rows:
                group = None  # group (dict) de l'étudiant dans cette partition
                # dans NotesTableCompat, à revoir
                etud_etat = self.get_etud_etat(row.id)  # row.id == etudid
                tr_classes = []
                if etud_etat == scu.DEMISSION:
                    gr_name = "Dém."
                    tr_classes.append("dem")
                elif etud_etat == DEF:
                    gr_name = "Déf."
                    tr_classes.append("def")
                else:
                    group = partition_etud_groups.get(row["etudid"])
                    gr_name = group["group_name"] if group else ""
                if gr_name:
                    row.add_cell(
                        cid,
                        partition["partition_name"],
                        gr_name,
                        "partition",
                        classes=col_classes,
                    )

                # Rangs dans groupe
                if (
                    partition["bul_show_rank"]
                    and (group is not None)
                    and (group["id"] in self.moy_gen_rangs_by_group)
                ):
                    rang = self.moy_gen_rangs_by_group[group["id"]][0]
                    row.add_cell(rg_cid, None, rang.get(row["etudid"], ""), "partition")

    def _recap_add_evaluations(self, table: tb.Table):
        """Ajoute les colonnes avec les notes aux évaluations
        rows est une liste de dict avec une clé "etudid"
        Les colonnes ont la classe css "evaluation"
        """
        # nouvelle ligne pour description évaluations:
        row_descr_eval = tb.BottomRow(
            table,
            "evaluations",
            title="Description évaluations",
            left_title_col_ids=["prenom", "nom_short"],
            category="bottom_infos",
            classes=["bottom_info"],
        )

        first_eval = True
        for modimpl in self.formsemestre.modimpls_sorted:
            evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl)
            eval_index = len(evals) - 1
            inscrits = {i.etudid for i in modimpl.inscriptions}
            first_eval_of_mod = True
            for e in evals:
                col_id = f"eval_{e.id}"
                title = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
                col_classes = ["evaluation"]
                if first_eval:
                    col_classes.append("first")
                elif first_eval_of_mod:
                    col_classes.append("first_of_mod")
                first_eval_of_mod = first_eval = False
                eval_index -= 1
                notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
                    e.evaluation_id
                )
                for row in table.rows:
                    etudid = row.id
                    if etudid in inscrits:
                        if etudid in notes_db:
                            val = notes_db[etudid]["value"]
                        else:
                            # Note manquante mais prise en compte immédiate: affiche ATT
                            val = scu.NOTES_ATTENTE
                        content = scu.fmt_note(val)
                        classes = col_classes + [
                            {
                                "ABS": "abs",
                                "ATT": "att",
                                "EXC": "exc",
                            }.get(content, "")
                        ]
                        row.add_cell(col_id, title, content, "", classes=classes)
                    else:
                        row.add_cell(
                            col_id,
                            title,
                            "ni",
                            "",
                            classes=col_classes + ["non_inscrit"],
                        )

                table.get_row_by_id("coef").row[col_id] = e.coefficient
                table.get_row_by_id("min").row[col_id] = "0"
                table.get_row_by_id("max").row[col_id] = scu.fmt_note(e.note_max)
                row_descr_eval.add_cell(
                    col_id,
                    None,
                    e.description or "",
                    target=url_for(
                        "notes.evaluation_listenotes",
                        scodoc_dept=g.scodoc_dept,
                        evaluation_id=e.id,
                    ),
                )