From a83ab8f68437163e1e11292fe333d9df9689dc05 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Mon, 22 Nov 2021 00:31:53 +0100 Subject: [PATCH] WIP: calcul des moyennes de modules BUT --- app/comp/moy_mod.py | 47 ++++-- app/models/evaluations.py | 2 +- app/models/formations.py | 25 +++ app/scodoc/sco_edit_module.py | 6 +- app/scodoc/sco_edit_ue.py | 87 ++--------- app/scodoc/sco_liste_notes.py | 257 ++++++++++++++++++++++--------- app/scodoc/sco_saisie_notes.py | 18 +-- app/scodoc/sco_ue_external.py | 2 +- app/scodoc/sco_utils.py | 3 + app/static/css/scodoc.css | 8 + tests/unit/sco_fake_gen.py | 2 +- tests/unit/test_but_modules.py | 89 +++++++++-- tests/unit/test_notes_modules.py | 8 +- 13 files changed, 370 insertions(+), 184 deletions(-) diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 5aa0f2536..4b186938d 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -41,11 +41,14 @@ from app.models import ModuleImpl, Evaluation, EvaluationUEPoids from app.scodoc import sco_utils as scu -def df_load_evaluations_poids(moduleimpl_id: int, default_poids=0.0) -> pd.DataFrame: +def df_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 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) """ modimpl = ModuleImpl.query.get(moduleimpl_id) evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() @@ -59,7 +62,7 @@ def df_load_evaluations_poids(moduleimpl_id: int, default_poids=0.0) -> pd.DataF df[eval_poids.ue_id][eval_poids.evaluation_id] = eval_poids.poids if default_poids is not None: df.fillna(value=default_poids, inplace=True) - return df + return df, ues def check_moduleimpl_conformity( @@ -83,12 +86,14 @@ def df_load_modimpl_notes(moduleimpl_id: int) -> pd.DataFrame: colonnes: evaluation_id (le nom de la colonne est l'evaluation_id en str) index (lignes): etudid + Résultat: (evals_notes, liste de évaluations du moduleimpl) + L'ensemble des étudiants est celui des inscrits au module. - Valeurs des notes: - note : float (valeur enregistrée brute, pas normalisée sur 20) + Les notes renvoyées sont "brutes" et peuvent prendre els valeurs: + note : float (valeur enregistrée brute, non normalisée sur 20) pas de note: NaN - absent: 0. + absent: NaN excusé: NOTES_NEUTRALISE (voir sco_utils) attente: NOTES_ATTENTE @@ -96,7 +101,7 @@ def df_load_modimpl_notes(moduleimpl_id: int) -> pd.DataFrame: """ etudids = [e.etudid for e in ModuleImpl.query.get(moduleimpl_id).inscriptions] evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id) - df = pd.DataFrame(index=etudids, dtype=float) # empty df with all students + evals_notes = pd.DataFrame(index=etudids, dtype=float) # empty df with all students for evaluation in evaluations: eval_df = pd.read_sql( @@ -107,14 +112,30 @@ def df_load_modimpl_notes(moduleimpl_id: int) -> pd.DataFrame: params={"evaluation_id": evaluation.evaluation_id}, index_col="etudid", ) - # Remplace les ABS (NULL en BD, donc NaN ici) par des zéros. - eval_df.fillna(value=0.0, inplace=True) - df = df.merge(eval_df, how="outer", left_index=True, right_index=True) + evals_notes = evals_notes.merge( + eval_df, how="outer", left_index=True, right_index=True + ) - return df + return evals_notes, evaluations -def compute_module_moy(evals_notes: pd.DataFrame, evals_poids: pd.DataFrame): +def normalize_evals_notes(evals_notes: pd.DataFrame, evaluations: list) -> pd.DataFrame: + """Transforme les notes brutes (en base) en valeurs entre 0 et 20: + les notes manquantes, ABS, EXC ATT sont mises à zéro, et les valeurs + normalisées entre 0 et 20. + Return: notes sur 20""" + # Le fillna (pour traiter les ABS) est inutile car le where matche le NaN + # eval_df.fillna(value=0.0, inplace=True) + return evals_notes.where(evals_notes > -1000, 0) / [ + e.note_max / 20.0 for e in evaluations + ] + + +def compute_module_moy( + evals_notes: pd.DataFrame, + evals_poids: pd.DataFrame, + evals_coefs=1.0, +) -> pd.DataFrame: """Calcule les moyennes des étudiants dans ce module - evals_notes : DataFrame, colonnes: EVALS, Lignes: etudid @@ -123,6 +144,8 @@ def compute_module_moy(evals_notes: pd.DataFrame, evals_poids: pd.DataFrame): - evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs + - evals_coefs: sequence, 1 coef par UE + 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) @@ -131,7 +154,7 @@ def compute_module_moy(evals_notes: pd.DataFrame, evals_poids: pd.DataFrame): nb_etuds = len(evals_notes) nb_ues = evals_poids.shape[1] etud_moy_module_arr = np.zeros((nb_etuds, nb_ues)) - evals_poids_arr = evals_poids.to_numpy().transpose() + evals_poids_arr = evals_poids.to_numpy().transpose() * evals_coefs evals_notes_arr = evals_notes.values # .to_numpy() val_neutres = np.array((scu.NOTES_NEUTRALISE, scu.NOTES_ATTENTE)) for i in range(nb_etuds): diff --git a/app/models/evaluations.py b/app/models/evaluations.py index df9cc812b..5921dddcc 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -46,7 +46,7 @@ class Evaluation(db.Model): ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True) def __repr__(self): - return f"" + def to_dict(self): + """as a dict, with the same conversions as in ScoDoc7""" + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + # ScoDoc7 output_formators + e["numero"] = e["numero"] if e["numero"] else 0 + e["ects"] = e["ects"] if e["ects"] else 0.0 + e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 + return e + def is_locked(self): """True if UE should not be modified (contains modules used in a locked formsemestre) @@ -95,6 +106,20 @@ class UniteEns(db.Model): return sco_edit_ue.ue_is_locked(self.id) + def guess_semestre_idx(self) -> None: + """Lorsqu'on prend une ancienne formation non APC, + les UE n'ont pas d'indication de semestre. + Cette méthode fixe le semestre en prenant celui du premier module, + ou à défaut le met à 1. + """ + if self.semestre_idx is None: + if self.modules: + self.semestre_idx = self.modules[0].semestre_id + else: + self.semestre_idx = 1 + db.session.add(self) + db.session.commit() + class Matiere(db.Model): """Matières: regroupe les modules d'une UE diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index d6aa1e251..c717c1dd7 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -587,7 +587,11 @@ def module_edit(module_id=None): ), ] # force module semestre_idx to its UE - module["semestre_id"] = a_module.ue.semestre_idx + if a_module.ue.semestre_idx: + module["semestre_id"] = a_module.ue.semestre_idx + # Filet de sécurité si jamais l'UE n'a pas non plus de semestre: + if not module["semestre_id"]: + module["semestre_id"] = 1 tf = TrivialFormulator( request.base_url, scu.get_request_args(), diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 121521e8f..7e89eaf23 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -467,8 +467,19 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list else: semestre_idx = int(semestre_idx) semestre_ids = range(1, parcours.NB_SEM + 1) - ues = ue_list(args={"formation_id": formation_id, "is_external": False}) - ues_externes = ue_list(args={"formation_id": formation_id, "is_external": True}) + # transition: on requete ici via l'ORM mais on utilise les fonctions ScoDoc7 + # basées sur des dicts + ues_obj = UniteEns.query.filter_by(formation_id=formation_id, is_external=False) + ues_externes_obj = UniteEns.query.filter_by( + formation_id=formation_id, is_external=True + ) + if is_apc: + # pour faciliter la transition des anciens programmes non APC + for ue in ues_obj: + ue.guess_semestre_idx() + ues = [ue.to_dict() for ue in ues_obj] + ues_externes = [ue.to_dict() for ue in ues_externes_obj] + # tri par semestre et numero: _add_ue_semestre_id(ues, is_apc) _add_ue_semestre_id(ues_externes, is_apc) @@ -928,78 +939,6 @@ def _ue_table_matieres( return "\n".join(H) -def _ue_table_ressources_saes( - parcours, - ue, - editable, - tag_editable, - arrow_up, - arrow_down, - arrow_none, - delete_icon, - delete_disabled_icon, - module_type=None, -): - """Édition de programme: liste des ressources et SAÉs d'une UE. - (pour les parcours APC_SAE) - """ - matieres = sco_edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]}) - if not matieres: - # Les formations APC (BUT) n'utilisent pas de matières - # mais il doit y en avoir une par UE - # silently fix this on-the-fly to ease migration - _ = sco_edit_matiere.do_matiere_create( - {"ue_id": ue["ue_id"], "titre": "APC", "numero": 1}, - ) - matieres = sco_edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]}) - assert matieres - mat = matieres[0] - - H = [ - """ - ") - return "\n".join(H) - - def _ue_table_modules( parcours, mat, diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 31fea7924..bb3a62bcc 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -31,9 +31,11 @@ import flask from flask import url_for, g, request +from app import models import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log +from app.comp import moy_mod from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc import sco_cache from app.scodoc import sco_edit_module @@ -204,6 +206,7 @@ def do_evaluation_listenotes(): group_ids=tf[2]["group_ids"], hide_groups=hide_groups, with_emails=with_emails, + mode=mode, ) @@ -216,15 +219,22 @@ def _make_table_notes( hide_groups=False, with_emails=False, group_ids=[], + mode="module", # "eval" or "module" ): """Table liste notes (une seule évaluation ou toutes celles d'un module)""" + # Code à ré-écrire ! if not evals: return "

