From 55d7270be77b3bdd5cb09c5c65e980c29e644b23 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Sat, 4 Feb 2023 02:20:25 +0100
Subject: [PATCH] WIP: refactoring code gen. tables

---
 app/tables/__init__.py |   8 +
 app/tables/recap.py    | 705 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 713 insertions(+)
 create mode 100644 app/tables/__init__.py
 create mode 100644 app/tables/recap.py

diff --git a/app/tables/__init__.py b/app/tables/__init__.py
new file mode 100644
index 000000000..59fca9d76
--- /dev/null
+++ b/app/tables/__init__.py
@@ -0,0 +1,8 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Génération de tableaux
+"""
diff --git a/app/tables/recap.py b/app/tables/recap.py
new file mode 100644
index 000000000..21c46f62a
--- /dev/null
+++ b/app/tables/recap.py
@@ -0,0 +1,705 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Table récapitulatif des résultats d'un semestre
+"""
+
+from flask import g, url_for
+import numpy as np
+
+from app.auth.models import User
+from app.comp.res_common import ResultatsSemestre
+from app.models import Identite
+from app.models.ues import UniteEns
+from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
+from app.scodoc import sco_evaluation_db
+from app.scodoc import sco_groups
+from app.scodoc import sco_utils as scu
+from app.tables import table_builder as tb
+
+
+class TableRecap(tb.Table):
+    """Table récap. des résultats.
+    allow_html: si vrai, peut mettre du HTML dans les valeurs
+
+    Result: Table, le contenu étant une ligne par étudiant.
+
+
+    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 classes:
+    - pour les lignes:
+        selected_row pour l'étudiant sélectionné
+    - les colonnes:
+        - 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>
+    """
+
+    def __init__(
+        self,
+        res: ResultatsSemestre,
+        convert_values=False,
+        include_evaluations=False,
+        mode_jury=False,
+    ):
+        super().__init__()
+        self.res = res
+        self.include_evaluations = include_evaluations
+        self.mode_jury = mode_jury
+
+        parcours = res.formsemestre.formation.get_parcours()
+        self.barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE
+        self.barre_valid_ue = parcours.NOTES_BARRE_VALID_UE
+        self.barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
+        self.cache_nomcomplet = {}  # cache uid : nomcomplet
+        if convert_values:
+            self.fmt_note = scu.fmt_note
+        else:
+            self.fmt_note = lambda x: x
+        # couples (modimpl, ue) effectivement présents dans la table:
+        self.modimpl_ue_ids = set()
+
+        etuds_inscriptions = res.formsemestre.etuds_inscriptions
+        ues = res.formsemestre.query_ues(with_sport=True)  # avec bonus
+        ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
+
+        for etudid in etuds_inscriptions:
+            etud = Identite.query.get(etudid)
+            row = RowRecap(self, etud)
+            self.add_row(row)
+            row.add_etud_cols()
+            row.add_moyennes_cols(ues_sans_bonus)
+
+        self.add_partitions()
+        self.add_cursus()
+        self.add_admissions()
+
+        # tri par rang croissant
+        if not res.formsemestre.block_moyenne_generale:
+            self.sort_rows(key=lambda row: row.rang_order)
+        else:
+            self.sort_rows(key=lambda row: row.nb_ues_validables, reverse=True)
+
+        # Lignes footer (min, max, ects, apo, ...)
+        self.add_bottom_rows(ues_sans_bonus)
+
+        # Evaluations:
+        if include_evaluations:
+            self.add_evaluations()
+
+        self.mark_empty_cols()
+        self.add_type_row()
+
+    def mark_empty_cols(self):
+        """Ajoute style "col_empty" aux colonnes de modules vides"""
+        # identifie les col. vides par la classe sur leur moyenne
+        row_moy = self.get_row_by_id("moy")
+        for col_id in self.column_ids:
+            cell: tb.Cell = row_moy.cells.get(col_id)
+            if cell and "col_empty" in cell.classes:
+                self.column_classes[col_id].append("col_empty")
+
+    def add_type_row(self):
+        """Ligne avec la classe de chaque colonne recap."""
+        # récupère le type à partir des classes css (hack...)
+        row_type = tb.BottomRow(
+            self,
+            "type_col",
+            left_title="Type col.",
+            left_title_col_ids=["prenom", "nom_short"],
+            category="bottom_infos",
+            classes=["bottom_info"],
+        )
+        for col_id in self.column_ids:
+            group_name = self.column_group.get(col_id, "")
+            if group_name.startswith("col_"):
+                group_name = group_name[4:]
+            row_type.add_cell(col_id, None, group_name)
+
+    def add_bottom_rows(self, ues):
+        """Les informations à mettre en bas de la table recap:
+        min, max, moy, ECTS, Apo."""
+        res = self.res
+        # Ordre des lignes: Min, Max, Moy, Coef, ECTS, Apo
+        row_min = tb.BottomRow(
+            self,
+            "min",
+            left_title="Min.",
+            left_title_col_ids=["prenom", "nom_short"],
+            category="bottom_infos",
+            classes=["bottom_info"],
+        )
+        row_max = tb.BottomRow(
+            self,
+            "max",
+            left_title="Max.",
+            left_title_col_ids=["prenom", "nom_short"],
+            category="bottom_infos",
+            classes=["bottom_info"],
+        )
+        row_moy = tb.BottomRow(
+            self,
+            "moy",
+            left_title="Moy.",
+            left_title_col_ids=["prenom", "nom_short"],
+            category="bottom_infos",
+            classes=["bottom_info"],
+        )
+        row_coef = tb.BottomRow(
+            self,
+            "coef",
+            left_title="Coef.",
+            left_title_col_ids=["prenom", "nom_short"],
+            category="bottom_infos",
+            classes=["bottom_info"],
+        )
+        row_ects = tb.BottomRow(
+            self,
+            "ects",
+            left_title="ECTS",
+            left_title_col_ids=["prenom", "nom_short"],
+            category="bottom_infos",
+            classes=["bottom_info"],
+        )
+        row_apo = tb.BottomRow(
+            self,
+            "apo",
+            left_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)
+            # 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, "")
+        row_ects.add_cell(
+            "moy_gen",
+            None,
+            sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]),
+        )
+        # --- MIN, MAX, MOY, APO
+        row_min.add_cell("moy_gen", None, self.fmt_note(res.etud_moy_gen.min()))
+        row_max.add_cell("moy_gen", None, self.fmt_note(res.etud_moy_gen.max()))
+        row_moy.add_cell("moy_gen", None, self.fmt_note(res.etud_moy_gen.mean()))
+
+        for ue in ues:
+            col_id = f"moy_ue_{ue.id}"
+            row_min.add_cell(col_id, None, self.fmt_note(res.etud_moy_ue[ue.id].min()))
+            row_max.add_cell(col_id, None, self.fmt_note(res.etud_moy_ue[ue.id].max()))
+            row_moy.add_cell(col_id, None, self.fmt_note(res.etud_moy_ue[ue.id].mean()))
+            row_apo.add_cell(col_id, None, ue.code_apogee or "")
+
+            for modimpl in res.formsemestre.modimpls_sorted:
+                if (modimpl.id, ue.id) in self.modimpl_ue_ids:
+                    col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
+                    if res.is_apc:
+                        coef = res.modimpl_coefs_df[modimpl.id][ue.id]
+                    else:
+                        coef = modimpl.module.coefficient or 0
+                    row_coef.add_cell(
+                        col_id,
+                        None,
+                        self.fmt_note(coef),
+                        group=f"col_ue_{ue.id}_modules",
+                    )
+                    notes = res.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, self.fmt_note(np.nanmin(notes)))
+                        row_max.add_cell(col_id, None, self.fmt_note(np.nanmax(notes)))
+                        moy = np.nanmean(notes)
+                    row_moy.add_cell(
+                        col_id,
+                        None,
+                        self.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 "")
+
+    def add_partitions(self):
+        """Ajoute les colonnes indiquant les groupes
+        La table contient des rows avec la clé etudid.
+
+        Les colonnes ont la classe css "partition"
+        """
+        self.insert_group("partition", after="identite_court")
+        partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
+            self.res.formsemestre.id
+        )
+        first_partition = True
+        for partition in partitions:
+            cid = f"part_{partition['partition_id']}"
+
+            partition_etud_groups = partitions_etud_groups[partition["partition_id"]]
+            for row in self.rows:
+                etudid = row.id
+                group = None  # group (dict) de l'étudiant dans cette partition
+                # dans NotesTableCompat, à revoir
+                etud_etat = self.res.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(etudid)
+                    gr_name = group["group_name"] if group else ""
+                if gr_name:
+                    row.add_cell(
+                        cid,
+                        partition["partition_name"],
+                        gr_name,
+                        group="partition",
+                        classes=[] if first_partition else ["partition_aux"],
+                        # la classe "partition" est ajoutée par la Table car c'est le group
+                        # la classe "partition_aux" est ajoutée à partir de la 2eme partition affichée
+                    )
+                    first_partition = False
+
+                # Rangs dans groupe
+                if (
+                    partition["bul_show_rank"]
+                    and (group is not None)
+                    and (group["id"] in self.res.moy_gen_rangs_by_group)
+                ):
+                    rang = self.res.moy_gen_rangs_by_group[group["id"]][0]
+                    rg_cid = cid + "_rg"  # rang dans la partition
+                    row.add_cell(
+                        rg_cid,
+                        f"Rg {partition['partition_name']}",
+                        rang.get(etudid, ""),
+                        group="partition",
+                        classes=["partition_aux", "partition_rangs"],
+                    )
+
+    def add_evaluations(self):
+        """Ajoute les colonnes avec les notes aux évaluations
+        pour tous les étudiants de la table.
+        Les colonnes ont la classe css "evaluation"
+        """
+        # nouvelle ligne pour description évaluations:
+        row_descr_eval = tb.BottomRow(
+            self,
+            "evaluations",
+            left_title="Description évaluations",
+            left_title_col_ids=["prenom", "nom_short"],
+            category="bottom_infos",
+            classes=["bottom_info"],
+        )
+
+        first_eval = True
+        for modimpl in self.res.formsemestre.modimpls_sorted:
+            evals = self.res.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 self.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 = self.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"],
+                        )
+
+                self.get_row_by_id("coef").row[col_id] = e.coefficient
+                self.get_row_by_id("min").row[col_id] = "0"
+                self.get_row_by_id("max").row[col_id] = self.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,
+                    ),
+                )
+
+    def add_admissions(self):
+        """Ajoute les colonnes "admission" pour tous les étduiants de la table
+        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 = self.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 self.rows:
+            etud = row.etud
+            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 add_cursus(self):
+        """Ajoute colonne avec code cursus, eg 'S1 S2 S1'
+        pour tous les étduiants de la table"""
+        self.insert_group("cursus", before="col_ues_validables")
+        cid = "code_cursus"
+        formation_code = self.res.formsemestre.formation.formation_code
+        for row in self.rows:
+            row.add_cell(
+                cid,
+                "Cursus",
+                " ".join(
+                    [
+                        f"S{ins.formsemestre.semestre_id}"
+                        for ins in reversed(row.etud.inscriptions())
+                        if ins.formsemestre.formation.formation_code == formation_code
+                    ]
+                ),
+                "cursus",
+            )
+
+
+class RowRecap(tb.Row):
+    "Ligne de la table recap, pour un étudiant"
+
+    def __init__(self, table: TableRecap, etud: Identite, *args, **kwargs):
+        super().__init__(table, etud.id, *args, **kwargs)
+        self.etud = etud
+        self.rang_order = None
+        "valeur pour tri rangs"
+        self.nb_ues_validables = None
+        self.nb_ues_warning = None
+        self.nb_ues_etud_parcours = None
+
+    def add_etud_cols(self):
+        """Ajoute colonnes étudiant: codes, noms"""
+        res = self.table.res
+        etud = self.etud
+        # --- Codes (seront cachés, mais exportés en excel)
+        self.add_cell("etudid", "etudid", etud.id, "etud_codes")
+        self.add_cell(
+            "code_nip",
+            "code_nip",
+            etud.code_nip or "",
+            "etud_codes",
+        )
+
+        # --- Rang
+        if not res.formsemestre.block_moyenne_generale:
+            self.rang_order = res.etud_moy_gen_ranks_int[etud.id]
+            self.add_cell(
+                "rang",
+                "Rg",
+                res.etud_moy_gen_ranks[etud.id],
+                "rang",
+                data={"order": f"{self.rang_order:05d}"},
+            )
+        else:
+            self.rang_order = -1
+        # --- Identité étudiant
+        url_bulletin = url_for(
+            "notes.formsemestre_bulletinetud",
+            scodoc_dept=g.scodoc_dept,
+            formsemestre_id=res.formsemestre.id,
+            etudid=etud.id,
+        )
+        self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
+        self.add_cell(
+            "nom_disp",
+            "Nom",
+            etud.nom_disp(),
+            "identite_detail",
+            data={"order": etud.sort_key},
+            target=url_bulletin,
+            target_attrs={"class": "etudinfo", "id": str(etud.id)},
+        )
+        self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
+        self.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={"class": "etudinfo", "id": str(etud.id)},
+        )
+
+    def add_moyennes_cols(
+        self,
+        ues_sans_bonus: list[UniteEns],
+    ):
+        """Ajoute cols moy_gen moy_ue et tous les modules..."""
+        etud = self.etud
+        table = self.table
+        res = table.res
+        # --- Moyenne générale
+        if not res.formsemestre.block_moyenne_generale:
+            moy_gen = res.etud_moy_gen.get(etud.id, False)
+            note_class = ""
+            if moy_gen is False:
+                moy_gen = scu.NO_NOTE_STR
+            elif isinstance(moy_gen, float) and moy_gen < table.barre_moy:
+                note_class = "moy_ue_warning"  # en rouge
+            self.add_cell(
+                "moy_gen",
+                "Moy",
+                table.fmt_note(moy_gen),
+                "col_moy_gen",
+                classes=[note_class],
+            )
+        # Ajoute bulle sur titre du pied de table:
+        if res.is_apc:
+            table.foot_title_row.cells["moy_gen"].target_attrs[
+                "title"
+            ] = "moyenne indicative"
+
+        # --- Moyenne d'UE
+        self.nb_ues_validables, self.nb_ues_warning = 0, 0
+        for ue in ues_sans_bonus:
+            ue_status = res.get_etud_ue_status(etud.id, ue.id)
+            if ue_status is not None:
+                self.add_ue_cols(ue, ue_status)
+                if table.mode_jury:
+                    # pas d'autre colonnes de résultats
+                    continue
+
+                # Bonus (sport) dans cette UE ?
+                # Le bonus sport appliqué sur cette UE
+                if (res.bonus_ues is not None) and (ue.id in res.bonus_ues):
+                    val = res.bonus_ues[ue.id][etud.id] or ""
+                    val_fmt = val_fmt_html = table.fmt_note(val)
+                    if val:
+                        val_fmt_html = f"""<span class="green-arrow-up"></span><span class="sp2l">{
+                            val_fmt
+                        }</span>"""
+                    self.add_cell(
+                        f"bonus_ue_{ue.id}",
+                        f"Bonus {ue.acronyme}",
+                        val_fmt_html,
+                        raw_content=val_fmt,
+                        group=f"col_ue_{ue.id}",
+                        classes=["col_ue_bonus"],
+                    )
+                # Les moyennes des modules (ou ressources et SAÉs) dans cette UE
+                self.add_ue_modimpls_cols(ue, ue_status["is_capitalized"])
+
+        self.nb_ues_etud_parcours = len(res.etud_ues_ids(etud.id))
+        ue_valid_txt = (
+            ue_valid_txt_html
+        ) = f"{self.nb_ues_validables}/{self.nb_ues_etud_parcours}"
+        if self.nb_ues_warning:
+            ue_valid_txt_html += " " + scu.EMO_WARNING
+        # place juste avant moy. gen.
+        table.insert_group("col_ues_validables", before="col_moy_gen")
+        classes = ["col_ue"]
+        if self.nb_ues_warning:
+            classes.append("moy_ue_warning")
+        elif self.nb_ues_validables < len(ues_sans_bonus):
+            classes.append("moy_inf")
+        self.add_cell(
+            "ues_validables",
+            "UEs",
+            ue_valid_txt_html,
+            group="col_ues_validables",
+            classes=classes,
+            raw_content=ue_valid_txt,
+            data={"order": self.nb_ues_validables},  #  tri
+        )
+
+        if table.mode_jury and res.validations:
+            if res.is_apc:
+                # formations BUT: pas de code semestre, concatene ceux des UEs
+                dec_ues = res.validations.decisions_jury_ues.get(etud.id)
+                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 = res.validations.decisions_jury.get(etud.id)
+                jury_code_sem = dec_sem["code"] if dec_sem else ""
+            self.add_cell("jury_code_sem", "Jury", jury_code_sem, "jury_code_sem")
+            self.add_cell(
+                "jury_link",
+                "",
+                f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
+                scodoc_dept=g.scodoc_dept, formsemestre_id=res.formsemestre.id, etudid=etud.id
+                )
+                }">{("saisir" if not jury_code_sem else "modifier")
+                    if res.formsemestre.etat else "voir"} décisions</a>""",
+                "col_jury_link",
+            )
+
+    def add_ue_cols(self, ue: UniteEns, ue_status: dict):
+        "Ajoute résultat UE au row (colonne col_ue)"
+        table = self.table
+        col_id = f"moy_ue_{ue.id}"
+        val = ue_status["moy"]
+        note_class = ""
+        if isinstance(val, float):
+            if val < table.barre_moy:
+                note_class = "moy_inf"
+            elif val >= table.barre_valid_ue:
+                note_class = "moy_ue_valid"
+                self.nb_ues_validables += 1
+            if val < table.barre_warning_ue:
+                note_class = "moy_ue_warning"  # notes très basses
+                self.nb_ues_warning += 1
+        self.add_cell(
+            col_id,
+            ue.acronyme,
+            table.fmt_note(val),
+            group=f"col_ue_{ue.id}",
+            classes=["col_ue", "col_moy_ue", note_class],
+        )
+        table.foot_title_row.cells[col_id].target_attrs[
+            "title"
+        ] = f"""{ue.titre} S{ue.semestre_idx or '?'}"""
+
+    def add_ue_modimpls_cols(self, ue: UniteEns, is_capitalized: bool):
+        """Ajoute à row les moyennes des modules (ou ressources et SAÉs) dans l'UE"""
+        # pylint: disable=invalid-unary-operand-type
+        etud = self.etud
+        table = self.table
+        res = table.res
+        for modimpl in res.modimpls_in_ue(ue, etud.id, with_bonus=False):
+            if is_capitalized:
+                val = "-c-"
+            else:
+                modimpl_results = res.modimpls_results.get(modimpl.id)
+                if modimpl_results:  # pas bonus
+                    if res.is_apc:  # BUT
+                        moys_vers_ue = modimpl_results.etuds_moy_module.get(ue.id)
+                        val = (
+                            moys_vers_ue.get(etud.id, "?")
+                            if moys_vers_ue is not None
+                            else ""
+                        )
+                    else:  # classique: Series indépendantes de l'UE
+                        val = modimpl_results.etuds_moy_module.get(etud.id, "?")
+                else:
+                    val = ""
+
+            col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
+
+            val_fmt_html = val_fmt = table.fmt_note(val)
+            if modimpl.module.module_type == scu.ModuleType.MALUS:
+                if val and not isinstance(val, str) and not np.isnan(val):
+                    if val >= 0:
+                        val_fmt_html = f"""<span class="red-arself-down"></span><span class="sp2l">+{
+                            val_fmt
+                        }</span>"""
+                    else:
+                        val_fmt_html = f"""<span class="green-arrow-up"></span><span class="sp2l malus_negatif">-{
+                            table.fmt_note(-val)}</span>"""
+                else:
+                    val_fmt = val_fmt_html = ""  # inscrit à ce malus, mais sans note
+
+            cell = self.add_cell(
+                col_id,
+                modimpl.module.code,
+                val_fmt_html,
+                raw_content=val_fmt,
+                group=f"col_ue_{ue.id}_modules",
+                classes=[
+                    f"col_{modimpl.module.type_abbrv()}",
+                    f"mod_ue_{ue.id}",
+                ],
+            )
+            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 = table.cache_nomcomplet.get(modimpl.responsable_id)
+            if nom_resp is None:
+                user = User.query.get(modimpl.responsable_id)
+                nom_resp = user.get_nomcomplet() if user else ""
+                table.cache_nomcomplet[modimpl.responsable_id] = nom_resp
+            table.foot_title_row.cells[col_id].target_attrs[
+                "title"
+            ] = f"{modimpl.module.titre} ({nom_resp})"
+            table.modimpl_ue_ids.add((modimpl.id, ue.id))
-- 
GitLab