WIP: new code table recap.

This commit is contained in:
Emmanuel Viennet 2023-01-22 16:39:46 -03:00
parent 4fd9012133
commit 62e67c8baf
4 changed files with 701 additions and 292 deletions

View File

@ -146,7 +146,7 @@ class EtudCursusBUT:
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee

View File

@ -30,6 +30,7 @@ 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):
@ -440,11 +441,11 @@ class ResultatsSemestre(ResultatsCache):
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: tuple avec
Result: XXX tuple avec
- rows: liste de dicts { column_id : value }
- titles: { column_id : title }
- columns_ids: (liste des id de colonnes)
@ -458,7 +459,7 @@ class ResultatsSemestre(ResultatsCache):
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:
On ajoute aussi des attributs: XXX
- pour les lignes:
_css_row_class (inutilisé pour le monent)
_<column_id>_class classe css:
@ -480,74 +481,83 @@ class ResultatsSemestre(ResultatsCache):
barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
NO_NOTE = "-" # contenu des cellules sans notes
rows = []
# column_id : title
titles = {}
# les titres en footer: les mêmes, mais avec des bulles et liens:
titles_bot = {}
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
# 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 = {"etudid": etudid}
row = tb.Row(table, etudid)
table.add_row(row)
# --- Codes (seront cachés, mais exportés en excel)
idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx)
idx = add_cell(
row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
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:
idx = add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
row.add_cell(
"rang",
"Rg",
self.etud_moy_gen_ranks[etudid],
"rang",
data={"order": f"{self.etud_moy_gen_ranks_int[etudid]:05d}"},
)
row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
# --- Identité étudiant
idx = add_cell(
row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx
)
idx = add_cell(
row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx
)
row["_nom_disp_order"] = etud.sort_key
idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx)
idx = add_cell(
row, "nom_short", "Nom", etud.nom_short, "identite_court", idx
)
row["_nom_short_order"] = etud.sort_key
row["_nom_short_target"] = url_for(
url_bulletin = url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre.id,
etudid=etudid,
)
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
row["_nom_disp_target"] = row["_nom_short_target"]
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
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},
target=url_bulletin,
target_attrs=f'class="etudinfo" id="{etudid}"',
)
idx = 30 # début des colonnes de notes
# --- Moyenne générale
if not self.formsemestre.block_moyenne_generale:
moy_gen = self.etud_moy_gen.get(etudid, False)
@ -555,22 +565,21 @@ class ResultatsSemestre(ResultatsCache):
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
idx = add_cell(
row,
note_class = "moy_ue_warning" # en rouge
row.add_cell(
"moy_gen",
"Moy",
fmt_note(moy_gen),
"col_moy_gen" + note_class,
idx,
)
titles_bot["_moy_gen_target_attrs"] = (
'title="moyenne indicative"' if self.is_apc else ""
"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
idx_ue_start = idx
for idx_ue, ue in enumerate(ues_sans_bonus):
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}"
@ -585,17 +594,18 @@ class ResultatsSemestre(ResultatsCache):
if val < barre_warning_ue:
note_class = " moy_ue_warning" # notes très basses
nb_ues_warning += 1
idx = add_cell(
row,
row.add_cell(
col_id,
ue.acronyme,
fmt_note(val),
"col_ue" + note_class,
idx_ue * 10000 + idx_ue_start,
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 '?'}" """
)
titles_bot[
f"_{col_id}_target_attrs"
] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
if mode_jury:
# pas d'autre colonnes de résultats
continue
@ -605,19 +615,18 @@ class ResultatsSemestre(ResultatsCache):
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>'
idx = add_cell(
row,
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,
"col_ue_bonus",
idx_ue * 10000 + idx_ue_start + 1,
group=f"col_ue_{ue.id}",
classes=["col_ue_bonus"],
raw_content=val_fmt,
)
row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE
idx_malus = idx # place pour colonne malus à gauche des modules
idx += 1
for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
if ue_status["is_capitalized"]:
val = "-c-"
@ -650,35 +659,38 @@ class ResultatsSemestre(ResultatsCache):
val_fmt_html = (
(scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
)
idx = add_cell(
row,
cell = row.add_cell(
col_id,
modimpl.module.code,
val_fmt_html,
# class col_res mod_ue_123
f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
idx_ue * 10000
+ idx_ue_start
+ 1
+ (modimpl.module.module_type or 0) * 1000
+ (modimpl.module.numero or 0),
group=f"col_ue_{ue.id}_modules",
classes=[
f"col_{modimpl.module.type_abbrv()}",
f"mod_ue_{ue.id}",
],
raw_content=val_fmt,
)
row[f"_{col_id}_xls"] = val_fmt
if modimpl.module.module_type == scu.ModuleType.MALUS:
titles[f"_{col_id}_col_order"] = idx_malus
titles_bot[f"_{col_id}_target"] = url_for(
# 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
titles_bot[
f"_{col_id}_target_attrs"
] = f""" title="{modimpl.module.titre} ({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 = (
@ -686,23 +698,26 @@ class ResultatsSemestre(ResultatsCache):
) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
if nb_ues_warning:
ue_valid_txt_html += " " + scu.EMO_WARNING
add_cell(
row,
# 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_ue col_ues_validables",
29, # juste avant moy. gen.
"col_ues_validables",
classes=classes,
raw_content=ue_valid_txt,
data={"order": nb_ues_validables}, # tri
)
row["_ues_validables_xls"] = ue_valid_txt
if nb_ues_warning:
row["_ues_validables_class"] += " moy_ue_warning"
elif nb_ues_validables < len(ues_sans_bonus):
row["_ues_validables_class"] += " moy_inf"
row["_ues_validables_order"] = nb_ues_validables # pour tri
if mode_jury and self.validations:
if self.is_apc:
# formations BUT: pas de code semestre, concatene ceux des UE
# 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(
@ -714,16 +729,8 @@ class ResultatsSemestre(ResultatsCache):
# formations classiques: code semestre
dec_sem = self.validations.decisions_jury.get(etudid)
jury_code_sem = dec_sem["code"] if dec_sem else ""
idx = add_cell(
row,
"jury_code_sem",
"Jury",
jury_code_sem,
"jury_code_sem",
1000,
)
idx = add_cell(
row,
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',
@ -731,131 +738,158 @@ class ResultatsSemestre(ResultatsCache):
)
}">{"saisir" if not jury_code_sem else "modifier"} décision</a>""",
"col_jury_link",
idx,
)
rows.append(row)
col_idx = self.recap_add_partitions(rows, titles)
self.recap_add_cursus(rows, titles, col_idx=col_idx + 1)
self._recap_add_admissions(rows, titles)
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:
rows.sort(key=lambda e: e["_rang_order"])
table.sort_rows(key=lambda e: e["_rang_order"])
else:
rows.sort(key=lambda e: e["_ues_validables_order"], reverse=True)
table.sort_rows(key=lambda e: e["_ues_validables_order"], reverse=True)
# INFOS POUR FOOTER
bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
bottom_infos = self._recap_bottom_infos(
table, ues_sans_bonus, modimpl_ids, fmt_note
)
if include_evaluations:
self._recap_add_evaluations(rows, titles, bottom_infos)
self._recap_add_evaluations(table)
# Ajoute style "col_empty" aux colonnes de modules vides
for col_id in titles:
c_class = f"_{col_id}_class"
if "col_empty" in bottom_infos["moy"].get(c_class, ""):
for row in rows:
row[c_class] = row.get(c_class, "") + " col_empty"
titles[c_class] += " col_empty"
for row in bottom_infos.values():
row[c_class] = row.get(c_class, "") + " col_empty"
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_class = {}
for col_id in titles:
klass = titles.get(f"_{col_id}_class")
if klass:
row_class[col_id] = " ".join(
cls[4:] for cls in klass.split() if cls.startswith("col_")
)
# cette case (nb d'UE validables) a deux classes col_xxx, on en garde une seule:
if "ues_validables" in row_class[col_id]:
row_class[col_id] = "ues_validables"
bottom_infos["type_col"] = row_class
# --- TABLE FOOTER: ECTS, moyennes, min, max...
footer_rows = []
for (bottom_line, row) in bottom_infos.items():
# Cases vides à styler:
row["moy_gen"] = row.get("moy_gen", "")
row["_moy_gen_class"] = "col_moy_gen"
# titre de la ligne:
row["prenom"] = row["nom_short"] = (
row.get("_title", "") or bottom_line.capitalize()
)
row["_tr_class"] = bottom_line.lower() + (
(" " + row["_tr_class"]) if "_tr_class" in row else ""
)
footer_rows.append(row)
titles_bot.update(titles)
footer_rows.append(titles_bot)
column_ids = [title for title in titles if not title.startswith("_")]
column_ids.sort(
key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000)
row_type = tb.BottomRow(
table,
"type_col",
title="Type col.",
left_title_col_ids=["prenom", "nom_short"],
category="bottom_infos",
classes=["bottom_info"],
)
return (rows, footer_rows, titles, column_ids)
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)
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
# 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"""
row_min, row_max, row_moy, row_coef, row_ects, row_apo = (
{"_tr_class": "bottom_info", "_title": "Min."},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info", "_title": "Code Apogée"},
# 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:
colid = f"moy_ue_{ue.id}"
row_ects[colid] = ue.ects
row_ects[f"_{colid}_class"] = "col_ue"
# style cases vides pour borders verticales
row_coef[colid] = ""
row_coef[f"_{colid}_class"] = "col_ue"
# row_apo[colid] = ue.code_apogee or ""
row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT])
row_ects["_moy_gen_class"] = "col_moy_gen"
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()))
row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min())
row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max())
row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean())
for ue in ues:
colid = f"moy_ue_{ue.id}"
row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min())
row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max())
row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean())
row_min[f"_{colid}_class"] = "col_ue"
row_max[f"_{colid}_class"] = "col_ue"
row_moy[f"_{colid}_class"] = "col_ue"
row_apo[colid] = ue.code_apogee or ""
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:
colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
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[colid] = fmt_note(coef)
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[colid] = np.nan
row_max[colid] = np.nan
row_min.add_cell(col_id, None, np.nan)
row_max.add_cell(col_id, None, np.nan)
moy = np.nan
else:
row_min[colid] = fmt_note(np.nanmin(notes))
row_max[colid] = fmt_note(np.nanmax(notes))
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[colid] = fmt_note(moy)
if np.isnan(moy):
# aucune note dans ce module
row_moy[f"_{colid}_class"] = "col_empty"
row_apo[colid] = modimpl.module.code_apogee or ""
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,
@ -892,9 +926,8 @@ class ResultatsSemestre(ResultatsCache):
row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class])
titles["group"] = "Gr"
def _recap_add_admissions(self, rows: list[dict], titles: dict):
def _recap_add_admissions(self, table: tb.Table):
"""Ajoute les colonnes "admission"
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "admission"
"""
fields = {
@ -904,87 +937,103 @@ class ResultatsSemestre(ResultatsCache):
"classement": "Rg. Adm.",
}
first = True
for i, cid in enumerate(fields):
titles[f"_{cid}_col_order"] = 100000 + i # tout à droite
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:
titles[f"_{cid}_class"] = "admission admission_first"
cell_head.classes.append("admission_first")
cell_foot.classes.append("admission_first")
first = False
else:
titles[f"_{cid}_class"] = "admission"
titles.update(fields)
for row in rows:
etud = Identite.query.get(row["etudid"])
for row in table.rows:
etud = Identite.query.get(row.id) # TODO XXX
admission = etud.admission.first()
first = True
for cid in fields:
row[cid] = getattr(admission, cid) or ""
for cid, title in fields.items():
cell = row.add_cell(
cid,
title,
getattr(admission, cid) or "",
"admission",
)
if first:
row[f"_{cid}_class"] = "admission admission_first"
cell.classes.append("admission_first")
first = False
else:
row[f"_{cid}_class"] = "admission"
def recap_add_cursus(self, rows: list[dict], titles: dict, col_idx: int = None):
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"
titles[cid] = "Cursus"
titles[f"_{cid}_col_order"] = col_idx
formation_code = self.formsemestre.formation.formation_code
for row in rows:
etud = Identite.query.get(row["etudid"])
row[cid] = " ".join(
[
f"S{ins.formsemestre.semestre_id}"
for ins in reversed(etud.inscriptions())
if ins.formsemestre.formation.formation_code == 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, rows: list[dict], titles: dict, col_idx: int = None
) -> int:
def recap_add_partitions(self, table: tb.Table):
"""Ajoute les colonnes indiquant les groupes
rows est une liste de dict avec une clé "etudid"
La table contient des rows avec la clé etudid.
Les colonnes ont la classe css "partition"
Renvoie l'indice de la dernière colonne utilisée
"""
table.insert_group("partition", after="parcours")
partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
self.formsemestre.id
)
first_partition = True
col_order = 10 if col_idx is None else col_idx
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']}"
rg_cid = cid + "_rg" # rang dans la partition
titles[cid] = partition["partition_name"]
if first_partition:
klass = "partition"
else:
klass = "partition partition_aux"
titles[f"_{cid}_class"] = klass
titles[f"_{cid}_col_order"] = col_order
titles[f"_{rg_cid}_col_order"] = col_order + 1
col_order += 2
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"]:
titles[rg_cid] = f"Rg {partition['partition_name']}"
titles[f"_{rg_cid}_class"] = "partition_rangs"
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 rows:
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["etudid"])
etud_etat = self.get_etud_etat(row.id) # row.id == etudid
tr_classes = []
if etud_etat == scu.DEMISSION:
gr_name = "Dém."
row["_tr_class"] = "dem"
tr_classes.append("dem")
elif etud_etat == DEF:
gr_name = "Déf."
row["_tr_class"] = "def"
tr_classes.append("def")
else:
group = partition_etud_groups.get(row["etudid"])
gr_name = group["group_name"] if group else ""
if gr_name:
row[cid] = gr_name
row[f"_{cid}_class"] = klass
row.add_cell(
cid,
partition["partition_name"],
gr_name,
"partition",
classes=col_classes,
)
# Rangs dans groupe
if (
partition["bul_show_rank"]
@ -992,72 +1041,78 @@ class ResultatsSemestre(ResultatsCache):
and (group["id"] in self.moy_gen_rangs_by_group)
):
rang = self.moy_gen_rangs_by_group[group["id"]][0]
row[rg_cid] = rang.get(row["etudid"], "")
row.add_cell(rg_cid, None, rang.get(row["etudid"], ""), "partition")
first_partition = False
return col_order
def _recap_add_evaluations(
self, rows: list[dict], titles: dict, bottom_infos: dict
):
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:
bottom_infos["descr_evaluation"] = {
"_tr_class": "bottom_info",
"_title": "Description évaluation",
}
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
index_col = 9000 # à droite
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:
cid = f"eval_{e.id}"
titles[
cid
] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
klass = "evaluation"
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:
klass += " first"
col_classes.append("first")
elif first_eval_of_mod:
klass += " first_of_mod"
titles[f"_{cid}_class"] = klass
col_classes.append("first_of_mod")
first_eval_of_mod = first_eval = False
titles[f"_{cid}_col_order"] = index_col
index_col += 1
eval_index -= 1
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
e.evaluation_id
)
for row in rows:
etudid = row["etudid"]
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
row[cid] = scu.fmt_note(val)
row[f"_{cid}_class"] = klass + {
"ABS": " abs",
"ATT": " att",
"EXC": " exc",
}.get(row[cid], "")
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[cid] = "ni"
row[f"_{cid}_class"] = klass + " non_inscrit"
row.add_cell(
col_id,
title,
"ni",
"",
classes=col_classes + ["non_inscrit"],
)
bottom_infos["coef"][cid] = e.coefficient
bottom_infos["min"][cid] = "0"
bottom_infos["max"][cid] = scu.fmt_note(e.note_max)
bottom_infos["descr_evaluation"][cid] = e.description or ""
bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
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,
),
)