Aucune évaluation !

" E = evals[0] moduleimpl_id = E["moduleimpl_id"] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] - Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"]) + modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] + module = models.Module.query.get(modimpl["module_id"]) + is_apc = module.formation.get_parcours().APC_SAE + if is_apc: + evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id) + else: + evals_poids, ues = None, None + sem = sco_formsemestre.get_formsemestre(modimpl["formsemestre_id"]) # (debug) check that all evals are in same module: for e in evals: if e["moduleimpl_id"] != moduleimpl_id: @@ -236,16 +246,12 @@ def _make_table_notes( keep_numeric = False # Si pas de groupe, affiche tout if not group_ids: - group_ids = [sco_groups.get_default_group(M["formsemestre_id"])] + group_ids = [sco_groups.get_default_group(modimpl["formsemestre_id"])] groups = sco_groups.listgroups(group_ids) gr_title = sco_groups.listgroups_abbrev(groups) gr_title_filename = sco_groups.listgroups_filename(groups) - etudid_etats = sco_groups.do_evaluation_listeetuds_groups( - E["evaluation_id"], groups, include_dems=True - ) - if anonymous_listing: columns_ids = ["code"] # cols in table else: @@ -269,7 +275,7 @@ def _make_table_notes( rows = [] - class keymgr(dict): # comment : key (pour regrouper les comments a la fin) + class KeyManager(dict): # comment : key (pour regrouper les comments a la fin) def __init__(self): self.lastkey = 1 @@ -279,7 +285,19 @@ def _make_table_notes( # self.lastkey = chr(ord(self.lastkey)+1) return str(r) - K = keymgr() + key_mgr = KeyManager() + + # code pour listings anonyme, à la place du nom + if sco_preferences.get_preference("anonymous_lst_code") == "INE": + anonymous_lst_key = "code_ine" + elif sco_preferences.get_preference("anonymous_lst_code") == "NIP": + anonymous_lst_key = "code_nip" + else: + anonymous_lst_key = "etudid" + + etudid_etats = sco_groups.do_evaluation_listeetuds_groups( + E["evaluation_id"], groups, include_dems=True + ) for etudid, etat in etudid_etats: css_row_class = None # infos identite etudiant @@ -295,11 +313,7 @@ def _make_table_notes( else: grc = etat - code = "" # code pour listings anonyme, à la place du nom - if sco_preferences.get_preference("anonymous_lst_code") == "INE": - code = etud["code_ine"] - elif sco_preferences.get_preference("anonymous_lst_code") == "NIP": - code = etud["code_nip"] + code = etud.get(anonymous_lst_key) if not code: # laisser le code vide n'aurait aucun sens, prenons l'etudid code = etudid @@ -310,7 +324,7 @@ def _make_table_notes( "etudid": etudid, "nom": etud["nom"].upper(), "_nomprenom_target": "formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" - % (M["formsemestre_id"], etudid), + % (modimpl["formsemestre_id"], etudid), "_nomprenom_td_attrs": 'id="%s" class="etudinfo"' % (etud["etudid"]), "prenom": etud["prenom"].lower().capitalize(), "nomprenom": etud["nomprenom"], @@ -322,7 +336,7 @@ def _make_table_notes( ) # Lignes en tête: - coefs = { + row_coefs = { "nom": "", "prenom": "", "nomprenom": "", @@ -331,7 +345,16 @@ def _make_table_notes( "_css_row_class": "sorttop fontitalic", "_table_part": "head", } - note_max = { + row_poids = { + "nom": "", + "prenom": "", + "nomprenom": "", + "group": "", + "code": "", + "_css_row_class": "sorttop poids", + "_table_part": "head", + } + row_note_max = { "nom": "", "prenom": "", "nomprenom": "", @@ -340,7 +363,7 @@ def _make_table_notes( "_css_row_class": "sorttop fontitalic", "_table_part": "head", } - moys = { + row_moys = { "_css_row_class": "moyenne sortbottom", "_table_part": "foot", #'_nomprenom_td_attrs' : 'colspan="2" ', @@ -352,12 +375,16 @@ def _make_table_notes( e["eval_state"] = sco_evaluations.do_evaluation_etat(e["evaluation_id"]) notes, nb_abs, nb_att = _add_eval_columns( e, + evals_poids, + ues, rows, titles, - coefs, - note_max, - moys, - K, + row_coefs, + row_poids, + row_note_max, + row_moys, + is_apc, + key_mgr, note_sur_20, keep_numeric, ) @@ -370,28 +397,51 @@ def _make_table_notes( key=lambda x: (x["nom"] or "", x["prenom"] or "") ) # sort by nom, prenom - # Si module, ajoute moyenne du module: - if len(evals) > 1: - _add_moymod_column( - sem["formsemestre_id"], - e, - rows, - titles, - coefs, - note_max, - moys, - note_sur_20, - keep_numeric, - ) - columns_ids.append("moymod") + # Si module, ajoute la (les) "moyenne(s) du module: + if mode == "module": + if len(evals) > 1: + # Moyenne de l'étudant dans le module + # Affichée même en APC à titre indicatif + _add_moymod_column( + sem["formsemestre_id"], + moduleimpl_id, + rows, + columns_ids, + titles, + row_coefs, + row_poids, + row_note_max, + row_moys, + is_apc, + keep_numeric, + ) + if is_apc: + # Ajoute une colonne par UE + _add_apc_columns( + moduleimpl_id, + evals_poids, + ues, + rows, + columns_ids, + titles, + row_coefs, + row_poids, + row_note_max, + row_moys, + keep_numeric, + ) # Ajoute colonnes emails tout à droite: if with_emails: columns_ids += ["email", "emailperso"] # Ajoute lignes en tête et moyennes if len(evals) > 0: - rows = [coefs, note_max] + rows - rows.append(moys) + rows_head = [row_coefs] + if is_apc: + rows_head.append(row_poids) + rows_head.append(row_note_max) + rows = rows_head + rows + rows.append(row_moys) # ajout liens HTMl vers affichage une evaluation: if format == "html" and len(evals) > 1: rlinks = {"_table_part": "head"} @@ -425,7 +475,7 @@ def _make_table_notes( if with_emails: gl = "&with_emails%3Alist=yes" + gl if len(evals) == 1: - evalname = "%s-%s" % (Mod["code"], ndb.DateDMYtoISO(E["jour"])) + evalname = "%s-%s" % (module.code, ndb.DateDMYtoISO(E["jour"])) hh = "%s, %s (%d étudiants)" % (E["description"], gr_title, len(etudid_etats)) filename = scu.make_filename("notes_%s_%s" % (evalname, gr_title_filename)) caption = hh @@ -437,8 +487,8 @@ def _make_table_notes( % (nb_abs, nb_att) ) else: - filename = scu.make_filename("notes_%s_%s" % (Mod["code"], gr_title_filename)) - title = "Notes du module %(code)s %(titre)s" % Mod + filename = scu.make_filename("notes_%s_%s" % (module.code, gr_title_filename)) + title = f"Notes {module.type_name()} {module.code} {module.titre}" title += " semestre %(titremois)s" % sem if gr_title and gr_title != "tous": title += " %s" % gr_title @@ -447,10 +497,11 @@ def _make_table_notes( if format == "pdf": caption = "" # same as pdf_title pdf_title = title - html_title = ( - """

