############################################################################## # ScoDoc # Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """Résultats semestre: méthodes communes aux formations classiques et APC """ from collections import Counter from collections.abc import Generator from functools import cached_property import numpy as np import pandas as pd from flask import g, url_for from app.auth.models import User from app.comp import res_sem from app.comp.res_cache import ResultatsCache from app.comp.jury import ValidationsSemestre from app.comp.moy_mod import ModuleImplResults from app.models import FormSemestre, FormSemestreUECoef from app.models import Identite from app.models import ModuleImpl, ModuleImplInscription from app.models.ues import UniteEns from app.scodoc.sco_cache import ResultatsSemestreCache from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM from app.scodoc import sco_evaluation_db from app.scodoc.sco_exceptions import ScoValueError from app.scodoc import sco_groups from app.scodoc import sco_utils as scu # Il faut bien distinguer # - ce qui est caché de façon persistente (via redis): # ce sont les attributs listés dans `_cached_attrs` # le stockage et l'invalidation sont gérés dans sco_cache.py # # - les valeurs cachées durant le temps d'une requête # (durée de vie de l'instance de ResultatsSemestre) # qui sont notamment les attributs décorés par `@cached_property`` # class ResultatsSemestre(ResultatsCache): """Les résultats (notes, ...) d'un formsemestre Classe commune à toutes les formations (classiques, BUT) """ _cached_attrs = ( "bonus", "bonus_ues", "dispense_ues", "etud_coef_ue_df", "etud_moy_gen_ranks", "etud_moy_gen", "etud_moy_ue", "modimpl_inscr_df", "modimpls_results", "moyennes_matieres", ) def __init__(self, formsemestre: FormSemestre): super().__init__(formsemestre, ResultatsSemestreCache) # BUT ou standard ? (apc == "approche par compétences") self.is_apc = formsemestre.formation.is_apc() # Attributs "virtuels", définis dans les sous-classes self.bonus: pd.Series = None # virtuel "Bonus sur moy. gen. Series de float, index etudid" self.bonus_ues: pd.DataFrame = None # virtuel "DataFrame de float, index etudid, columns: ue.id" self.dispense_ues: set[tuple[int, int]] = set() """set des dispenses d'UE: (etudid, ue_id), en APC seulement.""" # ResultatsSemestreBUT ou ResultatsSemestreClassic self.etud_moy_ue = {} "etud_moy_ue: DataFrame columns UE, rows etudid" self.etud_moy_gen: pd.Series = None self.etud_moy_gen_ranks = {} self.etud_moy_gen_ranks_int = {} self.moy_gen_rangs_by_group = None # virtual self.modimpl_inscr_df: pd.DataFrame = None "Inscriptions: row etudid, col modimlpl_id" self.modimpls_results: ModuleImplResults = None "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }" self.etud_coef_ue_df = None """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)""" self.modimpl_coefs_df: pd.DataFrame = None """Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef.""" self.validations = None self.moyennes_matieres = {} """Moyennes de matières, si calculées. { matiere_id : Series, index etudid }""" def __repr__(self): return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>" def compute(self): "Charge les notes et inscriptions et calcule toutes les moyennes" # voir ce qui est chargé / calculé ici et dans les sous-classes raise NotImplementedError() def get_inscriptions_counts(self) -> Counter: """Nombre d'inscrits, défaillants, démissionnaires. Exemple: res.get_inscriptions_counts()[scu.INSCRIT] Result: a collections.Counter instance """ return Counter(ins.etat for ins in self.formsemestre.inscriptions) @cached_property def etuds(self) -> list[Identite]: "Liste des inscrits au semestre, avec les démissionnaires et les défaillants" # nb: si la liste des inscrits change, ResultatsSemestre devient invalide return self.formsemestre.get_inscrits(include_demdef=True) @cached_property def etud_index(self) -> dict[int, int]: "dict { etudid : indice dans les inscrits }" return {e.id: idx for idx, e in enumerate(self.etuds)} def etud_ues_ids(self, etudid: int) -> list[int]: """Liste des UE auxquelles l'etudiant est inscrit, sans bonus (surchargée en BUT pour prendre en compte les parcours) """ # Pour les formations classiques, etudid n'est pas utilisé # car tous les étudiants sont inscrits à toutes les UE return [ue.id for ue in self.ues if ue.type != UE_SPORT] def etud_ues(self, etudid: int) -> Generator[UniteEns]: """Liste des UE auxquelles l'étudiant est inscrit.""" return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid)) def etud_ects_tot_sem(self, etudid: int) -> float: """Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)""" etud_ues = self.etud_ues(etudid) return sum([ue.ects or 0 for ue in etud_ues]) if etud_ues else 0.0 def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray: """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue. Utile pour stats bottom tableau recap. Résultat: 1d array of float """ # différent en BUT et classique: virtuelle raise NotImplementedError @cached_property def etuds_dict(self) -> dict[int, Identite]: """dict { etudid : Identite } inscrits au semestre, avec les démissionnaires et defs.""" return {etud.id: etud for etud in self.etuds} @cached_property def ues(self) -> list[UniteEns]: """Liste des UEs du semestre (avec les UE bonus sport) (indices des DataFrames). Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs. """ return self.formsemestre.query_ues(with_sport=True).all() @cached_property def ressources(self): "Liste des ressources du semestre, triées par numéro de module" return [ m for m in self.formsemestre.modimpls_sorted if m.module.module_type == scu.ModuleType.RESSOURCE ] @cached_property def saes(self): "Liste des SAÉs du semestre, triées par numéro de module" return [ m for m in self.formsemestre.modimpls_sorted if m.module.module_type == scu.ModuleType.SAE ] # --- JURY... def load_validations(self) -> ValidationsSemestre: """Load validations, set attribute and return value""" if not self.validations: self.validations = res_sem.load_formsemestre_validations(self.formsemestre) return self.validations def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]: """Liste des UEs du semestre qui doivent être validées Rappel: l'étudiant est inscrit à des modimpls et non à des UEs. - En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules du parcours. - En classique: toutes les UEs des modimpls auxquels l'étudiant est inscrit sont susceptibles d'être validées. Les UE "bonus" (sport) ne sont jamais "validables". """ if self.is_apc: return list(self.etud_ues(etudid)) # Formations classiques: # restreint aux UE auxquelles l'étudiant est inscrit (dans l'un des modimpls) ues = { modimpl.module.ue for modimpl in self.formsemestre.modimpls_sorted if self.modimpl_inscr_df[modimpl.id][etudid] } ues = sorted(list(ues), key=lambda x: x.numero or 0) return ues def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]: """Liste des modimpl de cette UE auxquels l'étudiant est inscrit. Utile en formations classiques, surchargée pour le BUT. Inclus modules bonus le cas échéant. """ # Utilisée pour l'affichage ou non de l'UE sur le bulletin # Méthode surchargée en BUT modimpls = [ modimpl for modimpl in self.formsemestre.modimpls_sorted if modimpl.module.ue.id == ue.id and self.modimpl_inscr_df[modimpl.id][etudid] ] if not with_bonus: return [ modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT ] return modimpls @cached_property def ue_au_dessus(self, seuil=10.0) -> pd.DataFrame: """DataFrame columns UE, rows etudid, valeurs: bool Par exemple, pour avoir le nombre d'UE au dessus de 10 pour l'étudiant etudid nb_ues_ok = sum(res.ue_au_dessus().loc[etudid]) """ return self.etud_moy_ue > (seuil - scu.NOTES_TOLERANCE) def apply_capitalisation(self): """Recalcule la moyenne générale pour prendre en compte d'éventuelles UE capitalisées. """ # Supposant qu'il y a peu d'UE capitalisées, # on recalcule les moyennes gen des etuds ayant des UE capitalisée. self.load_validations() ue_capitalisees = self.validations.ue_capitalisees for etudid in ue_capitalisees.index: recompute_mg = False # ue_codes = set(ue_capitalisees.loc[etudid]["ue_code"]) # for ue_code in ue_codes: # ue = ue_by_code.get(ue_code) # if ue is None: # ue = self.formsemestre.query_ues.filter_by(ue_code=ue_code) # ue_by_code[ue_code] = ue # Quand il y a une capitalisation, vérifie toutes les UEs sum_notes_ue = 0.0 sum_coefs_ue = 0.0 for ue in self.formsemestre.query_ues(): ue_cap = self.get_etud_ue_status(etudid, ue.id) if ue_cap is None: continue if ue_cap["is_capitalized"]: recompute_mg = True coef = ue_cap["coef_ue"] if not np.isnan(ue_cap["moy"]) and coef: sum_notes_ue += ue_cap["moy"] * coef sum_coefs_ue += coef if recompute_mg and sum_coefs_ue > 0.0: # On doit prendre en compte une ou plusieurs UE capitalisées # et donc recalculer la moyenne générale self.etud_moy_gen[etudid] = sum_notes_ue / sum_coefs_ue # Ajoute le bonus sport if self.bonus is not None and self.bonus[etudid]: self.etud_moy_gen[etudid] += self.bonus[etudid] self.etud_moy_gen[etudid] = max( 0.0, min(self.etud_moy_gen[etudid], 20.0) ) def get_etud_etat(self, etudid: int) -> str: "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)" ins = self.formsemestre.etuds_inscriptions.get(etudid, None) if ins is None: return "" return ins.etat def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict: """Donne les informations sur la capitalisation de l'UE ue pour cet étudiant. Résultat: Si pas capitalisée: None Si capitalisée: un dict, avec les colonnes de validation. """ capitalisations = self.validations.ue_capitalisees.loc[etudid] if isinstance(capitalisations, pd.DataFrame): ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code] if ue_cap.empty: return None if isinstance(ue_cap, pd.DataFrame): # si plusieurs fois capitalisée, prend le max cap_idx = ue_cap["moy_ue"].values.argmax() ue_cap = ue_cap.iloc[cap_idx] else: if capitalisations["ue_code"] == ue.ue_code: ue_cap = capitalisations else: return None # converti la Series en dict, afin que les np.int64 reviennent en int # et remplace les NaN (venant des NULL en base) par des None ue_cap_dict = ue_cap.to_dict() if ue_cap_dict["formsemestre_id"] is not None and np.isnan( ue_cap_dict["formsemestre_id"] ): ue_cap_dict["formsemestre_id"] = None if ue_cap_dict["compense_formsemestre_id"] is not None and np.isnan( ue_cap_dict["compense_formsemestre_id"] ): ue_cap_dict["compense_formsemestre_id"] = None return ue_cap_dict def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: """L'état de l'UE pour cet étudiant. Result: dict, ou None si l'UE n'est pas dans ce semestre. """ ue = UniteEns.query.get(ue_id) if ue.type == UE_SPORT: return { "is_capitalized": False, "was_capitalized": False, "is_external": False, "coef_ue": 0.0, "cur_moy_ue": 0.0, "moy": 0.0, "event_date": None, "ue": ue.to_dict(), "formsemestre_id": None, "capitalized_ue_id": None, "ects_pot": 0.0, "ects": 0.0, "ects_ue": ue.ects, } if not ue_id in self.etud_moy_ue: return None if not self.validations: self.validations = res_sem.load_formsemestre_validations(self.formsemestre) cur_moy_ue = self.etud_moy_ue[ue_id][etudid] moy_ue = cur_moy_ue is_capitalized = False # si l'UE prise en compte est une UE capitalisée # s'il y a precedemment une UE capitalisée (pas forcement meilleure): was_capitalized = False if etudid in self.validations.ue_capitalisees.index: ue_cap = self._get_etud_ue_cap(etudid, ue) if ue_cap and not np.isnan(ue_cap["moy_ue"]): was_capitalized = True if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue): moy_ue = ue_cap["moy_ue"] is_capitalized = True # Coef l'UE dans le semestre courant: if self.is_apc: # utilise les ECTS comme coef. coef_ue = ue.ects else: # formations classiques coef_ue = self.etud_coef_ue_df[ue_id][etudid] if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante if self.is_apc: # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS ue_capitalized = UniteEns.query.get(ue_cap["ue_id"]) coef_ue = ue_capitalized.ects if coef_ue is None: orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"]) raise ScoValueError( f"""L'UE capitalisée {ue_capitalized.acronyme} du semestre {orig_sem.titre_annee()} n'a pas d'indication d'ECTS. Corrigez ou faite corriger le programme via cette page. """ ) else: # Coefs de l'UE capitalisée en formation classique: # va chercher le coef dans le semestre d'origine coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue( ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] ) return { "is_capitalized": is_capitalized, "was_capitalized": was_capitalized, "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external, "coef_ue": coef_ue, "ects_pot": ue.ects or 0.0, "ects": self.validations.decisions_jury_ues.get(etudid, {}) .get(ue.id, {}) .get("ects", 0.0) if self.validations.decisions_jury_ues else 0.0, "ects_ue": ue.ects, "cur_moy_ue": cur_moy_ue, "moy": moy_ue, "event_date": ue_cap["event_date"] if is_capitalized else None, "ue": ue.to_dict(), "formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None, "capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None, } def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: "Détermine le coefficient de l'UE pour cet étudiant." # calcul différent en classique et BUT raise NotImplementedError() def get_etud_ue_cap_coef(self, etudid, ue, ue_cap): # UNUSED in ScoDoc 9 """Calcule le coefficient d'une UE capitalisée, pour cet étudiant, injectée dans le semestre courant. ue : ue du semestre courant ue_cap = resultat de formsemestre_get_etud_capitalisation { 'ue_id' (dans le semestre source), 'ue_code', 'moy', 'event_date','formsemestre_id' } """ # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ? ue_coef_db = FormSemestreUECoef.query.filter_by( formsemestre_id=self.formsemestre.id, ue_id=ue.id ).first() if ue_coef_db is not None: return ue_coef_db.coefficient # En APC: somme des coefs des modules vers cette UE # En classique: Capitalisation UE externe: quel coef appliquer ? # En ScoDoc 7, calculait la somme des coefs dans l'UE du semestre d'origine # ici si l'étudiant est inscrit dans le semestre courant, # somme des coefs des modules de l'UE auxquels il est inscrit return self.compute_etud_ue_coef(etudid, ue) # --- TABLEAU RECAP def get_table_recap( self, convert_values=False, include_evaluations=False, mode_jury=False, allow_html=True, ): """Table récap. des résultats. allow_html: si vrai, peut mettre du HTML dans les valeurs Result: tuple avec - rows: liste de dicts { column_id : value } - titles: { column_id : title } - columns_ids: (liste des id de colonnes) 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 attributs: - 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 """ 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 = [] # column_id : title titles = {} # les titres en footer: les mêmes, mais avec des bulles et liens: titles_bot = {} 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 row 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 for etudid in etuds_inscriptions: idx = 0 # index de la colonne etud = Identite.query.get(etudid) row = {"etudid": etudid} # --- Codes (seront cachés, mais exportés en excel) idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx) idx = add_cell( row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx ) # --- Rang idx = add_cell( row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx ) row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}" # --- Identité étudiant idx = add_cell( row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx ) idx = add_cell( row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx ) row["_nom_disp_order"] = etud.sort_key idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx) idx = add_cell( row, "nom_short", "Nom", etud.nom_short, "identite_court", idx ) row["_nom_short_order"] = etud.sort_key row["_nom_short_target"] = url_for( "notes.formsemestre_bulletinetud", scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid, ) row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"' row["_nom_disp_target"] = row["_nom_short_target"] row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"] idx = 30 # début des colonnes de notes # --- Moyenne générale 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 idx = add_cell( row, "moy_gen", "Moy", fmt_note(moy_gen), "col_moy_gen" + note_class, idx, ) titles_bot["_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 idx = add_cell( row, col_id, ue.acronyme, fmt_note(val), "col_ue" + note_class, idx, ) titles_bot[ f"_{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}' idx = add_cell( row, f"bonus_ue_{ue.id}", f"Bonus {ue.acronyme}", val_fmt_html if allow_html else val_fmt, "col_ue_bonus", idx, ) row[f"_bonus_ue_{ue.id}_xls"] = val_fmt # Les moyennes des modules (ou ressources et SAÉs) dans cette UE idx_malus = idx # place pour colonne malus à gauche des modules idx += 1 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 "" ) idx = add_cell( row, col_id, modimpl.module.code, val_fmt_html, # class col_res mod_ue_123 f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}", idx, ) row[f"_{col_id}_xls"] = val_fmt if modimpl.module.module_type == scu.ModuleType.MALUS: titles[f"_{col_id}_col_order"] = idx_malus titles_bot[f"_{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 titles_bot[ f"_{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 add_cell( row, "ues_validables", "UEs", ue_valid_txt_html, "col_ue col_ues_validables", 29, # juste avant moy. gen. ) row["_ues_validables_xls"] = ue_valid_txt if nb_ues_warning: row["_ues_validables_class"] += " moy_ue_warning" elif nb_ues_validables < len(ues_sans_bonus): row["_ues_validables_class"] += " moy_inf" row["_ues_validables_order"] = nb_ues_validables # pour tri if mode_jury and self.validations: if self.is_apc: # formations BUT: pas de code semestre, concatene ceux des UE 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 classiqes: code semestre dec_sem = self.validations.decisions_jury.get(etudid) jury_code_sem = dec_sem["code"] if dec_sem else "" idx = add_cell( row, "jury_code_sem", "Jury", jury_code_sem, "jury_code_sem", 1000, ) idx = add_cell( row, "jury_link", "", f"""{"saisir" if not jury_code_sem else "modifier"} décision""", "col_jury_link", idx, ) rows.append(row) self.recap_add_partitions(rows, titles) self._recap_add_admissions(rows, titles) # tri par rang croissant rows.sort(key=lambda e: e["_rang_order"]) # INFOS POUR FOOTER bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note) if include_evaluations: self._recap_add_evaluations(rows, titles, bottom_infos) # Ajoute style "col_empty" aux colonnes de modules vides for col_id in titles: c_class = f"_{col_id}_class" if "col_empty" in bottom_infos["moy"].get(c_class, ""): for row in rows: row[c_class] = row.get(c_class, "") + " col_empty" titles[c_class] += " col_empty" for row in bottom_infos.values(): row[c_class] = row.get(c_class, "") + " col_empty" # --- TABLE FOOTER: ECTS, moyennes, min, max... footer_rows = [] for (bottom_line, row) in bottom_infos.items(): # Cases vides à styler: row["moy_gen"] = row.get("moy_gen", "") row["_moy_gen_class"] = "col_moy_gen" # titre de la ligne: row["prenom"] = row["nom_short"] = ( row.get("_title", "") or bottom_line.capitalize() ) row["_tr_class"] = bottom_line.lower() + ( (" " + row["_tr_class"]) if "_tr_class" in row else "" ) footer_rows.append(row) titles_bot.update(titles) footer_rows.append(titles_bot) column_ids = [title for title in titles if not title.startswith("_")] column_ids.sort( key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000) ) return (rows, footer_rows, titles, column_ids) def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict: """Les informations à mettre en bas de la table: min, max, moy, ECTS""" row_min, row_max, row_moy, row_coef, row_ects, row_apo = ( {"_tr_class": "bottom_info", "_title": "Min."}, {"_tr_class": "bottom_info"}, {"_tr_class": "bottom_info"}, {"_tr_class": "bottom_info"}, {"_tr_class": "bottom_info"}, {"_tr_class": "bottom_info", "_title": "Code Apogée"}, ) # --- ECTS for ue in ues: colid = f"moy_ue_{ue.id}" row_ects[colid] = ue.ects row_ects[f"_{colid}_class"] = "col_ue" # style cases vides pour borders verticales row_coef[colid] = "" row_coef[f"_{colid}_class"] = "col_ue" # row_apo[colid] = ue.code_apogee or "" row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]) row_ects["_moy_gen_class"] = "col_moy_gen" # --- MIN, MAX, MOY, APO row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min()) row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max()) row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean()) for ue in ues: colid = f"moy_ue_{ue.id}" row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min()) row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max()) row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean()) row_min[f"_{colid}_class"] = "col_ue" row_max[f"_{colid}_class"] = "col_ue" row_moy[f"_{colid}_class"] = "col_ue" row_apo[colid] = ue.code_apogee or "" for modimpl in self.formsemestre.modimpls_sorted: if modimpl.id in modimpl_ids: colid = 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[colid] = fmt_note(coef) notes = self.modimpl_notes(modimpl.id, ue.id) if np.isnan(notes).all(): # aucune note valide row_min[colid] = np.nan row_max[colid] = np.nan moy = np.nan else: row_min[colid] = fmt_note(np.nanmin(notes)) row_max[colid] = fmt_note(np.nanmax(notes)) moy = np.nanmean(notes) row_moy[colid] = fmt_note(moy) if np.isnan(moy): # aucune note dans ce module row_moy[f"_{colid}_class"] = "col_empty" row_apo[colid] = 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é """Table recap: ajoute à row les colonnes sur les groupes pour cet etud""" # dec = self.get_etud_decision_sem(etudid) # if dec: # codes_nb[dec["code"]] += 1 row_class = "" etud_etat = self.get_etud_etat(etudid) if etud_etat == DEM: gr_name = "Dém." row_class = "dem" elif etud_etat == DEF: gr_name = "Déf." row_class = "def" else: # XXX probablement à revoir pour utiliser données cachées, # via get_etud_groups_in_partition ou autre group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id) gr_name = group["group_name"] or "" row["group"] = gr_name row["_group_class"] = "group" if row_class: row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class]) titles["group"] = "Gr" def _recap_add_admissions(self, rows: list[dict], titles: dict): """Ajoute les colonnes "admission" rows est une liste de dict avec une clé "etudid" Les colonnes ont la classe css "admission" """ fields = { "bac": "Bac", "specialite": "Spécialité", "type_admission": "Type Adm.", "classement": "Rg. Adm.", } first = True for i, cid in enumerate(fields): titles[f"_{cid}_col_order"] = 10000 + i # tout à droite if first: titles[f"_{cid}_class"] = "admission admission_first" first = False else: titles[f"_{cid}_class"] = "admission" titles.update(fields) for row in rows: etud = Identite.query.get(row["etudid"]) admission = etud.admission.first() first = True for cid in fields: row[cid] = getattr(admission, cid) or "" if first: row[f"_{cid}_class"] = "admission admission_first" first = False else: row[f"_{cid}_class"] = "admission" def recap_add_partitions(self, rows: list[dict], titles: dict, col_idx: int = None): """Ajoute les colonnes indiquant les groupes rows est une liste de dict avec une clé "etudid" Les colonnes ont la classe css "partition" """ partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups( self.formsemestre.id ) first_partition = True col_order = 10 if col_idx is None else col_idx for partition in partitions: cid = f"part_{partition['partition_id']}" rg_cid = cid + "_rg" # rang dans la partition titles[cid] = partition["partition_name"] if first_partition: klass = "partition" else: klass = "partition partition_aux" titles[f"_{cid}_class"] = klass titles[f"_{cid}_col_order"] = col_order titles[f"_{rg_cid}_col_order"] = col_order + 1 col_order += 2 if partition["bul_show_rank"]: titles[rg_cid] = f"Rg {partition['partition_name']}" titles[f"_{rg_cid}_class"] = "partition_rangs" partition_etud_groups = partitions_etud_groups[partition["partition_id"]] for row in rows: group = None # group (dict) de l'étudiant dans cette partition # dans NotesTableCompat, à revoir etud_etat = self.get_etud_etat(row["etudid"]) if etud_etat == scu.DEMISSION: gr_name = "Dém." row["_tr_class"] = "dem" elif etud_etat == DEF: gr_name = "Déf." row["_tr_class"] = "def" else: group = partition_etud_groups.get(row["etudid"]) gr_name = group["group_name"] if group else "" if gr_name: row[cid] = gr_name row[f"_{cid}_class"] = klass # Rangs dans groupe if ( partition["bul_show_rank"] and (group is not None) and (group["id"] in self.moy_gen_rangs_by_group) ): rang = self.moy_gen_rangs_by_group[group["id"]][0] row[rg_cid] = rang.get(row["etudid"], "") first_partition = False def _recap_add_evaluations( self, rows: list[dict], titles: dict, bottom_infos: dict ): """Ajoute les colonnes avec les notes aux évaluations rows est une liste de dict avec une clé "etudid" Les colonnes ont la classe css "evaluation" """ # nouvelle ligne pour description évaluations: bottom_infos["descr_evaluation"] = { "_tr_class": "bottom_info", "_title": "Description évaluation", } first_eval = True index_col = 9000 # à droite for modimpl in self.formsemestre.modimpls_sorted: evals = self.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: cid = f"eval_{e.id}" titles[ cid ] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}' klass = "evaluation" if first_eval: klass += " first" elif first_eval_of_mod: klass += " first_of_mod" titles[f"_{cid}_class"] = klass first_eval_of_mod = first_eval = False titles[f"_{cid}_col_order"] = index_col index_col += 1 eval_index -= 1 notes_db = sco_evaluation_db.do_evaluation_get_all_notes( e.evaluation_id ) for row in rows: etudid = row["etudid"] 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 row[cid] = scu.fmt_note(val) row[f"_{cid}_class"] = klass + { "ABS": " abs", "ATT": " att", "EXC": " exc", }.get(row[cid], "") else: row[cid] = "ni" row[f"_{cid}_class"] = klass + " non_inscrit" bottom_infos["coef"][cid] = e.coefficient bottom_infos["min"][cid] = "0" bottom_infos["max"][cid] = scu.fmt_note(e.note_max) bottom_infos["descr_evaluation"][cid] = e.description or "" bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for( "notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, evaluation_id=e.id, )