View File

@ -425,10 +425,22 @@ def _gen_formsemestre_recapcomplet_html(
'<div class="table_recap"><div class="message">aucun étudiant !</div></div>'
)
H = [
f"""<div class="table_recap"><table class="table_recap {
f"""<div class="table_recap">
{
table.html(
classes=[
'table_recap',
'apc' if formsemestre.formation.is_apc() else 'classic',
'jury' if mode_jury else ''
],
data={"filename":filename}
)
}
<table class="table_recap {
'apc' if formsemestre.formation.is_apc() else 'classic'
} {'jury' if mode_jury else ''}"
data-filename="{filename}">"""
data-filename="{filename}">
"""
]
# header
H.append(

342
app/scodoc/table_builder.py Normal file
View File

@ -0,0 +1,342 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Classes pour aider à construire des tables de résultats
"""
from collections import defaultdict
class Table:
"""Construction d'une table de résultats
table = Table()
row = table.new_row(id="xxx", category="yyy")
row.new_cell( col_id, title, content [,classes] [, idx], [group], [keys:dict={}] )
rows = table.get_rows([category="yyy"])
table.sort_rows(key [, reverse])
table.set_titles(titles)
table.update_titles(titles)
table.set_column_groups(groups: list[str])
table.sort_columns()
table.insert_group(group:str, [after=str], [before=str])
"""
def __init__(
self,
selected_row_id: str = None,
classes: list[str] = None,
data: dict[str, str] = None,
):
self.rows: list["Row"] = []
"ordered list of Rows"
self.row_by_id: dict[str, "Row"] = {}
self.classes = classes or []
"list of classes for the table element"
self.column_ids = []
"ordered list of columns ids"
self.data = data or {}
"data-xxx"
self.groups = []
"ordered list of column groups names"
self.head = []
self.foot = []
self.column_group = {}
"the group of the column: { col_id : group }"
self.column_index: dict[str, int] = {}
"index the column: { col_id : int }"
self.column_classes: defaultdict[str, list[str]] = defaultdict(lambda: [])
"classe ajoutée à toutes les cellules de la colonne: { col_id : class }"
self.selected_row_id = selected_row_id
"l'id de la ligne sélectionnée"
self.titles = {}
"Column title: { col_id : titre }"
self.head_title_row: "Row" = Row(self, "title_head", cell_elt="th")
self.foot_title_row: "Row" = Row(self, "title_foot", cell_elt="th")
self.empty_cell = Cell.empty()
def get_row_by_id(self, row_id) -> "Row":
"return the row, or None"
return self.row_by_id.get(row_id)
def _prepare(self):
"""Sort table elements before generation"""
self.sort_columns()
def html(self, classes: list[str] = None, data: dict[str, str] = None) -> str:
"""HTML version of the table
classes are prepended to existing classes
data may replace existing data
"""
self._prepare()
classes = classes + self.classes
data = self.data.copy().update(data)
elt_class = f"""class="{' '.join(classes)}" """ if classes else ""
attrs_str = " ".join(f' data-{k}="v"' for k, v in data.items())
newline = "\n"
header = (
f"""
<thead>
{ newline.join(row.html() for row in self.head) }
</thead>
"""
if self.head
else ""
)
footer = (
f"""
<tfoot>
{ newline.join(row.html() for row in self.foot) }
</tfoot>
"""
if self.foot
else ""
)
return f"""<table {elt_class} {attrs_str}>
{header}
{
newline.join(row.html() for row in self.rows)
}
{footer}
</table>
"""
def add_row(self, row: "Row") -> "Row":
"""Append a new row"""
self.rows.append(row)
self.row_by_id[row.id] = row
return row
def add_head_row(self, row: "Row") -> "Row":
"Add a row to table head"
# row = Row(self, cell_elt="th", category="head")
self.head.append(row)
self.row_by_id[row.id] = row
return row
def add_foot_row(self, row: "Row") -> "Row":
"Add a row to table foot"
self.foot.append(row)
self.row_by_id[row.id] = row
return row
def sort_rows(self, key: callable, reverse: bool = False):
"""Sort table rows"""
self.rows.sort(key=key, reverse=reverse)
def sort_columns(self):
"""Sort columns ids"""
groups_order = {group: i for i, group in enumerate(self.groups)}
self.column_ids.sort(
key=lambda col_id: (groups_order.get(self.column_group.get(col_id), col_id))
)
def insert_group(self, group: str, after: str = None, before: str = None):
"""Déclare un groupe de colonnes et le place avant ou après un autre groupe.
Si pas d'autre groupe indiqué, le place après, à droite du dernier.
Si le group existe déjà, ne fait rien (ne le déplace pas).
"""
if group in self.groups:
return
other = after or before
if other is None:
self.groups.append(group)
else:
if not other in self.groups:
raise ValueError(f"invalid column group '{other}'")
index = self.groups.index(other)
if after:
index += 1
self.groups.insert(index, group)
def set_groups(self, groups: list[str]):
"""Define column groups and set order"""
self.groups = groups
def set_titles(self, titles: dict[str, str]):
"""Set columns titles"""
self.titles = titles
def update_titles(self, titles: dict[str, str]):
"""Set columns titles"""
self.titles.update(titles)
def add_title(self, col_id, title: str = None) -> tuple["Cell", "Cell"]:
"""Record this title,
and create cells for footer and header if they don't already exist.
"""
title = title or ""
if not col_id in self.titles:
self.titles[col_id] = title
cell_head = self.head_title_row.cells.get(
col_id, self.head_title_row.add_cell(col_id, title, title)
)
cell_foot = self.foot_title_row.cells.get(
col_id, self.foot_title_row.add_cell(col_id, title, title)
)
return cell_head, cell_foot
class Row:
"""A row."""
def __init__(
self,
table: Table,
row_id=None,
category=None,
cell_elt: str = None,
classes: list[str] = None,
):
self.category = category
self.cells = {}
self.cell_elt = cell_elt
self.classes: list[str] = classes or []
"classes sur le <tr>"
self.cur_idx_by_group = defaultdict(lambda: 0)
self.id = row_id
self.table = table
def add_cell(
self,
col_id: str,
title: str,
content: str,
group: str = None,
attrs: list[str] = None,
classes: list[str] = None,
data: dict[str, str] = None,
elt: str = None,
idx: int = None,
raw_content=None,
target_attrs: dict = None,
target: str = None,
) -> "Cell":
"""Create cell and add it to the row.
classes is a list of css class names
"""
if idx is None:
idx = self.cur_idx_by_group[group]
self.cur_idx_by_group[group] += 1
else:
self.cur_idx_by_group[group] = idx
self.table.column_index[col_id] = idx
cell = Cell(
content,
classes + [group or ""], # ajoute le nom de groupe aux classes
elt=elt or self.cell_elt,
attrs=attrs,
data=data,
raw_content=raw_content,
target=target,
target_attrs=target_attrs,
)
return self.add_cell_instance(col_id, cell, column_group=group, title=title)
def add_cell_instance(
self, col_id: str, cell: "Cell", column_group: str = None, title: str = None
) -> "Cell":
"""Add a cell to the row.
Si title est None, il doit avoir été ajouté avec table.add_title().
"""
self.cells[col_id] = cell
if column_group is not None:
self.table.column_group[col_id] = column_group
if title is not None:
self.table.add_title(col_id, title)
return cell
def html(self) -> str:
"""html for row, with cells"""
elt_class = f"""class="{' '.join(self.classes)}" """ if self.classes else ""
tr_id = (
"""id="row_selected" """ if (self.id == self.table.selected_etudid) else ""
) # TODO XXX remplacer id par une classe
return f"""<tr {tr_id} {elt_class}>{
"".join([self.cells.get(col_id,
self.table.empty_cell).html(
column_classes=self.table.column_classes.get(col_id)
)
for col_id in self.table.column_ids ])
}</tr>"""
class BottomRow(Row):
"""Une ligne spéciale pour le pied de table
avec un titre à gauche
(répété sur les colonnes indiquées par left_title_col_ids),
et automatiquement ajouté au footer.
"""
def __init__(
self, *args, left_title_col_ids: list[str] = None, left_title=None, **kwargs
):
super().__init__(*args, **kwargs)
self.left_title_col_ids = left_title_col_ids
if left_title is not None:
self.set_left_title(left_title)
self.table.add_foot_row(self)
def set_left_title(self, title: str = ""):
"Fill left title cells"
for col_id in self.left_title_col_ids:
self.add_cell(col_id, None, title)
class Cell:
"""Une cellule de table"""
def __init__(
self,
content,
classes: list[str] = None,
elt="td",
attrs: list[str] = None,
data: dict = None,
raw_content=None,
target: str = None,
target_attrs: dict = None,
):
"""if specified, raw_content will be used for raw exports like xlsx"""
self.content = content
self.classes: list = classes or []
self.elt = elt if elt is not None else "td"
self.attrs = attrs or []
if self.elt == "th":
self.attrs["scope"] = "row"
self.data = data or {}
self.raw_content = raw_content # not yet used
self.target = target
self.target_attrs = target_attrs or {}
@classmethod
def empty(cls):
"create a new empty cell"
return cls("")
def __str__(self):
return str(self.content)
def html(self, column_classes: list[str] = None) -> str:
"html for cell"
attrs_str = f"""class="{' '.join(
[cls for cls in (self.classes + (column_classes or [])) if cls])
}" """
# Autres attributs:
attrs_str += " ".join([f"{k}={v}" for (k, v) in self.attrs.items()])
# et data-x
attrs_str += " ".join([f' data-{k}="v"' for k, v in self.data.items()])
if (self.target is not None) or self.target_attrs:
href = f'href="{self.target}"' if self.target else ""
target_attrs_str = " ".join(
[f"{k}={v}" for (k, v) in self.target_attrs.items()]
)
content = f"<a {href} {target_attrs_str}>{content}</a>"
return f"""<{self.elt} {attrs_str}>{self.content}</{self.elt}>"""