From 0fe5cdb4099b41585273fbf4c3e77d7c4bf30eef Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 26 Dec 2021 19:15:47 +0100 Subject: [PATCH] WIP: refactoring calculs --- app/but/bulletin_but.py | 54 ++--- app/but/bulletin_but_xml_compat.py | 10 +- app/comp/aux.py | 37 +++ app/comp/df_cache.py | 2 +- app/comp/moy_mod.py | 374 ++++++++++++++++------------- app/comp/moy_ue.py | 28 +-- app/comp/res_sem.py | 77 +++--- app/models/etudiants.py | 81 ++++++- app/models/moduleimpls.py | 4 +- app/scodoc/notes_table.py | 10 +- app/scodoc/sco_abs_views.py | 42 ++-- app/scodoc/sco_apogee_csv.py | 4 +- app/scodoc/sco_bulletins.py | 4 +- app/scodoc/sco_bulletins_json.py | 2 +- app/scodoc/sco_bulletins_xml.py | 2 +- app/scodoc/sco_etud.py | 37 +-- app/scodoc/sco_liste_notes.py | 2 +- app/scodoc/sco_page_etud.py | 3 +- app/scodoc/sco_poursuite_dut.py | 2 +- app/scodoc/sco_recapcomplet.py | 6 +- app/scodoc/sco_tag_module.py | 2 +- app/templates/sidebar.html | 92 ++++--- app/views/absences.py | 4 +- app/views/scolar.py | 5 +- pylintrc | 2 + sco_version.py | 2 +- tests/unit/test_but_modules.py | 135 +++++------ tests/unit/test_but_ues.py | 4 +- 28 files changed, 580 insertions(+), 447 deletions(-) create mode 100644 app/comp/aux.py diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 65756767f..658f8f634 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -4,19 +4,17 @@ # See LICENSE ############################################################################## +"""Génération bulletin BUT +""" + import datetime from flask import url_for, g -import numpy as np -import pandas as pd - -from app import db from app.comp import moy_ue, moy_sem, inscr_mod -from app.models import ModuleImpl from app.scodoc import sco_utils as scu from app.scodoc import sco_bulletins_json from app.scodoc import sco_preferences -from app.scodoc.sco_utils import jsnan, fmt_note +from app.scodoc.sco_utils import fmt_note from app.comp.res_sem import ResultatsSemestre, NotesTableCompat @@ -37,9 +35,7 @@ class ResultatsSemestreBUT(NotesTableCompat): ( self.sem_cube, self.modimpls_evals_poids, - self.modimpls_evals_notes, - modimpls_evaluations, - self.modimpls_evaluations_complete, + 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( @@ -74,16 +70,16 @@ class BulletinBUT(ResultatsSemestreBUT): etud_idx = self.etud_index[etud.id] ue_idx = self.modimpl_coefs_df.index.get_loc(ue.id) etud_moy_module = self.sem_cube[etud_idx] # module x UE - for mi in modimpls: - coef = self.modimpl_coefs_df[mi.id][ue.id] + for modimpl in modimpls: + coef = self.modimpl_coefs_df[modimpl.id][ue.id] if coef > 0: - d[mi.module.code] = { - "id": mi.id, + d[modimpl.module.code] = { + "id": modimpl.id, "coef": coef, "moyenne": fmt_note( - etud_moy_module[self.modimpl_coefs_df.columns.get_loc(mi.id)][ - ue_idx - ] + etud_moy_module[ + self.modimpl_coefs_df.columns.get_loc(modimpl.id) + ][ue_idx] ), } return d @@ -117,7 +113,7 @@ class BulletinBUT(ResultatsSemestreBUT): avec évaluations de chacun.""" d = {} # etud_idx = self.etud_index[etud.id] - for mi in modimpls: + for modimpl in modimpls: # mod_idx = self.modimpl_coefs_df.columns.get_loc(mi.id) # # moyennes indicatives (moyennes de moyennes d'UE) # try: @@ -131,14 +127,15 @@ class BulletinBUT(ResultatsSemestreBUT): # moy_indicative_mod = np.nanmean(self.sem_cube[etud_idx, mod_idx]) # except RuntimeWarning: # all nans in np.nanmean # pass - d[mi.module.code] = { - "id": mi.id, - "titre": mi.module.titre, - "code_apogee": mi.module.code_apogee, + modimpl_results = self.modimpls_results[modimpl.id] + d[modimpl.module.code] = { + "id": modimpl.id, + "titre": modimpl.module.titre, + "code_apogee": modimpl.module.code_apogee, "url": url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, - moduleimpl_id=mi.id, + moduleimpl_id=modimpl.id, ), "moyenne": { # # moyenne indicative de module: moyenne des UE, ignorant celles sans notes (nan) @@ -149,16 +146,17 @@ class BulletinBUT(ResultatsSemestreBUT): }, "evaluations": [ self.etud_eval_results(etud, e) - for eidx, e in enumerate(mi.evaluations) + for e in modimpl.evaluations if e.visibulletin - and self.modimpls_evaluations_complete[mi.id][eidx] + and modimpl_results.evaluations_etat[e.id].is_complete ], } return d def etud_eval_results(self, etud, e) -> dict: "dict resultats d'un étudiant à une évaluation" - eval_notes = self.modimpls_evals_notes[e.moduleimpl_id][e.id] # pd.Series + # eval_notes est une pd.Series avec toutes les notes des étudiants inscrits + eval_notes = self.modimpls_results[e.moduleimpl_id].evals_notes[e.id] notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() d = { "id": e.id, @@ -170,7 +168,7 @@ class BulletinBUT(ResultatsSemestreBUT): "poids": {p.ue.acronyme: p.poids for p in e.ue_poids}, "note": { "value": fmt_note( - self.modimpls_evals_notes[e.moduleimpl_id][e.id][etud.id], + eval_notes[etud.id], note_max=e.note_max, ), "min": fmt_note(notes_ok.min()), @@ -212,8 +210,8 @@ class BulletinBUT(ResultatsSemestreBUT): "numero": formsemestre.semestre_id, "groupes": [], # XXX TODO "absences": { # XXX TODO - "injustifie": 1, - "total": 33, + "injustifie": -1, + "total": -1, }, } semestre_infos.update( diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 6e2f14dbf..478141cbb 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -108,8 +108,8 @@ def bulletin_but_xml_compat( code_ine=etud.code_ine or "", nom=scu.quote_xml_attr(etud.nom), prenom=scu.quote_xml_attr(etud.prenom), - civilite=scu.quote_xml_attr(etud.civilite_str()), - sexe=scu.quote_xml_attr(etud.civilite_str()), # compat + civilite=scu.quote_xml_attr(etud.civilite_str), + sexe=scu.quote_xml_attr(etud.civilite_str), # compat photo_url=scu.quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)), email=scu.quote_xml_attr(etud.get_first_email() or ""), emailperso=scu.quote_xml_attr(etud.get_first_email("emailperso") or ""), @@ -216,9 +216,9 @@ def bulletin_but_xml_compat( Element( "note", value=scu.fmt_note( - results.modimpls_evals_notes[e.moduleimpl_id][ - e.id - ][etud.id] + results.modimpls_results[ + e.moduleimpl_id + ].evals_notes[e.id][etud.id] ), ) ) diff --git a/app/comp/aux.py b/app/comp/aux.py new file mode 100644 index 000000000..46285a4b1 --- /dev/null +++ b/app/comp/aux.py @@ -0,0 +1,37 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2021 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +import numpy as np + +"""Quelques classes auxiliaires pour les calculs des notes +""" + + +class StatsMoyenne: + """Une moyenne d'un ensemble étudiants sur quelque chose + (moyenne générale d'un semestre, d'un module, d'un groupe...) + et les statistiques associées: min, max, moy, effectif + """ + + def __init__(self, vals): + """Calcul les statistiques. + Les valeurs NAN ou non numériques sont toujours enlevées. + """ + self.moy = np.nanmean(vals) + self.min = np.nanmin(vals) + self.max = np.nanmax(vals) + self.size = len(vals) + self.nb_vals = self.size - np.count_nonzero(np.isnan(vals)) + + def to_dict(self): + "Tous les attributs dans un dict" + return { + "min": self.min, + "max": self.max, + "moy": self.moy, + "size": self.size, + "nb_vals": self.nb_vals, + } diff --git a/app/comp/df_cache.py b/app/comp/df_cache.py index dec325e2a..67b59242f 100644 --- a/app/comp/df_cache.py +++ b/app/comp/df_cache.py @@ -43,7 +43,7 @@ class ModuleCoefsCache(sco_cache.ScoDocCache): class EvaluationsPoidsCache(sco_cache.ScoDocCache): """Cache for poids evals Clé: moduleimpl_id - Valeur: DataFrame (df_load_evaluations_poids) + Valeur: DataFrame (load_evaluations_poids) """ prefix = "EPC" diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 40616cbab..dc912c286 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -31,17 +31,217 @@ 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. """ +from dataclasses import dataclass import numpy as np import pandas as pd -from pandas.core.frame import DataFrame from app import db -from app import models from app.models import ModuleImpl, Evaluation, EvaluationUEPoids from app.scodoc import sco_utils as scu -def df_load_evaluations_poids( +@dataclass +class EvaluationEtat: + """Classe pour stocker quelques infos sur les résultats d'une évaluation""" + + evaluation_id: int + nb_attente: int + 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. + Les attributs sont tous des objets simples cachables dans Redis; + les caches sont gérés par ResultatsSemestre. + """ + + def __init__(self, moduleimpl: ModuleImpl): + self.moduleimpl_id = moduleimpl.id + 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" + self.evaluations_completes = [] + "séquence de booléens, indiquant les évals à prendre en compte." + self.evaluations_etat = {} + "{ evaluation_id: EvaluationEtat }" + # + self.evals_notes = None + """DataFrame, colonnes: EVALS, Lignes: etudid + valeur: notes brutes, float ou NOTES_ATTENTE, NOTES_NEUTRALISE, + NOTES_ABSENCE. + Les NaN désignent les notes manquantes (non saisies). + """ + self.etuds_moy_module = None + """DataFrame, colonnes UE, lignes etud + = la note de l'étudiant dans chaque UE pour ce module. + ou NaN si les évaluations (dans lesquelles l'étudiant a des notes) + ne donnent pas de coef vers cette UE. + """ + self.load_notes() + + def load_notes(self): # ré-écriture de df_load_modimpl_notes + """Charge toutes les notes de toutes les évaluations du module. + Dataframe evals_notes + colonnes: le nom de la colonne est l'evaluation_id (int) + index (lignes): etudid (int) + + L'ensemble des étudiants est celui des inscrits au SEMESTRE. + + Les notes sont "brutes" (séries de floats) et peuvent prendre les valeurs: + note : float (valeur enregistrée brute, NON normalisée sur 20) + pas de note: NaN (rien en bd, ou étudiant non inscrit au module) + absent: NOTES_ABSENCE (NULL en bd) + excusé: NOTES_NEUTRALISE (voir sco_utils) + attente: NOTES_ATTENTE + + Évaluation "complete" (prise en compte dans les calculs) si: + - soit tous les étudiants inscrits au module ont des notes + - soit elle a été déclarée "à prise ne compte immédiate" (publish_incomplete) + + Évaluation "attente" (prise en compte dans les calculs, mais il y + manque des notes) ssi il y a des étudiants inscrits au semestre et au module + qui ont des notes ATT. + """ + moduleimpl = ModuleImpl.query.get(self.moduleimpl_id) + self.etudids = self._etudids() + + # --- Calcul nombre d'inscrits pour déterminer les évaluations "completes": + # on prend les inscrits au module ET au semestre (donc sans démissionnaires) + inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection( + self.etudids + ) + self.nb_inscrits_module = len(inscrits_module) + + # dataFrame vide, index = tous les inscrits au SEMESTRE + evals_notes = pd.DataFrame(index=self.etudids, dtype=float) + self.evaluations_completes = [] + for evaluation in moduleimpl.evaluations: + eval_df = self._load_evaluation_notes(evaluation) + # is_complete ssi tous les inscrits (non dem) au semestre ont une note + # ou évaluaton déclarée "à prise en compte immédiate" + is_complete = ( + len(set(eval_df.index).intersection(self.etudids)) + == self.nb_inscrits_module + ) or evaluation.publish_incomplete # immédiate + self.evaluations_completes.append(is_complete) + + # NULL en base => ABS (= -999) + eval_df.fillna(scu.NOTES_ABSENCE, inplace=True) + # Ce merge ne garde que les étudiants inscrits au module + # et met à NULL les notes non présentes + # (notes non saisies ou etuds non inscrits au module): + evals_notes = evals_notes.merge( + eval_df, how="left", left_index=True, right_index=True + ) + # Notes en attente: (on prend dans evals_notes pour ne pas avoir les dem.) + nb_att = sum(evals_notes[str(evaluation.id)] == scu.NOTES_ATTENTE) + self.evaluations_etat[evaluation.id] = EvaluationEtat( + evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete + ) + + # Force columns names to integers (evaluation ids) + evals_notes.columns = pd.Int64Index( + [int(x) for x in evals_notes.columns], dtype="int" + ) + self.evals_notes = evals_notes + + def _load_evaluation_notes(self, evaluation: Evaluation) -> pd.DataFrame: + """Charge les notes de l'évaluation + Resultat: dataframe, index: etudid ayant une note, valeur: note brute. + """ + eval_df = pd.read_sql_query( + """SELECT n.etudid, n.value AS "%(evaluation_id)s" + FROM notes_notes n, notes_moduleimpl_inscription i + WHERE evaluation_id=%(evaluation_id)s + AND n.etudid = i.etudid + AND i.moduleimpl_id = %(moduleimpl_id)s + """, + db.engine, + params={ + "evaluation_id": evaluation.id, + "moduleimpl_id": evaluation.moduleimpl.id, + }, + index_col="etudid", + ) + eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)]) + return eval_df + + def _etudids(self): + """L'index du dataframe est la liste des étudiants inscrits au semestre, + sans les démissionnaires. + """ + return [ + e.etudid + for e in ModuleImpl.query.get(self.moduleimpl_id).formsemestre.get_inscrits( + include_dem=False + ) + ] + + def compute_module_moy( + self, + evals_poids_df: pd.DataFrame, + ) -> pd.DataFrame: + """Calcule les moyennes des étudiants dans ce module + + Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs + + Résultat: DataFrame, colonnes UE, lignes etud + = la note de l'étudiant dans chaque UE pour ce module. + ou NaN si les évaluations (dans lesquelles l'étudiant a des notes) + ne donnent pas de coef vers cette UE. + """ + moduleimpl = ModuleImpl.query.get(self.moduleimpl_id) + nb_etuds, nb_evals = self.evals_notes.shape + nb_ues = evals_poids_df.shape[1] + 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_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 + # (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, nb_ues) + poids_stacked = np.stack([evals_poids] * nb_etuds) + evals_poids_etuds = np.where( + np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE, + poids_stacked, + 0, + ) + # Calcule la moyenne pondérée sur les notes disponibles: + evals_notes_stacked = np.stack([evals_notes] * 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 + ) / np.sum(evals_poids_etuds, axis=1) + self.etuds_moy_module = pd.DataFrame( + etuds_moy_module, + index=self.evals_notes.index, + columns=evals_poids_df.columns, + ) + return self.etuds_moy_module + + +def load_evaluations_poids( moduleimpl_id: int, default_poids=1.0 ) -> tuple[pd.DataFrame, list]: """Charge poids des évaluations d'un module et retourne un dataframe @@ -55,23 +255,25 @@ def df_load_evaluations_poids( ues = modimpl.formsemestre.query_ues(with_sport=False).all() ue_ids = [ue.id for ue in ues] evaluation_ids = [evaluation.id for evaluation in evaluations] - df = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) - for eval_poids in EvaluationUEPoids.query.join( + evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) + for ue_poids in EvaluationUEPoids.query.join( EvaluationUEPoids.evaluation ).filter_by(moduleimpl_id=moduleimpl_id): - df[eval_poids.ue_id][eval_poids.evaluation_id] = eval_poids.poids + evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids if default_poids is not None: - df.fillna(value=default_poids, inplace=True) - return df, ues + evals_poids.fillna(value=default_poids, inplace=True) + return evals_poids, ues -def check_moduleimpl_conformity( +def moduleimpl_is_conforme( moduleimpl, evals_poids: pd.DataFrame, modules_coefficients: pd.DataFrame ) -> bool: """Vérifie que les évaluations de ce moduleimpl sont bien conformes au PN. Un module est dit *conforme* si et seulement si la somme des poids de ses évaluations vers une UE de coefficient non nul est non nulle. + + Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs """ nb_evals, nb_ues = evals_poids.shape if nb_evals == 0: @@ -79,160 +281,10 @@ def check_moduleimpl_conformity( if nb_ues == 0: return False # situation absurde (pas d'UE) if len(modules_coefficients) != nb_ues: - raise ValueError("check_moduleimpl_conformity: nb ue incoherent") + raise ValueError("moduleimpl_is_conforme: nb ue incoherent") module_evals_poids = evals_poids.transpose().sum(axis=1).to_numpy() != 0 check = all( - (modules_coefficients[moduleimpl.module.id].to_numpy() != 0) + (modules_coefficients[moduleimpl.module_id].to_numpy() != 0) == module_evals_poids ) return check - - -def df_load_modimpl_notes(moduleimpl_id: int) -> tuple: - """Construit un dataframe avec toutes les notes de toutes les évaluations du module. - colonnes: le nom de la colonne est l'evaluation_id (int) - index (lignes): etudid (int) - - Résultat: (evals_notes, liste de évaluations du moduleimpl, - liste de booleens indiquant si l'évaluation est "complete") - - L'ensemble des étudiants est celui des inscrits au SEMESTRE. - - Les notes renvoyées sont "brutes" (séries de floats) et peuvent prendre les valeurs: - note : float (valeur enregistrée brute, non normalisée sur 20) - pas de note: NaN (rien en bd, ou étudiant non inscrit au module) - absent: NOTES_ABSENCE (NULL en bd) - excusé: NOTES_NEUTRALISE (voir sco_utils) - attente: NOTES_ATTENTE - - L'évaluation "complete" (prise en compte dans les calculs) si: - - soit tous les étudiants inscrits au module ont des notes - - soit elle a été déclarée "à prise ne compte immédiate" (publish_incomplete) - - N'utilise pas de cache ScoDoc. - """ - # L'index du dataframe est la liste des étudiants inscrits au semestre, - # sans les démissionnaires - etudids = [ - e.etudid - for e in ModuleImpl.query.get(moduleimpl_id).formsemestre.get_inscrits( - include_dem=False - ) - ] - evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() - # --- Calcul nombre d'inscrits pour détermnier si évaluation "complete": - if evaluations: - # on prend les inscrits au module ET au semestre (donc sans démissionnaires) - inscrits_module = { - ins.etud.id for ins in evaluations[0].moduleimpl.inscriptions - }.intersection(etudids) - nb_inscrits_module = len(inscrits_module) - else: - nb_inscrits_module = 0 - # empty df with all students: - evals_notes = pd.DataFrame(index=etudids, dtype=float) - evaluations_completes = [] - for evaluation in evaluations: - eval_df = pd.read_sql_query( - """SELECT n.etudid, n.value AS "%(evaluation_id)s" - FROM notes_notes n, notes_moduleimpl_inscription i - WHERE evaluation_id=%(evaluation_id)s - AND n.etudid = i.etudid - AND i.moduleimpl_id = %(moduleimpl_id)s - ORDER BY n.etudid - """, - db.engine, - params={ - "evaluation_id": evaluation.id, - "moduleimpl_id": evaluation.moduleimpl.id, - }, - index_col="etudid", - ) - eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)]) - # is_complete ssi tous les inscrits (non dem) au semestre ont une note - is_complete = ( - len(set(eval_df.index).intersection(etudids)) == nb_inscrits_module - ) or evaluation.publish_incomplete - evaluations_completes.append(is_complete) - # NULL en base => ABS (= -999) - eval_df.fillna(scu.NOTES_ABSENCE, inplace=True) - # Ce merge met à NULL les élements non présents - # (notes non saisies ou etuds non inscrits au module): - evals_notes = evals_notes.merge( - eval_df, how="left", left_index=True, right_index=True - ) - # Force columns names to integers (evaluation ids) - evals_notes.columns = pd.Int64Index( - [int(x) for x in evals_notes.columns], dtype="int64" - ) - return evals_notes, evaluations, evaluations_completes - - -def compute_module_moy( - evals_notes_df: pd.DataFrame, - evals_poids_df: pd.DataFrame, - evaluations: list, - evaluations_completes: list, -) -> pd.DataFrame: - """Calcule les moyennes des étudiants dans ce module - - - evals_notes : DataFrame, colonnes: EVALS, Lignes: etudid - valeur: notes brutes, float ou NOTES_ATTENTE, NOTES_NEUTRALISE, - NOTES_ABSENCE. - Les NaN désignent les notes manquantes (non saisies). - - - evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs - - - evaluations: séquence d'évaluations (utilisées pour le coef et - le barème) - - - evaluations_completes: séquence de booléens indiquant les - évals à prendre en compte. - - Résultat: DataFrame, colonnes UE, lignes etud - = la note de l'étudiant dans chaque UE pour ce module. - ou NaN si les évaluations (dans lesquelles l'étudiant à des notes) - ne donnent pas de coef vers cette UE. - """ - nb_etuds, nb_evals = evals_notes_df.shape - nb_ues = evals_poids_df.shape[1] - 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 evaluations], - dtype=float, - ) - * evaluations_completes - ).reshape(-1, 1) - 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( - evals_notes_df.values > scu.NOTES_ABSENCE, evals_notes_df.values, 0.0 - ) / [e.note_max / 20.0 for e in evaluations] - # Les poids des évals pour les é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, nb_ues) - poids_stacked = np.stack([evals_poids] * nb_etuds) - evals_poids_etuds = np.where( - np.stack([evals_notes_df.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE, - poids_stacked, - 0, - ) - # Calcule la moyenne pondérée sur les notes disponibles: - evals_notes_stacked = np.stack([evals_notes] * 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 - ) / np.sum(evals_poids_etuds, axis=1) - etuds_moy_module_df = pd.DataFrame( - etuds_moy_module, index=evals_notes_df.index, columns=evals_poids_df.columns - ) - return etuds_moy_module_df diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 74994f26d..0c640fe93 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -34,7 +34,6 @@ 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 @@ -134,34 +133,21 @@ def notes_sem_load_cube(formsemestre): Resultat: sem_cube : ndarray (etuds x modimpls x UEs) modimpls_evals_poids dict { modimpl.id : evals_poids } - modimpls_evals_notes dict { modimpl.id : evals_notes } - modimpls_evaluations dict { modimpl.id : liste des évaluations } - modimpls_evaluations_complete: {modimpl_id : liste de booleens (complete/non)} + modimpls_results dict { modimpl.id : ModuleImplResultsAPC } """ + modimpls_results = {} modimpls_evals_poids = {} - modimpls_evals_notes = {} - modimpls_evaluations = {} - modimpls_evaluations_complete = {} modimpls_notes = [] for modimpl in formsemestre.modimpls: - evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( - modimpl.id - ) - evals_poids, ues = moy_mod.df_load_evaluations_poids(modimpl.id) - etuds_moy_module = moy_mod.compute_module_moy( - evals_notes, evals_poids, evaluations, evaluations_completes - ) - modimpls_evals_poids[modimpl.id] = evals_poids - modimpls_evals_notes[modimpl.id] = evals_notes - modimpls_evaluations[modimpl.id] = evaluations - modimpls_evaluations_complete[modimpl.id] = evaluations_completes + 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) + modimpls_results[modimpl.id] = mod_results modimpls_notes.append(etuds_moy_module) return ( notes_sem_assemble_cube(modimpls_notes), modimpls_evals_poids, - modimpls_evals_notes, - modimpls_evaluations, - modimpls_evaluations_complete, + modimpls_results, ) diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py index 01a2e8720..c0b78b539 100644 --- a/app/comp/res_sem.py +++ b/app/comp/res_sem.py @@ -8,6 +8,8 @@ from collections import defaultdict 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.scodoc import sco_utils as scu from app.scodoc.sco_cache import ResultatsSemestreCache from app.scodoc.sco_codes_parcours import UE_SPORT @@ -28,14 +30,20 @@ class ResultatsSemestre: "modimpl_coefs_df", "etud_moy_ue", "modimpls_evals_poids", - "modimpls_evals_notes", + "modimpls_results", "etud_moy_gen", "etud_moy_gen_ranks", - "modimpls_evaluations_complete", ) def __init__(self, formsemestre): self.formsemestre = formsemestre + # BUT ou standard ? (apc == "approche par compétences") + self.is_apc = formsemestre.formation.is_apc() + # Attributs "virtuels", définis pas les sous-classes + # ResultatsSemestreBUT ou ResultatsSemestreStd + self.etud_moy_ue = {} + self.etud_moy_gen = {} + self.etud_moy_gen_ranks = {} # TODO def load_cached(self) -> bool: @@ -49,7 +57,6 @@ class ResultatsSemestre: def store(self): "Cache our data" - "Cache our dataframes" ResultatsSemestreCache.set( self.formsemestre.id, {attr: getattr(self, attr) for attr in self._cached_attrs}, @@ -58,7 +65,7 @@ class ResultatsSemestre: def compute(self): "Charge les notes et inscriptions et calcule toutes les moyennes" # voir ce qui est chargé / calculé ici et dans les sous-classes - TODO + raise NotImplementedError() @cached_property def etuds(self): @@ -78,9 +85,22 @@ class ResultatsSemestre: @cached_property def modimpls(self): - "Liste des modimpls du semestre (triée par numéro de module)" + """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() - modimpls.sort(key=lambda m: m.module.numero) + 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 @@ -96,32 +116,6 @@ class ResultatsSemestre: return [m for m in self.modimpls if m.module.module_type == scu.ModuleType.SAE] -class StatsMoyenne: - """Une moyenne d'un ensemble étudiants sur quelque chose - (moyenne générale d'un semestre, d'un module, d'un groupe...) - et les statistiques associées: min, max, moy, effectif - """ - - def __init__(self, vals): - """Calcul les statistiques. - Les valeurs NAN ou non numériques sont toujours enlevées. - """ - self.moy = np.nanmean(vals) - self.min = np.nanmin(vals) - self.max = np.nanmax(vals) - self.size = len(vals) - self.nb_vals = self.size - np.count_nonzero(np.isnan(vals)) - - def to_dict(self): - return { - "min": self.min, - "max": self.max, - "moy": self.moy, - "size": self.size, - "nb_vals": self.nb_vals, - } - - # Pour raccorder le code des anciens codes qui attendent une NoteTable class NotesTableCompat(ResultatsSemestre): """Implementation partielle de NotesTable WIP TODO @@ -158,11 +152,22 @@ class NotesTableCompat(ResultatsSemestre): ues.append(d) return ues - def get_modimpls(self): - return [m.to_dict() for m in self.results.modimpls] + def get_modimpls_dict(self, ue_id=None): + """Liste des modules pour une UE (ou toutes si ue_id==None), + triés par numéros (selon le type de formation) + """ + if ue_id is None: + return [m.to_dict() for m in self.modimpls] + else: + return [m.to_dict() for m in self.modimpls if m.module.ue.id == ue_id] - def get_etud_moy_gen(self, etudid): - return self.results.etud_moy_gen[etudid] + 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) + Si apc, moyenne indicative. + Si pas de notes: 'NA' + """ + return self.etud_moy_gen[etudid] def get_moduleimpls_attente(self): return [] # XXX TODO diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 0ae36bd28..220bf28be 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -4,7 +4,9 @@ et données rattachées (adresses, annotations, ...) """ -from flask import g, url_for +from functools import cached_property +from flask import abort, url_for +from flask import g, request from app import db from app import models @@ -53,14 +55,24 @@ class Identite(db.Model): def __repr__(self): return f"" + @classmethod + def from_request(cls, etudid=None, code_nip=None): + """Etudiant à partir de l'etudid ou du code_nip, soit + passés en argument soit retrouvés directement dans la requête web. + Erreur 404 si inexistant. + """ + args = make_etud_args(etudid=etudid, code_nip=code_nip) + return Identite.query.filter_by(**args).first_or_404() + + @property def civilite_str(self): """returns 'M.' ou 'Mme' ou '' (pour le genre neutre, personnes ne souhaitant pas d'affichage). """ return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] - def nom_disp(self): - "nom à afficher" + def nom_disp(self) -> str: + "Nom à afficher" if self.nom_usuel: return ( (self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel @@ -68,8 +80,33 @@ class Identite(db.Model): else: return self.nom + @cached_property + def nomprenom(self, reverse=False) -> str: + """Civilité/nom/prenom pour affichages: "M. Pierre Dupont" + Si reverse, "Dupont Pierre", sans civilité. + """ + nom = self.nom_usuel or self.nom + prenom = self.prenom_str + if reverse: + fields = (nom, prenom) + else: + fields = (self.civilite_str, prenom, nom) + return " ".join([x for x in fields if x]) + + @property + def prenom_str(self): + """Prénom à afficher. Par exemple: "Jean-Christophe" """ + if not self.prenom: + return "" + frags = self.prenom.split() + r = [] + for frag in frags: + fields = frag.split("-") + r.append("-".join([x.lower().capitalize() for x in fields])) + return " ".join(r) + def get_first_email(self, field="email") -> str: - "le mail associé à la première adrese de l'étudiant, ou None" + "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_bul(self, include_urls=True): @@ -120,6 +157,42 @@ class Identite(db.Model): return False +def make_etud_args( + etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True +) -> dict: + """forme args dict pour requete recherche etudiant + On peut specifier etudid + ou bien (si use_request) cherche dans la requete http: etudid, code_nip, code_ine + (dans cet ordre). + + Résultat: dict avec soit "etudid", soit "code_nip", soit "code_ine" + """ + args = None + if etudid: + args = {"etudid": etudid} + elif code_nip: + args = {"code_nip": code_nip} + elif use_request: # use form from current request (Flask global) + if request.method == "POST": + vals = request.form + elif request.method == "GET": + vals = request.args + else: + vals = {} + if "etudid" in vals: + args = {"etudid": int(vals["etudid"])} + elif "code_nip" in vals: + args = {"code_nip": str(vals["code_nip"])} + elif "code_ine" in vals: + args = {"code_ine": str(vals["code_ine"])} + if not args: + if abort_404: + abort(404, "pas d'étudiant sélectionné") + elif raise_exc: + raise ValueError("make_etud_args: pas d'étudiant sélectionné !") + return args + + class Adresse(db.Model): """Adresse d'un étudiant (le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 172fe0767..fe48555ef 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -50,7 +50,7 @@ class ModuleImpl(db.Model): if evaluations_poids is None: from app.comp import moy_mod - evaluations_poids, _ = moy_mod.df_load_evaluations_poids(self.id) + evaluations_poids, _ = moy_mod.load_evaluations_poids(self.id) df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids) return evaluations_poids @@ -69,7 +69,7 @@ class ModuleImpl(db.Model): return True from app.comp import moy_mod - return moy_mod.check_moduleimpl_conformity( + return moy_mod.moduleimpl_is_conforme( self, self.get_evaluations_poids(), self.module.formation.get_module_coefs(self.module.semestre_id), diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index 69c37694f..abbfec96b 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -296,7 +296,7 @@ class NotesTable: for ue in self._ues: is_cap[ue["ue_id"]] = ue_status[ue["ue_id"]]["is_capitalized"] - for modimpl in self.get_modimpls(): + for modimpl in self.get_modimpls_dict(): val = self.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) if is_cap[modimpl["module"]["ue_id"]]: t.append("-c-") @@ -428,8 +428,8 @@ class NotesTable: else: return [ue for ue in self._ues if ue["type"] != UE_SPORT] - def get_modimpls(self, ue_id=None): - "liste des modules pour une UE (ou toutes si ue_id==None), triés par matières." + def get_modimpls_dict(self, ue_id=None): + "Liste des modules pour une UE (ou toutes si ue_id==None), triés par matières." if ue_id is None: r = self._modimpls else: @@ -564,7 +564,7 @@ class NotesTable: Si non inscrit, moy == 'NI' et sum_coefs==0 """ assert ue_id - modimpls = self.get_modimpls(ue_id) + modimpls = self.get_modimpls_dict(ue_id) nb_notes = 0 # dans cette UE sum_notes = 0.0 sum_coefs = 0.0 @@ -921,7 +921,7 @@ class NotesTable: return infos - def get_etud_moy_gen(self, etudid): + def get_etud_moy_gen(self, etudid): # -> float | str """Moyenne generale de cet etudiant dans ce semestre. Prend en compte les UE capitalisées. Si pas de notes: 'NA' diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index c719df312..27de71d7d 100644 --- a/app/scodoc/sco_abs_views.py +++ b/app/scodoc/sco_abs_views.py @@ -32,6 +32,8 @@ import datetime from flask import url_for, g, request, abort +from app import log +from app.models import Identite import app.scodoc.sco_utils as scu from app.scodoc import notesdb as ndb from app.scodoc.scolog import logdb @@ -46,7 +48,6 @@ from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl from app.scodoc import sco_photos from app.scodoc import sco_preferences -from app import log from app.scodoc.sco_exceptions import ScoValueError @@ -71,8 +72,8 @@ def doSignaleAbsence( etudid: etudiant concerné. Si non spécifié, cherche dans les paramètres de la requête courante. """ - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - etudid = etud["etudid"] + etud = Identite.from_request(etudid) + if not moduleimpl_id: moduleimpl_id = None description_abs = description @@ -82,7 +83,7 @@ def doSignaleAbsence( for jour in dates: if demijournee == 2: sco_abs.add_absence( - etudid, + etud.id, jour, False, estjust, @@ -90,7 +91,7 @@ def doSignaleAbsence( moduleimpl_id, ) sco_abs.add_absence( - etudid, + etud.id, jour, True, estjust, @@ -100,7 +101,7 @@ def doSignaleAbsence( nbadded += 2 else: sco_abs.add_absence( - etudid, + etud.id, jour, demijournee, estjust, @@ -113,27 +114,27 @@ def doSignaleAbsence( J = "" else: J = "NON " - M = "" + indication_module = "" if moduleimpl_id and moduleimpl_id != "NULL": mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] formsemestre_id = mod["formsemestre_id"] nt = sco_cache.NotesTableCache.get(formsemestre_id) ues = nt.get_ues_stat_dict() for ue in ues: - modimpls = nt.get_modimpls(ue_id=ue["ue_id"]) + modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) for modimpl in modimpls: if modimpl["moduleimpl_id"] == moduleimpl_id: - M = "dans le module %s" % modimpl["module"]["code"] + indication_module = "dans le module %s" % modimpl["module"]["code"] H = [ html_sco_header.sco_header( - page_title="Signalement d'une absence pour %(nomprenom)s" % etud, + page_title=f"Signalement d'une absence pour {etud.nomprenom}", ), """

