diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index cc51e2a2..9441c031 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -71,7 +71,7 @@ class BulletinBUT(ResultatsSemestreBUT): "bonus": fmt_note(self.bonus_ues[ue.id][etud.id]) if self.bonus_ues is not None and ue.id in self.bonus_ues else fmt_note(0.0), - "malus": None, # XXX TODO voir ce qui est ici + "malus": self.malus[ue.id][etud.id], "capitalise": None, # "AAAA-MM-JJ" TODO "ressources": self.etud_ue_mod_results(etud, ue, self.ressources), "saes": self.etud_ue_mod_results(etud, ue, self.saes), diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index 485c233f..60361c32 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -104,7 +104,6 @@ class BonusSport: # sem_modimpl_moys_spo est (nb_etuds, nb_mod_sport) # ou (nb_etuds, nb_mod_sport, nb_ues_non_bonus) nb_etuds, nb_mod_sport = sem_modimpl_moys_spo.shape[:2] - nb_ues = len(ues) # Enlève les NaN du numérateur: sem_modimpl_moys_no_nan = np.nan_to_num(sem_modimpl_moys_spo, nan=0.0) @@ -162,7 +161,7 @@ class BonusSport: """ raise NotImplementedError("méthode virtuelle") - def get_bonus_ues(self) -> pd.Series: + def get_bonus_ues(self) -> pd.DataFrame: """Les bonus à appliquer aux UE Résultat: DataFrame de float, index etudid, columns: ue.id """ diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index f79982f7..2e2298db 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -43,6 +43,8 @@ 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 + @dataclass class EvaluationEtat: @@ -291,7 +293,12 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre... # Initialise poids non enregistrés: - default_poids = 1.0 if modimpl.module.ue.type == UE_SPORT else 0.0 + default_poids = ( + 1.0 + if modimpl.module.ue.type == UE_SPORT + or modimpl.module.module_type == ModuleType.MALUS + else 0.0 + ) if np.isnan(evals_poids.values.flat).any(): ue_coefs = modimpl.module.get_ue_coef_dict() diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 4c4f2a0c..8a8057ac 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -27,6 +27,7 @@ """Fonctions de calcul des moyennes d'UE (classiques ou BUT) """ +from re import X import numpy as np import pandas as pd @@ -380,3 +381,42 @@ def compute_ue_moys_classic( etud_moy_gen_s = pd.Series(etud_moy_gen, index=modimpl_inscr_df.index) return etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df + + +def compute_malus( + formsemestre: FormSemestre, + sem_modimpl_moys: np.array, + ues: list[UniteEns], + modimpl_inscr_df: pd.DataFrame, +) -> pd.DataFrame: + """Calcul le malus sur les UE + Dans chaque UE, on peut avoir un ou plusieurs modules de MALUS. + Leurs notes sont positives ou négatives. leur somme sera _soustraite_ à la moyenne + de chaque UE. + Arguments: + - sem_modimpl_moys : + notes moyennes aux modules (tous les étuds x tous les modimpls) + floats avec des NaN. + En classique: sem_matrix, ndarray (etuds x modimpls) + En APC: sem_cube, ndarray (etuds x modimpls x UEs non bonus) + - ues: les ues du semestre (incluant le bonus sport) + - modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl) + + Résultat: DataFrame de float, index etudid, columns: ue.id (sans NaN) + """ + ues_idx = [ue.id for ue in ues] + malus = pd.DataFrame(index=modimpl_inscr_df.index, columns=ues_idx, dtype=float) + for ue in ues: + if ue.type != UE_SPORT: + modimpl_mask = np.array( + [ + (m.module.module_type == ModuleType.MALUS) + and (m.module.ue.id == ue.id) + for m in formsemestre.modimpls_sorted + ] + ) + malus_moys = sem_modimpl_moys[:, modimpl_mask].sum(axis=1) + malus[ue.id] = malus_moys + + malus.fillna(0.0, inplace=True) + return malus diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 4423f3fa..c4697238 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -68,6 +68,12 @@ class ResultatsSemestreBUT(NotesTableCompat): 1.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns ) + # --- Modules de MALUS sur les UEs + self.malus = moy_ue.compute_malus( + self.formsemestre, self.sem_cube, self.ues, self.modimpl_inscr_df + ) + self.etud_moy_ue -= self.malus + # --- Bonus Sport & Culture if len(modimpls_sport) > 0: bonus_class = ScoDocSiteConfig.get_bonus_sport_class() diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 8d52d0c3..58c505ee 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -71,6 +71,16 @@ class ResultatsSemestreClassic(NotesTableCompat): self.modimpl_coefs, modimpl_standards_mask, ) + # --- Modules de MALUS sur les UEs et la moyenne générale + self.malus = moy_ue.compute_malus( + self.formsemestre, self.sem_matrix, self.ues, self.modimpl_inscr_df + ) + self.etud_moy_ue -= self.malus + # ajuste la moyenne générale (à l'aide des coefs d'UE) + self.etud_moy_gen -= (self.etud_coef_ue_df * self.malus).sum( + axis=1 + ) / self.etud_coef_ue_df.sum(axis=1) + # --- Bonus Sport & Culture bonus_class = ScoDocSiteConfig.get_bonus_sport_class() if bonus_class is not None: diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index ae143cbc..7bcfcd78 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -115,22 +115,30 @@ def do_module_create(args) -> int: return r -def module_create(matiere_id=None, module_type=None, semestre_id=None): - """Création d'un module""" +def module_create( + matiere_id=None, module_type=None, semestre_id=None, formation_id=None +): + """Formulaire de création d'un module + Si matiere_id est spécifié, le module sera créé dans cette matière (cas normal). + Sinon, donne le choix de l'UE de rattachement et utilise la première + matière de cette UE (si elle n'existe pas, la crée). + """ from app.scodoc import sco_formations from app.scodoc import sco_edit_ue - matiere = Matiere.query.get_or_404(matiere_id) - if matiere is None: - raise ScoValueError("invalid matiere !") - ue = matiere.ue - parcours = ue.formation.get_parcours() + if matiere_id: + matiere = Matiere.query.get_or_404(matiere_id) + ue = matiere.ue + formation = ue.formation + else: + formation = Formation.query.get_or_404(formation_id) + parcours = formation.get_parcours() is_apc = parcours.APC_SAE - ues = ue.formation.ues.order_by( + ues = formation.ues.order_by( UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme ).all() # cherche le numero adéquat (pour placer le module en fin de liste) - modules = matiere.ue.formation.modules.all() + modules = formation.modules.all() if modules: default_num = max([m.numero or 0 for m in modules]) + 10 else: @@ -143,9 +151,11 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): H = [ html_sco_header.sco_header(page_title=f"Création {object_name}"), ] - if is_apc: + if not matiere_id: H += [ - f"""

