1
0
forked from ScoDoc/ScoDoc
ScoDoc-Front/app/tables/recap.py

810 lines
30 KiB
Python

##############################################################################
# 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, FormSemestre, UniteEns
from app.scodoc.codes_cursus 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:
- 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,
row_class=None,
finalize=True,
read_only: bool = True,
**kwargs,
):
self.rows: list["RowRecap"] = [] # juste pour que VSCode nous aide sur .rows
super().__init__(row_class=row_class or RowRecap, **kwargs)
self.res = res
self.include_evaluations = include_evaluations
self.mode_jury = mode_jury
self.read_only = read_only # utilisé seulement dans sous-classes
cursus = res.formsemestre.formation.get_cursus()
self.barre_moy = cursus.BARRE_MOY - scu.NOTES_TOLERANCE
self.barre_valid_ue = cursus.NOTES_BARRE_VALID_UE
self.barre_warning_ue = cursus.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()
ues = res.formsemestre.query_ues(with_sport=True) # avec bonus
ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
if res.formsemestre.etuds_inscriptions: # table non vide
# Fixe l'ordre des groupes de colonnes communs:
groups = [
"etud_codes",
"rang",
"identite_court",
"identite_detail",
"partition",
"cursus",
"col_ues_validables",
]
if not res.formsemestre.block_moyenne_generale:
groups.append("col_moy_gen")
groups.append("abs")
self.set_groups(groups)
for etudid in res.formsemestre.etuds_inscriptions:
etud = Identite.get_etud(etudid)
row = self.row_class(self, etud)
self.add_row(row)
row.add_etud_cols()
row.add_moyennes_cols(ues_sans_bonus)
row.add_abs()
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 or 10000000)
else:
self.sort_rows(key=lambda row: row.nb_ues_validables or 0, reverse=True)
# Lignes footer (min, max, ects, apo, ...)
self.add_bottom_rows(ues_sans_bonus)
# Evaluations:
if include_evaluations:
self.add_evaluations()
if finalize:
self.finalize()
def finalize(self):
"""Termine la table: ajoute ligne avec les types,
et ajoute classe sur les colonnes vides"""
self.mark_empty_cols()
self.add_type_row()
def mark_empty_cols(self):
"""Ajoute classe "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].add("col_empty")
def add_type_row(self):
"""Ligne avec la classe de chaque colonne recap."""
# récupère le type à partir du groupe (enlève le préfixe "col_" si présent)
row_type = tb.BottomRow(
self,
"type_col",
left_title="Type col.",
left_title_col_ids=["prenom", "nom_short"],
category="bottom_infos",
classes=["bottom_info", "type_col"],
)
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", "apo"],
)
# --- 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)
# la colonne où placer les valeurs agrégats
col_id = "moy_gen" if "moy_gen" in self.column_ids else "code_cursus"
col_group = "col_moy_gen" if "moy_gen" in self.column_ids else "cursus"
row_ects.add_cell(
col_id,
None,
sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]),
group=col_group,
)
# --- MIN, MAX, MOY, APO
row_min.add_cell(
col_id,
None,
self.fmt_note(res.etud_moy_gen.min()),
group=col_group,
)
row_max.add_cell(
col_id,
None,
self.fmt_note(res.etud_moy_gen.max()),
group=col_group,
)
row_moy.add_cell(
col_id,
None,
self.fmt_note(res.etud_moy_gen.mean()),
group=col_group,
)
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()),
classes=["col_ue", "col_moy_ue"],
)
row_max.add_cell(
col_id,
None,
self.fmt_note(res.etud_moy_ue[ue.id].max()),
classes=["col_ue", "col_moy_ue"],
)
row_moy.add_cell(
col_id,
None,
self.fmt_note(res.etud_moy_ue[ue.id].mean()),
classes=["col_ue", "col_moy_ue"],
)
row_apo.add_cell(
col_id,
None,
ue.code_apogee or "",
classes=["col_ue", "col_moy_ue"],
)
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, "")
row_max.add_cell(col_id, None, "")
moy = ""
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 (moy == "" or 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
pour tous les étudiants de la table.
La table contient des rows avec la clé etudid.
Les colonnes ont la classe css "partition".
"""
self.group_titles["partition"] = "Partitions"
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
if etud_etat == scu.DEMISSION:
gr_name = "Dém."
row.add_class("dem")
elif etud_etat == DEF:
gr_name = "Déf."
row.add_class("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"
"""
self.group_titles["eval"] = "Évaluations"
# 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 = []
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, group="eval", classes=classes
)
else:
row.add_cell(
col_id,
title,
"ni",
group="eval",
classes=col_classes + ["non_inscrit"],
)
row_coef = self.get_row_by_id("coef")
row_coef.add_cell(
col_id,
None,
self.fmt_note(e.coefficient),
group="eval",
)
row_min = self.get_row_by_id("min")
row_min.add_cell(
col_id,
None,
0,
group="eval",
)
row_max = self.get_row_by_id("max")
row_max.add_cell(
col_id,
None,
self.fmt_note(e.note_max),
group="eval",
)
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,
),
target_attrs={"class": "stdlink"},
)
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 étudiants de la table"""
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
]
),
group="cursus",
)
def html(self, extra_classes: list[str] = None) -> str:
"""HTML: pour les tables recap, un div au contenu variable"""
return f"""
<div class="table_recap">
{
'<div class="message">aucun étudiant !</div>'
if self.is_empty()
else super().html(
extra_classes=[
"table_recap",
"apc" if self.res.formsemestre.formation.is_apc() else "classic",
"jury" if self.mode_jury else "",
"with_evaluations" if self.include_evaluations else "",
])
}
</div>
"""
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
self.table.group_titles.update(
{
"etud_codes": "Codes",
"identite_detail": "",
"identite_court": "",
}
)
# --- 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_abs(self):
"Ajoute les colonnes absences"
# Absences (nb d'abs non just. dans ce semestre)
nbabs, nbabsjust = self.table.res.formsemestre.get_abs_count(self.etud.id)
self.add_cell("nbabs", "Abs", nbabs, "abs")
self.add_cell("nbabsjust", "Just.", nbabsjust, "abs")
def add_moyennes_cols(
self,
ues_sans_bonus: list[UniteEns],
):
"""Ajoute cols moy_gen moy_ue et tous les modules..."""
etud = self.etud
table: TableRecap = self.table
res = table.res
# --- Si DEM ou DEF, ne montre aucun résultat d'UE ni moy. gen.
if res.get_etud_etat(etud.id) != scu.INSCRIT:
return
# --- 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),
group="col_moy_gen",
classes=[note_class],
)
# Ajoute bulle sur titre du pied de table:
cell = table.foot_title_row.cells.get("moy_gen")
if cell:
table.foot_title_row.cells["moy_gen"].target_attrs["title"] = (
"Moyenne générale indicative"
if res.is_apc
else "Moyenne générale du semestre"
)
# --- 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}",
column_classes={"col_ue_bonus", "col_res"},
)
# 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_parcours_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
cell_class = ""
if self.nb_ues_warning:
cell_class = "moy_ue_warning"
elif self.nb_ues_validables < len(ues_sans_bonus):
cell_class = "moy_inf"
self.add_cell(
"ues_validables",
"UEs",
ue_valid_txt_html,
group="col_ues_validables",
classes=[cell_class],
column_classes={"col_ue"},
raw_content=ue_valid_txt,
data={"order": self.nb_ues_validables}, # tri
)
def add_ue_cols(self, ue: UniteEns, ue_status: dict, col_group: str = None):
"Ajoute résultat UE au row (colonne col_ue)"
# sous-classé par JuryRow pour ajouter les codes
table: TableRecap = self.table
formsemestre: FormSemestre = table.res.formsemestre
table.group_titles[
"col_ue"
] = f"UEs du S{formsemestre.semestre_id} {formsemestre.annee_scolaire()}"
col_id = f"moy_ue_{ue.id}"
val = (
ue_status["moy"]
if (self.etud.id, ue.id) not in table.res.dispense_ues
else "="
)
note_classes = []
if isinstance(val, float):
if val < table.barre_moy:
note_classes = ["moy_inf"]
elif val >= table.barre_valid_ue:
note_classes = ["moy_ue_valid"]
self.nb_ues_validables += 1
if val < table.barre_warning_ue:
note_classes = ["moy_ue_warning"] # notes très basses
self.nb_ues_warning += 1
if ue_status["is_capitalized"]:
note_classes.append("ue_capitalized")
self.add_cell(
col_id,
ue.acronyme,
table.fmt_note(val),
group=col_group or f"col_ue_{ue.id}",
classes=note_classes,
column_classes={f"col_ue_{ue.id}", "col_moy_ue", "col_ue"},
)
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="sp2l">-{
val_fmt
}</span>"""
else:
val_fmt_html = f"""<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",
column_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))