From 8385941cf6f4a1f43826468dec2fcaa8f2a3380f Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 25 Jan 2022 10:45:13 +0100 Subject: [PATCH] =?UTF-8?q?WIP:=20calcul=20unifi=C3=A9,=20bonus=20sport=20?= =?UTF-8?q?BUT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/but/bulletin_but.py | 22 +- app/but/bulletin_but_xml_compat.py | 8 +- app/comp/aux.py | 14 +- app/comp/bonus_spo.py | 322 ++++++++++++++++++ app/comp/inscr_mod.py | 8 +- app/comp/moy_mod.py | 22 +- app/comp/moy_sem.py | 2 +- app/comp/moy_ue.py | 63 ++-- app/comp/res_but.py | 29 +- app/comp/res_classic.py | 44 ++- app/comp/res_common.py | 66 ++-- app/forms/main/config_forms.py | 6 +- app/models/formsemestre.py | 25 ++ app/models/moduleimpls.py | 15 +- app/models/modules.py | 5 +- app/models/preferences.py | 90 +++-- app/models/ues.py | 2 + app/scodoc/TrivialFormulator.py | 14 +- app/scodoc/bonus_sport.py | 3 +- app/scodoc/htmlutils.py | 13 +- app/scodoc/notes_table.py | 3 +- app/scodoc/sco_bulletins.py | 15 +- app/scodoc/sco_bulletins_json.py | 4 +- app/scodoc/sco_bulletins_xml.py | 9 +- app/scodoc/sco_cache.py | 3 +- app/scodoc/sco_config_actions.py | 2 +- app/scodoc/sco_edit_ue.py | 9 + app/scodoc/sco_formsemestre_status.py | 17 +- app/scodoc/sco_liste_notes.py | 28 +- app/scodoc/sco_moduleimpl_status.py | 4 +- app/static/css/scodoc.css | 14 + app/templates/pn/form_ues.html | 2 + .../versions/c95d5a3bf0de_couleur_ue.py | 28 ++ scodoc.py | 2 +- 34 files changed, 757 insertions(+), 156 deletions(-) create mode 100644 app/comp/bonus_spo.py create mode 100644 migrations/versions/c95d5a3bf0de_couleur_ue.py diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 2c6288bb4..da7677d45 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -13,6 +13,7 @@ from flask import url_for, g 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_codes_parcours import UE_SPORT from app.scodoc.sco_utils import fmt_note from app.comp.res_but import ResultatsSemestreBUT @@ -49,23 +50,30 @@ class BulletinBUT(ResultatsSemestreBUT): d = { "id": ue.id, "numero": ue.numero, + "type": ue.type, "ECTS": { "acquis": 0, # XXX TODO voir jury "total": ue.ects, }, + "color": ue.color, "competence": None, # XXX TODO lien avec référentiel - "moyenne": { - "value": fmt_note(self.etud_moy_ue[ue.id][etud.id]), - "min": fmt_note(self.etud_moy_ue[ue.id].min()), - "max": fmt_note(self.etud_moy_ue[ue.id].max()), - "moy": fmt_note(self.etud_moy_ue[ue.id].mean()), - }, - "bonus": None, # XXX TODO + "moyenne": None, + # Le bonus sport appliqué sur cette UE + "bonus": self.bonus_ues[ue.id][etud.id] + if self.bonus_ues is not None and ue.id in self.bonus_ues + else 0.0, "malus": None, # XXX TODO voir ce qui est ici "capitalise": None, # "AAAA-MM-JJ" TODO "ressources": self.etud_ue_mod_results(etud, ue, self.ressources), "saes": self.etud_ue_mod_results(etud, ue, self.saes), } + if ue.type != UE_SPORT: + d["moyenne"] = { + "value": fmt_note(self.etud_moy_ue[ue.id][etud.id]), + "min": fmt_note(self.etud_moy_ue[ue.id].min()), + "max": fmt_note(self.etud_moy_ue[ue.id].max()), + "moy": fmt_note(self.etud_moy_ue[ue.id].mean()), + } return d def etud_mods_results(self, etud, modimpls) -> dict: diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index f318f236e..69b8f5686 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -134,8 +134,12 @@ def bulletin_but_xml_compat( moy=scu.fmt_note(results.etud_moy_gen.mean()), # moyenne des moy. gen. ) ) - rang = 0 # XXX TODO rang de l'étduiant selon la moy gen indicative - bonus = 0 # XXX TODO valeur du bonus sport + rang = 0 # XXX TODO rang de l'étudiant selon la moy gen indicative + # valeur du bonus sport + if results.bonus is not None: + bonus = results.bonus[etud.id] + else: + bonus = 0 doc.append(Element("rang", value=str(rang), ninscrits=str(nb_inscrits))) # XXX TODO: ajouter "rang_group" : rangs dans les partitions doc.append(Element("note_max", value="20")) # notes toujours sur 20 diff --git a/app/comp/aux.py b/app/comp/aux.py index 6a758a64b..07517f362 100644 --- a/app/comp/aux.py +++ b/app/comp/aux.py @@ -19,12 +19,16 @@ class StatsMoyenne: def __init__(self, vals): """Calcul les statistiques. Les valeurs NAN ou non numériques sont toujours enlevées. + Si vals is None, renvoie des zéros (utilisé pour UE bonus) """ - 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)) + if vals is None: + self.moy = self.min = self.max = self.size = self.nb_vals = 0 + else: + 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): "Tous les attributs dans un dict" diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py new file mode 100644 index 000000000..bba0cd47a --- /dev/null +++ b/app/comp/bonus_spo.py @@ -0,0 +1,322 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Classes spécifiques de calcul du bonus sport, culture ou autres activités + +Les classes de Bonus fournissent deux méthodes: + - get_bonus_ues() + - get_bonus_moy_gen() + + +""" +import numpy as np +import pandas as pd + +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 bonus_sport +from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_utils import ModuleType + + +def get_bonus_sport_class_from_name(dept_id): + """La classe de bonus sport pour le département indiqué. + Note: en ScoDoc 9, le bonus sport est défini gloabelement et + ne dépend donc pas du département. + Résultat: une sous-classe de BonusSport + """ + raise NotImplementedError() + + +class BonusSport: + """Calcul du bonus sport. + + Arguments: + - sem_modimpl_moys : + notes moyennes aux modules (tous les étuds x tous les modimpls) + floats avec des NaN. + En classique: sem_matrix, ndarray (etuds x modimpls) + En APC: sem_cube, ndarray (etuds x modimpls x UEs) + - ues: les ues du semestre (incluant le bonus sport) + - modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl) + - modimpl_coefs: les coefs des modules + En classique: 1d ndarray de float (modimpl) + En APC: 2d ndarray de float, (modimpl x UE) <= attention à transposer + """ + + # Si vrai, en APC, si le bonus UE est None, reporte le bonus moy gen: + apc_apply_bonus_mg_to_ues = True + # Attributs virtuels: + seuil_moy_gen = None + proportion_point = None + bonus_moy_gen_limit = None + + name = "virtual" + + def __init__( + self, + formsemestre: FormSemestre, + sem_modimpl_moys: np.array, + ues: list, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, + ): + self.formsemestre = formsemestre + self.ues = ues + self.etuds_idx = modimpl_inscr_df.index # les étudiants inscrits au semestre + self.bonus_ues: pd.DataFrame = None # virtual + self.bonus_moy_gen: pd.Series = None # virtual + # Restreint aux modules standards des UE de type "sport": + modimpl_mask = np.array( + [ + (m.module.module_type == ModuleType.STANDARD) + and (m.module.ue.type == UE_SPORT) + for m in formsemestre.modimpls_sorted + ] + ) + self.modimpls_spo = [ + modimpl + for i, modimpl in enumerate(formsemestre.modimpls_sorted) + if modimpl_mask[i] + ] + "liste des modimpls sport" + + # Les moyennes des modules "sport": (une par UE en APC) + sem_modimpl_moys_spo = sem_modimpl_moys[:, modimpl_mask] + # Les inscriptions aux modules sport: + modimpl_inscr_spo = modimpl_inscr_df.values[:, modimpl_mask] + # Les coefficients des modules sport (en apc: nb_mod_sport x nb_ue) + modimpl_coefs_spo = modimpl_coefs[modimpl_mask] + # sem_modimpl_moys_spo est (nb_etuds, nb_mod_sport) + # ou (nb_etuds, nb_mod_sport, nb_ues) + nb_etuds, nb_mod_sport = sem_modimpl_moys_spo.shape[:2] + nb_ues = len(ues) + # Enlève les NaN du numérateur: + sem_modimpl_moys_no_nan = np.nan_to_num(sem_modimpl_moys_spo, nan=0.0) + + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + if formsemestre.formation.is_apc(): + # BUT + nb_ues_no_bonus = sem_modimpl_moys.shape[2] + # Duplique les inscriptions sur les UEs non bonus: + modimpl_inscr_spo_stacked = np.stack( + [modimpl_inscr_spo] * nb_ues_no_bonus, axis=2 + ) + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: + sem_modimpl_moys_inscrits = np.where( + modimpl_inscr_spo_stacked, sem_modimpl_moys_no_nan, 0.0 + ) + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + modimpl_coefs_etuds = np.where( + modimpl_inscr_spo_stacked, + np.stack([modimpl_coefs_spo.T] * nb_etuds), + 0.0, + ) + else: + # Formations classiques + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: + sem_modimpl_moys_inscrits = np.where( + modimpl_inscr_spo, sem_modimpl_moys_no_nan, 0.0 + ) + modimpl_coefs_spo = modimpl_coefs_spo.T + modimpl_coefs_etuds = np.where( + modimpl_inscr_spo, np.stack([modimpl_coefs_spo] * nb_etuds), 0.0 + ) + # Annule les coefs des modules NaN (nb_etuds x nb_mod_sport) + modimpl_coefs_etuds_no_nan = np.where( + np.isnan(sem_modimpl_moys_spo), 0.0, modimpl_coefs_etuds + ) + # + self.compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + + def compute_bonus( + self, + sem_modimpl_moys_inscrits: np.ndarray, + modimpl_coefs_etuds_no_nan: np.ndarray, + ): + """Calcul des bonus: méthode virtuelle à écraser. + Arguments: + - sem_modimpl_moys_inscrits: + ndarray (nb_etuds, mod_sport) ou en APC (nb_etuds, mods_sport, nb_ue) + les notes aux modules sports auxquel l'étudiant est inscrit, 0 sinon. Pas de nans. + - modimpl_coefs_etuds_no_nan: + les coefficients: float ndarray + + Résultat: None + """ + raise NotImplementedError("méthode virtuelle") + + def get_bonus_ues(self) -> pd.Series: + """Les bonus à appliquer aux UE + Résultat: DataFrame de float, index etudid, columns: ue.id + """ + if ( + self.formsemestre.formation.is_apc() + and self.apc_apply_bonus_mg_to_ues + and self.bonus_ues is None + ): + # reporte uniformément le bonus moyenne générale sur les UEs + # (assure la compatibilité de la plupart des anciens bonus avec le BUT) + # ues = self.formsemestre.query_ues(with_sport=False) + ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] + bonus_moy_gen = self.get_bonus_moy_gen() + bonus_ues = np.stack([bonus_moy_gen.values] * len(ues_idx), axis=1) + return pd.DataFrame(bonus_ues, index=self.etuds_idx, columns=ues_idx) + + return self.bonus_ues + + def get_bonus_moy_gen(self): + """Le bonus à appliquer à la moyenne générale. + Résultat: Series de float, index etudid + """ + return self.bonus_moy_gen + + +class BonusSportSimples(BonusSport): + """Les bonus sport simples calcule un bonus à partir des notes moyennes de modules + de l'UE sport, et ce bonus est soit appliqué sur la moyenne générale (formations classiques), + soit réparti sur les UE (formations APC). + + Le bonus est par défaut calculé comme: + Les points au-dessus du seuil (par défaut) 10 sur 20 obtenus dans chacun des + modules optionnels sont cumulés et une fraction de ces points cumulés s'ajoute + à la moyenne générale du semestre déjà obtenue par l'étudiant. + """ + + seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés + proportion_point = 0.05 # multiplie les points au dessus du seuil + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + bonus_moy_gen_arr = np.sum( + np.where( + sem_modimpl_moys_inscrits > self.seuil_moy_gen, + (sem_modimpl_moys_inscrits - self.seuil_moy_gen) + * self.proportion_point, + 0.0, + ), + axis=1, + ) + # en APC, applati la moyenne gen. XXX pourrait être fait en amont + if len(bonus_moy_gen_arr.shape) > 1: + bonus_moy_gen_arr = bonus_moy_gen_arr.sum(axis=1) + # Bonus moyenne générale, et 0 sur les UE + self.bonus_moy_gen = pd.Series( + bonus_moy_gen_arr, index=self.etuds_idx, dtype=float + ) + if self.bonus_moy_gen_limit is not None: + # Seuil: bonus (sur moy. gen.) limité à bonus_moy_gen_limit points + self.bonus_moy_gen = self.bonus_moy_gen.clip(upper=self.bonus_moy_gen_limit) + + # Laisse bonus_ues à None, en APC le bonus moy. gen. sera réparti sur les UEs. + + +# bonus_ue = np.stack([modimpl_coefs_spo.T] * nb_ues) + + +class BonusIUTV(BonusSportSimples): + """Calcul bonus modules optionels (sport, culture), règle IUT Villetaneuse + + Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université Paris 13 (sports, musique, deuxième langue, + culture, etc) non rattachés à une unité d'enseignement. Les points + au-dessus de 10 sur 20 obtenus dans chacune des matières + optionnelles sont cumulés et 5% de ces points cumulés s'ajoutent à + la moyenne générale du semestre déjà obtenue par l'étudiant. + """ + + name = "bonus_iutv" + pass # oui, c'ets le bonus par défaut + + +class BonusDirect(BonusSportSimples): + """Bonus direct: les points sont directement ajoutés à la moyenne générale. + Les coefficients sont ignorés: tous les points de bonus sont sommés. + (rappel: la note est ramenée sur 20 avant application). + """ + + name = "bonus_direct" + seuil_moy_gen = 0.0 # seuls le spoints au dessus du seuil sont comptés + proportion_point = 1.0 + + +class BonusIUTStDenis(BonusIUTV): + """Semblable à bonus_iutv mais sans coefficients et total limité à 0.5 points.""" + + name = "bonus_iut_stdenis" + bonus_moy_gen_limit = 0.5 + + +class BonusColmar(BonusSportSimples): + """Calcul bonus modules optionels (sport, culture), règle IUT Colmar. + + Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'U.H.A. (sports, musique, deuxième langue, culture, etc) non + rattachés à une unité d'enseignement. Les points au-dessus de 10 + sur 20 obtenus dans chacune des matières optionnelles sont cumulés + dans la limite de 10 points. 5% de ces points cumulés s'ajoutent à + la moyenne générale du semestre déjà obtenue par l'étudiant. + """ + + # note: cela revient à dire que l'on ajoute 5% des points au dessus de 10, + # et qu'on limite à 5% de 10, soit 0.5 points + # ce bonus est donc strictement identique à celui de St Denis (BonusIUTStDenis) + name = "bonus_colmar" + bonus_moy_gen_limit = 0.5 + + +class BonusVilleAvray: + """Calcul bonus modules optionels (sport, culture), règle IUT Ville d'Avray + + Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université Paris 10 (C2I) non rattachés à une unité d'enseignement. + Si la note est >= 10 et < 12, bonus de 0.1 point + Si la note est >= 12 et < 16, bonus de 0.2 point + Si la note est >= 16, bonus de 0.3 point + Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par + l'étudiant. + """ + + name = "bonus_iutva" + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + # Calcule moyenne pondérée des notes de sport: + bonus_moy_gen_arr = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + bonus_moy_gen_arr[bonus_moy_gen_arr >= 10.0] = 0.1 + bonus_moy_gen_arr[bonus_moy_gen_arr >= 12.0] = 0.2 + bonus_moy_gen_arr[bonus_moy_gen_arr >= 16.0] = 0.3 + + # Bonus moyenne générale, et 0 sur les UE + self.bonus_moy_gen = pd.Series( + bonus_moy_gen_arr, index=self.etuds_idx, dtype=float + ) + if self.bonus_moy_gen_limit is not None: + # Seuil: bonus (sur moy. gen.) limité à bonus_moy_gen_limit points + self.bonus_moy_gen = self.bonus_moy_gen.clip(upper=self.bonus_moy_gen_limit) + + # Laisse bonus_ues à None, en APC le bonus moy. gen. sera réparti sur les UEs. + + +def get_bonus_class_dict(start=BonusSport, d=None): + """Dictionnaire des classes de bonus + (liste les sous-classes de BonusSport ayant un nom) + Resultat: { name : class } + """ + if d is None: + d = {} + if start.name != "virtual": + d[start.name] = start + for subclass in start.__subclasses__(): + get_bonus_class_dict(subclass, d=d) + return d diff --git a/app/comp/inscr_mod.py b/app/comp/inscr_mod.py index 8a5f4bc8f..b9be1e9f0 100644 --- a/app/comp/inscr_mod.py +++ b/app/comp/inscr_mod.py @@ -21,7 +21,7 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame: value: bool (0/1 inscrit ou pas) """ # méthode la moins lente: une requete par module, merge les dataframes - moduleimpl_ids = [m.id for m in formsemestre.modimpls] + moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted] etudids = [inscr.etudid for inscr in formsemestre.inscriptions] df = pd.DataFrame(index=etudids, dtype=int) for moduleimpl_id in moduleimpl_ids: @@ -47,10 +47,10 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame: def df_load_modimpl_inscr_v0(formsemestre): # methode 0, pur SQL Alchemy, 1.5 à 2 fois plus lente - moduleimpl_ids = [m.id for m in formsemestre.modimpls] + moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted] etudids = [i.etudid for i in formsemestre.inscriptions] df = pd.DataFrame(False, columns=moduleimpl_ids, index=etudids, dtype=bool) - for modimpl in formsemestre.modimpls: + for modimpl in formsemestre.modimpls_sorted: ins_mod = df[modimpl.id] for inscr in modimpl.inscriptions: ins_mod[inscr.etudid] = True @@ -58,7 +58,7 @@ def df_load_modimpl_inscr_v0(formsemestre): def df_load_modimpl_inscr_v2(formsemestre): - moduleimpl_ids = [m.id for m in formsemestre.modimpls] + moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted] etudids = [i.etudid for i in formsemestre.inscriptions] df = pd.DataFrame(False, columns=moduleimpl_ids, index=etudids, dtype=bool) cursor = db.engine.execute( diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 2fee521d3..c3596f92a 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -65,8 +65,9 @@ class ModuleImplResults: self.module_id = moduleimpl.module.id self.etudids = None "liste des étudiants inscrits au SEMESTRE" + self.nb_inscrits_module = None - "nombre d'inscrits (non DEM) au module" + "nombre d'inscrits (non DEM) à ce module" self.evaluations_completes = [] "séquence de booléens, indiquant les évals à prendre en compte." self.evaluations_completes_dict = {} @@ -263,14 +264,12 @@ class ModuleImplResultsAPC(ModuleImplResults): return self.etuds_moy_module -def load_evaluations_poids( - moduleimpl_id: int, default_poids=1.0 -) -> tuple[pd.DataFrame, list]: +def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: """Charge poids des évaluations d'un module et retourne un dataframe rows = evaluations, columns = UE, value = poids (float). Les valeurs manquantes (évaluations sans coef vers des UE) sont - remplies par default_poids. - Résultat: (evals_poids, liste de UE du semestre) + remplies: 1 si le coef de ce module dans l'UE est non nul, zéro sinon. + Résultat: (evals_poids, liste de UEs du semestre sauf le sport) """ modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() @@ -282,8 +281,14 @@ def load_evaluations_poids( EvaluationUEPoids.evaluation ).filter_by(moduleimpl_id=moduleimpl_id): evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids - if default_poids is not None: - evals_poids.fillna(value=default_poids, inplace=True) + # Initialise poids non enregistrés: + if np.isnan(evals_poids.values.flat).any(): + ue_coefs = modimpl.module.get_ue_coef_dict() + for ue in ues: + evals_poids[ue.id][evals_poids[ue.id].isna()] = ( + 1 if ue_coefs.get(ue.id, 0.0) > 0 else 0 + ) + return evals_poids, ues @@ -296,6 +301,7 @@ def moduleimpl_is_conforme( évaluations vers une UE de coefficient non nul est non nulle. Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs + NB: les UEs dans evals_poids sont sans le bonus sport """ nb_evals, nb_ues = evals_poids.shape if nb_evals == 0: diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index 3c658988b..ae167d4e7 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -38,7 +38,7 @@ def compute_sem_moys_apc( = moyenne des moyennes d'UE, pondérée par la somme de leurs coefs etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid - modimpl_coefs_df: DataFrame, colonnes moduleimpl_id, lignes UE + modimpl_coefs_df: DataFrame, colonnes moduleimpl_id, lignes UE (sans ue bonus) Result: panda Series, index etudid, valeur float (moyenne générale) """ diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index ae8b98cca..cee1b8881 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -62,6 +62,10 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data .filter( (Module.module_type == ModuleType.RESSOURCE) | (Module.module_type == ModuleType.SAE) + | ( + (Module.ue_id == UniteEns.id) + & (UniteEns.type == sco_codes_parcours.UE_SPORT) + ) ) .order_by( Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code @@ -102,13 +106,13 @@ def df_load_modimpl_coefs( et modules du formsemestre. Si ues et modimpls sont None, prend tous ceux du formsemestre. Résultat: (module_coefs_df, ues, modules) - DataFrame rows = UEs, columns = modimpl, value = coef. + DataFrame rows = UEs (avec bonus), columns = modimpl, value = coef. """ if ues is None: ues = formsemestre.query_ues().all() ue_ids = [x.id for x in ues] if modimpls is None: - modimpls = formsemestre.modimpls.all() + modimpls = formsemestre.modimpls_sorted modimpl_ids = [x.id for x in modimpls] mod2impl = {m.module.id: m.id for m in modimpls} modimpl_coefs_df = pd.DataFrame(columns=modimpl_ids, index=ue_ids, dtype=float) @@ -134,7 +138,7 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray: assert len(modimpls_notes) modimpls_notes_arr = [df.values for df in modimpls_notes] modimpls_notes = np.stack(modimpls_notes_arr) - # passe de (mod x etud x ue) à (etud x mod x UE) + # passe de (mod x etud x ue) à (etud x mod x ue) return modimpls_notes.swapaxes(0, 1) @@ -144,10 +148,14 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: et assemble le cube. etuds: tous les inscrits au semestre (avec dem. et def.) - modimpls: _tous_ les modimpls de ce semestre - UEs: X?X voir quelles sont les UE considérées ici + modimpls: _tous_ les modimpls de ce semestre (y compris bonus sport) + UEs: toutes les UE du semestre (même si pas d'inscrits) SAUF le sport. - Resultat: + Attention: la liste des modimpls inclut les modules des UE sport, mais + elles ne sont pas dans la troisième dimension car elles n'ont pas de + "moyenne d'UE". + + Résultat: sem_cube : ndarray (etuds x modimpls x UEs) modimpls_evals_poids dict { modimpl.id : evals_poids } modimpls_results dict { modimpl.id : ModuleImplResultsAPC } @@ -155,7 +163,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: modimpls_results = {} modimpls_evals_poids = {} modimpls_notes = [] - for modimpl in formsemestre.modimpls: + for modimpl in formsemestre.modimpls_sorted: mod_results = moy_mod.ModuleImplResultsAPC(modimpl) evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id) etuds_moy_module = mod_results.compute_module_moy(evals_poids) @@ -194,26 +202,27 @@ def compute_ue_moys_apc( modimpls : liste des modules à considérer (dim. 1 du cube) ues : liste des UE (dim. 2 du cube) modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) - modimpl_coefs_df: matrice coefficients (UE x modimpl) + modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport - Resultat: DataFrame columns UE, rows etudid + Résultat: DataFrame columns UE (sans sport), rows etudid """ - nb_etuds, nb_modules, nb_ues = sem_cube.shape + nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape + nb_ues_tot = len(ues) assert len(modimpls) == nb_modules if nb_modules == 0 or nb_etuds == 0: return pd.DataFrame( index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index ) assert len(etuds) == nb_etuds - assert len(ues) == nb_ues assert modimpl_inscr_df.shape[0] == nb_etuds assert modimpl_inscr_df.shape[1] == nb_modules - assert modimpl_coefs_df.shape[0] == nb_ues + assert modimpl_coefs_df.shape[0] == nb_ues_no_bonus assert modimpl_coefs_df.shape[1] == nb_modules modimpl_inscr = modimpl_inscr_df.values modimpl_coefs = modimpl_coefs_df.values - # Duplique les inscriptions sur les UEs: - modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues, axis=2) + + # Duplique les inscriptions sur les UEs non bonus: + modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues_no_bonus, axis=2) # Enlève les NaN du numérateur: # si on veut prendre en compte les modules avec notes neutralisées ? sem_cube_no_nan = np.nan_to_num(sem_cube, nan=0.0) @@ -234,7 +243,9 @@ def compute_ue_moys_apc( modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1 ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) return pd.DataFrame( - etud_moy_ue, index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index + etud_moy_ue, + index=modimpl_inscr_df.index, # les etudids + columns=modimpl_coefs_df.index, # les UE sans les UE bonus sport ) @@ -244,6 +255,7 @@ def compute_ue_moys_classic( ues: list, modimpl_inscr_df: pd.DataFrame, modimpl_coefs: np.array, + modimpl_mask: np.array, ) -> tuple[pd.Series, pd.DataFrame, 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 @@ -251,13 +263,19 @@ def compute_ue_moys_classic( NA pas de notes disponibles ERR erreur dans une formule utilisateur. [XXX pas encore gérées ici] - sem_matrix: notes moyennes aux modules + L'éventuel bonus sport n'est PAS appliqué ici. + + Le masque modimpl_mask est un tableau de booléens (un par modimpl) qui + permet de sélectionner un sous-ensemble de modules (SAEs, tout sauf sport, ...). + + sem_matrix: notes moyennes aux modules (tous les étuds x tous les modimpls) ndarray (etuds x modimpls) (floats avec des NaN) etuds : listes des étudiants (dim. 0 de la matrice) - ues : liste des UE + ues : liste des UE du semestre modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_coefs: vecteur des coefficients de modules + modimpl_mask: masque des modimpls à prendre en compte Résultat: - moyennes générales: pd.Series, index etudid @@ -266,10 +284,15 @@ def compute_ue_moys_classic( les coefficients effectifs de chaque UE pour chaque étudiant (sommes de coefs de modules pris en compte) """ + # Restreint aux modules sélectionnés: + sem_matrix = sem_matrix[:, modimpl_mask] + modimpl_inscr = modimpl_inscr_df.values[:, modimpl_mask] + modimpl_coefs = modimpl_coefs[modimpl_mask] + 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: @@ -291,8 +314,8 @@ def compute_ue_moys_classic( 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] + [[m.module.ue == ue for m in formsemestre.modimpls_sorted] for ue in ues] + )[..., np.newaxis][:, modimpl_mask, :] modimpl_coefs_etuds_no_nan_stacked = np.stack( [modimpl_coefs_etuds_no_nan.T] * nb_ues ) diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 669380a31..2ae263d5b 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -10,6 +10,9 @@ import pandas as pd from app.comp import moy_ue, moy_sem, inscr_mod from app.comp.res_common import NotesTableCompat +from app.comp.bonus_spo import BonusSport +from app.models import ScoDocSiteConfig +from app.scodoc.sco_codes_parcours import UE_SPORT class ResultatsSemestreBUT(NotesTableCompat): @@ -37,26 +40,44 @@ class ResultatsSemestreBUT(NotesTableCompat): ) = 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 + self.formsemestre, ues=self.ues, modimpls=self.formsemestre.modimpls_sorted ) # 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) + + # Elimine les coefs des UE bonus sports + no_bonus = [ue.type != UE_SPORT for ue in self.ues] + modimpl_coefs_no_bonus_df = self.modimpl_coefs_df[no_bonus] self.etud_moy_ue = moy_ue.compute_ue_moys_apc( self.sem_cube, self.etuds, - self.modimpls, + self.formsemestre.modimpls_sorted, self.ues, self.modimpl_inscr_df, - self.modimpl_coefs_df, + modimpl_coefs_no_bonus_df, ) # Les coefficients d'UE ne sont pas utilisés en APC self.etud_coef_ue_df = pd.DataFrame( 1.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns ) self.etud_moy_gen = moy_sem.compute_sem_moys_apc( - self.etud_moy_ue, self.modimpl_coefs_df + self.etud_moy_ue, modimpl_coefs_no_bonus_df ) + # --- Bonus Sport & Culture + bonus_class = ScoDocSiteConfig.get_bonus_sport_class() + if bonus_class is not None: + bonus: BonusSport = bonus_class( + self.formsemestre, + self.sem_cube, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs_df.transpose(), + ) + self.bonus_ues = bonus.get_bonus_ues() + if self.bonus_ues is not None: + self.etud_moy_ue += self.bonus_ues # somme les dataframes + 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: diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 68972ced6..3c7428673 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -11,7 +11,11 @@ import pandas as pd from app.comp import moy_mod, moy_ue, moy_sem, inscr_mod from app.comp.res_common import NotesTableCompat +from app.comp.bonus_spo import BonusSport +from app.models import ScoDocSiteConfig from app.models.formsemestre import FormSemestre +from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_utils import ModuleType class ResultatsSemestreClassic(NotesTableCompat): @@ -41,11 +45,20 @@ class ResultatsSemestreClassic(NotesTableCompat): ) 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] + [m.module.coefficient for m in self.formsemestre.modimpls_sorted] ) - self.modimpl_idx = {m.id: i for i, m in enumerate(self.formsemestre.modimpls)} + self.modimpl_idx = { + m.id: i for i, m in enumerate(self.formsemestre.modimpls_sorted) + } "l'idx de la colonne du mod modimpl.id est modimpl_idx[modimpl.id]" + modimpl_standards_mask = np.array( + [ + (m.module.module_type == ModuleType.STANDARD) + and (m.module.ue.type != UE_SPORT) + for m in self.formsemestre.modimpls_sorted + ] + ) ( self.etud_moy_gen, self.etud_moy_ue, @@ -56,7 +69,28 @@ class ResultatsSemestreClassic(NotesTableCompat): self.ues, self.modimpl_inscr_df, self.modimpl_coefs, + modimpl_standards_mask, ) + # --- Bonus Sport & Culture + bonus_class = ScoDocSiteConfig.get_bonus_sport_class() + if bonus_class is not None: + bonus: BonusSport = bonus_class( + self.formsemestre, + self.sem_matrix, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs, + ) + self.bonus_ues = bonus.get_bonus_ues() + if self.bonus_ues is not None: + self.etud_moy_ue += self.bonus_ues # somme les dataframes + bonus_mg = bonus.get_bonus_moy_gen() + if bonus_mg is not None: + self.etud_moy_gen += bonus_mg + self.bonus = ( + bonus_mg # compat nt, utilisé pour l'afficher sur les bulletins + ) + # --- Classements: 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: @@ -85,9 +119,9 @@ class ResultatsSemestreClassic(NotesTableCompat): } -def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple: +def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]: """Calcule la matrice des notes du semestre - (charge toutes les notes, calcule les moyenne des modules + (charge toutes les notes, calcule les moyennes des modules et assemble la matrice) Resultat: sem_matrix : 2d-array (etuds x modimpls) @@ -95,7 +129,7 @@ def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple: """ modimpls_results = {} modimpls_notes = [] - for modimpl in formsemestre.modimpls: + for modimpl in formsemestre.modimpls_sorted: mod_results = moy_mod.ModuleImplResultsClassic(modimpl) etuds_moy_module = mod_results.compute_module_moy() modimpls_results[modimpl.id] = mod_results diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 1ff654686..9356bf89e 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -100,42 +100,28 @@ class ResultatsSemestre: @cached_property def ues(self) -> list[UniteEns]: - """Liste des UEs du semestre + """Liste des UEs du semestre (avec les UE bonus sport) (indices des DataFrames) """ return self.formsemestre.query_ues(with_sport=True).all() - @cached_property - def modimpls(self): - """Liste des modimpls du semestre - - triée par numéro de module en APC - - triée par numéros d'UE/matières/modules pour les formations standard. - """ - modimpls = self.formsemestre.modimpls.all() - if self.is_apc: - modimpls.sort(key=lambda m: (m.module.numero, m.module.code)) - else: - modimpls.sort( - key=lambda m: ( - m.module.ue.numero, - m.module.matiere.numero, - m.module.numero, - m.module.code, - ) - ) - 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 + 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.modimpls if m.module.module_type == scu.ModuleType.SAE] + return [ + m + for m in self.formsemestre.modimpls_sorted + if m.module.module_type == scu.ModuleType.SAE + ] @cached_property def ue_validables(self) -> list: @@ -163,16 +149,20 @@ class NotesTableCompat(ResultatsSemestre): développements (API malcommode et peu efficace). """ - _cached_attrs = ResultatsSemestre._cached_attrs + () + _cached_attrs = ResultatsSemestre._cached_attrs + ( + "bonus", + "bonus_ues", + ) def __init__(self, formsemestre: 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.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: (defaultdict(lambda: 0), nb_etuds) for m in self.modimpls + m.id: (None, nb_etuds) for m in self.formsemestre.modimpls_sorted } self.moy_min = "NA" self.moy_max = "NA" @@ -221,7 +211,11 @@ class NotesTableCompat(ResultatsSemestre): ues = [] for ue in self.formsemestre.query_ues(with_sport=not filter_sport): d = ue.to_dict() - d.update(StatsMoyenne(self.etud_moy_ue[ue.id]).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 @@ -230,9 +224,13 @@ class NotesTableCompat(ResultatsSemestre): triés par numéros (selon le type de formation) """ if ue_id is None: - return [m.to_dict() for m in self.modimpls] + return [m.to_dict() for m in self.formsemestre.modimpls_sorted] else: - return [m.to_dict() for m in self.modimpls if m.module.ue.id == ue_id] + return [ + m.to_dict() + for m in self.formsemestre.modimpls_sorted + 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. @@ -359,12 +357,16 @@ class NotesTableCompat(ResultatsSemestre): 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) + 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.modimpls: + for modimpl in self.formsemestre.modimpls_sorted: val = self.get_etud_mod_moy(modimpl.id, etudid) t.append(val) t.append(etudid) diff --git a/app/forms/main/config_forms.py b/app/forms/main/config_forms.py index 16be84518..26548f08d 100644 --- a/app/forms/main/config_forms.py +++ b/app/forms/main/config_forms.py @@ -310,7 +310,7 @@ class ScoDocConfigurationForm(FlaskForm): label="Fonction de calcul des bonus sport&culture", choices=[ (x, x if x else "Aucune") - for x in ScoDocSiteConfig.get_bonus_sport_func_names() + for x in ScoDocSiteConfig.get_bonus_sport_class_names() ], ) depts = FieldList(FormField(DeptForm)) @@ -363,7 +363,7 @@ class ScoDocConfigurationForm(FlaskForm): def select_action(self): if ( self.data["bonus_sport_func_name"] - != ScoDocSiteConfig.get_bonus_sport_func_name() + != ScoDocSiteConfig.get_bonus_sport_class_name() ): return BonusSportUpdate(self.data) for dept_entry in self.depts: @@ -381,7 +381,7 @@ def configuration(): raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) form = ScoDocConfigurationForm( data=_make_data( - bonus_sport=ScoDocSiteConfig.get_bonus_sport_func_name(), + bonus_sport=ScoDocSiteConfig.get_bonus_sport_class_name(), modele=sco_logos.list_logos(), ) ) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index a74f1671d..e1febc321 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -112,6 +112,9 @@ class FormSemestre(db.Model): if self.modalite is None: self.modalite = FormationModalite.DEFAULT_MODALITE + def __repr__(self): + return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>" + def to_dict(self): d = dict(self.__dict__) d.pop("_sa_instance_state", None) @@ -152,6 +155,28 @@ class FormSemestre(db.Model): sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT) return sem_ues.order_by(UniteEns.numero) + @cached_property + def modimpls_sorted(self) -> list[ModuleImpl]: + """Liste des modimpls du semestre + - triée par type/numéro/code en APC + - triée par numéros d'UE/matières/modules pour les formations standard. + """ + modimpls = self.modimpls.all() + if self.formation.is_apc(): + modimpls.sort( + key=lambda m: (m.module.module_type, m.module.numero, m.module.code) + ) + else: + modimpls.sort( + key=lambda m: ( + m.module.ue.numero, + m.module.matiere.numero, + m.module.numero, + m.module.code, + ) + ) + return modimpls + def est_courant(self) -> bool: """Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses) diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 2aa36da9c..d51a620b0 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -5,7 +5,7 @@ import pandas as pd from app import db from app.comp import df_cache -from app.models import UniteEns, Identite +from app.models import Identite, Module import app.scodoc.notesdb as ndb from app.scodoc import sco_utils as scu @@ -127,3 +127,16 @@ class ModuleImplInscription(db.Model): ModuleImpl, backref=db.backref("inscriptions", cascade="all, delete-orphan"), ) + + @classmethod + def nb_inscriptions_dans_ue( + cls, formsemestre_id: int, etudid: int, ue_id: int + ) -> int: + """Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" + return ModuleImplInscription.query.filter( + ModuleImplInscription.etudid == etudid, + ModuleImplInscription.moduleimpl_id == ModuleImpl.id, + ModuleImpl.formsemestre_id == formsemestre_id, + ModuleImpl.module_id == Module.id, + Module.ue_id == ue_id, + ).count() diff --git a/app/models/modules.py b/app/models/modules.py index 24e552461..ac82b127e 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -4,6 +4,7 @@ from app import db from app.models import APO_CODE_STR_LEN from app.scodoc import sco_utils as scu +from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_utils import ModuleType @@ -131,7 +132,8 @@ class Module(db.Model): def ue_coefs_list(self, include_zeros=True): """Liste des coefs vers les UE (pour les modules APC). - Si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre. + Si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre, + sauf UE bonus sport. Result: List of tuples [ (ue, coef) ] """ if not self.is_apc(): @@ -140,6 +142,7 @@ class Module(db.Model): # Toutes les UE du même semestre: ues_semestre = ( self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx) + .filter(UniteEns.type != UE_SPORT) .order_by(UniteEns.numero) .all() ) diff --git a/app/models/preferences.py b/app/models/preferences.py index 59c82ec80..f220ee177 100644 --- a/app/models/preferences.py +++ b/app/models/preferences.py @@ -3,7 +3,7 @@ """Model : preferences """ from app import db, log -from app.scodoc import bonus_sport +from app.comp import bonus_spo from app.scodoc.sco_exceptions import ScoValueError @@ -61,47 +61,80 @@ class ScoDocSiteConfig(db.Model): } @classmethod - def set_bonus_sport_func(cls, func_name): + def set_bonus_sport_class(cls, class_name): """Record bonus_sport config. - If func_name not defined, raise NameError + If class_name not defined, raise NameError """ - if func_name not in cls.get_bonus_sport_func_names(): - raise NameError("invalid function name for bonus_sport") + if class_name not in cls.get_bonus_sport_class_names(): + raise NameError("invalid class name for bonus_sport") c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() if c: - log("setting to " + func_name) - c.value = func_name + log("setting to " + class_name) + c.value = class_name else: - c = ScoDocSiteConfig(cls.BONUS_SPORT, func_name) + c = ScoDocSiteConfig(cls.BONUS_SPORT, class_name) db.session.add(c) db.session.commit() @classmethod - def get_bonus_sport_func_name(cls): + def get_bonus_sport_class_name(cls): """Get configured bonus function name, or None if None.""" - f = cls.get_bonus_sport_func_from_name() - if f is None: + klass = cls.get_bonus_sport_class_from_name() + if klass is None: return "" else: - return f.__name__ + return klass.name + + @classmethod + def get_bonus_sport_class(cls): + """Get configured bonus function, or None if None.""" + return cls.get_bonus_sport_class_from_name() + + @classmethod + def get_bonus_sport_class_from_name(cls, class_name=None): + """returns bonus class with specified name. + If name not specified, return the configured function. + None if no bonus function configured. + Raises ScoValueError if class_name not found in module bonus_sport. + """ + if class_name is None: + c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() + if c is None: + return None + class_name = c.value + if class_name == "": # pas de bonus défini + return None + klass = bonus_spo.get_bonus_class_dict().get(class_name) + if klass is None: + raise ScoValueError( + f"""Fonction de calcul bonus sport inexistante: {class_name}. + (contacter votre administrateur local).""" + ) + return klass + + @classmethod + def get_bonus_sport_class_names(cls): + """List available functions names + (starting with empty string to represent "no bonus function"). + """ + return [""] + sorted(bonus_spo.get_bonus_class_dict().keys()) @classmethod def get_bonus_sport_func(cls): - """Get configured bonus function, or None if None.""" - return cls.get_bonus_sport_func_from_name() - - @classmethod - def get_bonus_sport_func_from_name(cls, func_name=None): + """Fonction bonus_sport ScoDoc 7 XXX + Transitoire pour les tests durant la transition #sco92 + """ """returns bonus func with specified name. If name not specified, return the configured function. None if no bonus function configured. Raises ScoValueError if func_name not found in module bonus_sport. """ - if func_name is None: - c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() - if c is None: - return None - func_name = c.value + from app.scodoc import bonus_sport + + c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() + if c is None: + return None + func_name = c.value if func_name == "": # pas de bonus défini return None try: @@ -111,16 +144,3 @@ class ScoDocSiteConfig(db.Model): f"""Fonction de calcul maison inexistante: {func_name}. (contacter votre administrateur local).""" ) - - @classmethod - def get_bonus_sport_func_names(cls): - """List available functions names - (starting with empty string to represent "no bonus function"). - """ - return [""] + sorted( - [ - getattr(bonus_sport, name).__name__ - for name in dir(bonus_sport) - if name.startswith("bonus_") - ] - ) diff --git a/app/models/ues.py b/app/models/ues.py index 26223eefc..3497414c0 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -41,6 +41,8 @@ class UniteEns(db.Model): # coef UE, utilise seulement si l'option use_ue_coefs est activée: coefficient = db.Column(db.Float) + color = db.Column(db.Text()) + # relations matieres = db.relationship("Matiere", lazy="dynamic", backref="ue") modules = db.relationship("Module", lazy="dynamic", backref="ue") diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index 722b7ec9a..23b500393 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -73,7 +73,8 @@ def TrivialFormulator( input_type : 'text', 'textarea', 'password', 'radio', 'menu', 'checkbox', 'hidden', 'separator', 'file', 'date', 'datedmy' (avec validation), - 'boolcheckbox', 'text_suggest' + 'boolcheckbox', 'text_suggest', + 'color' (default text) size : text field width rows, cols: textarea geometry @@ -594,6 +595,11 @@ class TF(object): var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); """ ) + elif input_type == "color": + lem.append( + '') % values) else: raise ValueError("unkown input_type for form (%s)!" % input_type) explanation = descr.get("explanation", "") @@ -712,7 +718,11 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); R.append("%s" % title) R.append('' % klass) - if input_type == "text" or input_type == "text_suggest": + if ( + input_type == "text" + or input_type == "text_suggest" + or input_type == "color" + ): R.append(("%(" + field + ")s") % self.values) elif input_type in ("radio", "menu", "checkbox", "boolcheckbox"): if input_type == "boolcheckbox": diff --git a/app/scodoc/bonus_sport.py b/app/scodoc/bonus_sport.py index 75b08b501..5351ecb33 100644 --- a/app/scodoc/bonus_sport.py +++ b/app/scodoc/bonus_sport.py @@ -77,7 +77,6 @@ def bonus_iutv(notes_sport, coefs, infos=None): optionnelles sont cumulés et 5% de ces points cumulés s'ajoutent à la moyenne générale du semestre déjà obtenue par l'étudiant. """ - # breakpoint() bonus = sum([(x - 10) / 20.0 for x in notes_sport if x > 10]) return bonus @@ -91,7 +90,7 @@ def bonus_direct(notes_sport, coefs, infos=None): def bonus_iut_stdenis(notes_sport, coefs, infos=None): - """Semblable à bonus_iutv mais sans coefficients et total limité à 0.5 points.""" + """Semblable à bonus_iutv mais total limité à 0.5 points.""" points = sum([x - 10 for x in notes_sport if x > 10]) # points au dessus de 10 bonus = points * 0.05 # ou / 20 return min(bonus, 0.5) # bonus limité à 1/2 point diff --git a/app/scodoc/htmlutils.py b/app/scodoc/htmlutils.py index 3306b80d1..65101f1f4 100644 --- a/app/scodoc/htmlutils.py +++ b/app/scodoc/htmlutils.py @@ -29,6 +29,7 @@ """ from html.parser import HTMLParser from html.entities import name2codepoint +from multiprocessing.sharedctypes import Value import re from flask import g, url_for @@ -36,17 +37,23 @@ from flask import g, url_for from . import listhistogram -def horizontal_bargraph(value, mark): +def horizontal_bargraph(value, mark) -> str: """html drawing an horizontal bar and a mark used to vizualize the relative level of a student """ - tmpl = """ + try: + vals = {"value": int(value), "mark": int(mark)} + except ValueError: + return "" + return ( + """ """ - return tmpl % {"value": int(value), "mark": int(mark)} + % vals + ) def histogram_notes(notes): diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index eec178f14..090f18e01 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -170,7 +170,7 @@ class NotesTable: """ def __init__(self, formsemestre_id): - log(f"NotesTable( formsemestre_id={formsemestre_id} )") + # log(f"NotesTable( formsemestre_id={formsemestre_id} )") # raise NotImplementedError() # XXX if not formsemestre_id: raise ValueError("invalid formsemestre_id (%s)" % formsemestre_id) @@ -909,6 +909,7 @@ class NotesTable: if len(coefs_bonus_gen) == 1: coefs_bonus_gen = [1.0] # irrelevant, may be zero + # XXX attention: utilise anciens bonus_sport, évidemment bonus_func = ScoDocSiteConfig.get_bonus_sport_func() if bonus_func: bonus = bonus_func( diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 1941d051f..4dcf4d819 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -43,6 +43,7 @@ from flask import g, request from flask import url_for from flask_login import current_user from flask_mail import Message +from app.models.moduleimpls import ModuleImplInscription import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType @@ -285,19 +286,29 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): else: I["rang_nt"], I["rang_txt"] = "", "" I["note_max"] = 20.0 # notes toujours sur 20 - I["bonus_sport_culture"] = nt.bonus[etudid] + I["bonus_sport_culture"] = nt.bonus[etudid] if nt.bonus is not None else 0.0 # Liste les UE / modules /evals I["ues"] = [] I["matieres_modules"] = {} I["matieres_modules_capitalized"] = {} for ue in ues: + if ( + ModuleImplInscription.nb_inscriptions_dans_ue( + formsemestre_id, etudid, ue["ue_id"] + ) + == 0 + ): + continue u = ue.copy() ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) u["ue_status"] = ue_status # { 'moy', 'coef_ue', ...} if ue["type"] != sco_codes_parcours.UE_SPORT: u["cur_moy_ue_txt"] = scu.fmt_note(ue_status["cur_moy_ue"]) else: - x = scu.fmt_note(nt.bonus[etudid], keep_numeric=True) + if nt.bonus is not None: + x = scu.fmt_note(nt.bonus[etudid], keep_numeric=True) + else: + x = "" if isinstance(x, str): u["cur_moy_ue_txt"] = "pas de bonus" else: diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py index 5a6d542d0..89f655049 100644 --- a/app/scodoc/sco_bulletins_json.py +++ b/app/scodoc/sco_bulletins_json.py @@ -192,7 +192,9 @@ def formsemestre_bulletinetud_published_dict( ) d["note_max"] = dict(value=20) # notes toujours sur 20 - d["bonus_sport_culture"] = dict(value=nt.bonus[etudid]) + d["bonus_sport_culture"] = dict( + value=nt.bonus[etudid] if nt.bonus is not None else 0.0 + ) # Liste les UE / modules /evals d["ue"] = [] diff --git a/app/scodoc/sco_bulletins_xml.py b/app/scodoc/sco_bulletins_xml.py index aa2b95775..5d6ba7def 100644 --- a/app/scodoc/sco_bulletins_xml.py +++ b/app/scodoc/sco_bulletins_xml.py @@ -195,7 +195,12 @@ def make_xml_formsemestre_bulletinetud( ) ) doc.append(Element("note_max", value="20")) # notes toujours sur 20 - doc.append(Element("bonus_sport_culture", value=str(nt.bonus[etudid]))) + doc.append( + Element( + "bonus_sport_culture", + value=str(nt.bonus[etudid] if nt.bonus is not None else 0.0), + ) + ) # Liste les UE / modules /evals for ue in ues: ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) @@ -211,7 +216,7 @@ def make_xml_formsemestre_bulletinetud( if ue["type"] != sco_codes_parcours.UE_SPORT: v = ue_status["cur_moy_ue"] else: - v = nt.bonus[etudid] + v = nt.bonus[etudid] if nt.bonus is not None else 0.0 x_ue.append( Element( "note", diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index 17ddd09b6..59ebab2d5 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -98,8 +98,9 @@ class ScoDocCache: status = CACHE.set(key, value, timeout=cls.timeout) if not status: log("Error: cache set failed !") - except: + except Exception as exc: log("XXX CACHE Warning: error in set !!!") + log(exc) status = None return status diff --git a/app/scodoc/sco_config_actions.py b/app/scodoc/sco_config_actions.py index c3ccc9cb6..949567eca 100644 --- a/app/scodoc/sco_config_actions.py +++ b/app/scodoc/sco_config_actions.py @@ -168,7 +168,7 @@ class BonusSportUpdate(Action): def build_action(parameters): if ( parameters["bonus_sport_func_name"] - != ScoDocSiteConfig.get_bonus_sport_func_name() + != ScoDocSiteConfig.get_bonus_sport_class_name() ): return [BonusSportUpdate(parameters)] return [] diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 6b7884591..5282c5ce1 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -81,6 +81,7 @@ _ueEditor = ndb.EditableTable( "is_external", "code_apogee", "coefficient", + "color", ), sortkey="numero", input_formators={ @@ -358,6 +359,14 @@ def ue_edit(ue_id=None, create=False, formation_id=None): "explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement", }, ), + ( + "color", + { + "input_type": "color", + "title": "Couleur", + "explanation": "pour affichages", + }, + ), ] if create and not parcours.UE_IS_MODULE and not is_apc: fw.append( diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index d0ee4b1a7..2c5a8af43 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1107,6 +1107,7 @@ _TABLEAU_MODULES_HEAD = """ Module Inscrits Responsable +Coefs. Évaluations """ @@ -1213,7 +1214,21 @@ def formsemestre_tableau_modules( sco_users.user_info(modimpl["responsable_id"])["prenomnom"], ) ) - + H.append("") + if mod.module_type in (ModuleType.RESSOURCE, ModuleType.SAE): + coefs = mod.ue_coefs_list() + for coef in coefs: + if coef[1] > 0: + H.append( + f"""""" + ) + else: + H.append(f"""""") + H.append("") if mod.module_type in ( None, # ne devrait pas être nécessaire car la migration a remplacé les NULLs ModuleType.STANDARD, diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 4cd768886..a342e3126 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -37,7 +37,10 @@ from app.models.moduleimpls import ModuleImpl import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log +from app.comp import res_sem from app.comp import moy_mod +from app.comp.moy_mod import ModuleImplResults +from app.comp.res_common import NotesTableCompat from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc import sco_cache from app.scodoc import sco_edit_module @@ -432,7 +435,7 @@ def _make_table_notes( if is_apc: # Ajoute une colonne par UE _add_apc_columns( - moduleimpl_id, + modimpl, evals_poids, ues, rows, @@ -815,7 +818,7 @@ def _add_moymod_column( def _add_apc_columns( - moduleimpl_id, + modimpl, evals_poids, ues, rows, @@ -834,18 +837,23 @@ def _add_apc_columns( # => On recharge tout dans les nouveaux modèles # rows est une liste de dict avec une clé "etudid" # on va y ajouter une clé par UE du semestre - modimpl = ModuleImpl.query.get(moduleimpl_id) - evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( - moduleimpl_id - ) - etuds_moy_module = moy_mod.compute_module_moy( - evals_notes, evals_poids, evaluations, evaluations_completes - ) + nt: NotesTableCompat = res_sem.load_formsemestre_result(modimpl.formsemestre) + modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl.id] + + # XXX A ENLEVER TODO + # modimpl = ModuleImpl.query.get(moduleimpl_id) + + # evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( + # moduleimpl_id + # ) + # etuds_moy_module = moy_mod.compute_module_moy( + # evals_notes, evals_poids, evaluations, evaluations_completes + # ) if is_conforme: # valeur des moyennes vers les UEs: for row in rows: for ue in ues: - moy_ue = etuds_moy_module[ue.id].get(row["etudid"], "?") + moy_ue = modimpl_results.etuds_moy_module[ue.id].get(row["etudid"], "?") row[f"moy_ue_{ue.id}"] = scu.fmt_note(moy_ue, keep_numeric=keep_numeric) row[f"_moy_ue_{ue.id}_class"] = "moy_ue" # Nom et coefs des UE (lignes titres): diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 904c74c9a..af2e15dd9 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -171,7 +171,9 @@ def _ue_coefs_html(coefs_lst) -> str: """ + "\n".join( [ - f"""
{coef}
{ue.acronyme}
""" + f"""
{coef}
{ue.acronyme}
""" for ue, coef in coefs_lst ] ) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index ecb4f3b9a..d91ce7335 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1308,6 +1308,20 @@ td.formsemestre_status_cell { white-space: nowrap; } +span.mod_coef_indicator, span.ue_color_indicator { + display:inline-block; + width: 10px; + height: 10px; +} +span.mod_coef_indicator_zero { + display:inline-block; + width: 9px; + height: 9px; + border: 1px solid rgb(156, 156, 156); +} + + + span.status_ue_acro { font-weight: bold; } span.status_ue_title { font-style: italic; padding-left: 1cm;} span.status_module_cat { font-weight: bold; } diff --git a/app/templates/pn/form_ues.html b/app/templates/pn/form_ues.html index 8700116c1..d6b1fe127 100644 --- a/app/templates/pn/form_ues.html +++ b/app/templates/pn/form_ues.html @@ -30,6 +30,8 @@ }}">{% if editable and not ue.modules.count() %}{{icons.delete|safe}}{% else %}{{icons.delete_disabled|safe}}{% endif %} + {{ue.acronyme}} {{ue.titre}} diff --git a/migrations/versions/c95d5a3bf0de_couleur_ue.py b/migrations/versions/c95d5a3bf0de_couleur_ue.py new file mode 100644 index 000000000..f4dc60fc9 --- /dev/null +++ b/migrations/versions/c95d5a3bf0de_couleur_ue.py @@ -0,0 +1,28 @@ +"""couleur UE + +Revision ID: c95d5a3bf0de +Revises: f40fbaf5831c +Create Date: 2022-01-24 21:44:55.205544 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c95d5a3bf0de" +down_revision = "f40fbaf5831c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("notes_ue", sa.Column("color", sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("notes_ue", "color") + # ### end Alembic commands ### diff --git a/scodoc.py b/scodoc.py index 3db205e4b..cba555fcd 100755 --- a/scodoc.py +++ b/scodoc.py @@ -133,7 +133,7 @@ def user_create(username, role, dept, nom=None, prenom=None): # user-create "Create a new user" r = Role.get_named_role(role) if not r: - sys.stderr.write("user_create: role {r} does not exists\n".format(r=role)) + sys.stderr.write("user_create: role {r} does not exist\n".format(r=role)) return 1 u = User.query.filter_by(user_name=username).first() if u: