From 1a472bd19df1c36eb9235f8a294678e5615c0f91 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Thu, 30 Dec 2021 23:58:38 +0100 Subject: [PATCH] WIP: unification calculs --- app/but/bulletin_but.py | 43 +------- app/comp/moy_mod.py | 98 +++++++++++++---- app/comp/moy_sem.py | 2 +- app/comp/moy_ue.py | 88 +++++++++++++-- app/comp/res_but.py | 65 +++++++++++ app/comp/res_classic.py | 95 +++++++++++++++++ app/comp/res_sem.py | 190 ++++++++++++++++++++++++++------- app/models/etudiants.py | 16 +++ app/models/formsemestre.py | 8 +- app/scodoc/notes_table.py | 4 +- app/scodoc/sco_recapcomplet.py | 3 + tests/unit/test_but_ues.py | 4 +- 12 files changed, 503 insertions(+), 113 deletions(-) create mode 100644 app/comp/res_but.py create mode 100644 app/comp/res_classic.py diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 658f8f6340..e788d08c14 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -10,52 +10,11 @@ import datetime from flask import url_for, g -from app.comp import moy_ue, moy_sem, inscr_mod from app.scodoc import sco_utils as scu from app.scodoc import sco_bulletins_json from app.scodoc import sco_preferences from app.scodoc.sco_utils import fmt_note -from app.comp.res_sem import ResultatsSemestre, NotesTableCompat - - -class ResultatsSemestreBUT(NotesTableCompat): - """Résultats BUT: organisation des calculs""" - - _cached_attrs = NotesTableCompat._cached_attrs + () - - def __init__(self, formsemestre): - super().__init__(formsemestre) - - if not self.load_cached(): - self.compute() - self.store() - - def compute(self): - "Charge les notes et inscriptions et calcule toutes les moyennes" - ( - self.sem_cube, - self.modimpls_evals_poids, - self.modimpls_results, - ) = moy_ue.notes_sem_load_cube(self.formsemestre) - self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) - self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs( - self.formsemestre, ues=self.ues, modimpls=self.modimpls - ) - # l'idx de la colonne du mod modimpl.id est - # modimpl_coefs_df.columns.get_loc(modimpl.id) - # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) - self.etud_moy_ue = moy_ue.compute_ue_moys( - self.sem_cube, - self.etuds, - self.modimpls, - self.ues, - self.modimpl_inscr_df, - self.modimpl_coefs_df, - ) - self.etud_moy_gen = moy_sem.compute_sem_moys( - self.etud_moy_ue, self.modimpl_coefs_df - ) - self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) +from app.comp.res_but import ResultatsSemestreBUT class BulletinBUT(ResultatsSemestreBUT): diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index dc912c286d..5b217b3fd2 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -27,6 +27,8 @@ """Fonctions de calcul des moyennes de modules (modules, ressources ou SAÉ) +Pour les formations classiques et le BUT + Rappel: pour éviter les confusions, on appelera *poids* les coefficients d'une évaluation dans un module, et *coefficients* ceux utilisés pour le calcul de la moyenne générale d'une UE. @@ -49,10 +51,11 @@ class EvaluationEtat: is_complete: bool -class ModuleImplResultsAPC: - """Les notes des étudiants d'un moduleimpl. - Les poids des évals sont à part car on a a besoin sans les notes pour les tableaux - de bord. +class ModuleImplResults: + """Classe commune à toutes les formations (standard et APC). + Les notes des étudiants d'un moduleimpl. + Les poids des évals sont à part car on en a besoin sans les notes pour les + tableaux de bord. Les attributs sont tous des objets simples cachables dans Redis; les caches sont gérés par ResultatsSemestre. """ @@ -181,6 +184,31 @@ class ModuleImplResultsAPC: ) ] + def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array: + """Coefficients des évaluations, met à zéro ceux des évals incomplètes. + Résultat: 2d-array of floats, shape (nb_evals, 1) + """ + return ( + np.array( + [e.coefficient for e in moduleimpl.evaluations], + dtype=float, + ) + * self.evaluations_completes + ).reshape(-1, 1) + + def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array: + """Les notes des évaluations, + remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20. + Résultat: 2d array of floats, shape nb_etuds x nb_evaluations + """ + return np.where( + self.evals_notes.values > scu.NOTES_ABSENCE, self.evals_notes.values, 0.0 + ) / [e.note_max / 20.0 for e in moduleimpl.evaluations] + + +class ModuleImplResultsAPC(ModuleImplResults): + "Calcul des moyennes de modules à la mode BUT" + def compute_module_moy( self, evals_poids_df: pd.DataFrame, @@ -200,22 +228,14 @@ class ModuleImplResultsAPC: assert evals_poids_df.shape[0] == nb_evals # compat notes/poids if nb_etuds == 0: return pd.DataFrame(index=[], columns=evals_poids_df.columns) - # Coefficients des évaluations, met à zéro ceux des évals incomplètes: - evals_coefs = ( - np.array( - [e.coefficient for e in moduleimpl.evaluations], - dtype=float, - ) - * self.evaluations_completes - ).reshape(-1, 1) + evals_coefs = self.get_evaluations_coefs(moduleimpl) evals_poids = evals_poids_df.values * evals_coefs # -> evals_poids shape : (nb_evals, nb_ues) assert evals_poids.shape == (nb_evals, nb_ues) - # Remplace les notes ATT, EXC, ABS, NaN par zéro et mets les notes sur 20: - evals_notes = np.where( - self.evals_notes.values > scu.NOTES_ABSENCE, self.evals_notes.values, 0.0 - ) / [e.note_max / 20.0 for e in moduleimpl.evaluations] - # Les poids des évals pour les étudiant: là où il a des notes non neutralisées + evals_notes_20 = self.get_eval_notes_sur_20(moduleimpl) + + # Les poids des évals pour chaque étudiant: là où il a des notes + # non neutralisées # (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui) # Note: les NaN sont remplacés par des 0 dans evals_notes # et dans dans evals_poids_etuds @@ -228,7 +248,7 @@ class ModuleImplResultsAPC: 0, ) # Calcule la moyenne pondérée sur les notes disponibles: - evals_notes_stacked = np.stack([evals_notes] * nb_ues, axis=2) + evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2) with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) etuds_moy_module = np.sum( evals_poids_etuds * evals_notes_stacked, axis=1 @@ -288,3 +308,45 @@ def moduleimpl_is_conforme( == module_evals_poids ) return check + + +class ModuleImplResultsClassic(ModuleImplResults): + "Calcul des moyennes de modules des formations classiques" + + def compute_module_moy(self) -> pd.Series: + """Calcule les moyennes des étudiants dans ce module + + Résultat: Series, lignes etud + = la note (moyenne) de l'étudiant pour ce module. + ou NaN si les évaluations (dans lesquelles l'étudiant a des notes) + ne donnent pas de coef. + """ + modimpl = ModuleImpl.query.get(self.moduleimpl_id) + nb_etuds, nb_evals = self.evals_notes.shape + if nb_etuds == 0: + return pd.Series() + evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1) + assert evals_coefs.shape == (nb_evals,) + evals_notes_20 = self.get_eval_notes_sur_20(modimpl) + # Les coefs des évals pour chaque étudiant: là où il a des notes + # non neutralisées + # (ABS n'est pas neutralisée, mais ATTENTE et NEUTRALISE oui) + # Note: les NaN sont remplacés par des 0 dans evals_notes + # et dans dans evals_poids_etuds + # (rappel: la comparaison est toujours False face à un NaN) + # shape: (nb_etuds, nb_evals) + coefs_stacked = np.stack([evals_coefs] * nb_etuds) + evals_coefs_etuds = np.where( + self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0 + ) + # Calcule la moyenne pondérée sur les notes disponibles: + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etuds_moy_module = np.sum( + evals_coefs_etuds * evals_notes_20, axis=1 + ) / np.sum(evals_coefs_etuds, axis=1) + + self.etuds_moy_module = pd.Series( + etuds_moy_module, + index=self.evals_notes.index, + ) + return self.etuds_moy_module diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index 037b4cd068..b9e93475c2 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -31,7 +31,7 @@ import numpy as np import pandas as pd -def compute_sem_moys(etud_moy_ue_df, modimpl_coefs_df): +def compute_sem_moys_apc(etud_moy_ue_df, modimpl_coefs_df): """Calcule la moyenne générale indicative = moyenne des moyennes d'UE, pondérée par la somme de leurs coefs diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 0c640fe93c..1ca76334ae 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -25,7 +25,7 @@ # ############################################################################## -"""Fonctions de calcul des moyennes d'UE +"""Fonctions de calcul des moyennes d'UE (classiques ou BUT) """ import numpy as np import pandas as pd @@ -34,13 +34,14 @@ from app import db from app import models from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef from app.comp import moy_mod +from app.models.formsemestre import FormSemestre from app.scodoc import sco_codes_parcours def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.DataFrame: - """Charge les coefs des modules de la formation pour le semestre indiqué. + """Charge les coefs APC des modules de la formation pour le semestre indiqué. - Ces coefs lient les modules à chaque UE. + En APC, ces coefs lient les modules à chaque UE. Résultat: (module_coefs_df, ues, modules) DataFrame rows = UEs, columns = modules, value = coef. @@ -85,7 +86,7 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data def df_load_modimpl_coefs( formsemestre: models.FormSemestre, ues=None, modimpls=None ) -> pd.DataFrame: - """Charge les coefs des modules du formsemestre indiqué. + """Charge les coefs APC des modules du formsemestre indiqué. Comme df_load_module_coefs mais prend seulement les UE et modules du formsemestre. @@ -126,7 +127,7 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray: return modimpls_notes.swapaxes(0, 1) -def notes_sem_load_cube(formsemestre): +def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: """Calcule le cube des notes du semestre (charge toutes les notes, calcule les moyenne des modules et assemble le cube) @@ -151,7 +152,7 @@ def notes_sem_load_cube(formsemestre): ) -def compute_ue_moys( +def compute_ue_moys_apc( sem_cube: np.array, etuds: list, modimpls: list, @@ -159,7 +160,7 @@ def compute_ue_moys( modimpl_inscr_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame, ) -> pd.DataFrame: - """Calcul de la moyenne d'UE + """Calcul de la moyenne d'UE en mode APC (BUT). La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR NI non inscrit à (au moins un) module de cette UE NA pas de notes disponibles @@ -168,11 +169,11 @@ def compute_ue_moys( sem_cube: notes moyennes aux modules ndarray (etuds x modimpls x UEs) (floats avec des NaN) - etuds : lites des étudiants (dim. 0 du cube) + etuds : listes des étudiants (dim. 0 du cube) modimpls : liste des modules à considérer (dim. 1 du cube) ues : liste des UE (dim. 2 du cube) - module_inscr_df: matrice d'inscription du semestre (etud x modimpl) - module_coefs_df: matrice coefficients (UE x modimpl) + modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) + modimpl_coefs_df: matrice coefficients (UE x modimpl) Resultat: DataFrame columns UE, rows etudid """ @@ -214,3 +215,70 @@ def compute_ue_moys( return pd.DataFrame( etud_moy_ue, index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index ) + + +def compute_ue_moys_classic( + formsemestre: FormSemestre, + sem_matrix: np.array, + ues: list, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, +) -> pd.DataFrame: + """Calcul de la moyenne d'UE en mode classique. + La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR + NI non inscrit à (au moins un) module de cette UE + NA pas de notes disponibles + ERR erreur dans une formule utilisateur. [XXX pas encore gérées ici] + + sem_matrix: notes moyennes aux modules + ndarray (etuds x modimpls) + (floats avec des NaN) + etuds : listes des étudiants (dim. 0 de la matrice) + ues : liste des UE + modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) + modimpl_coefs: vecteur des coefficients de modules + + Résultat: + - moyennes générales: pd.Series, index etudid + - moyennes d'UE: DataFrame columns UE, rows etudid + """ + nb_etuds, nb_modules = sem_matrix.shape + assert len(modimpl_coefs) == nb_modules + nb_ues = len(ues) + modimpl_inscr = modimpl_inscr_df.values + # Enlève les NaN du numérateur: + sem_matrix_no_nan = np.nan_to_num(sem_matrix, nan=0.0) + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: + sem_matrix_inscrits = np.where(modimpl_inscr, sem_matrix_no_nan, 0.0) + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + modimpl_coefs_etuds = np.where( + modimpl_inscr, np.stack([modimpl_coefs.T] * nb_etuds), 0.0 + ) + # Annule les coefs des modules NaN (nb_etuds x nb_mods) + modimpl_coefs_etuds_no_nan = np.where( + np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds + ) + # Calcul des moyennes générales: + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etud_moy_gen = np.sum( + modimpl_coefs_etuds_no_nan * sem_matrix_inscrits, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + etud_moy_gen_s = pd.Series(etud_moy_gen, index=modimpl_inscr_df.index) + # Calcul des moyennes d'UE + ue_modules = np.array( + [[m.module.ue == ue for m in formsemestre.modimpls] for ue in ues] + )[..., np.newaxis] + modimpl_coefs_etuds_no_nan_stacked = np.stack( + [modimpl_coefs_etuds_no_nan.T] * nb_ues + ) + # nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions + coefs = (modimpl_coefs_etuds_no_nan_stacked * ue_modules).swapaxes(1, 2) + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etud_moy_ue = ( + np.sum(coefs * sem_matrix_inscrits, axis=2) / np.sum(coefs, axis=2) + ).T + etud_moy_ue_df = pd.DataFrame( + etud_moy_ue, index=modimpl_inscr_df.index, columns=[ue.id for ue in ues] + ) + return etud_moy_gen_s, etud_moy_ue_df diff --git a/app/comp/res_but.py b/app/comp/res_but.py new file mode 100644 index 0000000000..c3c548ac27 --- /dev/null +++ b/app/comp/res_but.py @@ -0,0 +1,65 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Résultats semestres BUT +""" + +from app.comp import moy_ue, moy_sem, inscr_mod +from app.comp.res_sem import NotesTableCompat + + +class ResultatsSemestreBUT(NotesTableCompat): + """Résultats BUT: organisation des calculs""" + + _cached_attrs = NotesTableCompat._cached_attrs + ( + "modimpl_coefs_df", + "modimpls_evals_poids", + "sem_cube", + ) + + def __init__(self, formsemestre): + super().__init__(formsemestre) + + if not self.load_cached(): + self.compute() + self.store() + + def compute(self): + "Charge les notes et inscriptions et calcule les moyennes d'UE et gen." + ( + self.sem_cube, + self.modimpls_evals_poids, + self.modimpls_results, + ) = moy_ue.notes_sem_load_cube(self.formsemestre) + self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) + self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs( + self.formsemestre, ues=self.ues, modimpls=self.modimpls + ) + # l'idx de la colonne du mod modimpl.id est + # modimpl_coefs_df.columns.get_loc(modimpl.id) + # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) + self.etud_moy_ue = moy_ue.compute_ue_moys_apc( + self.sem_cube, + self.etuds, + self.modimpls, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs_df, + ) + self.etud_moy_gen = moy_sem.compute_sem_moys_apc( + self.etud_moy_ue, self.modimpl_coefs_df + ) + self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) + + def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: + """La moyenne de l'étudiant dans le moduleimpl + En APC, il s'agit d'une moyenne indicative sans valeur. + Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM) + """ + mod_idx = self.modimpl_coefs_df.columns.get_loc(moduleimpl_id) + etud_idx = self.etud_index[etudid] + # moyenne sur les UE: + return self.sem_cube[etud_idx, mod_idx].mean() diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py new file mode 100644 index 0000000000..c119126738 --- /dev/null +++ b/app/comp/res_classic.py @@ -0,0 +1,95 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Résultats semestres classiques (non APC) +""" +import numpy as np +import pandas as pd +from app.comp import moy_mod, moy_ue, moy_sem, inscr_mod +from app.comp.res_sem import NotesTableCompat +from app.models.formsemestre import FormSemestre + + +class ResultatsSemestreClassic(NotesTableCompat): + """Résultats du semestre (formation classique): organisation des calculs.""" + + _cached_attrs = NotesTableCompat._cached_attrs + ( + "modimpl_coefs", + "modimpl_idx", + "sem_matrix", + ) + + def __init__(self, formsemestre): + super().__init__(formsemestre) + + if not self.load_cached(): + self.compute() + self.store() + # recalculé (aussi rapide que de les cacher) + self.moy_min = self.etud_moy_gen.min() + self.moy_max = self.etud_moy_gen.max() + self.moy_moy = self.etud_moy_gen.mean() + + def compute(self): + "Charge les notes et inscriptions et calcule les moyennes d'UE et gen." + self.sem_matrix, self.modimpls_results = notes_sem_load_matrix( + self.formsemestre + ) + self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) + self.modimpl_coefs = np.array( + [m.module.coefficient for m in self.formsemestre.modimpls] + ) + self.modimpl_idx = {m.id: i for i, m in enumerate(self.formsemestre.modimpls)} + "l'idx de la colonne du mod modimpl.id est modimpl_idx[modimpl.id]" + + self.etud_moy_gen, self.etud_moy_ue = moy_ue.compute_ue_moys_classic( + self.formsemestre, + self.sem_matrix, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs, + ) + self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) + + def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: + """La moyenne de l'étudiant dans le moduleimpl + Result: valeur float (peut être NaN) ou chaîne "NI" (non inscrit ou DEM) + """ + return self.modimpls_results[moduleimpl_id].etuds_moy_module.get(etudid, "NI") + + +def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple: + """Calcule la matrice des notes du semestre + (charge toutes les notes, calcule les moyenne des modules + et assemble la matrice) + Resultat: + sem_matrix : 2d-array (etuds x modimpls) + modimpls_results dict { modimpl.id : ModuleImplResultsClassic } + """ + modimpls_results = {} + modimpls_notes = [] + for modimpl in formsemestre.modimpls: + mod_results = moy_mod.ModuleImplResultsClassic(modimpl) + etuds_moy_module = mod_results.compute_module_moy() + modimpls_results[modimpl.id] = mod_results + modimpls_notes.append(etuds_moy_module) + return ( + notes_sem_assemble_matrix(modimpls_notes), + modimpls_results, + ) + + +def notes_sem_assemble_matrix(modimpls_notes: list[pd.Series]) -> np.ndarray: + """Réuni les notes moyennes des modules du semestre en une matrice + + modimpls_notes : liste des moyennes de module + (Series rendus par compute_module_moy, index: etud) + Resultat: ndarray (etud x module) + """ + modimpls_notes_arr = [s.values for s in modimpls_notes] + modimpls_notes = np.stack(modimpls_notes_arr) + # passe de (mod x etud) à (etud x mod) + return modimpls_notes.T diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py index c0b78b539f..a73c46b09c 100644 --- a/app/comp/res_sem.py +++ b/app/comp/res_sem.py @@ -9,10 +9,10 @@ from functools import cached_property import numpy as np import pandas as pd from app.comp.aux import StatsMoyenne -from app.models import ModuleImpl +from app.models import FormSemestre, ModuleImpl from app.scodoc import sco_utils as scu from app.scodoc.sco_cache import ResultatsSemestreCache -from app.scodoc.sco_codes_parcours import UE_SPORT +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): @@ -25,17 +25,14 @@ from app.scodoc.sco_codes_parcours import UE_SPORT # class ResultatsSemestre: _cached_attrs = ( - "sem_cube", - "modimpl_inscr_df", - "modimpl_coefs_df", - "etud_moy_ue", - "modimpls_evals_poids", - "modimpls_results", - "etud_moy_gen", "etud_moy_gen_ranks", + "etud_moy_gen", + "etud_moy_ue", + "modimpl_inscr_df", + "modimpls_results", ) - def __init__(self, formsemestre): + def __init__(self, formsemestre: FormSemestre): self.formsemestre = formsemestre # BUT ou standard ? (apc == "approche par compétences") self.is_apc = formsemestre.formation.is_apc() @@ -70,7 +67,7 @@ class ResultatsSemestre: @cached_property def etuds(self): "Liste des inscrits au semestre, sans les démissionnaires" - # nb: si les liste des inscrits change, ResultatsSemestre devient invalide + # nb: si la liste des inscrits change, ResultatsSemestre devient invalide return self.formsemestre.get_inscrits(include_dem=False) @cached_property @@ -119,12 +116,16 @@ class ResultatsSemestre: # 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. + + 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 + () - def __init__(self, formsemestre): + def __init__(self, formsemestre: FormSemestre): super().__init__(formsemestre) nb_etuds = len(self.etuds) self.bonus = defaultdict(lambda: 0.0) # XXX TODO @@ -132,6 +133,30 @@ class NotesTableCompat(ResultatsSemestre): self.mod_rangs = { m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.modimpls } + self.moy_min = "NA" + self.moy_max = "NA" + + 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 inscrlist(self) -> list[dict]: # utilisé par PE seulement + """Liste de dict etud, avec démissionnaires + classée dans l'ordre alphabétique de noms. + """ + etuds = self.formsemestre.get_inscrits(include_dem=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): @@ -161,6 +186,33 @@ class NotesTableCompat(ResultatsSemestre): else: return [m.to_dict() for m in self.modimpls if m.module.ue.id == ue_id] + 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: + return { + "code": ATT, # XXX TODO + "assidu": True, # XXX TODO + "event_date": "", + "compense_formsemestre_id": 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_moy_gen(self, etudid): # -> float | str """Moyenne générale de cet etudiant dans ce semestre. Prend en compte les UE capitalisées. (TODO) @@ -169,38 +221,27 @@ class NotesTableCompat(ResultatsSemestre): """ return self.etud_moy_gen[etudid] - def get_moduleimpls_attente(self): - return [] # XXX TODO + 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_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): + def get_etud_ue_status(self, etudid: int, ue_id: int): 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_etud_rang(self, etudid: int): + return self.etud_moy_gen_ranks.get(etudid, 99999) # XXX - def get_mod_stats(self, moduleimpl_id): - return { - "moy": "-", - "max": "-", - "min": "-", - "nb_notes": "-", - "nb_missing": "-", - "nb_valid_evals": "-", - } + 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): + def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]: + "liste des évaluations valides dans un module" mi = ModuleImpl.query.get(moduleimpl_id) evals_results = [] for e in mi.evaluations: @@ -219,3 +260,78 @@ class NotesTableCompat(ResultatsSemestre): } evals_results.append(d) return evals_results + + def get_moduleimpls_attente(self): + return [] # XXX TODO + + def get_mod_stats(self, moduleimpl_id): + 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.modimpls) + 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.modimpls: + 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 + } diff --git a/app/models/etudiants.py b/app/models/etudiants.py index eb018e7041..4fd756a9da 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -13,6 +13,7 @@ from app import models from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN +from app.scodoc import notesdb as ndb class Identite(db.Model): @@ -105,10 +106,25 @@ class Identite(db.Model): r.append("-".join([x.lower().capitalize() for x in fields])) return " ".join(r) + @cached_property + def sort_key(self) -> tuple: + "clé pour tris par ordre alphabétique" + return (self.nom_usuel or self.nom).lower(), self.prenom.lower() + def get_first_email(self, field="email") -> str: "Le mail associé à la première adrese de l'étudiant, ou None" return self.adresses[0].email or None if self.adresses.count() > 0 else None + def to_dict_scodoc7(self): + """Représentation dictionnaire, + compatible ScoDoc7 mais sans infos admission + """ + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators: (backward compat) + e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) + return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty + def to_dict_bul(self, include_urls=True): """Infos exportées dans les bulletins""" from app.scodoc import sco_photos diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 4ada23e87e..928dd6093b 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -3,6 +3,7 @@ """ScoDoc models: formsemestre """ import datetime +from functools import cached_property import flask_sqlalchemy @@ -251,7 +252,7 @@ class FormSemestre(db.Model): etudid, self.date_debut.isoformat(), self.date_fin.isoformat() ) - def get_inscrits(self, include_dem=False) -> list: + def get_inscrits(self, include_dem=False) -> list[Identite]: """Liste des étudiants inscrits à ce semestre Si all, tous les étudiants, avec les démissionnaires. """ @@ -260,6 +261,11 @@ class FormSemestre(db.Model): else: return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] + @cached_property + def etuds_inscriptions(self) -> dict: + """Map { etudid : inscription }""" + return {ins.etud.id: ins for ins in self.inscriptions} + # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre notes_formsemestre_responsables = db.Table( diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index abbfec96ba..ce8426f8c3 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -202,7 +202,7 @@ class NotesTable: self.inscrlist.sort(key=itemgetter("nomp")) # { etudid : rang dans l'ordre alphabetique } - self.rang_alpha = {e["etudid"]: i for i, e in enumerate(self.inscrlist)} + self._rang_alpha = {e["etudid"]: i for i, e in enumerate(self.inscrlist)} self.bonus = scu.DictDefault(defaultvalue=0) # Notes dans les modules { moduleimpl_id : { etudid: note_moyenne_dans_ce_module } } @@ -366,7 +366,7 @@ class NotesTable: moy = -float(x[0]) except (ValueError, TypeError): moy = 1000.0 - return (moy, self.rang_alpha[x[-1]]) + return (moy, self._rang_alpha[x[-1]]) def get_etudids(self, sorted=False): if sorted: diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index cfd3634eca..6f394455e2 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -37,6 +37,7 @@ from flask import make_response from app import log from app.but import bulletin_but +from app.comp.res_classic import ResultatsSemestreClassic from app.models import FormSemestre from app.models.etudiants import Identite @@ -303,6 +304,8 @@ def make_formsemestre_recapcomplet( args={"formsemestre_id": formsemestre_id} )[0] nt = sco_cache.NotesTableCache.get(formsemestre_id) + # XXX EXPERIMENTAL + # nt = ResultatsSemestreClassic(formsemestre) modimpls = nt.get_modimpls_dict() ues = nt.get_ues_stat_dict() # incluant le(s) UE de sport # diff --git a/tests/unit/test_but_ues.py b/tests/unit/test_but_ues.py index 947e2f11c2..8f9ec3ece0 100644 --- a/tests/unit/test_but_ues.py +++ b/tests/unit/test_but_ues.py @@ -71,7 +71,7 @@ def test_ue_moy(test_client): # Recalcul des moyennes sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) etuds = formsemestre.etuds.all() - etud_moy_ue = moy_ue.compute_ue_moys( + etud_moy_ue = moy_ue.compute_ue_moys_apc( sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df ) return etud_moy_ue @@ -114,7 +114,7 @@ def test_ue_moy(test_client): # Recalcule les notes: sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) etuds = formsemestre.etuds.all() - etud_moy_ue = moy_ue.compute_ue_moys( + etud_moy_ue = moy_ue.compute_ue_moys_apc( sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df ) assert etud_moy_ue[ue1.id][etudid] == n1