Signalement d'absences

""", ] if dates: H.append( """

Ajout de %d absences %sjustifiées du %s au %s %s

""" - % (nbadded, J, datedebut, datefin, M) + % (nbadded, J, datedebut, datefin, indication_module) ) else: H.append( @@ -142,11 +143,18 @@ def doSignaleAbsence( ) H.append( - """ -
""" - % etud + f""" +
+ """ ) H.append(sco_find_etud.form_search_etud()) H.append(html_sco_header.sco_footer()) @@ -200,7 +208,7 @@ def SignaleAbsenceEtud(): # etudid implied menu_module += """""" for ue in ues: - modimpls = nt.get_modimpls(ue_id=ue["ue_id"]) + modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) for modimpl in modimpls: menu_module += ( """\n""" diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index 94c2f86e0..74a9823dd 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -456,7 +456,7 @@ class ApoEtud(dict): return VOID_APO_RES # Elements Modules - modimpls = nt.get_modimpls() + modimpls = nt.get_modimpls_dict() module_code_found = False for modimpl in modimpls: if code in modimpl["module"]["code_apogee"].split(","): @@ -978,7 +978,7 @@ class ApoData(object): s.add(code) continue # associé à un module: - modimpls = nt.get_modimpls() + modimpls = nt.get_modimpls_dict() for modimpl in modimpls: if code in modimpl["module"]["code_apogee"].split(","): s.add(code) diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 7990ead8d..0a3fd9598 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -219,7 +219,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): # --- Notes ues = nt.get_ues_stat_dict() - modimpls = nt.get_modimpls() + modimpls = nt.get_modimpls_dict() moy_gen = nt.get_etud_moy_gen(etudid) I["nb_inscrits"] = len(nt.etud_moy_gen_ranks) I["moy_gen"] = scu.fmt_note(moy_gen) @@ -352,7 +352,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): etudid, formsemestre_id, ue_status["capitalized_ue_id"], - nt_cap.get_modimpls(), + nt_cap.get_modimpls_dict(), nt_cap, version, ) diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py index 6900a450c..5300d6a26 100644 --- a/app/scodoc/sco_bulletins_json.py +++ b/app/scodoc/sco_bulletins_json.py @@ -154,7 +154,7 @@ def formsemestre_bulletinetud_published_dict( partitions_etud_groups[pid] = sco_groups.get_etud_groups_in_partition(pid) ues = nt.get_ues_stat_dict() - modimpls = nt.get_modimpls() + modimpls = nt.get_modimpls_dict() nbetuds = len(nt.etud_moy_gen_ranks) mg = scu.fmt_note(nt.get_etud_moy_gen(etudid)) if ( diff --git a/app/scodoc/sco_bulletins_xml.py b/app/scodoc/sco_bulletins_xml.py index a0d14a136..4e3f0fcee 100644 --- a/app/scodoc/sco_bulletins_xml.py +++ b/app/scodoc/sco_bulletins_xml.py @@ -152,7 +152,7 @@ def make_xml_formsemestre_bulletinetud( nt = sco_cache.NotesTableCache.get(formsemestre_id) # > toutes notes ues = nt.get_ues_stat_dict() - modimpls = nt.get_modimpls() + modimpls = nt.get_modimpls_dict() nbetuds = len(nt.etud_moy_gen_ranks) mg = scu.fmt_note(nt.get_etud_moy_gen(etudid)) if ( diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index 7dc30c829..8771eced7 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -38,7 +38,7 @@ from flask_mail import Message from app import email from app import log - +from app.models.etudiants import make_etud_args import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app.scodoc.sco_exceptions import ScoGenError, ScoValueError @@ -87,6 +87,8 @@ def force_uppercase(s): def format_nomprenom(etud, reverse=False): """Formatte civilité/nom/prenom pour affichages: "M. Pierre Dupont" Si reverse, "Dupont Pierre", sans civilité. + + DEPRECATED: utiliser Identite.nomprenom """ nom = etud.get("nom_disp", "") or etud.get("nom_usuel", "") or etud["nom"] prenom = format_prenom(etud["prenom"]) @@ -99,7 +101,9 @@ def format_nomprenom(etud, reverse=False): def format_prenom(s): - "Formatte prenom etudiant pour affichage" + """Formatte prenom etudiant pour affichage + DEPRECATED: utiliser Identite.prenom_str + """ if not s: return "" frags = s.split() @@ -590,35 +594,6 @@ etudident_edit = _etudidentEditor.edit etudident_create = _etudidentEditor.create -def make_etud_args(etudid=None, code_nip=None, use_request=True, raise_exc=True): - """forme args dict pour requete recherche etudiant - On peut specifier etudid - ou bien (si use_request) cherche dans la requete http: etudid, code_nip, code_ine - (dans cet ordre). - """ - args = None - if etudid: - args = {"etudid": etudid} - elif code_nip: - args = {"code_nip": code_nip} - elif use_request: # use form from current request (Flask global) - if request.method == "POST": - vals = request.form - elif request.method == "GET": - vals = request.args - else: - vals = {} - if "etudid" in vals: - args = {"etudid": int(vals["etudid"])} - elif "code_nip" in vals: - args = {"code_nip": str(vals["code_nip"])} - elif "code_ine" in vals: - args = {"code_ine": str(vals["code_ine"])} - if not args and raise_exc: - raise ValueError("getEtudInfo: no parameter !") - return args - - def log_unknown_etud(): """Log request: cas ou getEtudInfo n'a pas ramene de resultat""" etud_args = make_etud_args(raise_exc=False) diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 6da2e3f8c..3ac60a223 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -240,7 +240,7 @@ def _make_table_notes( if is_apc: modimpl = ModuleImpl.query.get(moduleimpl_id) is_conforme = modimpl.check_apc_conformity() - evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id) + evals_poids, ues = moy_mod.load_evaluations_poids(moduleimpl_id) if not ues: is_apc = False else: diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 573d79455..a16fab1e3 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -36,6 +36,7 @@ from flask_login import current_user import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log +from app.models.etudiants import make_etud_args from app.scodoc import html_sco_header from app.scodoc import htmlutils from app.scodoc import sco_archives_etud @@ -156,7 +157,7 @@ def ficheEtud(etudid=None): # la sidebar est differente s'il y a ou pas un etudid # voir html_sidebar.sidebar() g.etudid = etudid - args = sco_etud.make_etud_args(etudid=etudid) + args = make_etud_args(etudid=etudid) etuds = sco_etud.etudident_list(cnx, args) if not etuds: log("ficheEtud: etudid=%s request.args=%s" % (etudid, request.args)) diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index 94772dfe9..8a36f685e 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -75,7 +75,7 @@ def etud_get_poursuite_info(sem, etud): ] # Moyennes et rang des modules - modimpls = nt.get_modimpls() # recupération des modules + modimpls = nt.get_modimpls_dict() # recupération des modules modules = [] rangs = [] for ue in ues: # on parcourt chaque UE diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index ddcdc9548..cfd3634ec 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -302,10 +302,8 @@ def make_formsemestre_recapcomplet( sem = sco_formsemestre.do_formsemestre_list( args={"formsemestre_id": formsemestre_id} )[0] - nt = sco_cache.NotesTableCache.get( - formsemestre_id - ) # > get_modimpls, get_ues_stat_dict, get_table_moyennes_triees, get_etud_decision_sem, get_etud_etat, get_etud_rang, get_nom_short, get_mod_stats, nt.moy_moy, get_etud_decision_sem, - modimpls = nt.get_modimpls() + nt = sco_cache.NotesTableCache.get(formsemestre_id) + modimpls = nt.get_modimpls_dict() ues = nt.get_ues_stat_dict() # incluant le(s) UE de sport # if formsemestre.formation.is_apc(): diff --git a/app/scodoc/sco_tag_module.py b/app/scodoc/sco_tag_module.py index 50ec8f01c..88040957b 100644 --- a/app/scodoc/sco_tag_module.py +++ b/app/scodoc/sco_tag_module.py @@ -270,7 +270,7 @@ def get_etud_tagged_modules(etudid, tagname): R = [] for sem in etud["sems"]: nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"]) - modimpls = nt.get_modimpls() + modimpls = nt.get_modimpls_dict() for modimpl in modimpls: tags = module_tag_list(module_id=modimpl["module_id"]) if tagname in tags: diff --git a/app/templates/sidebar.html b/app/templates/sidebar.html index ba55e6886..3334dbb6a 100644 --- a/app/templates/sidebar.html +++ b/app/templates/sidebar.html @@ -3,96 +3,92 @@ \ No newline at end of file diff --git a/app/views/absences.py b/app/views/absences.py index edb2204cb..37753d671 100644 --- a/app/views/absences.py +++ b/app/views/absences.py @@ -431,7 +431,7 @@ def SignaleAbsenceGrHebdo( modimpls_list = [] ues = nt.get_ues_stat_dict() for ue in ues: - modimpls_list += nt.get_modimpls(ue_id=ue["ue_id"]) + modimpls_list += nt.get_modimpls_dict(ue_id=ue["ue_id"]) menu_module = "" for modimpl in modimpls_list: @@ -599,7 +599,7 @@ def SignaleAbsenceGrSemestre( modimpls_list = [] ues = nt.get_ues_stat_dict() for ue in ues: - modimpls_list += nt.get_modimpls(ue_id=ue["ue_id"]) + modimpls_list += nt.get_modimpls_dict(ue_id=ue["ue_id"]) menu_module = "" for modimpl in modimpls_list: diff --git a/app/views/scolar.py b/app/views/scolar.py index b83dbcace..f79c5798d 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -41,6 +41,7 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed from wtforms import SubmitField +from app import log from app.decorators import ( scodoc, scodoc7func, @@ -50,12 +51,12 @@ from app.decorators import ( login_required, ) from app.models.etudiants import Identite +from app.models.etudiants import make_etud_args from app.views import scolar_bp as bp import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb -from app import log from app.scodoc.scolog import logdb from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( @@ -455,7 +456,7 @@ def etud_info(etudid=None, format="xml"): if not format in ("xml", "json"): raise ScoValueError("format demandé non supporté par cette fonction.") t0 = time.time() - args = sco_etud.make_etud_args(etudid=etudid) + args = make_etud_args(etudid=etudid) cnx = ndb.GetDBConnexion() etuds = sco_etud.etudident_list(cnx, args) if not etuds: diff --git a/pylintrc b/pylintrc index 057a85cd0..21454d07e 100644 --- a/pylintrc +++ b/pylintrc @@ -19,3 +19,5 @@ ignored-classes=Permission, # and thus existing member attributes cannot be deduced by static analysis). It # supports qualified module names, as well as Unix pattern matching. ignored-modules=entreprises + +good-names=d,e,f,i,j,k,t,u,v,x,y,z,H,F,ue \ No newline at end of file diff --git a/sco_version.py b/sco_version.py index bc906576b..84e9b0f57 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.1.16" +SCOVERSION = "9.2.0a" SCONAME = "ScoDoc" diff --git a/tests/unit/test_but_modules.py b/tests/unit/test_but_modules.py index a73c98bbc..07705c211 100644 --- a/tests/unit/test_but_modules.py +++ b/tests/unit/test_but_modules.py @@ -4,6 +4,8 @@ et calcul moyennes modules """ import numpy as np import pandas as pd +from app.models.modules import Module +from app.models.moduleimpls import ModuleImpl from tests.unit import setup from app import db @@ -135,70 +137,72 @@ def test_module_conformity(test_client): ) assert isinstance(modules_coefficients, pd.DataFrame) assert modules_coefficients.shape == (nb_ues, nb_mods) - evals_poids, ues = moy_mod.df_load_evaluations_poids(evaluation.moduleimpl_id) + evals_poids, ues = moy_mod.load_evaluations_poids(evaluation.moduleimpl_id) assert isinstance(evals_poids, pd.DataFrame) assert len(ues) == nb_ues assert all(evals_poids.dtypes == np.float64) assert evals_poids.shape == (nb_evals, nb_ues) - assert not moy_mod.check_moduleimpl_conformity( + assert not moy_mod.moduleimpl_is_conforme( evaluation.moduleimpl, evals_poids, modules_coefficients ) -def test_module_moy_elem(test_client): - """Vérification calcul moyenne d'un module - (notes entrées dans un DataFrame sans passer par ScoDoc) - """ - # Création de deux évaluations: - e1 = Evaluation(note_max=20.0, coefficient=1.0) - e2 = Evaluation(note_max=20.0, coefficient=1.0) - db.session.add(e1) - db.session.add(e2) - db.session.commit() - # Repris du notebook CalculNotesBUT.ipynb - data = [ # Les notes de chaque étudiant dans les 2 evals: - { - e1.id: 11.0, - e2.id: 16.0, - }, - { - e1.id: None, # une absence - e2.id: 17.0, - }, - { - e1.id: 13.0, - e2.id: NOTES_NEUTRALISE, # une abs EXC - }, - { - e1.id: 14.0, - e2.id: 19.0, - }, - { - e1.id: NOTES_ATTENTE, # une ATT (traitée comme EXC) - e2.id: None, # et une ABS - }, - ] - evals_notes_df = pd.DataFrame( - data, index=["etud1", "etud2", "etud3", "etud4", "etud5"] - ) - # Poids des évaluations (1 ligne / évaluation) - data = [ - {"UE1": 1, "UE2": 0, "UE3": 0}, - {"UE1": 2, "UE2": 5, "UE3": 0}, - ] - evals_poids_df = pd.DataFrame(data, index=[e1.id, e2.id], dtype=float) - evaluations = [e1, e2] - etuds_moy_module_df = moy_mod.compute_module_moy( - evals_notes_df.fillna(0.0), evals_poids_df, evaluations, [True, True] - ) - NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN) - r = etuds_moy_module_df.fillna(NAN) - assert tuple(r.loc["etud1"]) == (14 + 1 / 3, 16.0, NAN) - assert tuple(r.loc["etud2"]) == (11 + 1 / 3, 17.0, NAN) - assert tuple(r.loc["etud3"]) == (13, NAN, NAN) - assert tuple(r.loc["etud4"]) == (17 + 1 / 3, 19, NAN) - assert tuple(r.loc["etud5"]) == (0.0, 0.0, NAN) - # note: les notes UE3 sont toutes NAN car les poids vers l'UE3 sont nuls +# En ScoDoc 9.2 test ne peut plus exister car compute_module_moy +# est maintenant incorporé dans la classe ModuleImplResultsAPC +# def test_module_moy_elem(test_client): +# """Vérification calcul moyenne d'un module +# (notes entrées dans un DataFrame sans passer par ScoDoc) +# """ +# # Création de deux évaluations: +# e1 = Evaluation(note_max=20.0, coefficient=1.0) +# e2 = Evaluation(note_max=20.0, coefficient=1.0) +# db.session.add(e1) +# db.session.add(e2) +# db.session.flush() +# # Repris du notebook CalculNotesBUT.ipynb +# data = [ # Les notes de chaque étudiant dans les 2 evals: +# { +# e1.id: 11.0, +# e2.id: 16.0, +# }, +# { +# e1.id: None, # une absence +# e2.id: 17.0, +# }, +# { +# e1.id: 13.0, +# e2.id: NOTES_NEUTRALISE, # une abs EXC +# }, +# { +# e1.id: 14.0, +# e2.id: 19.0, +# }, +# { +# e1.id: NOTES_ATTENTE, # une ATT (traitée comme EXC) +# e2.id: None, # et une ABS +# }, +# ] +# evals_notes_df = pd.DataFrame( +# data, index=["etud1", "etud2", "etud3", "etud4", "etud5"] +# ) +# # Poids des évaluations (1 ligne / évaluation) +# data = [ +# {"UE1": 1, "UE2": 0, "UE3": 0}, +# {"UE1": 2, "UE2": 5, "UE3": 0}, +# ] +# evals_poids_df = pd.DataFrame(data, index=[e1.id, e2.id], dtype=float) +# evaluations = [e1, e2] +# etuds_moy_module_df = moy_mod.compute_module_moy( +# evals_notes_df.fillna(0.0), evals_poids_df, evaluations, [True, True] +# ) +# NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN) +# r = etuds_moy_module_df.fillna(NAN) +# assert tuple(r.loc["etud1"]) == (14 + 1 / 3, 16.0, NAN) +# assert tuple(r.loc["etud2"]) == (11 + 1 / 3, 17.0, NAN) +# assert tuple(r.loc["etud3"]) == (13, NAN, NAN) +# assert tuple(r.loc["etud4"]) == (17 + 1 / 3, 19, NAN) +# assert tuple(r.loc["etud5"]) == (0.0, 0.0, NAN) +# # note: les notes UE3 sont toutes NAN car les poids vers l'UE3 sont nuls def test_module_moy(test_client): @@ -237,7 +241,7 @@ def test_module_moy(test_client): nb_evals = models.Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).count() assert nb_evals == 2 nb_ues = 3 - + modimpl = ModuleImpl.query.get(moduleimpl_id) # --- Change les notes et recalcule les moyennes du module # (rappel: on a deux évaluations: evaluation1, evaluation2, et un seul étudiant) def change_notes(n1, n2): @@ -245,17 +249,14 @@ def test_module_moy(test_client): _ = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, n1)]) _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, n2)]) # Calcul de la moyenne du module - evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id) + evals_poids, ues = moy_mod.load_evaluations_poids(moduleimpl_id) assert evals_poids.shape == (nb_evals, nb_ues) - evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( - moduleimpl_id - ) - assert evals_notes[evaluations[0].id].dtype == np.float64 - assert evaluation1.id == evaluations[0].id - assert evaluation2.id == evaluations[1].id - etuds_moy_module = moy_mod.compute_module_moy( - evals_notes, evals_poids, evaluations, evaluations_completes - ) + + mod_results = moy_mod.ModuleImplResultsAPC(modimpl) + evals_notes = mod_results.evals_notes + assert evals_notes[evaluation1.id].dtype == np.float64 + + etuds_moy_module = mod_results.compute_module_moy(evals_poids) return etuds_moy_module # --- Notes ordinaires: diff --git a/tests/unit/test_but_ues.py b/tests/unit/test_but_ues.py index d27463f40..947e2f11c 100644 --- a/tests/unit/test_but_ues.py +++ b/tests/unit/test_but_ues.py @@ -69,7 +69,7 @@ def test_ue_moy(test_client): _ = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, n1)]) _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, n2)]) # Recalcul des moyennes - sem_cube, _, _, _, _ = moy_ue.notes_sem_load_cube(formsemestre) + sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) etuds = formsemestre.etuds.all() etud_moy_ue = moy_ue.compute_ue_moys( sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df @@ -112,7 +112,7 @@ def test_ue_moy(test_client): exception_raised = True assert exception_raised # Recalcule les notes: - sem_cube, _, _, _, _ = moy_ue.notes_sem_load_cube(formsemestre) + sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) etuds = formsemestre.etuds.all() etud_moy_ue = moy_ue.compute_ue_moys( sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df