From d2923f090c126c5bd7c29128cae4b512c77a2b86 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 29 Jan 2023 17:52:39 -0300 Subject: [PATCH] WIP: table recap --- app/api/formsemestres.py | 11 +- app/comp/res_common.py | 653 ++++++++++++++++----------------- app/scodoc/sco_recapcomplet.py | 54 +-- app/scodoc/sco_utils.py | 9 +- app/scodoc/table_builder.py | 211 +++++++---- app/static/css/scodoc.css | 4 + app/static/js/table_recap.js | 4 +- 7 files changed, 487 insertions(+), 459 deletions(-) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index acc749ca..3ec02479 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -476,16 +476,17 @@ def formsemestre_resultat(formsemestre_id: int): formsemestre: FormSemestre = query.first_or_404(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - rows, footer_rows, titles, column_ids = res.get_table_recap( + table = res.get_table_recap( convert_values=convert_values, include_evaluations=False, mode_jury=False, allow_html=False, ) # Supprime les champs inutiles (mise en forme) - table = [{k: row[k] for k in row if not k[0] == "_"} for row in rows] - # Ajoute les groupes + rows = table.to_list() + # Ajoute le groupe de chaque partition: etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id) - for row in table: + for row in rows: row["partitions"] = etud_groups.get(row["etudid"], {}) - return jsonify(table) + + return jsonify(rows) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 9caf7283..e8b1c893 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -440,15 +440,12 @@ class ResultatsSemestre(ResultatsCache): convert_values=False, 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: XXX tuple avec - - rows: liste de dicts { column_id : value } - - titles: { column_id : title } - - columns_ids: (liste des id de colonnes) + Result: Table, le contenu étant une ligne par étudiant. + Si convert_values, transforme les notes en chaines ("12.34"). Les colonnes générées sont: @@ -459,291 +456,41 @@ 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: XXX + On ajoute aussi des classes: - pour les lignes: - _css_row_class (inutilisé pour le monent) - __class classe css: - - la moyenne générale a la classe col_moy_gen - - les colonnes SAE ont la classe col_sae - - les colonnes Resources ont la classe col_res - - les colonnes d'UE ont la classe col_ue - - les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_ - __order : clé de tri + selected_row pour l'étudiant sélectionné + - les colonnes: + - la moyenne générale a la classe col_moy_gen + - les colonnes SAE ont la classe col_sae + - les colonnes Resources ont la classe col_res + - les colonnes d'UE ont la classe col_ue + - les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_ """ - if convert_values: - fmt_note = scu.fmt_note - else: - fmt_note = lambda x: x - - parcours = self.formsemestre.formation.get_parcours() - barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE - barre_valid_ue = parcours.NOTES_BARRE_VALID_UE - barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING - NO_NOTE = "-" # contenu des cellules sans notes - rows = [] - 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 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() + # Quelques infos stockées dans la Table + parcours = self.formsemestre.formation.get_parcours() + table.barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE + table.barre_valid_ue = parcours.NOTES_BARRE_VALID_UE + table.barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING + table.cache_nomcomplet = {} # cache uid : nomcomplet + if convert_values: + table.fmt_note = scu.fmt_note + else: + table.fmt_note = lambda x: x + # couples (modimpl, ue) effectivement présents dans la table: + table.modimpl_ue_ids = set() + for etudid in etuds_inscriptions: - idx = 0 # index de la colonne etud = Identite.query.get(etudid) row = tb.Row(table, etudid) table.add_row(row) - # --- Codes (seront cachés, mais exportés en excel) - 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: - row.add_cell( - "rang", - "Rg", - self.etud_moy_gen_ranks[etudid], - "rang", - data={"order": f"{self.etud_moy_gen_ranks_int[etudid]:05d}"}, - ) - # --- Identité étudiant - url_bulletin = url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - formsemestre_id=self.formsemestre.id, - etudid=etudid, - ) - 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, - "etudid": etud.id, - "nomprenom": etud.nomprenom, - }, - target=url_bulletin, - target_attrs=f'class="etudinfo" id="{etudid}"', - ) - - # --- Moyenne générale - if not self.formsemestre.block_moyenne_generale: - moy_gen = self.etud_moy_gen.get(etudid, False) - note_class = "" - 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 - row.add_cell( - "moy_gen", - "Moy", - fmt_note(moy_gen), - "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 - 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}" - val = ue_status["moy"] - note_class = "" - if isinstance(val, float): - if val < barre_moy: - note_class = " moy_inf" - elif val >= barre_valid_ue: - note_class = " moy_ue_valid" - nb_ues_validables += 1 - if val < barre_warning_ue: - note_class = " moy_ue_warning" # notes très basses - nb_ues_warning += 1 - row.add_cell( - col_id, - ue.acronyme, - fmt_note(val), - 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 '?'}" """ - ) - if mode_jury: - # pas d'autre colonnes de résultats - continue - # Bonus (sport) dans cette UE ? - # Le bonus sport appliqué sur cette UE - if (self.bonus_ues is not None) and (ue.id in self.bonus_ues): - 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 - }""" - row.add_cell( - f"bonus_ue_{ue.id}", - f"Bonus {ue.acronyme}", - val_fmt_html if allow_html else val_fmt, - group=f"col_ue_{ue.id}", - classes=["col_ue_bonus"], - raw_content=val_fmt, - ) - # Les moyennes des modules (ou ressources et SAÉs) dans cette UE - for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False): - if ue_status["is_capitalized"]: - val = "-c-" - else: - modimpl_results = self.modimpls_results.get(modimpl.id) - if modimpl_results: # pas bonus - if self.is_apc: # BUT - moys_vers_ue = modimpl_results.etuds_moy_module.get( - ue.id - ) - val = ( - moys_vers_ue.get(etudid, "?") - if moys_vers_ue is not None - else "" - ) - else: # classique: Series indépendante de l'UE - val = modimpl_results.etuds_moy_module.get( - etudid, "?" - ) - else: - val = "" - - col_id = ( - f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" - ) - val_fmt = val_fmt_html = fmt_note(val) - if convert_values and ( - modimpl.module.module_type == scu.ModuleType.MALUS - ): - val_fmt_html = ( - (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else "" - ) - cell = row.add_cell( - col_id, - modimpl.module.code, - val_fmt_html, - group=f"col_ue_{ue.id}_modules", - classes=[ - f"col_{modimpl.module.type_abbrv()}", - f"mod_ue_{ue.id}", - ], - raw_content=val_fmt, - ) - if modimpl.module.module_type == scu.ModuleType.MALUS: - # positionne la colonne à droite de l'UE - cell.group = f"col_ue_{ue.id}_malus" - table.insert_group(cell.group, after=f"col_ue_{ue.id}") - - table.foot_title_row.cells[col_id].target = url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=modimpl.id, - ) - - nom_resp = 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 - 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 = ( - ue_valid_txt_html - ) = f"{nb_ues_validables}/{nb_ues_etud_parcours}" - if nb_ues_warning: - ue_valid_txt_html += " " + scu.EMO_WARNING - # 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_ues_validables", - classes=classes, - raw_content=ue_valid_txt, - data={"order": nb_ues_validables}, # tri - ) - - if mode_jury and self.validations: - if self.is_apc: - # 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( - [dec_ues[ue_id].get("code", "") for ue_id in dec_ues] - ) - else: - jury_code_sem = "" - else: - # formations classiques: code semestre - dec_sem = self.validations.decisions_jury.get(etudid) - jury_code_sem = dec_sem["code"] if dec_sem else "" - 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") - if self.formsemestre.etat else "voir"} décisions""", - "col_jury_link", - ) + self._recap_add_etud(row, etud) + self._recap_add_moyennes(row, etud, ues_sans_bonus, mode_jury) self.recap_add_partitions(table) self.recap_add_cursus(table) @@ -751,21 +498,22 @@ class ResultatsSemestre(ResultatsCache): # tri par rang croissant if not self.formsemestre.block_moyenne_generale: - table.sort_rows(key=lambda e: e["_rang_order"]) + table.sort_rows(key=lambda row: row.rang_order) else: - table.sort_rows(key=lambda e: e["_ues_validables_order"], reverse=True) + table.sort_rows(key=lambda row: row.nb_ues_validables, reverse=True) - # INFOS POUR FOOTER - bottom_infos = self._recap_bottom_infos( - table, ues_sans_bonus, modimpl_ids, fmt_note - ) + # Lignes footer (min, max, ects, apo, ...) + self._recap_add_bottom_rows(table, ues_sans_bonus) + + # Evaluations: if include_evaluations: self._recap_add_evaluations(table) # Ajoute style "col_empty" aux colonnes de modules vides row_moy = table.get_row_by_id("moy") for col_id in table.column_ids: - if "col_empty" in row_moy.cells[col_id]: + cell: tb.Cell = row_moy.cells.get(col_id) + if cell and "col_empty" in cell.classes: table.column_classes[col_id].append("col_empty") # Ligne avec la classe de chaque colonne @@ -773,7 +521,7 @@ class ResultatsSemestre(ResultatsCache): row_type = tb.BottomRow( table, "type_col", - title="Type col.", + left_title="Type col.", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info"], @@ -784,20 +532,265 @@ class ResultatsSemestre(ResultatsCache): group_name = group_name[4:] row_type.add_cell(col_id, None, group_name) - # 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: + def _recap_add_etud(self, row: tb.Row, etud: Identite): + """Ajoute colonnes étudiant: codes, noms""" + # --- Codes (seront cachés, mais exportés en excel) + row.add_cell("etudid", "etudid", etud.id, "etud_codes") + row.add_cell( + "code_nip", + "code_nip", + etud.code_nip or "", + "etud_codes", + ) + + # --- Rang + if not self.formsemestre.block_moyenne_generale: + row.rang_order = self.etud_moy_gen_ranks_int[etud.id] + row.add_cell( + "rang", + "Rg", + self.etud_moy_gen_ranks[etud.id], + "rang", + data={"order": f"{row.rang_order:05d}"}, + ) + else: + row.rang_order = -1 + # --- Identité étudiant + url_bulletin = url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=self.formsemestre.id, + etudid=etud.id, + ) + 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={"class": "etudinfo", "id": str(etud.id)}, + ) + 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, + "etudid": etud.id, + "nomprenom": etud.nomprenom, + }, + target=url_bulletin, + target_attrs={"class": "etudinfo", "id": str(etud.id)}, + ) + + def _recap_add_moyennes( + self, + row: tb.Row, + etud: Identite, + ues_sans_bonus: list[UniteEns], + mode_jury=False, + ): + """Ajoute cols moy_gen moy_ue et tous les modules...""" + table = row.table + + # --- Moyenne générale + if not self.formsemestre.block_moyenne_generale: + moy_gen = self.etud_moy_gen.get(etud.id, False) + note_class = "" + if moy_gen is False: + moy_gen = scu.NO_NOTE_STR + elif isinstance(moy_gen, float) and moy_gen < table.barre_moy: + note_class = "moy_ue_warning" # en rouge + row.add_cell( + "moy_gen", + "Moy", + table.fmt_note(moy_gen), + "col_moy_gen", + classes=[note_class], + ) + # Ajoute bulle sur titre du pied de table: + if self.is_apc: + table.foot_title_row.cells["moy_gen"].target_attrs[ + "title" + ] = "moyenne indicative" + + # --- Moyenne d'UE + row.nb_ues_validables, row.nb_ues_warning = 0, 0 + for ue in ues_sans_bonus: + ue_status = self.get_etud_ue_status(etud.id, ue.id) + if ue_status is not None: + self._recap_add_ue(row, ue, ue_status) + if mode_jury: + # pas d'autre colonnes de résultats + continue + + # Bonus (sport) dans cette UE ? + # Le bonus sport appliqué sur cette UE + if (self.bonus_ues is not None) and (ue.id in self.bonus_ues): + val = self.bonus_ues[ue.id][etud.id] or "" + val_fmt = val_fmt_html = table.fmt_note(val) + if val: + val_fmt_html = f"""{ + val_fmt + }""" + row.add_cell( + f"bonus_ue_{ue.id}", + f"Bonus {ue.acronyme}", + val_fmt_html, + raw_content=val_fmt, + group=f"col_ue_{ue.id}", + classes=["col_ue_bonus"], + ) + # Les moyennes des modules (ou ressources et SAÉs) dans cette UE + self._recap_add_ue_modimpls(row, ue, etud, ue_status["is_capitalized"]) + + row.nb_ues_etud_parcours = len(self.etud_ues_ids(etud.id)) + ue_valid_txt = ( + ue_valid_txt_html + ) = f"{row.nb_ues_validables}/{row.nb_ues_etud_parcours}" + if row.nb_ues_warning: + ue_valid_txt_html += " " + scu.EMO_WARNING + # place juste avant moy. gen. + table.insert_group("col_ues_validables", before="col_moy_gen") + classes = ["col_ue"] + if row.nb_ues_warning: + classes.append("moy_ue_warning") + elif row.nb_ues_validables < len(ues_sans_bonus): + classes.append("moy_inf") + row.add_cell( + "ues_validables", + "UEs", + ue_valid_txt_html, + "col_ues_validables", + classes=classes, + raw_content=ue_valid_txt, + data={"order": row.nb_ues_validables}, # tri + ) + + if mode_jury and self.validations: + if self.is_apc: + # formations BUT: pas de code semestre, concatene ceux des UEs + dec_ues = self.validations.decisions_jury_ues.get(etud.id) + if dec_ues: + jury_code_sem = ",".join( + [dec_ues[ue_id].get("code", "") for ue_id in dec_ues] + ) + else: + jury_code_sem = "" + else: + # formations classiques: code semestre + dec_sem = self.validations.decisions_jury.get(etud.id) + jury_code_sem = dec_sem["code"] if dec_sem else "" + 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") + if self.formsemestre.etat else "voir"} décisions""", + "col_jury_link", + ) + + def _recap_add_ue(self, row: tb.Row, ue: UniteEns, ue_status: dict): + "Ajoute résultat UE au row (colonne col_ue)" + table = row.table + col_id = f"moy_ue_{ue.id}" + val = ue_status["moy"] + note_class = "" + if isinstance(val, float): + if val < table.barre_moy: + note_class = " moy_inf" + elif val >= table.barre_valid_ue: + note_class = " moy_ue_valid" + row.nb_ues_validables += 1 + if val < table.barre_warning_ue: + note_class = " moy_ue_warning" # notes très basses + row.nb_ues_warning += 1 + row.add_cell( + col_id, + ue.acronyme, + table.fmt_note(val), + group=f"col_ue_{ue.id}", + classes=["col_ue", note_class], + ) + row.table.foot_title_row.cells[col_id].target_attrs[ + "title" + ] = f"""{ue.titre} S{ue.semestre_idx or '?'}""" + + def _recap_add_ue_modimpls( + self, row: tb.Row, ue: UniteEns, etud: Identite, is_capitalized: bool + ): + """Ajoute à row les moyennes des modules (ou ressources et SAÉs) dans l'UE""" + table = row.table + for modimpl in self.modimpls_in_ue(ue, etud.id, with_bonus=False): + if is_capitalized: + val = "-c-" + else: + modimpl_results = self.modimpls_results.get(modimpl.id) + if modimpl_results: # pas bonus + if self.is_apc: # BUT + moys_vers_ue = modimpl_results.etuds_moy_module.get(ue.id) + val = ( + moys_vers_ue.get(etud.id, "?") + if moys_vers_ue is not None + else "" + ) + else: # classique: Series indépendante de l'UE + val = modimpl_results.etuds_moy_module.get(etud.id, "?") + else: + val = "" + + col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + val_fmt = val_fmt_html = table.fmt_note(val) + if modimpl.module.module_type == scu.ModuleType.MALUS: + val_fmt_html = (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else "" + cell = row.add_cell( + col_id, + modimpl.module.code, + val_fmt_html, + raw_content=val_fmt, + group=f"col_ue_{ue.id}_modules", + classes=[ + f"col_{modimpl.module.type_abbrv()}", + f"mod_ue_{ue.id}", + ], + ) + if modimpl.module.module_type == scu.ModuleType.MALUS: + # positionne la colonne à droite de l'UE + cell.group = f"col_ue_{ue.id}_malus" + table.insert_group(cell.group, after=f"col_ue_{ue.id}") + + table.foot_title_row.cells[col_id].target = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=modimpl.id, + ) + + nom_resp = table.cache_nomcomplet.get(modimpl.responsable_id) + if nom_resp is None: + user = User.query.get(modimpl.responsable_id) + nom_resp = user.get_nomcomplet() if user else "" + table.cache_nomcomplet[modimpl.responsable_id] = nom_resp + table.foot_title_row.cells[col_id].target_attrs[ + "title" + ] = f"{modimpl.module.titre} ({nom_resp})" + table.modimpl_ue_ids.add((modimpl.id, ue.id)) + + def _recap_add_bottom_rows(self, table: tb.Table, ues): """Les informations à mettre en bas de la table: min, max, moy, ECTS, Apo""" # Ordre des lignes: Min, Max, Moy, Coef, ECTS, Apo row_min = tb.BottomRow( table, "min", - title="Min.", + left_title="Min.", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info"], @@ -805,7 +798,7 @@ class ResultatsSemestre(ResultatsCache): row_max = tb.BottomRow( table, "max", - title="Max.", + left_title="Max.", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info"], @@ -813,7 +806,7 @@ class ResultatsSemestre(ResultatsCache): row_moy = tb.BottomRow( table, "moy", - title="Moy.", + left_title="Moy.", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info"], @@ -821,7 +814,7 @@ class ResultatsSemestre(ResultatsCache): row_coef = tb.BottomRow( table, "coef", - title="Coef.", + left_title="Coef.", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info"], @@ -829,7 +822,7 @@ class ResultatsSemestre(ResultatsCache): row_ects = tb.BottomRow( table, "ects", - title="ECTS", + left_title="ECTS", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info"], @@ -837,7 +830,7 @@ class ResultatsSemestre(ResultatsCache): row_apo = tb.BottomRow( table, "apo", - title="Code Apogée", + left_title="Code Apogée", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info"], @@ -847,36 +840,46 @@ class ResultatsSemestre(ResultatsCache): # titre (à gauche) sur 2 colonnes pour s'adapter à l'affichage des noms/prenoms for ue in ues: col_id = f"moy_ue_{ue.id}" - row_ects.add_cell(col_id, None, ue.ects, "col_ue") + row_ects.add_cell(col_id, None, ue.ects) # ajoute cell UE vides sur ligne coef pour borders verticales # XXX TODO classes dans table sur colonne ajoutées à tous les TD - row_coef.add_cell(col_id, None, "", "col_ue") + row_coef.add_cell(col_id, None, "") row_ects.add_cell( "moy_gen", None, sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]), - "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.add_cell("moy_gen", None, table.fmt_note(self.etud_moy_gen.min())) + row_max.add_cell("moy_gen", None, table.fmt_note(self.etud_moy_gen.max())) + row_moy.add_cell("moy_gen", None, table.fmt_note(self.etud_moy_gen.mean())) for ue in ues: 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_min.add_cell( + col_id, None, table.fmt_note(self.etud_moy_ue[ue.id].min()) + ) + row_max.add_cell( + col_id, None, table.fmt_note(self.etud_moy_ue[ue.id].max()) + ) + row_moy.add_cell( + col_id, None, table.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: + if (modimpl.id, ue.id) in table.modimpl_ue_ids: 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.add_cell(col_id, None, fmt_note(coef)) + row_coef.add_cell( + col_id, + None, + table.fmt_note(coef), + group=f"col_ue_{ue.id}_modules", + ) notes = self.modimpl_notes(modimpl.id, ue.id) if np.isnan(notes).all(): # aucune note valide @@ -884,27 +887,18 @@ class ResultatsSemestre(ResultatsCache): row_max.add_cell(col_id, None, np.nan) moy = np.nan else: - row_min.add_cell(col_id, None, fmt_note(np.nanmin(notes))) - row_max.add_cell(col_id, None, fmt_note(np.nanmax(notes))) + row_min.add_cell(col_id, None, table.fmt_note(np.nanmin(notes))) + row_max.add_cell(col_id, None, table.fmt_note(np.nanmax(notes))) moy = np.nanmean(notes) row_moy.add_cell( col_id, None, - fmt_note(moy), + table.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, - "max": row_max, - "moy": row_moy, - "coef": row_coef, - "ects": row_ects, - "apo": row_apo, - } - def _recap_etud_groups_infos( self, etudid: int, row: dict, titles: dict ): # XXX non utilisé @@ -992,13 +986,13 @@ class ResultatsSemestre(ResultatsCache): Les colonnes ont la classe css "partition" """ - table.insert_group("partition", after="parcours") + table.insert_group("partition", after="identite_court") partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups( self.formsemestre.id ) first_partition = True for partition in partitions: - col_classes = ["partition"] + col_classes = [] # la classe "partition" sera ajoutée par la table if not first_partition: col_classes.append("partition_aux") first_partition = False @@ -1017,6 +1011,7 @@ class ResultatsSemestre(ResultatsCache): partition_etud_groups = partitions_etud_groups[partition["partition_id"]] for row in table.rows: + etudid = row.id group = None # group (dict) de l'étudiant dans cette partition # dans NotesTableCompat, à revoir etud_etat = self.get_etud_etat(row.id) # row.id == etudid @@ -1028,7 +1023,7 @@ class ResultatsSemestre(ResultatsCache): gr_name = "Déf." tr_classes.append("def") else: - group = partition_etud_groups.get(row["etudid"]) + group = partition_etud_groups.get(etudid) gr_name = group["group_name"] if group else "" if gr_name: row.add_cell( @@ -1046,7 +1041,7 @@ class ResultatsSemestre(ResultatsCache): and (group["id"] in self.moy_gen_rangs_by_group) ): rang = self.moy_gen_rangs_by_group[group["id"]][0] - row.add_cell(rg_cid, None, rang.get(row["etudid"], ""), "partition") + row.add_cell(rg_cid, None, rang.get(etudid, ""), "partition") def _recap_add_evaluations(self, table: tb.Table): """Ajoute les colonnes avec les notes aux évaluations @@ -1057,7 +1052,7 @@ class ResultatsSemestre(ResultatsCache): row_descr_eval = tb.BottomRow( table, "evaluations", - title="Description évaluations", + left_title="Description évaluations", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info"], @@ -1090,7 +1085,7 @@ class ResultatsSemestre(ResultatsCache): else: # Note manquante mais prise en compte immédiate: affiche ATT val = scu.NOTES_ATTENTE - content = scu.fmt_note(val) + content = table.fmt_note(val) classes = col_classes + [ { "ABS": "abs", @@ -1110,7 +1105,7 @@ class ResultatsSemestre(ResultatsCache): 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) + table.get_row_by_id("max").row[col_id] = table.fmt_note(e.note_max) row_descr_eval.add_cell( col_id, None, diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 470b0879..a92366ba 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -415,59 +415,27 @@ def _gen_formsemestre_recapcomplet_html( selected_etudid=None, ) -> str: """Génère le html""" - rows, footer_rows, titles, column_ids = res.get_table_recap( + table = res.get_table_recap( convert_values=True, include_evaluations=include_evaluations, mode_jury=mode_jury, ) - if not rows: - return ( - '
aucun étudiant !
' - ) - H = [ - f"""
+ table.data["filename"] = filename + table.select_row(selected_etudid) + return f""" +
{ - table.html( - classes=[ + '
aucun étudiant !
' + if table.is_empty() + else table.html( + extra_classes=[ 'table_recap', 'apc' if formsemestre.formation.is_apc() else 'classic', 'jury' if mode_jury else '' - ], - data={"filename":filename} - ) + ]) } - + """ - ] - # header - H.append( - f""" - - {scu.gen_row(column_ids, titles, "th")} - - """ - ) - # body - H.append("") - for row in rows: - H.append(f"{scu.gen_row(column_ids, row, selected_etudid=selected_etudid)}\n") - H.append("\n") - # footer - H.append("") - idx_last = len(footer_rows) - 1 - for i, row in enumerate(footer_rows): - H.append(f'{scu.gen_row(column_ids, row, "th" if i == idx_last else "td")}\n') - H.append( - """ - -
-
- """ - ) - return "".join(H) def gen_formsemestre_recapcomplet_excel( diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 00365b4b..2942e436 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -74,6 +74,8 @@ NOTES_NEUTRALISE = -1000.0 # notes non prises en comptes dans moyennes NOTES_SUPPRESS = -1001.0 # note a supprimer NOTES_ATTENTE = -1002.0 # note "en attente" (se calcule comme une note neutralisee) +NO_NOTE_STR = "-" # contenu des cellules de tableaux html sans notes + # ---- CODES INSCRIPTION AUX SEMESTRES # (champ etat de FormSemestreInscription) INSCRIT = "I" @@ -1178,11 +1180,10 @@ def gen_row( ): "html table row" klass = row.get("_tr_class") + if row.get("etudid", "") == selected_etudid: + klass += " row_selected" tr_class = f'class="{klass}"' if klass else "" - tr_id = ( - f"""id="row_selected" """ if (row.get("etudid", "") == selected_etudid) else "" - ) - return f"""{ + return f"""{ "".join([gen_cell(key, row, elt, with_col_class=with_col_classes) for key in keys if not key.startswith('_')]) }""" diff --git a/app/scodoc/table_builder.py b/app/scodoc/table_builder.py index 404bfa9f..8ddd70c8 100644 --- a/app/scodoc/table_builder.py +++ b/app/scodoc/table_builder.py @@ -9,7 +9,39 @@ from collections import defaultdict -class Table: +class Element: + def __init__( + self, + elt: str, + content=None, + classes: list[str] = None, + attrs: dict[str, str] = None, + data: dict = None, + ): + self.elt = elt + self.attrs = attrs or {} + self.classes = classes or [] + "list of classes for the element" + self.content = content + self.data = data or {} + "data-xxx" + + def html(self, extra_classes: list[str] = None) -> str: + "html for element" + classes = [cls for cls in (self.classes + (extra_classes or [])) if cls] + attrs_str = f"""class="{' '.join(classes)}" """ if classes else "" + # 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()]) + return f"""<{self.elt} {attrs_str}>{self.html_content()}""" + + def html_content(self) -> str: + "Le contenu de l'élément, en html." + return str(self.content or "") + + +class Table(Element): """Construction d'une table de résultats table = Table() @@ -22,33 +54,33 @@ class Table: table.update_titles(titles) table.set_column_groups(groups: list[str]) - table.sort_columns() table.insert_group(group:str, [after=str], [before=str]) + + Ordre des colonnes: groupées par groupes, et dans chaque groupe par ordre d'insertion + On fixe l'ordre des groupes par ordre d'insertion + ou par insert_group ou par set_column_groups. + """ def __init__( self, selected_row_id: str = None, classes: list[str] = None, - data: dict[str, str] = None, + attrs: dict[str, str] = None, + data: dict = None, ): + super().__init__("table", classes=classes, attrs=attrs, data=data) 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 @@ -59,24 +91,39 @@ class Table: self.foot_title_row: "Row" = Row(self, "title_foot", cell_elt="th") self.empty_cell = Cell.empty() + def _prepare(self): + """Prepare the table before generation: + Sort table columns, add header/footer titles rows + """ + self.sort_columns() + # Titres + self.add_head_row(self.head_title_row) + self.add_foot_row(self.foot_title_row) + 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 is_empty(self) -> bool: + "true if table has no rows" + return len(self.rows) == 0 - 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 - """ + def select_row(self, row_id): + "mark rows as 'selected'" + self.selected_row_id = row_id + + def to_list(self) -> list[dict]: + """as a list, each row is a dict""" 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()) + return [row.to_dict() for row in self.rows] + + def html(self, extra_classes: list[str] = None) -> str: + """HTML version of the table""" + self._prepare() + return super().html(extra_classes=extra_classes) + + def html_content(self) -> str: + """Le contenu de la table en html.""" newline = "\n" header = ( f""" @@ -96,13 +143,14 @@ class Table: if self.foot else "" ) - return f""" + return f""" {header} + { newline.join(row.html() for row in self.rows) } + {footer} -
""" def add_row(self, row: "Row") -> "Row": @@ -131,8 +179,12 @@ class Table: def sort_columns(self): """Sort columns ids""" groups_order = {group: i for i, group in enumerate(self.groups)} + cols_order = {col_id: i for i, col_id in enumerate(self.column_ids)} self.column_ids.sort( - key=lambda col_id: (groups_order.get(self.column_group.get(col_id), col_id)) + key=lambda col_id: ( + groups_order.get(self.column_group.get(col_id), col_id), + cols_order[col_id], + ) ) def insert_group(self, group: str, after: str = None, before: str = None): @@ -165,23 +217,26 @@ class Table: """Set columns titles""" self.titles.update(titles) - def add_title(self, col_id, title: str = None) -> tuple["Cell", "Cell"]: + def add_title( + self, col_id, title: str = None, classes: list[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: + if col_id not 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 + self.head_title_row.cells[col_id] = self.head_title_row.add_cell( + col_id, None, title, classes=classes + ) + self.foot_title_row.cells[col_id] = self.foot_title_row.add_cell( + col_id, None, title, classes=classes + ) + + return self.head_title_row.cells.get(col_id), self.foot_title_row.cells[col_id] -class Row: +class Row(Element): """A row.""" def __init__( @@ -191,13 +246,15 @@ class Row: category=None, cell_elt: str = None, classes: list[str] = None, + attrs: dict[str, str] = None, + data: dict = None, ): + super().__init__("tr", classes=classes, attrs=attrs, data=data) 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 @@ -211,23 +268,17 @@ class Row: 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. + group: groupe de colonnes 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 + (classes or []) + [group or ""], # ajoute le nom de groupe aux classes elt=elt or self.cell_elt, attrs=attrs, data=data, @@ -243,28 +294,42 @@ class Row: """Add a cell to the row. Si title est None, il doit avoir été ajouté avec table.add_title(). """ + cell.data["group"] = column_group self.cells[col_id] = cell + if col_id not in self.table.column_ids: + self.table.column_ids.append(col_id) + self.table.insert_group(column_group) 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) + self.table.add_title(col_id, title, classes=cell.classes) return cell - def html(self) -> str: + def html(self, extra_classes: list[str] = None) -> 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 ]) - }""" + if (self.id is not None) and self.id == getattr(self.table, "selected_row_id"): + self.classes.append("row_selected") + return super().html(extra_classes=extra_classes) + + def html_content(self) -> str: + "Le contenu du row en html." + return "".join( + [ + self.cells.get(col_id, self.table.empty_cell).html( + extra_classes=self.table.column_classes.get(col_id) + ) + for col_id in self.table.column_ids + ] + ) + + def to_dict(self) -> dict: + """row as a dict, with only cell contents""" + return { + col_id: self.cells.get(col_id, self.table.empty_cell).raw_content + for col_id in self.table.column_ids + } class BottomRow(Row): @@ -289,7 +354,7 @@ class BottomRow(Row): self.add_cell(col_id, None, title) -class Cell: +class Cell(Element): """Une cellule de table""" def __init__( @@ -297,21 +362,21 @@ class Cell: content, classes: list[str] = None, elt="td", - attrs: list[str] = None, + attrs: dict[str, 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 [] + super().__init__( + elt if elt is not None else "td", content, classes, attrs, data + ) if self.elt == "th": self.attrs["scope"] = "row" + self.data = data or {} - self.raw_content = raw_content # not yet used + self.raw_content = raw_content or content self.target = target self.target_attrs = target_attrs or {} @@ -323,20 +388,14 @@ class Cell: 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()]) - + def html_content(self) -> str: + "content of the table cell, as html" + # entoure le contenu par un lien ? 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()] + [f'{k}="{v}"' for (k, v) in self.target_attrs.items()] ) - content = f"{content}" - return f"""<{self.elt} {attrs_str}>{self.content}""" + return f"{super().html_content()}" + + return super().html_content() diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 42c2ecac..ff76d3c4 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -4029,6 +4029,10 @@ table.table_recap .rang { text-align: right; } +table.table_recap .cursus { + white-space: nowrap; +} + table.table_recap .col_ue, table.table_recap .col_ue_code, table.table_recap .col_moy_gen, diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js index 7242ed39..3e59dc51 100644 --- a/app/static/js/table_recap.js +++ b/app/static/js/table_recap.js @@ -1,7 +1,7 @@ // Tableau recap notes $(function () { $(function () { - let hidden_colums = ["codes", "identite_detail", "partition_aux", "partition_rangs", "admission", "col_empty"]; + let hidden_colums = ["etud_codes", "identite_detail", "partition_aux", "partition_rangs", "admission", "col_empty"]; let mode_jury_but_bilan = $('table.table_recap').hasClass("table_jury_but_bilan"); if (mode_jury_but_bilan) { // table bilan décisions: cache les notes @@ -247,7 +247,7 @@ $(function () { }); // Pour montrer et surligner l'étudiant sélectionné: $(function () { - let row_selected = document.querySelector("#row_selected"); + let row_selected = document.querySelector(".row_selected"); if (row_selected) { /*row_selected.scrollIntoView(); window.scrollBy(0, -50);*/