############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """Table récapitulatif des résultats d'un semestre """ from functools import partial from flask import g, url_for import numpy as np from app import db from app.auth.models import User from app.comp.res_common import ResultatsSemestre from app.models import Identite, Evaluation, FormSemestre, UniteEns from app.scodoc.codes_cursus 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: - 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, read_only: bool = 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 self.read_only = read_only # utilisé seulement dans sous-classes cursus = res.formsemestre.formation.get_cursus() self.barre_moy = cursus.BARRE_MOY - scu.NOTES_TOLERANCE self.barre_valid_ue = cursus.NOTES_BARRE_VALID_UE self.barre_warning_ue = cursus.BARRE_UE_DISPLAY_WARNING self.cache_nomcomplet = {} # cache uid : nomcomplet if convert_values: self.fmt_note = scu.fmt_note else: self.fmt_note = partial(scu.fmt_note, keep_numeric=True) # couples (modimpl, ue) effectivement présents dans la table: self.modimpl_ue_ids = set() ues = res.formsemestre.get_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") groups.append("abs") self.set_groups(groups) for etudid in res.formsemestre.etuds_inscriptions: etud = Identite.get_etud(etudid) row = self.row_class(self, etud) self.add_row(row) row.add_etud_cols() row.add_moyennes_cols(ues_sans_bonus) row.add_abs() 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 or 10000000) else: self.sort_rows(key=lambda row: row.nb_ues_validables or 0, 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 "code_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, "") row_max.add_cell(col_id, None, "") moy = "" 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 (moy == "" or 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" """ self.group_titles["eval"] = "Évaluations" # 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.date_debut.strftime(scu.DATE_FMT) if e.date_debut else "" }""" col_classes = [] 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 if e.evaluation_type != Evaluation.EVALUATION_BONUS else "" ) content = self.fmt_note(val, note_max=e.note_max) if e.evaluation_type != Evaluation.EVALUATION_BONUS: classes = col_classes + [ { "ABS": "abs", "ATT": "att", "EXC": "exc", }.get(content, "") ] else: classes = col_classes + ["col_eval_bonus"] row.add_cell( col_id, title, content, group="eval", classes=classes ) else: row.add_cell( col_id, title, "ni", group="eval", 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, 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 étudiants 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 if admission: 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 étudiants de la table""" 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 ] ), group="cursus", ) def html(self, extra_classes: list[str] = None) -> str: """HTML: pour les tables recap, un div au contenu variable""" return f"""
{ '
aucun étudiant !
' if self.is_empty() else super().html( extra_classes=[ "table_recap", "apc" if self.res.formsemestre.formation.is_apc() else "classic", "jury" if self.mode_jury else "", "with_evaluations" if self.include_evaluations else "", ]) }
""" 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": "", "rang": "", } ) # --- 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, }, no_excel=True, target=url_bulletin, target_attrs={"class": "etudinfo", "id": str(etud.id)}, ) def add_abs(self): "Ajoute les colonnes absences" # Absences (nb d'abs non just. dans ce semestre) _, nbabsjust, nbabs = self.table.res.formsemestre.get_abs_count(self.etud.id) self.add_cell("nbabs", "Abs", f"{nbabs:1.0f}", "abs", raw_content=nbabs) self.add_cell( "nbabsjust", "Just.", f"{nbabsjust:1.0f}", "abs", raw_content=nbabsjust, ) def add_moyennes_cols( self, ues_sans_bonus: list[UniteEns], ): """Ajoute cols moy_gen moy_ue et tous les modules...""" etud = self.etud table: TableRecap = self.table res = table.res # --- Si DEM ou DEF, ne montre aucun résultat d'UE ni moy. gen. if res.get_etud_etat(etud.id) != scu.INSCRIT: return # --- 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: cell.target_attrs["title"] = ( "Moyenne générale indicative" if res.is_apc else "Moyenne générale du semestre" ) self.add_ues_cols(ues_sans_bonus) def add_ues_cols(self, ues_sans_bonus: list[UniteEns]): """Ajout des colonnes concernant les UEs - pour chaque UE : - sa moyenne (et son code en mode jury) - les moyennes de modules dans l'UE - la colonne UEs avec le nombre d'UEs validables """ etudid = self.etud.id table: TableRecap = self.table res = table.res self.nb_ues_validables, self.nb_ues_warning = 0, 0 for ue in ues_sans_bonus: ue_status = res.get_etud_ue_status(etudid, 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][etudid] 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_parcours_ues_ids(etudid)) 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 < self.nb_ues_etud_parcours: # 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 d'une UE au row (colonne col_ue)" # sous-classé par JuryRow pour ajouter les codes table: TableRecap = self.table formsemestre: FormSemestre = table.res.formsemestre table.group_titles["col_ue"] = ( f"UEs du S{formsemestre.semestre_id} {formsemestre.annee_scolaire()}" ) col_id = f"moy_ue_{ue.id}" val = ( ue_status["moy"] if (self.etud.id, ue.id) not in table.res.dispense_ues else "=" ) cell_classes = [] # Cette UE est-elle dans le parcours de l'étudiant (dans ce semestre) ? ue_hors_parcours = ue.id not in self.table.res.etud_parcours_ues_ids( self.etud.id ) target_attrs = {} # pour décorer la cellule (bulle) if isinstance(val, float): if val < table.barre_moy: cell_classes = ["moy_inf"] elif val >= table.barre_valid_ue: cell_classes = ["moy_ue_valid"] if not ue_hors_parcours: self.nb_ues_validables += 1 if val < table.barre_warning_ue: cell_classes = ["moy_ue_warning"] # notes très basses self.nb_ues_warning += 1 if ue_status["is_capitalized"]: cell_classes.append("ue_capitalized") if ue_hors_parcours: cell_classes.append("ue_hors_parcours") target_attrs["title"] = "UE hors du parcours actuel de l'étudiant" self.add_cell( col_id, ue.acronyme, table.fmt_note(val), group=col_group or f"col_ue_{ue.id}", classes=cell_classes, column_classes={f"col_ue_{ue.id}", "col_moy_ue", "col_ue"}, target_attrs=target_attrs, ) table.foot_title_row.cells[col_id].target_attrs[ "title" ] = f"""{ue.titre or ue.acronyme} 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 = db.session.get(User, 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))