diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 9418f96d..4d10383c 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -113,6 +113,8 @@ class ModuleImplResults: """ self.evals_etudids_sans_note = {} """dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions.""" + self.evals_type = {} + """Type de chaque eval { evaluation.id : evaluation.evaluation_type }""" self.load_notes(etudids, etudids_actifs) self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index) """1 bool par etud, indique si sa moyenne de module vient de la session2""" @@ -164,7 +166,10 @@ class ModuleImplResults: self.evaluations_completes = [] self.evaluations_completes_dict = {} self.etudids_attente = set() # empty + self.evals_type = {} + evaluation: Evaluation for evaluation in moduleimpl.evaluations: + self.evals_type[evaluation.id] = evaluation.evaluation_type eval_df = self._load_evaluation_notes(evaluation) # is_complete ssi # tous les inscrits (non dem) au module ont une note @@ -270,6 +275,26 @@ class ModuleImplResults: * self.evaluations_completes ).reshape(-1, 1) + def get_evaluations_session2_coefs(self, modimpl: ModuleImpl) -> np.array: + """Coefficients des évaluations de session 2. + Les évals de session 2 sont réputées "complètes": elles sont toujours + prises en compte mais seules les notes numériques et ABS sont utilisées. + Résultat: 2d-array of floats, shape (nb_evals, 1) + """ + return ( + np.array( + [ + ( + e.coefficient + if e.evaluation_type == Evaluation.EVALUATION_SESSION2 + else 0.0 + ) + for e in modimpl.evaluations + ], + dtype=float, + ) + ).reshape(-1, 1) + # was _list_notes_evals_titles def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]: "Liste des évaluations complètes" @@ -310,18 +335,15 @@ class ModuleImplResults: return eval_list[0] return None - def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None: - """L'évaluation de deuxième session de ce module, ou None s'il n'en a pas. - Session 2: remplace la note de moyenne des autres évals. + def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]: + """Les évaluations de deuxième session de ce module, ou None s'il n'en a pas. + La moyenne des notes de Session 2 remplace la note de moyenne des autres évals. """ - eval_list = [ + return [ e for e in moduleimpl.evaluations if e.evaluation_type == Evaluation.EVALUATION_SESSION2 ] - if eval_list: - return eval_list[0] - return None def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]: """Les évaluations bonus de ce module, ou liste vide s'il n'en a pas.""" @@ -370,6 +392,7 @@ class ModuleImplResultsAPC(ModuleImplResults): return pd.DataFrame(index=[], columns=evals_poids_df.columns) if nb_ues == 0: return pd.DataFrame(index=self.evals_notes.index, columns=[]) + # coefs des évals complètes normales (pas rattr., session 2 ni bonus): evals_coefs = self.get_evaluations_coefs(modimpl) evals_poids = evals_poids_df.values * evals_coefs # -> evals_poids shape : (nb_evals, nb_ues) @@ -398,26 +421,30 @@ class ModuleImplResultsAPC(ModuleImplResults): ) / np.sum(evals_poids_etuds, axis=1) # etuds_moy_module shape: nb_etuds x nb_ues - # Application des évaluations bonus: - etuds_moy_module = self.apply_bonus( - etuds_moy_module, - modimpl, - evals_poids_df, - evals_notes_stacked, - ) - # Session2 : quand elle existe, remplace la note de module - eval_session2 = self.get_evaluation_session2(modimpl) - if eval_session2: - notes_session2 = self.evals_notes[eval_session2.id].values - # n'utilise que les notes valides (pas ATT, EXC, ABS, NaN) - etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE + evals_session2 = self.get_evaluations_session2(modimpl) + if evals_session2: + # Calcul moyenne notes session2 et remplace (si la note session 2 existe) + evals_coefs_s2 = self.get_evaluations_session2_coefs(modimpl) + evals_poids_s2 = evals_poids_df.values * evals_coefs_s2 + poids_stacked_s2 = np.stack( + [evals_poids_s2] * nb_etuds + ) # nb_etuds, nb_evals, nb_ues + evals_poids_etuds_s2 = np.where( + np.stack([self.evals_notes.values] * nb_ues, axis=2) + > scu.NOTES_NEUTRALISE, + poids_stacked_s2, + 0, + ) + + etuds_moy_module_s2 = np.sum( + evals_poids_etuds_s2 * evals_notes_stacked, axis=1 + ) / np.sum(evals_poids_etuds_s2, axis=1) + # Vrai si toutes les UEs ont bien une note de session 2 calculée: + etuds_use_session2 = np.all(np.isfinite(etuds_moy_module_s2), axis=1) etuds_moy_module = np.where( etuds_use_session2[:, np.newaxis], - np.tile( - (notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis], - nb_ues, - ), + etuds_moy_module_s2, etuds_moy_module, ) self.etuds_use_session2 = pd.Series( @@ -435,7 +462,7 @@ class ModuleImplResultsAPC(ModuleImplResults): np.nan, ) # "Étend" le rattrapage sur les UE: la note de rattrapage est la même - # pour toutes les UE mais ne remplace que là où elle est supérieure + # pour toutes les UE mais ne remplace que là où elle est supérieure XXX TODO notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1) # prend le max etuds_use_rattrapage = notes_rat_ues > etuds_moy_module @@ -446,6 +473,13 @@ class ModuleImplResultsAPC(ModuleImplResults): self.etuds_use_rattrapage = pd.Series( etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index ) + # Application des évaluations bonus: + etuds_moy_module = self.apply_bonus( + etuds_moy_module, + modimpl, + evals_poids_df, + evals_notes_stacked, + ) self.etuds_moy_module = pd.DataFrame( etuds_moy_module, index=self.evals_notes.index, @@ -525,6 +559,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: return evals_poids, ues +# appelé par ModuleImpl.check_apc_conformity() def moduleimpl_is_conforme( moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame ) -> bool: @@ -546,12 +581,12 @@ def moduleimpl_is_conforme( if len(modimpl_coefs_df) != nb_ues: # il arrive (#bug) que le cache ne soit pas à jour... sco_cache.invalidate_formsemestre() - raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent") + return app.critical_error("moduleimpl_is_conforme: err 1") if moduleimpl.id not in modimpl_coefs_df: # soupçon de bug cache coef ? sco_cache.invalidate_formsemestre() - raise ScoBugCatcher("Erreur 454 - merci de ré-essayer") + return app.critical_error("moduleimpl_is_conforme: err 2") module_evals_poids = evals_poids.transpose().sum(axis=1) != 0 return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids)) @@ -593,22 +628,28 @@ class ModuleImplResultsClassic(ModuleImplResults): evals_coefs_etuds * evals_notes_20, axis=1 ) / np.sum(evals_coefs_etuds, axis=1) - # Application des évaluations bonus: - etuds_moy_module = self.apply_bonus( - etuds_moy_module, - modimpl, - evals_notes_20, - ) - # Session2 : quand elle existe, remplace la note de module - eval_session2 = self.get_evaluation_session2(modimpl) - if eval_session2: - notes_session2 = self.evals_notes[eval_session2.id].values - # n'utilise que les notes valides (pas ATT, EXC, ABS, NaN) - etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE + evals_session2 = self.get_evaluations_session2(modimpl) + if evals_session2: + # Calculer la moyenne des évaluations de session2 + # n'utilise que les notes valides et ABS (0). + # Même calcul que pour les évals normales, mais avec seulement les + # coefs des évals de session 2: + evals_coefs_s2 = self.get_evaluations_session2_coefs(modimpl).reshape(-1) + coefs_stacked_s2 = np.stack([evals_coefs_s2] * nb_etuds) + # zéro partout sauf si une note ou ABS: + evals_coefs_etuds_s2 = np.where( + self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked_s2, 0 + ) + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etuds_moy_module_s2 = np.sum( + evals_coefs_etuds_s2 * evals_notes_20, axis=1 + ) / np.sum(evals_coefs_etuds_s2, axis=1) + + etuds_use_session2 = np.isfinite(etuds_moy_module_s2) etuds_moy_module = np.where( etuds_use_session2, - notes_session2 / (eval_session2.note_max / 20.0), + etuds_moy_module_s2, etuds_moy_module, ) self.etuds_use_session2 = pd.Series( @@ -633,6 +674,13 @@ class ModuleImplResultsClassic(ModuleImplResults): self.etuds_use_rattrapage = pd.Series( etuds_use_rattrapage, index=self.evals_notes.index ) + + # Application des évaluations bonus: + etuds_moy_module = self.apply_bonus( + etuds_moy_module, + modimpl, + evals_notes_20, + ) self.etuds_moy_module = pd.Series( etuds_moy_module, index=self.evals_notes.index, diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 7e9e83cb..d247301f 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -207,7 +207,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: etudids, etudids_actifs = formsemestre.etudids_actifs() for modimpl in formsemestre.modimpls_sorted: mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs) - evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id) + evals_poids = modimpl.get_evaluations_poids() etuds_moy_module = mod_results.compute_module_moy(evals_poids) modimpls_results[modimpl.id] = mod_results modimpls_evals_poids[modimpl.id] = evals_poids diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 97dc82e3..36f5066e 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -6,6 +6,7 @@ from flask import abort, g from flask_login import current_user from flask_sqlalchemy.query import Query +import app from app import db from app.auth.models import User from app.comp import df_cache @@ -78,7 +79,9 @@ class ModuleImpl(ScoDocModel): ] or self.module.get_edt_ids() def get_evaluations_poids(self) -> pd.DataFrame: - """Les poids des évaluations vers les UE (accès via cache)""" + """Les poids des évaluations vers les UEs (accès via cache redis). + Toutes les évaluations sont considérées (normales, bonus, rattr., etc.) + """ evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id) if evaluations_poids is None: from app.comp import moy_mod @@ -108,20 +111,37 @@ class ModuleImpl(ScoDocModel): """Invalide poids cachés""" df_cache.EvaluationsPoidsCache.delete(self.id) - def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool: - """true si les poids des évaluations du module permettent de satisfaire - les coefficients du PN. + def check_apc_conformity( + self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE + ) -> bool: + """true si les poids des évaluations du type indiqué (normales par défaut) + du module permettent de satisfaire les coefficients du PN. """ + # appelé par formsemestre_status, liste notes, et moduleimpl_status if not self.module.formation.get_cursus().APC_SAE or ( - self.module.module_type != scu.ModuleType.RESSOURCE - and self.module.module_type != scu.ModuleType.SAE + self.module.module_type + not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE} ): return True # Non BUT, toujours conforme from app.comp import moy_mod + mod_results = res.modimpls_results.get(self.id) + if mod_results is None: + app.critical_error("check_apc_conformity: err 1") + + selected_evaluations_ids = [ + eval_id + for eval_id, eval_type in mod_results.evals_type.items() + if eval_type == evaluation_type + ] + if not selected_evaluations_ids: + return True # conforme si pas d'évaluations + selected_evaluations_poids = self.get_evaluations_poids().loc[ + selected_evaluations_ids + ] return moy_mod.moduleimpl_is_conforme( self, - self.get_evaluations_poids(), + selected_evaluations_poids, res.modimpl_coefs_df, ) diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 4aeae9da..9270199c 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -64,7 +64,7 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str: can_edit_notes_ens = modimpl.can_edit_notes(current_user) if can_edit_notes and nbnotes != 0: - sup_label = "Supprimer évaluation impossible (il y a des notes)" + sup_label = "Suppression évaluation impossible (il y a des notes)" else: sup_label = "Supprimer évaluation" @@ -344,9 +344,23 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None): # if not modimpl.check_apc_conformity(nt): H.append( - """
Les poids des évaluations de ce module ne sont - pas encore conformes au PN. - Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE. + """
Les poids des évaluations de ce + module ne permettent pas d'évaluer toutes les UEs (compétences) + prévues par les coefficients du programme. + Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE. + Vérifiez les poids des évaluations. +
""" + ) + if not modimpl.check_apc_conformity( + nt, evaluation_type=Evaluation.EVALUATION_SESSION2 + ): + H.append( + """
+ Il y a des évaluations de deuxième session + mais leurs poids ne permettent pas d'évaluer toutes les UEs (compétences) + prévues par les coefficients du programme. + La deuxième session en sera donc pas prise en compte. + Vérifiez les poids de ces évaluations.
""" ) diff --git a/tests/unit/test_but_modules.py b/tests/unit/test_but_modules.py index e12e6009..684248e6 100644 --- a/tests/unit/test_but_modules.py +++ b/tests/unit/test_but_modules.py @@ -247,7 +247,7 @@ 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, _ = moy_mod.load_evaluations_poids(moduleimpl_id) + evals_poids = modimpl.get_evaluations_poids() assert evals_poids.shape == (nb_evals, nb_ues) etudids, etudids_actifs = formsemestre.etudids_actifs() mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs) diff --git a/tests/unit/test_notes_rattrapage.py b/tests/unit/test_notes_rattrapage.py index 4435cb2f..d7888e98 100644 --- a/tests/unit/test_notes_rattrapage.py +++ b/tests/unit/test_notes_rattrapage.py @@ -151,8 +151,9 @@ def test_notes_rattrapage(test_client): res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) mod_res = res.modimpls_results[moduleimpl_id] # retrouve l'éval. de session 2: - eval_session2 = mod_res.get_evaluation_session2(moduleimpl) - assert eval_session2.id == e_session2["id"] + evals_session2 = mod_res.get_evaluations_session2(moduleimpl) + assert len(evals_session2) == 1 + assert evals_session2[0].id == e_session2["id"] # Les deux évaluations sont considérées comme complètes: assert len(mod_res.get_evaluations_completes(moduleimpl)) == 2