diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 4ff2849f8..65756767f 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -4,7 +4,6 @@ # See LICENSE ############################################################################## -from collections import defaultdict import datetime from flask import url_for, g import numpy as np @@ -15,62 +14,24 @@ from app import db from app.comp import moy_ue, moy_sem, inscr_mod from app.models import ModuleImpl from app.scodoc import sco_utils as scu -from app.scodoc.sco_cache import ResultatsSemestreBUTCache from app.scodoc import sco_bulletins_json from app.scodoc import sco_preferences from app.scodoc.sco_utils import jsnan, fmt_note +from app.comp.res_sem import ResultatsSemestre, NotesTableCompat -class ResultatsSemestreBUT: - """Structure légère pour stocker les résultats du semestre et - générer les bulletins. - __init__ : charge depuis le cache ou calcule - """ +class ResultatsSemestreBUT(NotesTableCompat): + """Résultats BUT: organisation des calculs""" - _cached_attrs = ( - "sem_cube", - "modimpl_inscr_df", - "modimpl_coefs_df", - "etud_moy_ue", - "modimpls_evals_poids", - "modimpls_evals_notes", - "etud_moy_gen", - "etud_moy_gen_ranks", - "modimpls_evaluations_complete", - ) + _cached_attrs = NotesTableCompat._cached_attrs + () def __init__(self, formsemestre): - self.formsemestre = formsemestre - self.ues = formsemestre.query_ues().all() - self.modimpls = formsemestre.modimpls.all() - self.etuds = self.formsemestre.get_inscrits(include_dem=False) - self.etud_index = {e.id: idx for idx, e in enumerate(self.etuds)} - self.saes = [ - m for m in self.modimpls if m.module.module_type == scu.ModuleType.SAE - ] - self.ressources = [ - m for m in self.modimpls if m.module.module_type == scu.ModuleType.RESSOURCE - ] + super().__init__(formsemestre) + if not self.load_cached(): self.compute() self.store() - def load_cached(self) -> bool: - "Load cached dataframes, returns False si pas en cache" - data = ResultatsSemestreBUTCache.get(self.formsemestre.id) - if not data: - return False - for attr in self._cached_attrs: - setattr(self, attr, data[attr]) - return True - - def store(self): - "Cache our dataframes" - ResultatsSemestreBUTCache.set( - self.formsemestre.id, - {attr: getattr(self, attr) for attr in self._cached_attrs}, - ) - def compute(self): "Charge les notes et inscriptions et calcule toutes les moyennes" ( @@ -100,6 +61,13 @@ class ResultatsSemestreBUT: ) self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) + +class BulletinBUT(ResultatsSemestreBUT): + """Génération du bulletin BUT. + Cette classe génère des dictionnaires avec toutes les informations + du bulletin, qui sont immédiatement traduisibles en JSON. + """ + def etud_ue_mod_results(self, etud, ue, modimpls) -> dict: "dict synthèse résultats dans l'UE pour les modules indiqués" d = {} @@ -233,7 +201,7 @@ class ResultatsSemestreBUT: }, "formsemestre_id": formsemestre.id, "etat_inscription": etat_inscription, - "options": bulletin_option_affichage(formsemestre), + "options": sco_preferences.bulletin_option_affichage(formsemestre.id), } semestre_infos = { "etapes": [str(x.etape_apo) for x in formsemestre.etapes if x.etape_apo], @@ -298,125 +266,3 @@ class ResultatsSemestreBUT: ) return d - - -def bulletin_option_affichage(formsemestre): - "dict avec les options d'affichages (préférences) pour ce semestre" - prefs = sco_preferences.SemPreferences(formsemestre.id) - fields = ( - "bul_show_abs", - "bul_show_abs_modules", - "bul_show_ects", - "bul_show_codemodules", - "bul_show_matieres", - "bul_show_rangs", - "bul_show_ue_rangs", - "bul_show_mod_rangs", - "bul_show_moypromo", - "bul_show_minmax", - "bul_show_minmax_mod", - "bul_show_minmax_eval", - "bul_show_coef", - "bul_show_ue_cap_details", - "bul_show_ue_cap_current", - "bul_show_temporary", - "bul_temporary_txt", - "bul_show_uevalid", - "bul_show_date_inscr", - ) - # on enlève le "bul_" de la clé: - return {field[4:]: prefs[field] for field in fields} - - -# Pour raccorder le code des anciens bulletins qui attendent une NoteTable -class APCNotesTableCompat: - """Implementation partielle de NotesTable pour les formations APC - Accès aux notes et rangs. - """ - - def __init__(self, formsemestre): - self.results = ResultatsSemestreBUT(formsemestre) - nb_etuds = len(self.results.etuds) - self.rangs = self.results.etud_moy_gen_ranks - self.moy_min = self.results.etud_moy_gen.min() - self.moy_max = self.results.etud_moy_gen.max() - self.moy_moy = self.results.etud_moy_gen.mean() - self.bonus = defaultdict(lambda: 0.0) # XXX - self.ue_rangs = { - u.id: (defaultdict(lambda: 0.0), nb_etuds) for u in self.results.ues - } - self.mod_rangs = { - m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.results.modimpls - } - - def get_ues(self): - ues = [] - for ue in self.results.ues: - d = ue.to_dict() - d.update( - { - "max": self.results.etud_moy_ue[ue.id].max(), - "min": self.results.etud_moy_ue[ue.id].min(), - "moy": self.results.etud_moy_ue[ue.id].mean(), - "nb_moy": len(self.results.etud_moy_ue), - } - ) - ues.append(d) - return ues - - def get_modimpls(self): - return [m.to_dict() for m in self.results.modimpls] - - def get_etud_moy_gen(self, etudid): - return self.results.etud_moy_gen[etudid] - - def get_moduleimpls_attente(self): - return [] # XXX TODO - - def get_etud_rang(self, etudid): - return self.rangs[etudid] - - def get_etud_rang_group(self, etudid, group_id): - return (None, 0) # XXX unimplemented TODO - - def get_etud_ue_status(self, etudid, ue_id): - return { - "cur_moy_ue": self.results.etud_moy_ue[ue_id][etudid], - "is_capitalized": False, # XXX TODO - } - - def get_etud_mod_moy(self, moduleimpl_id, etudid): - mod_idx = self.results.modimpl_coefs_df.columns.get_loc(moduleimpl_id) - etud_idx = self.results.etud_index[etudid] - # moyenne sur les UE: - self.results.sem_cube[etud_idx, mod_idx].mean() - - def get_mod_stats(self, moduleimpl_id): - return { - "moy": "-", - "max": "-", - "min": "-", - "nb_notes": "-", - "nb_missing": "-", - "nb_valid_evals": "-", - } - - def get_evals_in_mod(self, moduleimpl_id): - mi = ModuleImpl.query.get(moduleimpl_id) - evals_results = [] - for e in mi.evaluations: - d = e.to_dict() - 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": self.results.modimpls_evals_notes[e.moduleimpl_id][e.id][ - etud.id - ], - } - for etud in self.results.etuds - } - evals_results.append(d) - return evals_results diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 9e234dc0b..6e2f14dbf 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -314,13 +314,3 @@ def bulletin_but_xml_compat( return None else: return sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING) - - -""" -formsemestre_id=718 -etudid=12496 -from app.but.bulletin_but import * -mapp.set_sco_dept("RT") -sem = FormSemestre.query.get(formsemestre_id) -r = ResultatsSemestreBUT(sem) -""" diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py new file mode 100644 index 000000000..01a2e8720 --- /dev/null +++ b/app/comp/res_sem.py @@ -0,0 +1,216 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +from collections import defaultdict +from functools import cached_property +import numpy as np +import pandas as pd +from app.scodoc import sco_utils as scu +from app.scodoc.sco_cache import ResultatsSemestreCache +from app.scodoc.sco_codes_parcours import UE_SPORT + +# 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: + _cached_attrs = ( + "sem_cube", + "modimpl_inscr_df", + "modimpl_coefs_df", + "etud_moy_ue", + "modimpls_evals_poids", + "modimpls_evals_notes", + "etud_moy_gen", + "etud_moy_gen_ranks", + "modimpls_evaluations_complete", + ) + + def __init__(self, formsemestre): + self.formsemestre = formsemestre + # TODO + + def load_cached(self) -> bool: + "Load cached dataframes, returns False si pas en cache" + data = ResultatsSemestreCache.get(self.formsemestre.id) + if not data: + return False + for attr in self._cached_attrs: + setattr(self, attr, data[attr]) + return True + + def store(self): + "Cache our data" + "Cache our dataframes" + ResultatsSemestreCache.set( + self.formsemestre.id, + {attr: getattr(self, attr) for attr in self._cached_attrs}, + ) + + 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 + TODO + + @cached_property + def etuds(self): + "Liste des inscrits au semestre, sans les démissionnaires" + # nb: si les liste des inscrits change, ResultatsSemestre devient invalide + return self.formsemestre.get_inscrits(include_dem=False) + + @cached_property + def etud_index(self): + "dict { etudid : indice dans les inscrits }" + return {e.id: idx for idx, e in enumerate(self.etuds)} + + @cached_property + def ues(self): + "Liste des UE du semestre" + return self.formsemestre.query_ues().all() + + @cached_property + def modimpls(self): + "Liste des modimpls du semestre (triée par numéro de module)" + modimpls = self.formsemestre.modimpls.all() + modimpls.sort(key=lambda m: m.module.numero) + return modimpls + + @cached_property + def ressources(self): + "Liste des ressources du semestre, triées par numéro de module" + return [ + m for m in self.modimpls 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.modimpls if m.module.module_type == scu.ModuleType.SAE] + + +class StatsMoyenne: + """Une moyenne d'un ensemble étudiants sur quelque chose + (moyenne générale d'un semestre, d'un module, d'un groupe...) + et les statistiques associées: min, max, moy, effectif + """ + + def __init__(self, vals): + """Calcul les statistiques. + Les valeurs NAN ou non numériques sont toujours enlevées. + """ + self.moy = np.nanmean(vals) + self.min = np.nanmin(vals) + self.max = np.nanmax(vals) + self.size = len(vals) + self.nb_vals = self.size - np.count_nonzero(np.isnan(vals)) + + def to_dict(self): + return { + "min": self.min, + "max": self.max, + "moy": self.moy, + "size": self.size, + "nb_vals": self.nb_vals, + } + + +# Pour raccorder le code des anciens codes qui attendent une NoteTable +class NotesTableCompat(ResultatsSemestre): + """Implementation partielle de NotesTable WIP TODO + Accès aux notes et rangs. + """ + + _cached_attrs = ResultatsSemestre._cached_attrs + () + + def __init__(self, formsemestre): + super().__init__(formsemestre) + nb_etuds = len(self.etuds) + self.bonus = defaultdict(lambda: 0.0) # XXX TODO + self.ue_rangs = {u.id: (defaultdict(lambda: 0.0), nb_etuds) for u in self.ues} + self.mod_rangs = { + m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.modimpls + } + + @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.ues: + if filter_sport and ue.type == UE_SPORT: + continue + d = ue.to_dict() + d.update(StatsMoyenne(self.etud_moy_ue[ue.id]).to_dict()) + ues.append(d) + return ues + + def get_modimpls(self): + return [m.to_dict() for m in self.results.modimpls] + + def get_etud_moy_gen(self, etudid): + return self.results.etud_moy_gen[etudid] + + def get_moduleimpls_attente(self): + return [] # XXX TODO + + def get_etud_rang(self, etudid): + return self.etud_moy_gen_ranks[etudid] + + def get_etud_rang_group(self, etudid, group_id): + return (None, 0) # XXX unimplemented TODO + + def get_etud_ue_status(self, etudid, ue_id): + return { + "cur_moy_ue": self.results.etud_moy_ue[ue_id][etudid], + "is_capitalized": False, # XXX TODO + } + + def get_etud_mod_moy(self, moduleimpl_id, etudid): + mod_idx = self.results.modimpl_coefs_df.columns.get_loc(moduleimpl_id) + etud_idx = self.results.etud_index[etudid] + # moyenne sur les UE: + self.results.sem_cube[etud_idx, mod_idx].mean() + + def get_mod_stats(self, moduleimpl_id): + return { + "moy": "-", + "max": "-", + "min": "-", + "nb_notes": "-", + "nb_missing": "-", + "nb_valid_evals": "-", + } + + def get_evals_in_mod(self, moduleimpl_id): + mi = ModuleImpl.query.get(moduleimpl_id) + evals_results = [] + for e in mi.evaluations: + d = e.to_dict() + 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": self.results.modimpls_evals_notes[e.moduleimpl_id][e.id][ + etud.id + ], + } + for etud in self.results.etuds + } + evals_results.append(d) + return evals_results diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 450ea2ccf..98edbba1e 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -84,7 +84,11 @@ class FormSemestre(db.Model): etapes = db.relationship( "FormSemestreEtape", cascade="all,delete", backref="formsemestre" ) - modimpls = db.relationship("ModuleImpl", backref="formsemestre", lazy="dynamic") + modimpls = db.relationship( + "ModuleImpl", + backref="formsemestre", + lazy="dynamic", + ) etuds = db.relationship( "Identite", secondary="notes_formsemestre_inscription", diff --git a/app/pe/pe_tagtable.py b/app/pe/pe_tagtable.py index e32a11735..caba16362 100644 --- a/app/pe/pe_tagtable.py +++ b/app/pe/pe_tagtable.py @@ -68,7 +68,7 @@ class TableTag(object): self.taglist = [] self.resultats = {} - self.rangs = {} + self.etud_moy_gen_ranks = {} self.statistiques = {} # ***************************************************************************************************************** @@ -117,15 +117,15 @@ class TableTag(object): # ----------------------------------------------------------------------------------------------------------- def get_moy_from_stats(self, tag): - """ Renvoie la moyenne des notes calculées pour d'un tag donné""" + """Renvoie la moyenne des notes calculées pour d'un tag donné""" return self.statistiques[tag][0] if tag in self.statistiques else None def get_min_from_stats(self, tag): - """ Renvoie la plus basse des notes calculées pour d'un tag donné""" + """Renvoie la plus basse des notes calculées pour d'un tag donné""" return self.statistiques[tag][1] if tag in self.statistiques else None def get_max_from_stats(self, tag): - """ Renvoie la plus haute des notes calculées pour d'un tag donné""" + """Renvoie la plus haute des notes calculées pour d'un tag donné""" return self.statistiques[tag][2] if tag in self.statistiques else None # ----------------------------------------------------------------------------------------------------------- @@ -236,7 +236,7 @@ class TableTag(object): return moystr def str_res_d_un_etudiant(self, etudid, delim=";"): - """Renvoie sur une ligne les résultats d'un étudiant à tous les tags (par ordre alphabétique). """ + """Renvoie sur une ligne les résultats d'un étudiant à tous les tags (par ordre alphabétique).""" return delim.join( [self.str_resTag_d_un_etudiant(tag, etudid) for tag in self.get_all_tags()] ) @@ -256,7 +256,7 @@ class TableTag(object): # ----------------------------------------------------------------------- def str_tagtable(self, delim=";", decimal_sep=","): - """Renvoie une chaine de caractère listant toutes les moyennes, les rangs des étudiants pour tous les tags. """ + """Renvoie une chaine de caractère listant toutes les moyennes, les rangs des étudiants pour tous les tags.""" entete = ["etudid", "nom", "prenom"] for tag in self.get_all_tags(): entete += [titre + "_" + tag for titre in ["note", "rang", "nb_inscrit"]] diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index 052b387e1..69c37694f 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -25,7 +25,9 @@ # ############################################################################## -"""Calculs sur les notes et cache des resultats +"""Calculs sur les notes et cache des résultats + + Ancien code ScoDoc 7 en cours de rénovation """ from operator import itemgetter @@ -102,7 +104,7 @@ def comp_ranks(T): def get_sem_ues_modimpls(formsemestre_id, modimpls=None): """Get liste des UE du semestre (à partir des moduleimpls) - (utilisé quand on ne peut pas construire nt et faire nt.get_ues()) + (utilisé quand on ne peut pas construire nt et faire nt.get_ues_stat_dict()) """ if modimpls is None: modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) @@ -316,7 +318,7 @@ class NotesTable: self.moy_min = self.moy_max = "NA" # calcul rangs (/ moyenne generale) - self.rangs = comp_ranks(T) + self.etud_moy_gen_ranks = comp_ranks(T) self.rangs_groupes = ( {} @@ -417,43 +419,14 @@ class NotesTable: else: return ' (%s) ' % etat - def get_ues(self, filter_sport=False, filter_non_inscrit=False, etudid=None): - """liste des ue, ordonnée par numero. - Si filter_non_inscrit, retire les UE dans lesquelles l'etudiant n'est - inscrit à aucun module. + 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 """ - if not filter_sport and not filter_non_inscrit: + if not filter_sport: return self._ues - - if filter_sport: - ues_src = [ue for ue in self._ues if ue["type"] != UE_SPORT] else: - ues_src = self._ues - if not filter_non_inscrit: - return ues_src - ues = [] - for ue in ues_src: - if self.get_etud_ue_status(etudid, ue["ue_id"])["is_capitalized"]: - # garde toujours les UE capitalisees - has_note = True - else: - has_note = False - # verifie que l'etud. est inscrit a au moins un module de l'UE - # (en fait verifie qu'il a une note) - modimpls = self.get_modimpls(ue["ue_id"]) - - for modi in modimpls: - moy = self.get_etud_mod_moy(modi["moduleimpl_id"], etudid) - try: - float(moy) - has_note = True - break - except: - pass - if has_note: - ues.append(ue) - return ues + return [ue for ue in self._ues if ue["type"] != UE_SPORT] def get_modimpls(self, ue_id=None): "liste des modules pour une UE (ou toutes si ue_id==None), triés par matières." @@ -522,7 +495,7 @@ class NotesTable: Les moyennes d'UE ne tiennent pas compte des capitalisations. """ - ues = self.get_ues() + ues = self.get_ues_stat_dict() sum_moy = 0 # la somme des moyennes générales valides nb_moy = 0 # le nombre de moyennes générales valides for ue in ues: @@ -561,9 +534,9 @@ class NotesTable: i = 0 for ue in ues: i += 1 - ue["nb_moy"] = len(ue["_notes"]) - if ue["nb_moy"] > 0: - ue["moy"] = sum(ue["_notes"]) / ue["nb_moy"] + ue["nb_vals"] = len(ue["_notes"]) + if ue["nb_vals"] > 0: + ue["moy"] = sum(ue["_notes"]) / ue["nb_vals"] ue["max"] = max(ue["_notes"]) ue["min"] = min(ue["_notes"]) else: @@ -767,7 +740,7 @@ class NotesTable: sem_ects_pot_fond = 0.0 sem_ects_pot_pro = 0.0 - for ue in self.get_ues(): + for ue in self.get_ues_stat_dict(): # - On calcule la moyenne d'UE courante: if not block_computation: mu = self.comp_etud_moy_ue(etudid, ue_id=ue["ue_id"], cnx=cnx) @@ -981,7 +954,7 @@ class NotesTable: return self.T def get_etud_rang(self, etudid) -> str: - return self.rangs.get(etudid, "999") + return self.etud_moy_gen_ranks.get(etudid, "999") def get_etud_rang_group(self, etudid, group_id): """Returns rank of etud in this group and number of etuds in group. @@ -1347,7 +1320,7 @@ class NotesTable: # Rappel des épisodes précédents: T est une liste de liste # Colonnes: 0 moy_gen, moy_ue1, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid - ues = self.get_ues() # incluant le(s) UE de sport + ues = self.get_ues_stat_dict() # incluant le(s) UE de sport for t in self.T: etudid = t[-1] if etudid in results.etud_moy_gen: # evite les démissionnaires @@ -1358,4 +1331,4 @@ class NotesTable: # re-trie selon la nouvelle moyenne générale: self.T.sort(key=self._row_key) # Remplace aussi le rang: - self.rangs = results.etud_moy_gen_ranks + self.etud_moy_gen_ranks = results.etud_moy_gen_ranks diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index 4e5aaa469..c719df312 100644 --- a/app/scodoc/sco_abs_views.py +++ b/app/scodoc/sco_abs_views.py @@ -118,7 +118,7 @@ def doSignaleAbsence( mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] formsemestre_id = mod["formsemestre_id"] nt = sco_cache.NotesTableCache.get(formsemestre_id) - ues = nt.get_ues(etudid=etudid) + ues = nt.get_ues_stat_dict() for ue in ues: modimpls = nt.get_modimpls(ue_id=ue["ue_id"]) for modimpl in modimpls: @@ -175,7 +175,7 @@ def SignaleAbsenceEtud(): # etudid implied "abs_require_module", formsemestre_id ) nt = sco_cache.NotesTableCache.get(formsemestre_id) - ues = nt.get_ues(etudid=etudid) + ues = nt.get_ues_stat_dict() if require_module: menu_module = """