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 # prend la "meilleure" validation
if (not previous_validation) or ( if (not previous_validation) or (
sco_codes.BUT_CODES_ORDERED[validation_rcue.code] 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][ self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee 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.sco_exceptions import ScoValueError
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc import table_builder as tb
# Il faut bien distinguer # Il faut bien distinguer
# - ce qui est caché de façon persistente (via redis): # - ce qui est caché de façon persistente (via redis):
@ -440,11 +441,11 @@ class ResultatsSemestre(ResultatsCache):
include_evaluations=False, include_evaluations=False,
mode_jury=False, mode_jury=False,
allow_html=True, allow_html=True,
): ) -> tb.Table:
"""Table récap. des résultats. """Table récap. des résultats.
allow_html: si vrai, peut mettre du HTML dans les valeurs 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 } - rows: liste de dicts { column_id : value }
- titles: { column_id : title } - titles: { column_id : title }
- columns_ids: (liste des id de colonnes) - 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_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 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: - pour les lignes:
_css_row_class (inutilisé pour le monent) _css_row_class (inutilisé pour le monent)
_<column_id>_class classe css: _<column_id>_class classe css:
@ -480,74 +481,83 @@ class ResultatsSemestre(ResultatsCache):
barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
NO_NOTE = "-" # contenu des cellules sans notes NO_NOTE = "-" # contenu des cellules sans notes
rows = [] 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 dict_nom_res = {} # cache uid : nomcomplet
def add_cell( # def add_cell(
row: dict, # row: dict,
col_id: str, # col_id: str,
title: str, # title: str,
content: str, # content: str,
classes: str = "", # classes: str = "",
idx: int = 100, # idx: int = 100,
): # ):
"Add a cell to our table. classes is a list of css class names" # "Add a cell to our table. classes is a list of css class names"
row[col_id] = content # row[col_id] = content
if classes: # if classes:
row[f"_{col_id}_class"] = classes + f" c{idx}" # row[f"_{col_id}_class"] = classes + f" c{idx}"
if not col_id in titles: # if not col_id in titles:
titles[col_id] = title # titles[col_id] = title
titles[f"_{col_id}_col_order"] = idx # titles[f"_{col_id}_col_order"] = idx
if classes: # if classes:
titles[f"_{col_id}_class"] = classes # titles[f"_{col_id}_class"] = classes
return idx + 1 # return idx + 1
etuds_inscriptions = self.formsemestre.etuds_inscriptions etuds_inscriptions = self.formsemestre.etuds_inscriptions
ues = self.formsemestre.query_ues(with_sport=True) # avec bonus ues = self.formsemestre.query_ues(with_sport=True) # avec bonus
ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
modimpl_ids = set() # modimpl effectivement présents dans la table modimpl_ids = set() # modimpl effectivement présents dans la table
table = tb.Table()
for etudid in etuds_inscriptions: for etudid in etuds_inscriptions:
idx = 0 # index de la colonne idx = 0 # index de la colonne
etud = Identite.query.get(etudid) 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) # --- Codes (seront cachés, mais exportés en excel)
idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx) row.add_cell("etudid", "etudid", etudid, "codes")
idx = add_cell( row.add_cell(
row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx "code_nip",
"code_nip",
etud.code_nip or "",
"codes",
) )
# --- Rang # --- Rang
if not self.formsemestre.block_moyenne_generale: if not self.formsemestre.block_moyenne_generale:
idx = add_cell( row.add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx "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 # --- Identité étudiant
idx = add_cell( url_bulletin = url_for(
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(
"notes.formsemestre_bulletinetud", "notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre.id, formsemestre_id=self.formsemestre.id,
etudid=etudid, etudid=etudid,
) )
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"' row.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
row["_nom_disp_target"] = row["_nom_short_target"] row.add_cell(
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"] "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 # --- Moyenne générale
if not self.formsemestre.block_moyenne_generale: if not self.formsemestre.block_moyenne_generale:
moy_gen = self.etud_moy_gen.get(etudid, False) moy_gen = self.etud_moy_gen.get(etudid, False)
@ -556,21 +566,20 @@ class ResultatsSemestre(ResultatsCache):
moy_gen = NO_NOTE moy_gen = NO_NOTE
elif isinstance(moy_gen, float) and moy_gen < barre_moy: elif isinstance(moy_gen, float) and moy_gen < barre_moy:
note_class = "moy_ue_warning" # en rouge note_class = "moy_ue_warning" # en rouge
idx = add_cell( row.add_cell(
row,
"moy_gen", "moy_gen",
"Moy", "Moy",
fmt_note(moy_gen), fmt_note(moy_gen),
"col_moy_gen" + note_class, "col_moy_gen",
idx, classes=[note_class],
) )
titles_bot["_moy_gen_target_attrs"] = ( # 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 "" 'title="moyenne indicative"' if self.is_apc else ""
) )
# --- Moyenne d'UE # --- Moyenne d'UE
nb_ues_validables, nb_ues_warning = 0, 0 nb_ues_validables, nb_ues_warning = 0, 0
idx_ue_start = idx for ue in ues_sans_bonus:
for idx_ue, ue in enumerate(ues_sans_bonus):
ue_status = self.get_etud_ue_status(etudid, ue.id) ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status is not None: if ue_status is not None:
col_id = f"moy_ue_{ue.id}" col_id = f"moy_ue_{ue.id}"
@ -585,17 +594,18 @@ class ResultatsSemestre(ResultatsCache):
if val < barre_warning_ue: if val < barre_warning_ue:
note_class = " moy_ue_warning" # notes très basses note_class = " moy_ue_warning" # notes très basses
nb_ues_warning += 1 nb_ues_warning += 1
idx = add_cell( row.add_cell(
row,
col_id, col_id,
ue.acronyme, ue.acronyme,
fmt_note(val), fmt_note(val),
"col_ue" + note_class, group=f"col_ue_{ue.id}",
idx_ue * 10000 + idx_ue_start, 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: if mode_jury:
# pas d'autre colonnes de résultats # pas d'autre colonnes de résultats
continue continue
@ -605,19 +615,18 @@ class ResultatsSemestre(ResultatsCache):
val = self.bonus_ues[ue.id][etud.id] or "" val = self.bonus_ues[ue.id][etud.id] or ""
val_fmt = val_fmt_html = fmt_note(val) val_fmt = val_fmt_html = fmt_note(val)
if val: if val:
val_fmt_html = f'<span class="green-arrow-up"></span><span class="sp2l">{val_fmt}</span>' val_fmt_html = f"""<span class="green-arrow-up"></span><span class="sp2l">{
idx = add_cell( val_fmt
row, }</span>"""
row.add_cell(
f"bonus_ue_{ue.id}", f"bonus_ue_{ue.id}",
f"Bonus {ue.acronyme}", f"Bonus {ue.acronyme}",
val_fmt_html if allow_html else val_fmt, val_fmt_html if allow_html else val_fmt,
"col_ue_bonus", group=f"col_ue_{ue.id}",
idx_ue * 10000 + idx_ue_start + 1, 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 # 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): for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
if ue_status["is_capitalized"]: if ue_status["is_capitalized"]:
val = "-c-" val = "-c-"
@ -650,35 +659,38 @@ class ResultatsSemestre(ResultatsCache):
val_fmt_html = ( val_fmt_html = (
(scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else "" (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
) )
idx = add_cell( cell = row.add_cell(
row,
col_id, col_id,
modimpl.module.code, modimpl.module.code,
val_fmt_html, val_fmt_html,
# class col_res mod_ue_123 group=f"col_ue_{ue.id}_modules",
f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}", classes=[
idx_ue * 10000 f"col_{modimpl.module.type_abbrv()}",
+ idx_ue_start f"mod_ue_{ue.id}",
+ 1 ],
+ (modimpl.module.module_type or 0) * 1000 raw_content=val_fmt,
+ (modimpl.module.numero or 0),
) )
row[f"_{col_id}_xls"] = val_fmt
if modimpl.module.module_type == scu.ModuleType.MALUS: if modimpl.module.module_type == scu.ModuleType.MALUS:
titles[f"_{col_id}_col_order"] = idx_malus # positionne la colonne à droite de l'UE
titles_bot[f"_{col_id}_target"] = url_for( 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", "notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id, moduleimpl_id=modimpl.id,
) )
nom_resp = dict_nom_res.get(modimpl.responsable_id) nom_resp = dict_nom_res.get(modimpl.responsable_id)
if nom_resp is None: if nom_resp is None:
user = User.query.get(modimpl.responsable_id) user = User.query.get(modimpl.responsable_id)
nom_resp = user.get_nomcomplet() if user else "" nom_resp = user.get_nomcomplet() if user else ""
dict_nom_res[modimpl.responsable_id] = nom_resp dict_nom_res[modimpl.responsable_id] = nom_resp
titles_bot[ table.foot_title_row.cells[
f"_{col_id}_target_attrs" col_id
] = f""" title="{modimpl.module.titre} ({nom_resp})" """ ].target_attrs = (
f""" title="{modimpl.module.titre} ({nom_resp})" """
)
modimpl_ids.add(modimpl.id) modimpl_ids.add(modimpl.id)
nb_ues_etud_parcours = len(self.etud_ues_ids(etudid)) nb_ues_etud_parcours = len(self.etud_ues_ids(etudid))
ue_valid_txt = ( ue_valid_txt = (
@ -686,23 +698,26 @@ class ResultatsSemestre(ResultatsCache):
) = f"{nb_ues_validables}/{nb_ues_etud_parcours}" ) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
if nb_ues_warning: if nb_ues_warning:
ue_valid_txt_html += " " + scu.EMO_WARNING ue_valid_txt_html += " " + scu.EMO_WARNING
add_cell( # place juste avant moy. gen.
row, 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_validables",
"UEs", "UEs",
ue_valid_txt_html, ue_valid_txt_html,
"col_ue col_ues_validables", "col_ues_validables",
29, # juste avant moy. gen. 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 mode_jury and self.validations:
if self.is_apc: 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) dec_ues = self.validations.decisions_jury_ues.get(etudid)
if dec_ues: if dec_ues:
jury_code_sem = ",".join( jury_code_sem = ",".join(
@ -714,16 +729,8 @@ class ResultatsSemestre(ResultatsCache):
# formations classiques: code semestre # formations classiques: code semestre
dec_sem = self.validations.decisions_jury.get(etudid) dec_sem = self.validations.decisions_jury.get(etudid)
jury_code_sem = dec_sem["code"] if dec_sem else "" jury_code_sem = dec_sem["code"] if dec_sem else ""
idx = add_cell( row.add_cell("jury_code_sem", "Jury", jury_code_sem, "jury_code_sem")
row, row.add_cell(
"jury_code_sem",
"Jury",
jury_code_sem,
"jury_code_sem",
1000,
)
idx = add_cell(
row,
"jury_link", "jury_link",
"", "",
f"""<a href="{url_for('notes.formsemestre_validation_etud_form', 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>""", }">{"saisir" if not jury_code_sem else "modifier"} décision</a>""",
"col_jury_link", "col_jury_link",
idx,
) )
rows.append(row)
col_idx = self.recap_add_partitions(rows, titles) self.recap_add_partitions(table)
self.recap_add_cursus(rows, titles, col_idx=col_idx + 1) self.recap_add_cursus(table)
self._recap_add_admissions(rows, titles) self._recap_add_admissions(table)
# tri par rang croissant # tri par rang croissant
if not self.formsemestre.block_moyenne_generale: 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: 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 # 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: 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 # Ajoute style "col_empty" aux colonnes de modules vides
for col_id in titles: row_moy = table.get_row_by_id("moy")
c_class = f"_{col_id}_class" for col_id in table.column_ids:
if "col_empty" in bottom_infos["moy"].get(c_class, ""): if "col_empty" in row_moy.cells[col_id]:
for row in rows: table.column_classes[col_id].append("col_empty")
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"
# Ligne avec la classe de chaque colonne # Ligne avec la classe de chaque colonne
# récupère le type à partir des classes css (hack...) # récupère le type à partir des classes css (hack...)
row_class = {} row_type = tb.BottomRow(
for col_id in titles: table,
klass = titles.get(f"_{col_id}_class") "type_col",
if klass: title="Type col.",
row_class[col_id] = " ".join( left_title_col_ids=["prenom", "nom_short"],
cls[4:] for cls in klass.split() if cls.startswith("col_") category="bottom_infos",
classes=["bottom_info"],
) )
# cette case (nb d'UE validables) a deux classes col_xxx, on en garde une seule: for col_id in table.column_ids:
if "ues_validables" in row_class[col_id]: group_name = table.column_group.get(col_id, "")
row_class[col_id] = "ues_validables" if group_name.startswith("col_"):
bottom_infos["type_col"] = row_class group_name = group_name[4:]
row_type.add_cell(col_id, None, group_name)
# --- TABLE FOOTER: ECTS, moyennes, min, max... # Titres
footer_rows = [] table.add_head_row(self.head_title_row)
for (bottom_line, row) in bottom_infos.items(): table.add_foot_row(self.foot_title_row)
# Cases vides à styler: return table
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)
)
return (rows, footer_rows, titles, column_ids)
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict: 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""" """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 = ( # Ordre des lignes: Min, Max, Moy, Coef, ECTS, Apo
{"_tr_class": "bottom_info", "_title": "Min."}, row_min = tb.BottomRow(
{"_tr_class": "bottom_info"}, table,
{"_tr_class": "bottom_info"}, "min",
{"_tr_class": "bottom_info"}, title="Min.",
{"_tr_class": "bottom_info"}, left_title_col_ids=["prenom", "nom_short"],
{"_tr_class": "bottom_info", "_title": "Code Apogée"}, 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 # --- ECTS
# titre (à gauche) sur 2 colonnes pour s'adapter à l'affichage des noms/prenoms
for ue in ues: for ue in ues:
colid = f"moy_ue_{ue.id}" col_id = f"moy_ue_{ue.id}"
row_ects[colid] = ue.ects row_ects.add_cell(col_id, None, ue.ects, "col_ue")
row_ects[f"_{colid}_class"] = "col_ue" # ajoute cell UE vides sur ligne coef pour borders verticales
# style cases vides pour borders verticales # XXX TODO classes dans table sur colonne ajoutées à tous les TD
row_coef[colid] = "" row_coef.add_cell(col_id, None, "", "col_ue")
row_coef[f"_{colid}_class"] = "col_ue" row_ects.add_cell(
# row_apo[colid] = ue.code_apogee or "" "moy_gen",
row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]) None,
row_ects["_moy_gen_class"] = "col_moy_gen" sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]),
"col_moy_gen",
)
# --- MIN, MAX, MOY, APO # --- 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: for ue in ues:
colid = f"moy_ue_{ue.id}" col_id = f"moy_ue_{ue.id}"
row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min()) row_min.add_cell(col_id, None, fmt_note(self.etud_moy_ue[ue.id].min()))
row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max()) row_max.add_cell(col_id, None, fmt_note(self.etud_moy_ue[ue.id].max()))
row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean()) row_moy.add_cell(col_id, None, fmt_note(self.etud_moy_ue[ue.id].mean()))
row_min[f"_{colid}_class"] = "col_ue" row_apo.add_cell(col_id, None, ue.code_apogee or "")
row_max[f"_{colid}_class"] = "col_ue"
row_moy[f"_{colid}_class"] = "col_ue"
row_apo[colid] = ue.code_apogee or ""
for modimpl in self.formsemestre.modimpls_sorted: for modimpl in self.formsemestre.modimpls_sorted:
if modimpl.id in modimpl_ids: 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: if self.is_apc:
coef = self.modimpl_coefs_df[modimpl.id][ue.id] coef = self.modimpl_coefs_df[modimpl.id][ue.id]
else: else:
coef = modimpl.module.coefficient or 0 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) notes = self.modimpl_notes(modimpl.id, ue.id)
if np.isnan(notes).all(): if np.isnan(notes).all():
# aucune note valide # aucune note valide
row_min[colid] = np.nan row_min.add_cell(col_id, None, np.nan)
row_max[colid] = np.nan row_max.add_cell(col_id, None, np.nan)
moy = np.nan moy = np.nan
else: else:
row_min[colid] = fmt_note(np.nanmin(notes)) row_min.add_cell(col_id, None, fmt_note(np.nanmin(notes)))
row_max[colid] = fmt_note(np.nanmax(notes)) row_max.add_cell(col_id, None, fmt_note(np.nanmax(notes)))
moy = np.nanmean(notes) moy = np.nanmean(notes)
row_moy[colid] = fmt_note(moy) row_moy.add_cell(
if np.isnan(moy): col_id,
# aucune note dans ce module None,
row_moy[f"_{colid}_class"] = "col_empty" fmt_note(moy),
row_apo[colid] = modimpl.module.code_apogee or "" # 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, ... return { # { key : row } avec key = min, max, moy, coef, ...
"min": row_min, "min": row_min,
@ -892,9 +926,8 @@ class ResultatsSemestre(ResultatsCache):
row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class]) row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class])
titles["group"] = "Gr" 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" """Ajoute les colonnes "admission"
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "admission" Les colonnes ont la classe css "admission"
""" """
fields = { fields = {
@ -904,87 +937,103 @@ class ResultatsSemestre(ResultatsCache):
"classement": "Rg. Adm.", "classement": "Rg. Adm.",
} }
first = True first = True
for i, cid in enumerate(fields): for cid, title in fields.items():
titles[f"_{cid}_col_order"] = 100000 + i # tout à droite cell_head, cell_foot = table.add_title(cid, title)
cell_head.classes.append("admission")
cell_foot.classes.append("admission")
if first: if first:
titles[f"_{cid}_class"] = "admission admission_first" cell_head.classes.append("admission_first")
cell_foot.classes.append("admission_first")
first = False first = False
else:
titles[f"_{cid}_class"] = "admission" for row in table.rows:
titles.update(fields) etud = Identite.query.get(row.id) # TODO XXX
for row in rows:
etud = Identite.query.get(row["etudid"])
admission = etud.admission.first() admission = etud.admission.first()
first = True first = True
for cid in fields: for cid, title in fields.items():
row[cid] = getattr(admission, cid) or "" cell = row.add_cell(
cid,
title,
getattr(admission, cid) or "",
"admission",
)
if first: if first:
row[f"_{cid}_class"] = "admission admission_first" cell.classes.append("admission_first")
first = False 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'""" """Ajoute colonne avec code cursus, eg 'S1 S2 S1'"""
table.insert_group("cursus", before="col_ues_validables")
cid = "code_cursus" cid = "code_cursus"
titles[cid] = "Cursus"
titles[f"_{cid}_col_order"] = col_idx
formation_code = self.formsemestre.formation.formation_code formation_code = self.formsemestre.formation.formation_code
for row in rows: for row in table.rows:
etud = Identite.query.get(row["etudid"]) etud = Identite.query.get(row.id) # TODO XXX à optimiser: etud dans row
row[cid] = " ".join( row.add_cell(
cid,
"Cursus",
" ".join(
[ [
f"S{ins.formsemestre.semestre_id}" f"S{ins.formsemestre.semestre_id}"
for ins in reversed(etud.inscriptions()) for ins in reversed(etud.inscriptions())
if ins.formsemestre.formation.formation_code == formation_code if ins.formsemestre.formation.formation_code == formation_code
] ]
),
"cursus",
) )
def recap_add_partitions( def recap_add_partitions(self, table: tb.Table):
self, rows: list[dict], titles: dict, col_idx: int = None
) -> int:
"""Ajoute les colonnes indiquant les groupes """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" 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( partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
self.formsemestre.id self.formsemestre.id
) )
first_partition = True first_partition = True
col_order = 10 if col_idx is None else col_idx
for partition in partitions: 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']}" cid = f"part_{partition['partition_id']}"
rg_cid = cid + "_rg" # rang dans la partition cell_head, cell_foot = table.add_title(cid, partition["partition_name"])
titles[cid] = partition["partition_name"] cell_head.classes += col_classes
if first_partition: cell_foot.classes += col_classes
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
if partition["bul_show_rank"]: if partition["bul_show_rank"]:
titles[rg_cid] = f"Rg {partition['partition_name']}" rg_cid = cid + "_rg" # rang dans la partition
titles[f"_{rg_cid}_class"] = "partition_rangs" 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"]] 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 group = None # group (dict) de l'étudiant dans cette partition
# dans NotesTableCompat, à revoir # 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: if etud_etat == scu.DEMISSION:
gr_name = "Dém." gr_name = "Dém."
row["_tr_class"] = "dem" tr_classes.append("dem")
elif etud_etat == DEF: elif etud_etat == DEF:
gr_name = "Déf." gr_name = "Déf."
row["_tr_class"] = "def" tr_classes.append("def")
else: else:
group = partition_etud_groups.get(row["etudid"]) group = partition_etud_groups.get(row["etudid"])
gr_name = group["group_name"] if group else "" gr_name = group["group_name"] if group else ""
if gr_name: if gr_name:
row[cid] = gr_name row.add_cell(
row[f"_{cid}_class"] = klass cid,
partition["partition_name"],
gr_name,
"partition",
classes=col_classes,
)
# Rangs dans groupe # Rangs dans groupe
if ( if (
partition["bul_show_rank"] partition["bul_show_rank"]
@ -992,72 +1041,78 @@ class ResultatsSemestre(ResultatsCache):
and (group["id"] in self.moy_gen_rangs_by_group) and (group["id"] in self.moy_gen_rangs_by_group)
): ):
rang = self.moy_gen_rangs_by_group[group["id"]][0] 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 def _recap_add_evaluations(self, table: tb.Table):
return col_order
def _recap_add_evaluations(
self, rows: list[dict], titles: dict, bottom_infos: dict
):
"""Ajoute les colonnes avec les notes aux évaluations """Ajoute les colonnes avec les notes aux évaluations
rows est une liste de dict avec une clé "etudid" rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "evaluation" Les colonnes ont la classe css "evaluation"
""" """
# nouvelle ligne pour description évaluations: # nouvelle ligne pour description évaluations:
bottom_infos["descr_evaluation"] = { row_descr_eval = tb.BottomRow(
"_tr_class": "bottom_info", table,
"_title": "Description évaluation", "evaluations",
} title="Description évaluations",
left_title_col_ids=["prenom", "nom_short"],
category="bottom_infos",
classes=["bottom_info"],
)
first_eval = True first_eval = True
index_col = 9000 # à droite
for modimpl in self.formsemestre.modimpls_sorted: for modimpl in self.formsemestre.modimpls_sorted:
evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl) evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl)
eval_index = len(evals) - 1 eval_index = len(evals) - 1
inscrits = {i.etudid for i in modimpl.inscriptions} inscrits = {i.etudid for i in modimpl.inscriptions}
first_eval_of_mod = True first_eval_of_mod = True
for e in evals: for e in evals:
cid = f"eval_{e.id}" col_id = f"eval_{e.id}"
titles[ title = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
cid col_classes = ["evaluation"]
] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
klass = "evaluation"
if first_eval: if first_eval:
klass += " first" col_classes.append("first")
elif first_eval_of_mod: elif first_eval_of_mod:
klass += " first_of_mod" col_classes.append("first_of_mod")
titles[f"_{cid}_class"] = klass
first_eval_of_mod = first_eval = False first_eval_of_mod = first_eval = False
titles[f"_{cid}_col_order"] = index_col
index_col += 1
eval_index -= 1 eval_index -= 1
notes_db = sco_evaluation_db.do_evaluation_get_all_notes( notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
e.evaluation_id e.evaluation_id
) )
for row in rows: for row in table.rows:
etudid = row["etudid"] etudid = row.id
if etudid in inscrits: if etudid in inscrits:
if etudid in notes_db: if etudid in notes_db:
val = notes_db[etudid]["value"] val = notes_db[etudid]["value"]
else: else:
# Note manquante mais prise en compte immédiate: affiche ATT # Note manquante mais prise en compte immédiate: affiche ATT
val = scu.NOTES_ATTENTE val = scu.NOTES_ATTENTE
row[cid] = scu.fmt_note(val) content = scu.fmt_note(val)
row[f"_{cid}_class"] = klass + { classes = col_classes + [
{
"ABS": "abs", "ABS": "abs",
"ATT": "att", "ATT": "att",
"EXC": "exc", "EXC": "exc",
}.get(row[cid], "") }.get(content, "")
]
row.add_cell(col_id, title, content, "", classes=classes)
else: else:
row[cid] = "ni" row.add_cell(
row[f"_{cid}_class"] = klass + " non_inscrit" col_id,
title,
"ni",
"",
classes=col_classes + ["non_inscrit"],
)
bottom_infos["coef"][cid] = e.coefficient table.get_row_by_id("coef").row[col_id] = e.coefficient
bottom_infos["min"][cid] = "0" table.get_row_by_id("min").row[col_id] = "0"
bottom_infos["max"][cid] = scu.fmt_note(e.note_max) table.get_row_by_id("max").row[col_id] = scu.fmt_note(e.note_max)
bottom_infos["descr_evaluation"][cid] = e.description or "" row_descr_eval.add_cell(
bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for( col_id,
None,
e.description or "",
target=url_for(
"notes.evaluation_listenotes", "notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
evaluation_id=e.id, 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>' '<div class="table_recap"><div class="message">aucun étudiant !</div></div>'
) )
H = [ 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' 'apc' if formsemestre.formation.is_apc() else 'classic'
} {'jury' if mode_jury else ''}" } {'jury' if mode_jury else ''}"
data-filename="{filename}">""" data-filename="{filename}">
"""
] ]
# header # header
H.append( 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}>"""