############################################################################## # ScoDoc # Copyright (c) 1999 - 2023 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 cell 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 if not self.formsemestre.block_moyenne_generale: 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 if not self.formsemestre.block_moyenne_generale: 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 idx_ue_start = idx for idx_ue, ue in enumerate(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_ue * 10000 + idx_ue_start, ) 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_ue * 10000 + idx_ue_start + 1, ) 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_ue * 10000 + idx_ue_start + 1 + (modimpl.module.module_type or 0) * 1000 + (modimpl.module.numero or 0), ) 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 classiques: 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) col_idx = self.recap_add_partitions(rows, titles) self.recap_add_cursus(rows, titles, col_idx=col_idx + 1) self._recap_add_admissions(rows, titles) # tri par rang croissant if not self.formsemestre.block_moyenne_generale: rows.sort(key=lambda e: e["_rang_order"]) else: rows.sort(key=lambda e: e["_ues_validables_order"], reverse=True) # 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" # Ligne avec la classe de chaque colonne # récupère le type à partir des classes css (hack...) row_class = {} for col_id in titles: klass = titles.get(f"_{col_id}_class") if klass: row_class[col_id] = " ".join( cls[4:] for cls in klass.split() if cls.startswith("col_") ) # cette case (nb d'UE validables) a deux classes col_xxx, on en garde une seule: if "ues_validables" in row_class[col_id]: row_class[col_id] = "ues_validables" bottom_infos["type_col"] = row_class # --- 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, Apo""" 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_cursus(self, rows: list[dict], titles: dict, col_idx: int = None): """Ajoute colonne avec code cursus, eg 'S1 S2 S1'""" cid = "code_cursus" titles[cid] = "Cursus" titles[f"_{cid}_col_order"] = col_idx formation_code = self.formsemestre.formation.formation_code for row in rows: etud = Identite.query.get(row["etudid"]) row[cid] = " ".join( [ f"S{ins.formsemestre.semestre_id}" for ins in reversed(etud.inscriptions()) if ins.formsemestre.formation.formation_code == formation_code ] ) def recap_add_partitions( self, rows: list[dict], titles: dict, col_idx: int = None ) -> int: """Ajoute les colonnes indiquant les groupes rows est une liste de dict avec une clé "etudid" Les colonnes ont la classe css "partition" Renvoie l'indice de la dernière colonne utilisée """ 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 return col_order 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, )