diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 2e2298dbe..f890b6a61 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -41,7 +41,6 @@ from app import db from app.models import ModuleImpl, Evaluation, EvaluationUEPoids from app.scodoc import sco_utils as scu from app.scodoc.sco_codes_parcours import UE_SPORT -from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_utils import ModuleType @@ -92,6 +91,10 @@ class ModuleImplResults: ne donnent pas de coef vers cette UE. """ self.load_notes() + 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""" + self.etuds_use_rattrapage = pd.Series(False, index=self.evals_notes.index) + """1 bool par etud, indique si sa moyenne de module utilise la note de rattrapage""" def load_notes(self): # ré-écriture de df_load_modimpl_notes """Charge toutes les notes de toutes les évaluations du module. @@ -135,8 +138,11 @@ class ModuleImplResults: eval_df = self._load_evaluation_notes(evaluation) # is_complete ssi tous les inscrits (non dem) au semestre ont une note # ou évaluation déclarée "à prise en compte immédiate" - is_complete = evaluation.publish_incomplete or ( - not (inscrits_module - set(eval_df.index)) + # Les évaluations de rattrapage et 2eme session sont toujours incomplètes + # car on calcule leur moyenne à part. + is_complete = (evaluation.evaluation_type == scu.EVALUATION_NORMALE) and ( + evaluation.publish_incomplete + or (not (inscrits_module - set(eval_df.index))) ) self.evaluations_completes.append(is_complete) self.evaluations_completes_dict[evaluation.id] = is_complete @@ -212,6 +218,33 @@ class ModuleImplResults: self.evals_notes.values > scu.NOTES_ABSENCE, self.evals_notes.values, 0.0 ) / [e.note_max / 20.0 for e in moduleimpl.evaluations] + def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl): + """L'évaluation de rattrapage de ce module, ou None s'il n'en a pas. + Rattrapage: la moyenne du module est la meilleure note entre moyenne + des autres évals et la note eval rattrapage. + """ + eval_list = [ + e + for e in moduleimpl.evaluations + if e.evaluation_type == scu.EVALUATION_RATTRAPAGE + ] + if eval_list: + return eval_list[0] + return None + + def get_evaluation_session2(self, moduleimpl: ModuleImpl): + """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. + """ + eval_list = [ + e + for e in moduleimpl.evaluations + if e.evaluation_type == scu.EVALUATION_SESSION2 + ] + if eval_list: + return eval_list[0] + return None + class ModuleImplResultsAPC(ModuleImplResults): "Calcul des moyennes de modules à la mode BUT" @@ -229,7 +262,7 @@ class ModuleImplResultsAPC(ModuleImplResults): 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) + modimpl = 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 @@ -237,11 +270,11 @@ 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=[]) - evals_coefs = self.get_evaluations_coefs(moduleimpl) + evals_coefs = self.get_evaluations_coefs(modimpl) evals_poids = evals_poids_df.values * evals_coefs # -> evals_poids shape : (nb_evals, nb_ues) assert evals_poids.shape == (nb_evals, nb_ues) - evals_notes_20 = self.get_eval_notes_sur_20(moduleimpl) + evals_notes_20 = self.get_eval_notes_sur_20(modimpl) # Les poids des évals pour chaque étudiant: là où il a des notes # non neutralisées @@ -262,6 +295,45 @@ class ModuleImplResultsAPC(ModuleImplResults): etuds_moy_module = np.sum( evals_poids_etuds * evals_notes_stacked, axis=1 ) / np.sum(evals_poids_etuds, axis=1) + + # 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 + 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, + ) + self.etuds_use_session2 = pd.Series( + etuds_use_session2, index=self.evals_notes.index + ) + else: + # Rattrapage: remplace la note de module ssi elle est supérieure + eval_rat = self.get_evaluation_rattrapage(modimpl) + if eval_rat: + notes_rat = self.evals_notes[eval_rat.id].values + # remplace les notes invalides (ATT, EXC...) par des NaN + notes_rat = np.where( + notes_rat > scu.NOTES_ABSENCE, + notes_rat / (eval_rat.note_max / 20.0), + np.nan, + ) + # prend le max + etuds_use_rattrapage = notes_rat > etuds_moy_module + etuds_moy_module = np.where( + etuds_use_rattrapage[:, np.newaxis], + np.tile(notes_rat[:, np.newaxis], nb_ues), + etuds_moy_module, + ) + self.etuds_use_rattrapage = pd.Series( + etuds_use_rattrapage, index=self.evals_notes.index + ) self.etuds_moy_module = pd.DataFrame( etuds_moy_module, index=self.evals_notes.index, @@ -371,8 +443,42 @@ class ModuleImplResultsClassic(ModuleImplResults): evals_coefs_etuds * evals_notes_20, axis=1 ) / np.sum(evals_coefs_etuds, axis=1) + # 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 + etuds_moy_module = np.where( + etuds_use_session2, + notes_session2 / (eval_session2.note_max / 20.0), + etuds_moy_module, + ) + self.etuds_use_session2 = pd.Series( + etuds_use_session2, index=self.evals_notes.index + ) + else: + # Rattrapage: remplace la note de module ssi elle est supérieure + eval_rat = self.get_evaluation_rattrapage(modimpl) + if eval_rat: + notes_rat = self.evals_notes[eval_rat.id].values + # remplace les notes invalides (ATT, EXC...) par des NaN + notes_rat = np.where( + notes_rat > scu.NOTES_ABSENCE, + notes_rat / (eval_rat.note_max / 20.0), + np.nan, + ) + # prend le max + etuds_use_rattrapage = notes_rat > etuds_moy_module + etuds_moy_module = np.where( + etuds_use_rattrapage, notes_rat, etuds_moy_module + ) + self.etuds_use_rattrapage = pd.Series( + etuds_use_rattrapage, index=self.evals_notes.index + ) self.etuds_moy_module = pd.Series( etuds_moy_module, index=self.evals_notes.index, ) + return self.etuds_moy_module diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 7bc2bff2c..47caaa03a 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -162,6 +162,7 @@ class NotesTableCompat(ResultatsSemestre): _cached_attrs = ResultatsSemestre._cached_attrs + ( "bonus", "bonus_ues", + "malus", ) def __init__(self, formsemestre: FormSemestre): diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index d059701ae..2234fdfa1 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -22,6 +22,7 @@ from app.models.etudiants import Identite from app.scodoc import sco_codes_parcours from app.scodoc import sco_preferences from app.scodoc.sco_vdi import ApoEtapeVDI +from app.scodoc.sco_permissions import Permission class FormSemestre(db.Model): @@ -169,14 +170,24 @@ class FormSemestre(db.Model): else: modimpls.sort( key=lambda m: ( - m.module.ue.numero, - m.module.matiere.numero, - m.module.numero, - m.module.code, + m.module.ue.numero or 0, + m.module.matiere.numero or 0, + m.module.numero or 0, + m.module.code or "", ) ) return modimpls + def can_be_edited_by(self, user): + """Vrai si user peut modifier ce semestre""" + if not user.has_permission(Permission.ScoImplement): # pas chef + if not self.resp_can_edit or user.id not in [ + resp.id for resp in self.responsables + ]: + return False + + return True + def est_courant(self) -> bool: """Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses) @@ -425,7 +436,7 @@ class FormSemestreUECoef(db.Model): class FormSemestreUEComputationExpr(db.Model): - """Formules utilisateurs pour calcul moyenne UE""" + """Formules utilisateurs pour calcul moyenne UE (désactivées en 9.2+).""" __tablename__ = "notes_formsemestre_ue_computation_expr" __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),) diff --git a/app/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py index f1e481cf1..12dd9d12b 100644 --- a/app/scodoc/sco_bulletins_standard.py +++ b/app/scodoc/sco_bulletins_standard.py @@ -619,7 +619,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): prefs=prefs, ) - if nbeval: # boite autour des evaluations (en pdf) + if nbeval: # boite autour des évaluations (en pdf) P[-1]["_pdf_style"].append( ("BOX", (1, 1 - nbeval), (-1, 0), 0.2, self.PDF_LIGHT_GRAY) ) diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index 7bcfcd78d..ab5292a78 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -289,7 +289,10 @@ def module_create( "type": "int", "title": "UE de rattachement", "explanation": "utilisée notamment pour les malus", - "labels": [f"{u.acronyme} {u.titre}" for u in ues], + "labels": [ + f"S{u.semestre_idx if u.semestre_idx is not None else '.'} / {u.acronyme} {u.titre}" + for u in ues + ], "allowed_values": [u.id for u in ues], }, ), diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index b5c3d4ab2..0615db1e8 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -231,13 +231,17 @@ def do_ue_delete(ue_id, delete_validations=False, force=False): return None -def ue_create(formation_id=None): - """Creation d'une UE""" - return ue_edit(create=True, formation_id=formation_id) +def ue_create(formation_id=None, default_semestre_idx=None): + """Formulaire création d'une UE""" + return ue_edit( + create=True, + formation_id=formation_id, + default_semestre_idx=default_semestre_idx, + ) -def ue_edit(ue_id=None, create=False, formation_id=None): - """Modification ou création d'une UE""" +def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=None): + """Formulaire modification ou création d'une UE""" create = int(create) if not create: U = ue_list(args={"ue_id": ue_id}) @@ -250,7 +254,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None): submitlabel = "Modifier les valeurs" else: title = "Création d'une UE" - initvalues = {} + initvalues = {"semestre_idx": default_semestre_idx} submitlabel = "Créer cette UE" formation = Formation.query.get(formation_id) if not formation: diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index ab178c150..2f5efbc4e 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -207,7 +207,7 @@ def evaluation_create_form( { "size": 6, "type": "float", - "explanation": "coef. dans le module (choisi librement par l'enseignant)", + "explanation": "coef. dans le module (choisi librement par l'enseignant, non utilisé pour rattrapage et 2ème session)", "allow_null": False, }, ) diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 6de171d16..619e2d078 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -118,10 +118,16 @@ def formsemestre_editwithmodules(formsemestre_id): vals = scu.get_request_args() if not vals.get("tf_submitted", False): H.append( - """
Seuls les modules cochés font partie de ce semestre. Pour les retirer, les décocher et appuyer sur le bouton "modifier". -
-Attention : s'il y a déjà des évaluations dans un module, il ne peut pas être supprimé !
-Les modules ont toujours un responsable. Par défaut, c'est le directeur des études.
""" + """Seuls les modules cochés font partie de ce semestre. + Pour les retirer, les décocher et appuyer sur le bouton "modifier". +
+Attention : s'il y a déjà des évaluations dans un module, + il ne peut pas être supprimé !
+Les modules ont toujours un responsable. + Par défaut, c'est le directeur des études.
+Un semestre ne peut comporter qu'une seule UE "bonus + sport/culture"
+ """ ) return "\n".join(H) + html_sco_header.sco_footer() @@ -739,6 +745,7 @@ def do_formsemestre_createwithmodules(edit=False): # Modules sélectionnés: # (retire le "MI" du début du nom de champs) module_ids_checked = [int(x[2:]) for x in tf[2]["tf-checked"]] + _formsemestre_check_ue_bonus_unicity(module_ids_checked) if not edit: if formation.is_apc(): _formsemestre_check_module_list( @@ -882,6 +889,18 @@ def _formsemestre_check_module_list(module_ids, semestre_idx): ) +def _formsemestre_check_ue_bonus_unicity(module_ids): + """Vérifie qu'il n'y a qu'une seule UE bonus associée aux modules choisis""" + ues = [Module.query.get_or_404(module_id).ue for module_id in module_ids] + ues_bonus = {ue.id for ue in ues if ue.type == sco_codes_parcours.UE_SPORT} + if len(ues_bonus) > 1: + raise ScoValueError( + """Les modules de bonus sélectionnés ne sont pas tous dans la même UE bonus. + Changez la sélection ou modifiez la structure du programme de formation.""", + dest_url="javascript:history.back();", + ) + + def formsemestre_delete_moduleimpls(formsemestre_id, module_ids_to_del): """Delete moduleimpls module_ids_to_del: list of module_id (warning: not moduleimpl) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 97ac05dd6..1f440c8f2 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1152,30 +1152,19 @@ def formsemestre_tableau_modules( f"""