diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py index 7f5c00b86b..682a03777d 100644 --- a/app/but/cursus_but.py +++ b/app/but/cursus_but.py @@ -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 diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 8326b64038..e43b1a8c67 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -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__, ... les moyennes de ressources dans l'UE moy_sae__, ... 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) __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'{val_fmt}' - idx = add_cell( - row, + val_fmt_html = f"""{ + val_fmt + }""" + 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"""{"saisir" if not jury_code_sem else "modifier"} décision""", "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, + ), ) diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index de6299316e..470b08799a 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -425,10 +425,22 @@ def _gen_formsemestre_recapcomplet_html( '
aucun étudiant !
' ) H = [ - f"""
+ { + table.html( + classes=[ + 'table_recap', + 'apc' if formsemestre.formation.is_apc() else 'classic', + 'jury' if mode_jury else '' + ], + data={"filename":filename} + ) + } +
""" + data-filename="{filename}"> + """ ] # header H.append( diff --git a/app/scodoc/table_builder.py b/app/scodoc/table_builder.py new file mode 100644 index 0000000000..404bfa9f8e --- /dev/null +++ b/app/scodoc/table_builder.py @@ -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""" + + { newline.join(row.html() for row in self.head) } + + """ + if self.head + else "" + ) + footer = ( + f""" + + { newline.join(row.html() for row in self.foot) } + + """ + if self.foot + else "" + ) + return f"""
+ {header} + { + newline.join(row.html() for row in self.rows) + } + {footer} +
+ """ + + 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 " + 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"""{ + "".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 ]) + }""" + + +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"{content}" + return f"""<{self.elt} {attrs_str}>{self.content}"""