From 55d7270be77b3bdd5cb09c5c65e980c29e644b23 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 4 Feb 2023 02:20:25 +0100 Subject: [PATCH] WIP: refactoring code gen. tables --- app/tables/__init__.py | 8 + app/tables/recap.py | 705 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 713 insertions(+) create mode 100644 app/tables/__init__.py create mode 100644 app/tables/recap.py diff --git a/app/tables/__init__.py b/app/tables/__init__.py new file mode 100644 index 000000000..59fca9d76 --- /dev/null +++ b/app/tables/__init__.py @@ -0,0 +1,8 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Génération de tableaux +""" diff --git a/app/tables/recap.py b/app/tables/recap.py new file mode 100644 index 000000000..21c46f62a --- /dev/null +++ b/app/tables/recap.py @@ -0,0 +1,705 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Table récapitulatif des résultats d'un semestre +""" + +from flask import g, url_for +import numpy as np + +from app.auth.models import User +from app.comp.res_common import ResultatsSemestre +from app.models import Identite +from app.models.ues import UniteEns +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF +from app.scodoc import sco_evaluation_db +from app.scodoc import sco_groups +from app.scodoc import sco_utils as scu +from app.tables import table_builder as tb + + +class TableRecap(tb.Table): + """Table récap. des résultats. + allow_html: si vrai, peut mettre du HTML dans les valeurs + + Result: Table, le contenu étant une ligne par étudiant. + + + Si convert_values, transforme les notes en chaines ("12.34"). + Les colonnes générées sont: + etudid + rang : rang indicatif (basé sur moy gen) + moy_gen : moy gen indicative + moy_ue_, ..., les moyennes d'UE + moy_res__, ... les moyennes de ressources dans l'UE + moy_sae__, ... les moyennes de SAE dans l'UE + + On ajoute aussi des classes: + - pour les lignes: + 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_ + """ + + def __init__( + self, + res: ResultatsSemestre, + convert_values=False, + include_evaluations=False, + mode_jury=False, + ): + super().__init__() + self.res = res + self.include_evaluations = include_evaluations + self.mode_jury = mode_jury + + parcours = res.formsemestre.formation.get_parcours() + self.barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE + self.barre_valid_ue = parcours.NOTES_BARRE_VALID_UE + self.barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING + self.cache_nomcomplet = {} # cache uid : nomcomplet + if convert_values: + self.fmt_note = scu.fmt_note + else: + self.fmt_note = lambda x: x + # couples (modimpl, ue) effectivement présents dans la table: + self.modimpl_ue_ids = set() + + etuds_inscriptions = res.formsemestre.etuds_inscriptions + ues = res.formsemestre.query_ues(with_sport=True) # avec bonus + ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] + + for etudid in etuds_inscriptions: + etud = Identite.query.get(etudid) + row = RowRecap(self, etud) + self.add_row(row) + row.add_etud_cols() + row.add_moyennes_cols(ues_sans_bonus) + + self.add_partitions() + self.add_cursus() + self.add_admissions() + + # tri par rang croissant + if not res.formsemestre.block_moyenne_generale: + self.sort_rows(key=lambda row: row.rang_order) + else: + self.sort_rows(key=lambda row: row.nb_ues_validables, reverse=True) + + # Lignes footer (min, max, ects, apo, ...) + self.add_bottom_rows(ues_sans_bonus) + + # Evaluations: + if include_evaluations: + self.add_evaluations() + + self.mark_empty_cols() + self.add_type_row() + + def mark_empty_cols(self): + """Ajoute style "col_empty" aux colonnes de modules vides""" + # identifie les col. vides par la classe sur leur moyenne + row_moy = self.get_row_by_id("moy") + for col_id in self.column_ids: + cell: tb.Cell = row_moy.cells.get(col_id) + if cell and "col_empty" in cell.classes: + self.column_classes[col_id].append("col_empty") + + def add_type_row(self): + """Ligne avec la classe de chaque colonne recap.""" + # récupère le type à partir des classes css (hack...) + row_type = tb.BottomRow( + self, + "type_col", + left_title="Type col.", + left_title_col_ids=["prenom", "nom_short"], + category="bottom_infos", + classes=["bottom_info"], + ) + for col_id in self.column_ids: + group_name = self.column_group.get(col_id, "") + if group_name.startswith("col_"): + group_name = group_name[4:] + row_type.add_cell(col_id, None, group_name) + + def add_bottom_rows(self, ues): + """Les informations à mettre en bas de la table recap: + min, max, moy, ECTS, Apo.""" + res = self.res + # Ordre des lignes: Min, Max, Moy, Coef, ECTS, Apo + row_min = tb.BottomRow( + self, + "min", + left_title="Min.", + left_title_col_ids=["prenom", "nom_short"], + category="bottom_infos", + classes=["bottom_info"], + ) + row_max = tb.BottomRow( + self, + "max", + left_title="Max.", + left_title_col_ids=["prenom", "nom_short"], + category="bottom_infos", + classes=["bottom_info"], + ) + row_moy = tb.BottomRow( + self, + "moy", + left_title="Moy.", + left_title_col_ids=["prenom", "nom_short"], + category="bottom_infos", + classes=["bottom_info"], + ) + row_coef = tb.BottomRow( + self, + "coef", + left_title="Coef.", + left_title_col_ids=["prenom", "nom_short"], + category="bottom_infos", + classes=["bottom_info"], + ) + row_ects = tb.BottomRow( + self, + "ects", + left_title="ECTS", + left_title_col_ids=["prenom", "nom_short"], + category="bottom_infos", + classes=["bottom_info"], + ) + row_apo = tb.BottomRow( + self, + "apo", + left_title="Code Apogée", + left_title_col_ids=["prenom", "nom_short"], + category="bottom_infos", + classes=["bottom_info"], + ) + + # --- ECTS + # titre (à gauche) sur 2 colonnes pour s'adapter à l'affichage des noms/prenoms + for ue in ues: + col_id = f"moy_ue_{ue.id}" + row_ects.add_cell(col_id, None, ue.ects) + # 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, "") + row_ects.add_cell( + "moy_gen", + None, + sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]), + ) + # --- MIN, MAX, MOY, APO + row_min.add_cell("moy_gen", None, self.fmt_note(res.etud_moy_gen.min())) + row_max.add_cell("moy_gen", None, self.fmt_note(res.etud_moy_gen.max())) + row_moy.add_cell("moy_gen", None, self.fmt_note(res.etud_moy_gen.mean())) + + for ue in ues: + col_id = f"moy_ue_{ue.id}" + row_min.add_cell(col_id, None, self.fmt_note(res.etud_moy_ue[ue.id].min())) + row_max.add_cell(col_id, None, self.fmt_note(res.etud_moy_ue[ue.id].max())) + row_moy.add_cell(col_id, None, self.fmt_note(res.etud_moy_ue[ue.id].mean())) + row_apo.add_cell(col_id, None, ue.code_apogee or "") + + for modimpl in res.formsemestre.modimpls_sorted: + if (modimpl.id, ue.id) in self.modimpl_ue_ids: + col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + if res.is_apc: + coef = res.modimpl_coefs_df[modimpl.id][ue.id] + else: + coef = modimpl.module.coefficient or 0 + row_coef.add_cell( + col_id, + None, + self.fmt_note(coef), + group=f"col_ue_{ue.id}_modules", + ) + notes = res.modimpl_notes(modimpl.id, ue.id) + if np.isnan(notes).all(): + # aucune note valide + row_min.add_cell(col_id, None, np.nan) + row_max.add_cell(col_id, None, np.nan) + moy = np.nan + else: + row_min.add_cell(col_id, None, self.fmt_note(np.nanmin(notes))) + row_max.add_cell(col_id, None, self.fmt_note(np.nanmax(notes))) + moy = np.nanmean(notes) + row_moy.add_cell( + col_id, + None, + self.fmt_note(moy), + # aucune note dans ce module ? + classes=["col_empty" if np.isnan(moy) else ""], + ) + row_apo.add_cell(col_id, None, modimpl.module.code_apogee or "") + + def add_partitions(self): + """Ajoute les colonnes indiquant les groupes + La table contient des rows avec la clé etudid. + + Les colonnes ont la classe css "partition" + """ + self.insert_group("partition", after="identite_court") + partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups( + self.res.formsemestre.id + ) + first_partition = True + for partition in partitions: + cid = f"part_{partition['partition_id']}" + + partition_etud_groups = partitions_etud_groups[partition["partition_id"]] + for row in self.rows: + etudid = row.id + group = None # group (dict) de l'étudiant dans cette partition + # dans NotesTableCompat, à revoir + etud_etat = self.res.get_etud_etat(row.id) # row.id == etudid + tr_classes = [] + if etud_etat == scu.DEMISSION: + gr_name = "Dém." + tr_classes.append("dem") + elif etud_etat == DEF: + gr_name = "Déf." + tr_classes.append("def") + else: + group = partition_etud_groups.get(etudid) + gr_name = group["group_name"] if group else "" + if gr_name: + row.add_cell( + cid, + partition["partition_name"], + gr_name, + group="partition", + classes=[] if first_partition else ["partition_aux"], + # la classe "partition" est ajoutée par la Table car c'est le group + # la classe "partition_aux" est ajoutée à partir de la 2eme partition affichée + ) + first_partition = False + + # Rangs dans groupe + if ( + partition["bul_show_rank"] + and (group is not None) + and (group["id"] in self.res.moy_gen_rangs_by_group) + ): + rang = self.res.moy_gen_rangs_by_group[group["id"]][0] + rg_cid = cid + "_rg" # rang dans la partition + row.add_cell( + rg_cid, + f"Rg {partition['partition_name']}", + rang.get(etudid, ""), + group="partition", + classes=["partition_aux", "partition_rangs"], + ) + + def add_evaluations(self): + """Ajoute les colonnes avec les notes aux évaluations + pour tous les étudiants de la table. + Les colonnes ont la classe css "evaluation" + """ + # nouvelle ligne pour description évaluations: + row_descr_eval = tb.BottomRow( + self, + "evaluations", + left_title="Description évaluations", + left_title_col_ids=["prenom", "nom_short"], + category="bottom_infos", + classes=["bottom_info"], + ) + + first_eval = True + for modimpl in self.res.formsemestre.modimpls_sorted: + evals = self.res.modimpls_results[modimpl.id].get_evaluations_completes( + modimpl + ) + eval_index = len(evals) - 1 + inscrits = {i.etudid for i in modimpl.inscriptions} + first_eval_of_mod = True + for e in evals: + col_id = f"eval_{e.id}" + title = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}' + col_classes = ["evaluation"] + if first_eval: + col_classes.append("first") + elif first_eval_of_mod: + col_classes.append("first_of_mod") + first_eval_of_mod = first_eval = False + eval_index -= 1 + notes_db = sco_evaluation_db.do_evaluation_get_all_notes( + e.evaluation_id + ) + for row in self.rows: + etudid = row.id + if etudid in inscrits: + if etudid in notes_db: + val = notes_db[etudid]["value"] + else: + # Note manquante mais prise en compte immédiate: affiche ATT + val = scu.NOTES_ATTENTE + content = self.fmt_note(val) + classes = col_classes + [ + { + "ABS": "abs", + "ATT": "att", + "EXC": "exc", + }.get(content, "") + ] + row.add_cell(col_id, title, content, "", classes=classes) + else: + row.add_cell( + col_id, + title, + "ni", + "", + classes=col_classes + ["non_inscrit"], + ) + + self.get_row_by_id("coef").row[col_id] = e.coefficient + self.get_row_by_id("min").row[col_id] = "0" + self.get_row_by_id("max").row[col_id] = self.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, + ), + ) + + def add_admissions(self): + """Ajoute les colonnes "admission" pour tous les étduiants de la table + Les colonnes ont la classe css "admission" + """ + fields = { + "bac": "Bac", + "specialite": "Spécialité", + "type_admission": "Type Adm.", + "classement": "Rg. Adm.", + } + first = True + for cid, title in fields.items(): + cell_head, cell_foot = self.add_title(cid, title) + cell_head.classes.append("admission") + cell_foot.classes.append("admission") + if first: + cell_head.classes.append("admission_first") + cell_foot.classes.append("admission_first") + first = False + + for row in self.rows: + etud = row.etud + admission = etud.admission.first() + first = True + for cid, title in fields.items(): + cell = row.add_cell( + cid, + title, + getattr(admission, cid) or "", + "admission", + ) + if first: + cell.classes.append("admission_first") + first = False + + def add_cursus(self): + """Ajoute colonne avec code cursus, eg 'S1 S2 S1' + pour tous les étduiants de la table""" + self.insert_group("cursus", before="col_ues_validables") + cid = "code_cursus" + formation_code = self.res.formsemestre.formation.formation_code + for row in self.rows: + row.add_cell( + cid, + "Cursus", + " ".join( + [ + f"S{ins.formsemestre.semestre_id}" + for ins in reversed(row.etud.inscriptions()) + if ins.formsemestre.formation.formation_code == formation_code + ] + ), + "cursus", + ) + + +class RowRecap(tb.Row): + "Ligne de la table recap, pour un étudiant" + + def __init__(self, table: TableRecap, etud: Identite, *args, **kwargs): + super().__init__(table, etud.id, *args, **kwargs) + self.etud = etud + self.rang_order = None + "valeur pour tri rangs" + self.nb_ues_validables = None + self.nb_ues_warning = None + self.nb_ues_etud_parcours = None + + def add_etud_cols(self): + """Ajoute colonnes étudiant: codes, noms""" + res = self.table.res + etud = self.etud + # --- Codes (seront cachés, mais exportés en excel) + self.add_cell("etudid", "etudid", etud.id, "etud_codes") + self.add_cell( + "code_nip", + "code_nip", + etud.code_nip or "", + "etud_codes", + ) + + # --- Rang + if not res.formsemestre.block_moyenne_generale: + self.rang_order = res.etud_moy_gen_ranks_int[etud.id] + self.add_cell( + "rang", + "Rg", + res.etud_moy_gen_ranks[etud.id], + "rang", + data={"order": f"{self.rang_order:05d}"}, + ) + else: + self.rang_order = -1 + # --- Identité étudiant + url_bulletin = url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=res.formsemestre.id, + etudid=etud.id, + ) + self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail") + self.add_cell( + "nom_disp", + "Nom", + etud.nom_disp(), + "identite_detail", + data={"order": etud.sort_key}, + target=url_bulletin, + target_attrs={"class": "etudinfo", "id": str(etud.id)}, + ) + self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail") + self.add_cell( + "nom_short", + "Nom", + etud.nom_short, + "identite_court", + data={ + "order": etud.sort_key, + "etudid": etud.id, + "nomprenom": etud.nomprenom, + }, + target=url_bulletin, + target_attrs={"class": "etudinfo", "id": str(etud.id)}, + ) + + def add_moyennes_cols( + self, + ues_sans_bonus: list[UniteEns], + ): + """Ajoute cols moy_gen moy_ue et tous les modules...""" + etud = self.etud + table = self.table + res = table.res + # --- Moyenne générale + if not res.formsemestre.block_moyenne_generale: + moy_gen = res.etud_moy_gen.get(etud.id, False) + note_class = "" + if moy_gen is False: + moy_gen = scu.NO_NOTE_STR + elif isinstance(moy_gen, float) and moy_gen < table.barre_moy: + note_class = "moy_ue_warning" # en rouge + self.add_cell( + "moy_gen", + "Moy", + table.fmt_note(moy_gen), + "col_moy_gen", + classes=[note_class], + ) + # Ajoute bulle sur titre du pied de table: + if res.is_apc: + table.foot_title_row.cells["moy_gen"].target_attrs[ + "title" + ] = "moyenne indicative" + + # --- Moyenne d'UE + self.nb_ues_validables, self.nb_ues_warning = 0, 0 + for ue in ues_sans_bonus: + ue_status = res.get_etud_ue_status(etud.id, ue.id) + if ue_status is not None: + self.add_ue_cols(ue, ue_status) + if table.mode_jury: + # pas d'autre colonnes de résultats + continue + + # Bonus (sport) dans cette UE ? + # Le bonus sport appliqué sur cette UE + if (res.bonus_ues is not None) and (ue.id in res.bonus_ues): + val = res.bonus_ues[ue.id][etud.id] or "" + val_fmt = val_fmt_html = table.fmt_note(val) + if val: + val_fmt_html = f"""{ + val_fmt + }""" + self.add_cell( + f"bonus_ue_{ue.id}", + f"Bonus {ue.acronyme}", + val_fmt_html, + raw_content=val_fmt, + group=f"col_ue_{ue.id}", + classes=["col_ue_bonus"], + ) + # Les moyennes des modules (ou ressources et SAÉs) dans cette UE + self.add_ue_modimpls_cols(ue, ue_status["is_capitalized"]) + + self.nb_ues_etud_parcours = len(res.etud_ues_ids(etud.id)) + ue_valid_txt = ( + ue_valid_txt_html + ) = f"{self.nb_ues_validables}/{self.nb_ues_etud_parcours}" + if self.nb_ues_warning: + ue_valid_txt_html += " " + scu.EMO_WARNING + # place juste avant moy. gen. + table.insert_group("col_ues_validables", before="col_moy_gen") + classes = ["col_ue"] + if self.nb_ues_warning: + classes.append("moy_ue_warning") + elif self.nb_ues_validables < len(ues_sans_bonus): + classes.append("moy_inf") + self.add_cell( + "ues_validables", + "UEs", + ue_valid_txt_html, + group="col_ues_validables", + classes=classes, + raw_content=ue_valid_txt, + data={"order": self.nb_ues_validables}, # tri + ) + + if table.mode_jury and res.validations: + if res.is_apc: + # formations BUT: pas de code semestre, concatene ceux des UEs + dec_ues = res.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 = res.validations.decisions_jury.get(etud.id) + jury_code_sem = dec_sem["code"] if dec_sem else "" + self.add_cell("jury_code_sem", "Jury", jury_code_sem, "jury_code_sem") + self.add_cell( + "jury_link", + "", + f"""{("saisir" if not jury_code_sem else "modifier") + if res.formsemestre.etat else "voir"} décisions""", + "col_jury_link", + ) + + def add_ue_cols(self, ue: UniteEns, ue_status: dict): + "Ajoute résultat UE au row (colonne col_ue)" + table = self.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" + self.nb_ues_validables += 1 + if val < table.barre_warning_ue: + note_class = "moy_ue_warning" # notes très basses + self.nb_ues_warning += 1 + self.add_cell( + col_id, + ue.acronyme, + table.fmt_note(val), + group=f"col_ue_{ue.id}", + classes=["col_ue", "col_moy_ue", note_class], + ) + table.foot_title_row.cells[col_id].target_attrs[ + "title" + ] = f"""{ue.titre} S{ue.semestre_idx or '?'}""" + + def add_ue_modimpls_cols(self, ue: UniteEns, is_capitalized: bool): + """Ajoute à row les moyennes des modules (ou ressources et SAÉs) dans l'UE""" + # pylint: disable=invalid-unary-operand-type + etud = self.etud + table = self.table + res = table.res + for modimpl in res.modimpls_in_ue(ue, etud.id, with_bonus=False): + if is_capitalized: + val = "-c-" + else: + modimpl_results = res.modimpls_results.get(modimpl.id) + if modimpl_results: # pas bonus + if res.is_apc: # BUT + moys_vers_ue = modimpl_results.etuds_moy_module.get(ue.id) + val = ( + moys_vers_ue.get(etud.id, "?") + if moys_vers_ue is not None + else "" + ) + else: # classique: Series indépendantes de l'UE + val = modimpl_results.etuds_moy_module.get(etud.id, "?") + else: + val = "" + + col_id = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + + val_fmt_html = val_fmt = table.fmt_note(val) + if modimpl.module.module_type == scu.ModuleType.MALUS: + if val and not isinstance(val, str) and not np.isnan(val): + if val >= 0: + val_fmt_html = f"""+{ + val_fmt + }""" + else: + val_fmt_html = f"""-{ + table.fmt_note(-val)}""" + else: + val_fmt = val_fmt_html = "" # inscrit à ce malus, mais sans note + + cell = self.add_cell( + col_id, + modimpl.module.code, + val_fmt_html, + raw_content=val_fmt, + group=f"col_ue_{ue.id}_modules", + 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))