############################################################################## # 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, row_class=None, finalize=True, **kwargs, ): self.rows: list["RowRecap"] = [] # juste pour que VSCode nous aide sur .rows super().__init__(row_class=row_class or RowRecap, **kwargs) 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() ues = res.formsemestre.query_ues(with_sport=True) # avec bonus ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] if res.formsemestre.etuds_inscriptions: # table non vide # Fixe l'ordre des groupes de colonnes communs: groups = [ "etud_codes", "rang", "identite_court", "identite_detail", "partition", "cursus", "col_ues_validables", ] if not res.formsemestre.block_moyenne_generale: groups.append("col_moy_gen") self.set_groups(groups) for etudid in res.formsemestre.etuds_inscriptions: etud = Identite.query.get(etudid) row = self.row_class(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() if finalize: self.finalize() def finalize(self): """Termine la table: ajoute ligne avec les types, et ajoute classe sur les colonnes vides""" self.mark_empty_cols() self.add_type_row() def mark_empty_cols(self): """Ajoute classe "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].add("col_empty") def add_type_row(self): """Ligne avec la classe de chaque colonne recap.""" # récupère le type à partir du groupe (enlève le préfixe "col_" si présent) row_type = tb.BottomRow( self, "type_col", left_title="Type col.", left_title_col_ids=["prenom", "nom_short"], category="bottom_infos", classes=["bottom_info", "type_col"], ) 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", "apo"], ) # --- 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) # la colonne où placer les valeurs agrégats col_id = "moy_gen" if "moy_gen" in self.column_ids else "cursus" col_group = "col_moy_gen" if "moy_gen" in self.column_ids else "cursus" row_ects.add_cell( col_id, None, sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]), group=col_group, ) # --- MIN, MAX, MOY, APO row_min.add_cell( col_id, None, self.fmt_note(res.etud_moy_gen.min()), group=col_group, ) row_max.add_cell( col_id, None, self.fmt_note(res.etud_moy_gen.max()), group=col_group, ) row_moy.add_cell( col_id, None, self.fmt_note(res.etud_moy_gen.mean()), group=col_group, ) 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()), classes=["col_ue", "col_moy_ue"], ) row_max.add_cell( col_id, None, self.fmt_note(res.etud_moy_ue[ue.id].max()), classes=["col_ue", "col_moy_ue"], ) row_moy.add_cell( col_id, None, self.fmt_note(res.etud_moy_ue[ue.id].mean()), classes=["col_ue", "col_moy_ue"], ) row_apo.add_cell( col_id, None, ue.code_apogee or "", classes=["col_ue", "col_moy_ue"], ) 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 pour tous les étudiants de la table. La table contient des rows avec la clé etudid. Les colonnes ont la classe css "partition". """ self.group_titles["partition"] = "Partitions" 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 if etud_etat == scu.DEMISSION: gr_name = "Dém." row.add_class("dem") elif etud_etat == DEF: gr_name = "Déf." row.add_class("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"], ) row_coef = self.get_row_by_id("coef") row_coef.add_cell( col_id, None, self.fmt_note(e.coefficient), group="eval", ) row_min = self.get_row_by_id("min") row_min.add_cell( col_id, None, 0, group="eval", ) row_max = self.get_row_by_id("max") row_max.add_cell( col_id, None, self.fmt_note(e.note_max), group="eval", ) 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, ), target_attrs={"class": "stdlink"}, ) 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""" cid = "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 self.table.group_titles.update( { "etud_codes": "Codes", "identite_detail": "", "identite_court": "", } ) # --- 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), group="col_moy_gen", classes=[note_class], ) # Ajoute bulle sur titre du pied de table: cell = table.foot_title_row.cells.get("moy_gen") if cell: table.foot_title_row.cells["moy_gen"].target_attrs["title"] = ( "Moyenne générale indicative" if res.is_apc else "Moyenne générale du semestre" ) # --- 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}", column_classes={"col_ue_bonus", "col_res"}, ) # 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 cell_class = "" if self.nb_ues_warning: cell_class = "moy_ue_warning" elif self.nb_ues_validables < len(ues_sans_bonus): cell_class = "moy_inf" self.add_cell( "ues_validables", "UEs", ue_valid_txt_html, group="col_ues_validables", classes=[cell_class], column_classes={"col_ue"}, raw_content=ue_valid_txt, data={"order": self.nb_ues_validables}, # tri ) def add_ue_cols(self, ue: UniteEns, ue_status: dict, col_group: str = None): "Ajoute résultat UE au row (colonne col_ue)" # sous-classé par JuryRow pour ajouter les codes table = self.table table.group_titles["col_ue"] = "UEs du semestre" 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=col_group or f"col_ue_{ue.id}", classes=[note_class], column_classes={f"col_ue_{ue.id}", "col_moy_ue", "col_ue"}, ) 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", column_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))