Création {object_name} dans la formation {ue.formation.acronyme}, Semestre {ue.semestre_idx}, {ue.acronyme}

""" + f"""

Création {object_name} dans la formation {formation.acronyme} +

+ """ ] else: H += [ @@ -158,7 +168,6 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): render_template( "scodoc/help/modules.html", is_apc=is_apc, - ue=ue, semestre_id=semestre_id, ) ] @@ -170,7 +179,7 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): "size": 10, "explanation": "code du module, ressource ou SAÉ. Exemple M1203, R2.01, ou SAÉ 3.4. Ce code doit être unique dans la formation.", "allow_null": False, - "validator": lambda val, field, formation_id=ue.formation_id: check_module_code_unicity( + "validator": lambda val, field, formation_id=formation_id: check_module_code_unicity( val, field, formation_id ), }, @@ -192,6 +201,15 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): ] semestres_indices = list(range(1, parcours.NB_SEM + 1)) + if is_apc: + module_types = scu.ModuleType # tous les types + else: + # ne propose pas SAE et Ressources: + module_types = set(scu.ModuleType) - { + scu.ModuleType.RESSOURCE, + scu.ModuleType.SAE, + } + descr += [ ( "module_type", @@ -199,8 +217,8 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): "input_type": "menu", "title": "Type", "explanation": "", - "labels": [x.name.capitalize() for x in scu.ModuleType], - "allowed_values": [str(int(x)) for x in scu.ModuleType], + "labels": [x.name.capitalize() for x in module_types], + "allowed_values": [str(int(x)) for x in module_types], }, ), ( @@ -256,11 +274,30 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): ), ] + if matiere_id: + descr += [ + ("ue_id", {"default": ue.id, "input_type": "hidden"}), + ("matiere_id", {"default": matiere_id, "input_type": "hidden"}), + ] + else: + # choix de l'UE de rattachement + descr += [ + ( + "ue_id", + { + "input_type": "menu", + "type": "int", + "title": "UE de rattachement", + "explanation": "utilisée notamment pour les malus", + "labels": [f"{u.acronyme} {u.titre}" for u in ues], + "allowed_values": [u.id for u in ues], + }, + ), + ] + descr += [ # ('ects', { 'size' : 4, 'type' : 'float', 'title' : 'ECTS', 'explanation' : 'nombre de crédits ECTS (inutilisés: les crédits sont associés aux UE)' }), - ("formation_id", {"default": ue.formation_id, "input_type": "hidden"}), - ("ue_id", {"default": ue.id, "input_type": "hidden"}), - ("matiere_id", {"default": matiere.id, "input_type": "hidden"}), + ("formation_id", {"default": formation.id, "input_type": "hidden"}), ( "code_apogee", { @@ -290,6 +327,20 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): if tf[0] == 0: return "\n".join(H) + tf[1] + html_sco_header.sco_footer() else: + if not matiere_id: + # formulaire avec choix UE de rattachement + ue = UniteEns.query.get(tf[2]["ue_id"]) + if ue is None: + raise ValueError("UE invalide") + matiere = ue.matieres.first() + if matiere: + tf[2]["matiere_id"] = matiere.id + else: + matiere_id = sco_edit_matiere.do_matiere_create( + {"ue_id": ue.id, "titre": ue.titre, "numero": 1}, + ) + tf[2]["matiere_id"] = matiere_id + tf[2]["semestre_id"] = ue.semestre_idx _ = do_module_create(tf[2]) @@ -298,7 +349,7 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, - formation_id=ue.formation_id, + formation_id=formation.id, semestre_idx=tf[2]["semestre_id"], ) ) @@ -493,6 +544,13 @@ def module_edit(module_id=None): soyez prudents ! """ ) + if is_apc: + module_types = scu.ModuleType # tous les types + else: + # ne propose pas SAE et Ressources, sauf si déjà de ce type... + module_types = ( + set(scu.ModuleType) - {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE} + ) | {a_module.module_type} descr = [ ( @@ -514,8 +572,8 @@ def module_edit(module_id=None): "input_type": "menu", "title": "Type", "explanation": "", - "labels": [x.name.capitalize() for x in scu.ModuleType], - "allowed_values": [str(int(x)) for x in scu.ModuleType], + "labels": [x.name.capitalize() for x in module_types], + "allowed_values": [str(int(x)) for x in module_types], "enabled": unlocked, }, ), diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 5b333a9f..b5c3d4ab 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -998,6 +998,7 @@ def _ue_table_matieres( H.append( _ue_table_modules( parcours, + ue, mat, modules, editable, @@ -1031,6 +1032,7 @@ def _ue_table_matieres( def _ue_table_modules( parcours, + ue, mat, modules, editable, @@ -1121,8 +1123,12 @@ def _ue_table_modules( tag_cls, ",".join(sco_tag_module.module_tag_list(mod["module_id"])), ) + if ue["semestre_idx"] is not None and mod["semestre_id"] != ue["semestre_idx"]: + warning_semestre = ' incohérent ?' + else: + warning_semestre = "" H.append( - " %s %s" % (parcours.SESSION_NAME, mod["semestre_id"]) + " %s %s%s" % (parcours.SESSION_NAME, mod["semestre_id"], warning_semestre) + " (%s)" % heurescoef + tag_edit ) diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index f1cf2264..6de171d1 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -546,7 +546,7 @@ def do_formsemestre_createwithmodules(edit=False): for mod in mods: if mod["semestre_id"] == semestre_id and ( (not edit) # creation => tous modules - or (not formation.is_apc()) # pas BUT, on peux mixer les semestres + or (not formation.is_apc()) # pas BUT, on peut mixer les semestres or (semestre_id == formsemestre.semestre_id) # module du semestre or (mod["module_id"] in module_ids_set) # module déjà présent ): diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 3cf49e3b..700115e5 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -219,7 +219,9 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None): page_title=f"{mod_type_name} {Mod['code']} {Mod['titre']}" ), f"""

{mod_type_name} - {Mod['code']} {Mod['titre']}

+ {Mod['code']} {Mod['titre']} + {"dans l'UE " + modimpl.module.ue.acronyme if modimpl.module.module_type == scu.ModuleType.MALUS else ""} +
diff --git a/app/templates/pn/form_mods.html b/app/templates/pn/form_mods.html index e5ca79e5..2be3cfe2 100644 --- a/app/templates/pn/form_mods.html +++ b/app/templates/pn/form_mods.html @@ -71,14 +71,22 @@ {% endfor %} - {% if editable and matiere_parent %} -
  • {{create_element_msg}} + )}}" + {% else %}"{{ + url_for("notes.module_create", + scodoc_dept=g.scodoc_dept, + module_type=module_type|int, + formation_id=formation.id + )}}" + {% endif %} + >{{create_element_msg}}
  • {% endif %} {% endif %}