############################################################################## # ScoDoc # Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## from collections import defaultdict, Counter from functools import cached_property import numpy as np import pandas as pd from app.comp.aux_stats import StatsMoyenne from app.comp.res_cache import ResultatsCache from app.comp import res_sem from app.comp.moy_mod import ModuleImplResults from app.models import FormSemestre, Identite, ModuleImpl from app.models.ues import UniteEns from app.scodoc import sco_utils as scu from app.scodoc import sco_evaluations from app.scodoc.sco_cache import ResultatsSemestreCache from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF # 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): _cached_attrs = ( "etud_moy_gen_ranks", "etud_moy_gen", "etud_moy_ue", "modimpl_inscr_df", "modimpls_results", "etud_coef_ue_df", ) 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 # ResultatsSemestreBUT ou ResultatsSemestreClassic self.etud_moy_ue = {} "etud_moy_ue: DataFrame columns UE, rows etudid" self.etud_moy_gen = {} self.etud_moy_gen_ranks = {} 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)""" 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)} @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 ] 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. XXX notion à implémenter, pour l'instant toutes les UE du semestre. - En classique: toutes les UEs des modimpls auxquels l'étufdiant est inscrit sont susceptibles d'être validées. Les UE "bonus" (sport) ne sont jamais "validables". """ if self.is_apc: # TODO: introduire la notion de parcours (#sco93) return self.formsemestre.query_ues().filter(UniteEns.type != UE_SPORT).all() else: # 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_id, etudid) -> list[ModuleImpl]: """Liste des modimpl de cette UE auxquels l'étudiant est inscrit""" # sert pour l'affichage ou non de l'UE sur le bulletin return [ modimpl for modimpl in self.formsemestre.modimpls_sorted if modimpl.module.ue.id == ue_id and self.modimpl_inscr_df[modimpl.id][etudid] ] @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) # Pour raccorder le code des anciens codes qui attendent une NoteTable class NotesTableCompat(ResultatsSemestre): """Implementation partielle de NotesTable WIP TODO Les méthodes définies dans cette classe sont là pour conserver la compatibilité abvec les codes anciens et il n'est pas recommandé de les utiliser dans de nouveaux développements (API malcommode et peu efficace). """ _cached_attrs = ResultatsSemestre._cached_attrs + ( "bonus", "bonus_ues", "malus", ) def __init__(self, formsemestre: FormSemestre): super().__init__(formsemestre) nb_etuds = len(self.etuds) self.bonus = None # virtuel self.bonus_ues = None # virtuel self.ue_rangs = {u.id: (None, nb_etuds) for u in self.ues} self.mod_rangs = { m.id: (None, nb_etuds) for m in self.formsemestre.modimpls_sorted } self.moy_min = "NA" self.moy_max = "NA" self.moy_moy = "NA" self.expr_diagnostics = "" self.parcours = self.formsemestre.formation.get_parcours() self.validations = None def get_etudids(self, sorted=False) -> list[int]: """Liste des etudids inscrits, incluant les démissionnaires. Si sorted, triée par moy. générale décroissante Sinon, triée par ordre alphabetique de NOM """ # Note: pour avoir les inscrits non triés, # utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ] if sorted: # Tri par moy. generale décroissante return [x[-1] for x in self.T] return [x["etudid"] for x in self.inscrlist] @cached_property def sem(self) -> dict: """le formsemestre, comme un dict (nt.sem)""" return self.formsemestre.to_dict() @cached_property def inscrlist(self) -> list[dict]: # utilisé par PE seulement """Liste des inscrits au semestre (avec DEM et DEF), sous forme de dict etud, classée dans l'ordre alphabétique de noms. """ etuds = self.formsemestre.get_inscrits(include_demdef=True) etuds.sort(key=lambda e: e.sort_key) return [e.to_dict_scodoc7() for e in etuds] @cached_property def stats_moy_gen(self): """Stats (moy/min/max) sur la moyenne générale""" return StatsMoyenne(self.etud_moy_gen) def get_ues_stat_dict(self, filter_sport=False): # was get_ues() """Liste des UEs, ordonnée par numero. Si filter_sport, retire les UE de type SPORT. Résultat: liste de dicts { champs UE U stats moyenne UE } """ ues = [] for ue in self.formsemestre.query_ues(with_sport=not filter_sport): d = ue.to_dict() if ue.type != UE_SPORT: moys = self.etud_moy_ue[ue.id] else: moys = None d.update(StatsMoyenne(moys).to_dict()) ues.append(d) return ues def get_modimpls_dict(self, ue_id=None) -> list[dict]: """Liste des modules pour une UE (ou toutes si ue_id==None), triés par numéros (selon le type de formation) """ modimpls_dict = [] for modimpl in self.formsemestre.modimpls_sorted: if ue_id == None or modimpl.module.ue.id == ue_id: d = modimpl.to_dict() # compat ScoDoc < 9.2: ajoute matières d["mat"] = modimpl.module.matiere.to_dict() modimpls_dict.append(d) return modimpls_dict def get_etud_decision_ues(self, etudid: int) -> dict: """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu. Ne tient pas compte des UE capitalisées. { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : } Ne renvoie aucune decision d'UE pour les défaillants """ if self.get_etud_etat(etudid) == DEF: return {} else: if not self.validations: self.validations = res_sem.load_formsemestre_validations( self.formsemestre ) return self.validations.decisions_jury_ues.get(etudid, None) def get_etud_decision_sem(self, etudid: int) -> dict: """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu. { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id } Si état défaillant, force le code a DEF """ if self.get_etud_etat(etudid) == DEF: return { "code": DEF, "assidu": False, "event_date": "", "compense_formsemestre_id": None, } else: if not self.validations: self.validations = res_sem.load_formsemestre_validations( self.formsemestre ) return self.validations.decisions_jury.get(etudid, None) 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_mat_moy(self, matiere_id, etudid): """moyenne d'un étudiant dans une matière (ou NA si pas de notes)""" # non supporté en 9.2 return "na" def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: """La moyenne de l'étudiant dans le moduleimpl En APC, il s'agira d'une moyenne indicative sans valeur. Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM) """ raise NotImplementedError() # virtual method def get_etud_moy_gen(self, etudid): # -> float | str """Moyenne générale de cet etudiant dans ce semestre. Prend(ra) en compte les UE capitalisées. (TODO) XXX Si apc, moyenne indicative. Si pas de notes: 'NA' """ return self.etud_moy_gen[etudid] def get_etud_ects_pot(self, etudid: int) -> dict: """ Un dict avec les champs ects_pot : (float) nb de crédits ECTS qui seraient validés (sous réserve de validation par le jury), ects_pot_fond: (float) nb d'ECTS issus d'UE fondamentales (non électives) Ce sont les ECTS des UE au dessus de la barre (10/20 en principe), avant le jury (donc non encore enregistrées). """ # was nt.get_etud_moy_infos # XXX pour compat nt, à remplacer ultérieurement ues = self.get_etud_ue_validables(etudid) ects_pot = 0.0 for ue in ues: if ( ue.id in self.etud_moy_ue and ue.ects is not None and self.etud_moy_ue[ue.id][etudid] > self.parcours.NOTES_BARRE_VALID_UE ): ects_pot += ue.ects return { "ects_pot": ects_pot, "ects_fond": 0.0, # not implemented (anciennemment pour école ingé) } def get_etud_ue_status(self, etudid: int, ue_id: int): coef_ue = self.etud_coef_ue_df[ue_id][etudid] return { "is_capitalized": False, # XXX TODO "is_external": False, # XXX TODO "coef_ue": coef_ue, # XXX TODO "cur_moy_ue": self.etud_moy_ue[ue_id][etudid], "moy": self.etud_moy_ue[ue_id][etudid], } def get_etud_rang(self, etudid: int): return self.etud_moy_gen_ranks.get(etudid, 99999) # XXX def get_etud_rang_group(self, etudid: int, group_id: int): return (None, 0) # XXX unimplemented TODO def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]: """Liste d'informations (compat NotesTable) sur évaluations completes de ce module. Évaluation "complete" ssi toutes notes saisies ou en attente. """ modimpl = ModuleImpl.query.get(moduleimpl_id) evals_results = [] for e in modimpl.evaluations: if self.modimpls_results[moduleimpl_id].evaluations_completes_dict[e.id]: d = e.to_dict() moduleimpl_results = self.modimpls_results[e.moduleimpl_id] d["heure_debut"] = e.heure_debut # datetime.time d["heure_fin"] = e.heure_fin d["jour"] = e.jour # datetime d["notes"] = { etud.id: { "etudid": etud.id, "value": moduleimpl_results.evals_notes[e.id][etud.id], } for etud in self.etuds } d["etat"] = { "evalattente": moduleimpl_results.evaluations_etat[e.id].nb_attente, } evals_results.append(d) return evals_results def get_evaluations_etats(self): """[ {...evaluation et son etat...} ]""" # TODO: à moderniser if not hasattr(self, "_evaluations_etats"): self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem( self.formsemestre.id ) return self._evaluations_etats def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]: """Liste des états des évaluations de ce module""" # XXX TODO à moderniser: lent, recharge des donénes que l'on a déjà... return [ e for e in self.get_evaluations_etats() if e["moduleimpl_id"] == moduleimpl_id ] def get_moduleimpls_attente(self): """Liste des modimpls du semestre ayant des notes en attente""" return [ modimpl for modimpl in self.formsemestre.modimpls_sorted if self.modimpls_results[modimpl.id].en_attente ] def get_mod_stats(self, moduleimpl_id: int) -> dict: """Stats sur les notes obtenues dans un modimpl Vide en APC """ return { "moy": "-", "max": "-", "min": "-", "nb_notes": "-", "nb_missing": "-", "nb_valid_evals": "-", } def get_nom_short(self, etudid): "formatte nom d'un etud (pour table recap)" etud = self.identdict[etudid] return ( (etud["nom_usuel"] or etud["nom"]).upper() + " " + etud["prenom"].capitalize()[:2] + "." ) @cached_property def T(self): return self.get_table_moyennes_triees() def get_table_moyennes_triees(self) -> list: """Result: liste de tuples moy_gen, moy_ue_0, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid """ table_moyennes = [] etuds_inscriptions = self.formsemestre.etuds_inscriptions for etudid in etuds_inscriptions: moy_gen = self.etud_moy_gen.get(etudid, False) if moy_gen is False: # pas de moyenne: démissionnaire ou def t = ( ["-"] + ["0.00"] * len(self.ues) + ["NI"] * len(self.formsemestre.modimpls_sorted) ) else: moy_ues = self.etud_moy_ue.loc[etudid] t = [moy_gen] + list(moy_ues) # TODO UE capitalisées: ne pas afficher moyennes modules for modimpl in self.formsemestre.modimpls_sorted: val = self.get_etud_mod_moy(modimpl.id, etudid) t.append(val) t.append(etudid) table_moyennes.append(t) # tri par moyennes décroissantes, # en laissant les démissionnaires à la fin, par ordre alphabetique etuds = [ins.etud for ins in etuds_inscriptions.values()] etuds.sort(key=lambda e: e.sort_key) self._rang_alpha = {e.id: i for i, e in enumerate(etuds)} table_moyennes.sort(key=self._row_key) return table_moyennes def _row_key(self, x): """clé de tri par moyennes décroissantes, en laissant les demissionnaires à la fin, par ordre alphabetique. (moy_gen, rang_alpha) """ try: moy = -float(x[0]) except (ValueError, TypeError): moy = 1000.0 return (moy, self._rang_alpha[x[-1]]) @cached_property def identdict(self) -> dict: """{ etudid : etud_dict } pour tous les inscrits au semestre""" return { ins.etud.id: ins.etud.to_dict_scodoc7() for ins in self.formsemestre.inscriptions }