Notes du module %s %s

""" - % (moduleimpl_id, Mod["code"], Mod["titre"]) - ) + html_title = f"""

Notes {module.type_name()} {module.code} {module.titre}

+ """ base_url = "evaluation_listenotes?moduleimpl_id=%s" % moduleimpl_id + gl # display tab = GenTable( @@ -469,7 +520,7 @@ def _make_table_notes( html_title=html_title, pdf_title=pdf_title, html_class="table_leftalign notes_evaluation", - preferences=sco_preferences.SemPreferences(M["formsemestre_id"]), + preferences=sco_preferences.SemPreferences(modimpl["formsemestre_id"]), # html_generate_cells=False # la derniere ligne (moyennes) est incomplete ) @@ -497,7 +548,7 @@ def _make_table_notes( + "\n", '

', ] - commentkeys = list(K.items()) # [ (comment, key), ... ] + commentkeys = list(key_mgr.items()) # [ (comment, key), ... ] commentkeys.sort(key=lambda x: int(x[1])) for (comment, key) in commentkeys: C.append( @@ -526,7 +577,19 @@ def _make_table_notes( def _add_eval_columns( - e, rows, titles, coefs, note_max, moys, K, note_sur_20, keep_numeric + e, + evals_poids, + ues, + rows, + titles, + row_coefs, + row_poids, + row_note_max, + row_moys, + is_apc, + K, + note_sur_20, + keep_numeric, ): """Add eval e""" nb_notes = 0 @@ -594,19 +657,23 @@ def _add_eval_columns( } ) - coefs[evaluation_id] = "coef. %s" % e["coefficient"] + row_coefs[evaluation_id] = "coef. %s" % e["coefficient"] + if is_apc: + row_poids[evaluation_id] = _mini_table_eval_ue_poids( + evaluation_id, evals_poids, ues + ) if note_sur_20: nmax = 20.0 else: nmax = e["note_max"] if keep_numeric: - note_max[evaluation_id] = nmax + row_note_max[evaluation_id] = nmax else: - note_max[evaluation_id] = "/ %s" % nmax + row_note_max[evaluation_id] = "/ %s" % nmax if nb_notes > 0: - moys[evaluation_id] = "%.3g" % (sum_notes / nb_notes) - moys[ + row_moys[evaluation_id] = "%.3g" % (sum_notes / nb_notes) + row_moys[ "_" + str(evaluation_id) + "_help" ] = "moyenne sur %d notes (%s le %s)" % ( nb_notes, @@ -614,7 +681,7 @@ def _add_eval_columns( e["jour"], ) else: - moys[evaluation_id] = "" + row_moys[evaluation_id] = "" titles[evaluation_id] = "%(description)s (%(jour)s)" % e @@ -628,15 +695,29 @@ def _add_eval_columns( return notes, nb_abs, nb_att # pour histogramme +def _mini_table_eval_ue_poids(evaluation_id, evals_poids, ues): + "contenu de la cellule: poids" + return ( + """" + + "
""" + + "".join([f"{ue.acronyme}" for ue in ues]) + + "
" + + "".join([f"{evals_poids[ue.id][evaluation_id]}" for ue in ues]) + + "
" + ) + + def _add_moymod_column( formsemestre_id, - e, + moduleimpl_id, rows, + columns_ids, titles, - coefs, - note_max, - moys, - note_sur_20, + row_coefs, + row_poids, + row_note_max, + row_moys, + is_apc, keep_numeric, ): """Ajoute la colonne moymod à rows""" @@ -647,23 +728,61 @@ def _add_moymod_column( notes = [] # liste des notes numeriques, pour calcul histogramme uniquement for row in rows: etudid = row["etudid"] - val = nt.get_etud_mod_moy( - e["moduleimpl_id"], etudid - ) # note sur 20, ou 'NA','NI' + val = nt.get_etud_mod_moy(moduleimpl_id, etudid) # note sur 20, ou 'NA','NI' row[col_id] = scu.fmt_note(val, keep_numeric=keep_numeric) row["_" + col_id + "_td_attrs"] = ' class="moyenne" ' if not isinstance(val, str): notes.append(val) nb_notes = nb_notes + 1 sum_notes += val - coefs[col_id] = "(avec abs)" + row_coefs[col_id] = "(avec abs)" + if is_apc: + row_poids[col_id] = "à titre indicatif" if keep_numeric: - note_max[col_id] = 20.0 + row_note_max[col_id] = 20.0 else: - note_max[col_id] = "/ 20" + row_note_max[col_id] = "/ 20" titles[col_id] = "Moyenne module" + columns_ids.append(col_id) if nb_notes > 0: - moys[col_id] = "%.3g" % (sum_notes / nb_notes) - moys["_" + col_id + "_help"] = "moyenne des moyennes" + row_moys[col_id] = "%.3g" % (sum_notes / nb_notes) + row_moys["_" + col_id + "_help"] = "moyenne des moyennes" else: - moys[col_id] = "" + row_moys[col_id] = "" + + +def _add_apc_columns( + moduleimpl_id, + evals_poids, + ues, + rows, + columns_ids, + titles, + row_coefs, + row_poids, + row_note_max, + row_moys, + keep_numeric, +): + """Ajoute les colonnes moyennes vers les UE""" + # On raccorde ici les nouveaux calculs de notes (BUT 2021) + # sur l'ancien code ScoDoc + # => 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 + + evals_notes, evaluations = moy_mod.df_load_modimpl_notes(moduleimpl_id) + evals_notes_sur_20 = moy_mod.normalize_evals_notes(evals_notes, evaluations) + etud_moy_module = moy_mod.compute_module_moy( + evals_notes_sur_20, evals_poids, [e.coefficient for e in evaluations] + ) + + for row in rows: + for ue in ues: + moy_ue = etud_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" + for ue in ues: + col_id = f"moy_ue_{ue.id}" + titles[col_id] = ue.acronyme + columns_ids.append(col_id) diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index bd075df14..c1ba813dd 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -255,7 +255,7 @@ def do_evaluation_upload_xls(): diag.append("Notes invalides pour: " + ", ".join(etudsnames)) raise InvalidNoteValue() else: - nb_changed, nb_suppress, existing_decisions = _notes_add( + nb_changed, nb_suppress, existing_decisions = notes_add( authuser, evaluation_id, L, comment ) # news @@ -345,7 +345,7 @@ def do_evaluation_set_missing(evaluation_id, value, dialog_confirmed=False): ) # ok comment = "Initialisation notes manquantes" - nb_changed, _, _ = _notes_add(current_user, evaluation_id, L, comment) + nb_changed, _, _ = notes_add(current_user, evaluation_id, L, comment) # news M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] @@ -407,7 +407,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False): notes = [(etudid, scu.NOTES_SUPPRESS) for etudid in NotesDB.keys()] if not dialog_confirmed: - nb_changed, nb_suppress, existing_decisions = _notes_add( + nb_changed, nb_suppress, existing_decisions = notes_add( current_user, evaluation_id, notes, do_it=False ) msg = ( @@ -425,7 +425,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False): ) # modif - nb_changed, nb_suppress, existing_decisions = _notes_add( + nb_changed, nb_suppress, existing_decisions = notes_add( current_user, evaluation_id, notes, comment="effacer tout" ) assert nb_changed == nb_suppress @@ -454,7 +454,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False): return html_sco_header.sco_header() + "\n".join(H) + html_sco_header.sco_footer() -def _notes_add(user, evaluation_id: int, notes: list, comment=None, do_it=True): +def notes_add(user, evaluation_id: int, notes: list, comment=None, do_it=True) -> tuple: """ Insert or update notes notes is a list of tuples (etudid,value) @@ -462,7 +462,7 @@ def _notes_add(user, evaluation_id: int, notes: list, comment=None, do_it=True): WOULD be changed or suppressed. Nota: - si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log) - Return number of changed notes + Return tuple (nb_changed, nb_suppress, existing_decisions) """ now = psycopg2.Timestamp( *time.localtime()[:6] @@ -563,7 +563,7 @@ def _notes_add(user, evaluation_id: int, notes: list, comment=None, do_it=True): else: # suppression ancienne note if do_it: log( - "_notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s" + "notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s" % (evaluation_id, etudid, oldval) ) cursor.execute( @@ -587,7 +587,7 @@ def _notes_add(user, evaluation_id: int, notes: list, comment=None, do_it=True): if has_existing_decision(M, E, etudid): existing_decisions.append(etudid) except: - log("*** exception in _notes_add") + log("*** exception in notes_add") if do_it: cnx.rollback() # abort # inval cache @@ -1265,7 +1265,7 @@ def save_note(etudid=None, evaluation_id=None, value=None, comment=""): else: L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod) if L: - nbchanged, _, existing_decisions = _notes_add( + nbchanged, _, existing_decisions = notes_add( authuser, evaluation_id, L, comment=comment, do_it=True ) sco_news.add( diff --git a/app/scodoc/sco_ue_external.py b/app/scodoc/sco_ue_external.py index 748df97ae..6e7fff307 100644 --- a/app/scodoc/sco_ue_external.py +++ b/app/scodoc/sco_ue_external.py @@ -167,7 +167,7 @@ def external_ue_inscrit_et_note(moduleimpl_id, formsemestre_id, notes_etuds): description="note externe", ) # Saisie des notes - _, _, _ = sco_saisie_notes._notes_add( + _, _, _ = sco_saisie_notes.notes_add( current_user, evaluation_id, list(notes_etuds.items()), diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 8be4b2bd6..90c11f521 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -44,6 +44,7 @@ import unicodedata import urllib from urllib.parse import urlparse, parse_qsl, urlunparse, urlencode +import numpy as np from PIL import Image as PILImage import pydot import requests @@ -138,6 +139,8 @@ def fmt_note(val, note_max=None, keep_numeric=False): if val == NOTES_ATTENTE: return "ATT" # attente, note neutralisee if isinstance(val, float) or isinstance(val, int): + if np.isnan(val): + return "/" if note_max != None and note_max > 0: val = val * 20.0 / note_max if keep_numeric: diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 5040be984..afa6fe303 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1040,6 +1040,14 @@ td.colcomment, span.colcomment { color: rgb(80,100,80); } +table.notes_evaluation table.eval_poids { + font-size: 50%; +} +table.notes_evaluation td.moy_ue { + font-weight: bold; + color:rgb(1, 116, 96); +} + h2.formsemestre, .gtrcontent h2 { margin-top: 2px; font-size: 130%; diff --git a/tests/unit/sco_fake_gen.py b/tests/unit/sco_fake_gen.py index 371cd21db..df530862f 100644 --- a/tests/unit/sco_fake_gen.py +++ b/tests/unit/sco_fake_gen.py @@ -302,7 +302,7 @@ class ScoFake(object): ): if user is None: user = self.default_user - return sco_saisie_notes._notes_add( + return sco_saisie_notes.notes_add( user, evaluation["evaluation_id"], [(etud["etudid"], note)], diff --git a/tests/unit/test_but_modules.py b/tests/unit/test_but_modules.py index 468cf56da..847c4a65a 100644 --- a/tests/unit/test_but_modules.py +++ b/tests/unit/test_but_modules.py @@ -3,13 +3,14 @@ Test modèles évaluations avec poids BUT """ import numpy as np import pandas as pd +from app.models.etudiants import Identite from tests.unit import sco_fake_gen from app import db from app import models from app.comp import moy_mod from app.comp import moy_ue -from app.scodoc import sco_codes_parcours +from app.scodoc import sco_codes_parcours, sco_saisie_notes from app.scodoc.sco_utils import NOTES_ATTENTE, NOTES_NEUTRALISE """ @@ -175,28 +176,29 @@ def _setup_module_evaluation(ue_coefs=(1.0, 2.0, 3.0)): coefficient=0, ) evaluation_id = _e1["evaluation_id"] - return formation_id, evaluation_id, ue1, ue2, ue3 + return G, formation_id, sem, evaluation_id, ue1, ue2, ue3 def test_module_conformity(test_client): """Vérification coefficients module<->UE vs poids des évaluations""" - formation_id, evaluation_id, ue1, ue2, ue3 = _setup_module_evaluation() + _, formation_id, _, evaluation_id, ue1, ue2, ue3 = _setup_module_evaluation() semestre_idx = 2 nb_ues = 3 # 3 UEs dans ce test nb_mods = 1 # 1 seul module nb_evals = 1 # 1 seule evaluation pour l'instant p1, p2, p3 = 1.0, 2.0, 0.0 # poids de l'éval vers les UE 1, 2 et 3 evaluation = models.Evaluation.query.get(evaluation_id) - evaluation.set_ue_poids_dict({ue1.id: p1, ue2.id: p2}) - assert evaluation.get_ue_poids_dict() == {ue1.id: p1, ue2.id: p2} + evaluation.set_ue_poids_dict({ue1.id: p1, ue2.id: p2, ue3.id: p3}) + assert evaluation.get_ue_poids_dict() == {ue1.id: p1, ue2.id: p2, ue3.id: p3} # On n'est pas conforme car p3 est nul alors que c3 est non nul modules_coefficients, _ues, _modules = moy_ue.df_load_ue_coefs( formation_id, semestre_idx ) assert isinstance(modules_coefficients, pd.DataFrame) assert modules_coefficients.shape == (nb_ues, nb_mods) - evals_poids = moy_mod.df_load_evaluations_poids(evaluation.moduleimpl_id) + evals_poids, ues = moy_mod.df_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( @@ -204,9 +206,9 @@ def test_module_conformity(test_client): ) -def test_module_moy(): +def test_module_moy_elem(test_client): """Vérification calcul moyenne d'un module - (calcul bas niveau) + (notes entrées dans un DataFrame sans passer par ScoDoc) """ # Repris du notebook CalculNotesBUT.ipynb data = [ # Les notes de chaque étudiant dans les 2 evals: @@ -240,12 +242,71 @@ def test_module_moy(): {"UE1": 2, "UE2": 5, "UE3": 0}, ] evals_poids = pd.DataFrame(data, index=["EVAL1", "EVAL2"], dtype=float) - etud_moy_module_df = moy_mod.compute_module_moy(evals_notes, evals_poids) + etud_moy_module_df = moy_mod.compute_module_moy( + evals_notes.fillna(0.0), evals_poids + ) NAN = 666.0 # pour pouvoir comparer NaN et NaN (car NaN != NaN) r = etud_moy_module_df.fillna(NAN) - tuple(r.loc["etud1"]) == (14 + 1 / 3, 16.0, NAN) - tuple(r.loc["etud2"]) == (11 + 1 / 3, 17.0, NAN) - tuple(r.loc["etud3"]) == (13, NAN, NAN) - tuple(r.loc["etud4"]) == (17 + 1 / 3, 19, NAN) - tuple(r.loc["etud5"]) == (0.0, 0.0, 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): + """Test calcul moyenne module avec saisie des notes via ScoDoc""" + coef_e1, coef_e2 = 7.0, 11.0 # coefficients des évaluations + G, formation_id, sem, evaluation1_id, ue1, ue2, ue3 = _setup_module_evaluation() + etud = G.create_etud(nom="test") + G.inscrit_etudiant(sem, etud) + etudid = etud["etudid"] + evaluation1 = models.Evaluation.query.get(evaluation1_id) + # Crée une deuxième évaluation dans le même moduleimpl: + evaluation2_id = G.create_evaluation( + moduleimpl_id=evaluation1.moduleimpl_id, + jour="02/01/2021", + description="evaluation 2", + coefficient=coef_e2, + )["evaluation_id"] + evaluation2 = models.Evaluation.query.get(evaluation2_id) + # Coefficients de l'eval 1 + evaluation1.coefficient = coef_e1 + # Poids des évaluations: + e1p1, e1p2, e1p3 = 1.0, 2.0, 0.0 # poids de l'éval 1 vers les UE 1, 2 et 3 + e2p1, e2p2, e2p3 = 0.0, 1.0, 0.0 # poids de l'éval 2 vers les UE + evaluation1.set_ue_poids_dict({ue1.id: e1p1, ue2.id: e1p2, ue3.id: e1p3}) + evaluation2.set_ue_poids_dict({ue1.id: e2p1, ue2.id: e2p2, ue3.id: e2p3}) + # Saisie d'une note dans chaque éval + note1, note2 = 11.0, 12.0 + t = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, note1)]) + assert t == (1, 0, []) + _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, note2)]) + # + # Vérifications + moduleimpl_id = evaluation1.moduleimpl_id + nb_evals = models.Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).count() + assert nb_evals == 2 + nb_ues = 3 + # Calcul de la moyenne du module + evals_poids, ues = moy_mod.df_load_evaluations_poids(moduleimpl_id) + assert evals_poids.shape == (nb_evals, nb_ues) + evals_notes, evaluations = moy_mod.df_load_modimpl_notes(moduleimpl_id) + evals_notes_sur_20 = moy_mod.normalize_evals_notes(evals_notes, evaluations) + etud_moy_module = moy_mod.compute_module_moy( + evals_notes_sur_20, evals_poids, [coef_e1, coef_e2] + ) + # Moyenne dans les UE 1, 2, 3: + moy_ue1 = etud_moy_module[ue1.id][etudid] + assert moy_ue1 == ((note1 * e1p1 * coef_e1) + (note2 * e2p1 * coef_e2)) / ( + e1p1 * coef_e1 + e2p1 * coef_e2 + ) + moy_ue2 = etud_moy_module[ue2.id][etudid] + assert moy_ue2 == ((note1 * e1p2 * coef_e1) + (note2 * e2p2 * coef_e2)) / ( + e1p2 * coef_e1 + e2p2 * coef_e2 + ) + moy_ue3 = etud_moy_module[ue3.id][etudid] + assert np.isnan(moy_ue3) + # moy_ue3 == ((note1 * e1p3 * coef_e1) + (note2 * e2p3 * coef_e2)) / ( + # e1p3 * coef_e1 + e2p3 * coef_e2) diff --git a/tests/unit/test_notes_modules.py b/tests/unit/test_notes_modules.py index f8e406815..934b4d90b 100644 --- a/tests/unit/test_notes_modules.py +++ b/tests/unit/test_notes_modules.py @@ -274,6 +274,10 @@ def test_notes_modules(test_client): {"etudid": etudid, "moduleimpl_id": mi2["moduleimpl_id"]}, formsemestre_id=formsemestre_id, ) + sco_moduleimpl.do_moduleimpl_inscription_create( + {"etudid": etuds[1]["etudid"], "moduleimpl_id": mi2["moduleimpl_id"]}, + formsemestre_id=formsemestre_id, + ) nt = sco_cache.NotesTableCache.get(formsemestre_id) ue_status = nt.get_etud_ue_status(etudid, ue_id) assert ue_status["nb_missing"] == 1 # mi2 n'a pas encore de note @@ -288,8 +292,8 @@ def test_notes_modules(test_client): _, _, _ = G.create_note(evaluation=e_m2, etud=etud, note=19.5) nt = sco_cache.NotesTableCache.get(formsemestre_id) ue_status = nt.get_etud_ue_status(etudid, ue_id) - assert ue_status["nb_missing"] == 0 - assert ue_status["nb_notes"] == 2 + assert ue_status["nb_missing"] == 1 # manque une note + assert ue_status["nb_notes"] == 1 # Moyenne d'UE si l'un des modules est EXC ("NA") # 2 modules, notes EXC dans le premier, note valide n dans le second