############################################################################## # 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].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) 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()), # classes=["col_moy_gen"], ) row_max.add_cell( "moy_gen", None, self.fmt_note(res.etud_moy_gen.max()), classes=["col_moy_gen"], ) row_moy.add_cell( "moy_gen", None, self.fmt_note(res.etud_moy_gen.mean()), # classes=["col_moy_gen"], ) 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 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 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"], ) 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), group="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}", column_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") 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 ) 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=[note_class], column_classes={"col_ue", "col_moy_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))