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""" {ue["acronyme"]} {titre} - """ + """ ) - if can_edit: - H.append( - ' ' - % (formsemestre_id, ue["ue_id"]) - ) - H.append( - scu.icontag( - "formula", - title="Mode calcul moyenne d'UE", - style="vertical-align:middle", - ) - ) - if can_edit: - H.append("") expr = sco_compute_moy.get_ue_expression( formsemestre_id, ue["ue_id"], html_quote=True ) if expr: H.append( - """ %s""" - % expr + f""" {expr} + formule inutilisée en 9.2: supprimer""" ) H.append("") diff --git a/app/templates/pn/form_ues.html b/app/templates/pn/form_ues.html index d6b1fe127..e56e19875 100644 --- a/app/templates/pn/form_ues.html +++ b/app/templates/pn/form_ues.html @@ -46,16 +46,17 @@ {% endfor %} - {% endfor %} {% if editable %} {% endif %} + {% endfor %} \ No newline at end of file diff --git a/app/views/notes.py b/app/views/notes.py index b17036316..4b299ee52 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -30,23 +30,19 @@ Module notes: issu de ScoDoc7 / ZNotes.py Emmanuel Viennet, 2021 """ -import sys -import time -import datetime -import pprint + from operator import itemgetter from xml.etree import ElementTree import flask -from flask import url_for, jsonify, render_template +from flask import flash, jsonify, render_template, url_for from flask import current_app, g, request from flask_login import current_user from werkzeug.utils import redirect from app.models.formsemestre import FormSemestre +from app.models.formsemestre import FormSemestreUEComputationExpr from app.models.ues import UniteEns -from config import Config - from app import api from app import db from app import models @@ -1245,76 +1241,28 @@ def view_module_abs(moduleimpl_id, format="html"): return "\n".join(H) + tab.html() + html_sco_header.sco_footer() -@bp.route("/edit_ue_expr", methods=["GET", "POST"]) +@bp.route("/delete_ue_expr//", methods=["GET", "POST"]) @scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def edit_ue_expr(formsemestre_id, ue_id): - """Edition formule calcul moyenne UE""" - # Check access - sem = sco_formsemestre_edit.can_edit_sem(formsemestre_id) - if not sem: +def delete_ue_expr(formsemestre_id: int, ue_id: int): + """Efface une expression de calcul d'UE""" + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not formsemestre.can_be_edited_by(current_user): raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération") - cnx = ndb.GetDBConnexion() - # - ue = sco_edit_ue.ue_list({"ue_id": ue_id})[0] - H = [ - html_sco_header.html_sem_header( - "Modification règle de calcul de l'UE %s (%s)" - % (ue["acronyme"], ue["titre"]), - ), - _EXPR_HELP % {"target": "de l'UE", "objs": "modules", "ordre": ""}, - ] - el = sco_compute_moy.formsemestre_ue_computation_expr_list( - cnx, {"formsemestre_id": formsemestre_id, "ue_id": ue_id} - ) - if el: - initvalues = el[0] - else: - initvalues = {} - form = [ - ("ue_id", {"input_type": "hidden"}), - ("formsemestre_id", {"input_type": "hidden"}), - ( - "computation_expr", - { - "title": "Formule de calcul", - "input_type": "textarea", - "rows": 4, - "cols": 60, - "explanation": "formule de calcul (expérimental)", - }, - ), - ] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - form, - submitlabel="Modifier formule de calcul", - cancelbutton="Annuler", - initvalues=initvalues, - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect( - "formsemestre_status?formsemestre_id=" + str(formsemestre_id) - ) - else: - if el: - el[0]["computation_expr"] = tf[2]["computation_expr"] - sco_compute_moy.formsemestre_ue_computation_expr_edit(cnx, el[0]) - else: - sco_compute_moy.formsemestre_ue_computation_expr_create(cnx, tf[2]) - - sco_cache.invalidate_formsemestre( - formsemestre_id=formsemestre_id - ) # > modif regle calcul - return flask.redirect( - "formsemestre_status?formsemestre_id=" - + str(formsemestre_id) - + "&head_message=règle%20de%20calcul%20modifiée" + expr = FormSemestreUEComputationExpr.query.filter_by( + formsemestre_id=formsemestre_id, ue_id=ue_id + ).first() + if expr is not None: + db.session.delete(expr) + db.session.commit() + flash("formule supprimée") + return flask.redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + head_message="formule supprimée", ) + ) @bp.route("/formsemestre_enseignants_list") diff --git a/app/views/users.py b/app/views/users.py index 06f49979b..06157cbce 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -269,7 +269,6 @@ def create_user_form(user_name=None, edit=0, all_roles=False): for i in range(len(displayed_roles_strings)): if displayed_roles_strings[i] not in editable_roles_strings: disabled_roles[i] = True - breakpoint() descr = [ ("edit", {"input_type": "hidden", "default": edit}), ("nom", {"title": "Nom", "size": 20, "allow_null": False}),