From ce5e379d29b65561755d255c80425b8f60865682 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Tue, 1 Feb 2022 17:42:43 +0100
Subject: [PATCH 1/7] formules d'UE: affichage pour suppression

---
 app/models/formsemestre.py            | 13 +++-
 app/scodoc/sco_formsemestre_status.py | 23 ++-----
 app/views/notes.py                    | 96 ++++++---------------------
 3 files changed, 40 insertions(+), 92 deletions(-)

diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index d059701a..599fcda2 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):
@@ -177,6 +178,16 @@ class FormSemestre(db.Model):
             )
         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_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
index 97ac05dd..1f440c8f 100644
--- a/app/scodoc/sco_formsemestre_status.py
+++ b/app/scodoc/sco_formsemestre_status.py
@@ -1152,30 +1152,19 @@ def formsemestre_tableau_modules(
                 f"""<tr class="formsemestre_status_ue"><td colspan="4">
                 <span class="status_ue_acro">{ue["acronyme"]}</span>
                 <span class="status_ue_title">{titre}</span>
-                </td><td>"""
+                </td><td colspan="2">"""
             )
-            if can_edit:
-                H.append(
-                    ' <a href="edit_ue_expr?formsemestre_id=%s&ue_id=%s">'
-                    % (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("</a>")
 
             expr = sco_compute_moy.get_ue_expression(
                 formsemestre_id, ue["ue_id"], html_quote=True
             )
             if expr:
                 H.append(
-                    """ <span class="formula" title="mode de calcul de la moyenne d'UE">%s</span>"""
-                    % expr
+                    f""" <span class="formula" title="mode de calcul de la moyenne d'UE">{expr}</span>
+                    <span class="warning">formule inutilisée en 9.2: <a href="{
+                        url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, ue_id=ue["ue_id"]   )
+                    }
+                    ">supprimer</a></span>"""
                 )
 
             H.append("</td></tr>")
diff --git a/app/views/notes.py b/app/views/notes.py
index b1703631..4b299ee5 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/<int:formsemestre_id>/<int:ue_id>", 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")

From 4480783d892c8447d697c12e5a45e72e2e391ae5 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Tue, 1 Feb 2022 17:43:22 +0100
Subject: [PATCH 2/7] =?UTF-8?q?breakpoint=20oubli=C3=A9=20(users)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/views/users.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/app/views/users.py b/app/views/users.py
index 06f49979..06157cbc 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}),

From 7a4e0206a380fd821c82ad093fb4c00fbaf77ad0 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Tue, 1 Feb 2022 18:28:51 +0100
Subject: [PATCH 3/7] Fix: cache malus

---
 app/comp/res_common.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index 7bc2bff2..47caaa03 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):

From e3c9f187dd75a6e14b63764b13a8260b2e6ce084 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Wed, 2 Feb 2022 09:36:12 +0100
Subject: [PATCH 4/7] =?UTF-8?q?Notes=20rattrapage=20et=202eme=20session=20?=
 =?UTF-8?q?en=20BUT=20et=20DUT=20(pas=20affich=C3=A9es=20sur=20buls)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/comp/moy_mod.py                  | 118 +++++++++++++++++++++++++--
 app/scodoc/sco_bulletins_standard.py |   2 +-
 app/scodoc/sco_evaluation_edit.py    |   2 +-
 3 files changed, 114 insertions(+), 8 deletions(-)

diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index 2e2298db..f890b6a6 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/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py
index f1e481cf..12dd9d12 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_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py
index ab178c15..2f5efbc4 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,
                 },
             )

From 2764ff1074f97f381536e25c7dac1ac79120dd80 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Wed, 2 Feb 2022 09:51:28 +0100
Subject: [PATCH 5/7] Fix: tri modimpls sans numeros

---
 app/models/formsemestre.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 599fcda2..2234fdfa 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -170,10 +170,10 @@ 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

From 29d295db61cca1cf80afd3985d9914e806d30e37 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Wed, 2 Feb 2022 10:23:40 +0100
Subject: [PATCH 6/7] =?UTF-8?q?Edition=20UE=20BUT:=20semestre=20par=20d?=
 =?UTF-8?q?=C3=A9faut?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/scodoc/sco_edit_ue.py      | 16 ++++++++++------
 app/templates/pn/form_ues.html |  3 ++-
 2 files changed, 12 insertions(+), 7 deletions(-)

diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index b5c3d4ab..0615db1e 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/templates/pn/form_ues.html b/app/templates/pn/form_ues.html
index d6b1fe12..e56e1987 100644
--- a/app/templates/pn/form_ues.html
+++ b/app/templates/pn/form_ues.html
@@ -46,16 +46,17 @@
                 </li>
             {% endfor %}
         </ul>
-        {% endfor %}
         {% if editable %}
         <ul>
             <li class="notes_ue_list notes_ue_list_add"><a class="stdlink" href="{{
                 url_for('notes.ue_create', 
                         scodoc_dept=g.scodoc_dept, 
                         formation_id=formation.id,
+                        default_semestre_idx=semestre_idx,
                         )}}"
                     >ajouter une UE</a>
             </li>
         </ul>
         {% endif %}
+        {% endfor %}
 </div>
\ No newline at end of file

From 154dc5283e9c991cf10ac9d53052cdaf47d99536 Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Wed, 2 Feb 2022 10:49:34 +0100
Subject: [PATCH 7/7] =?UTF-8?q?V=C3=A9rification=20unicit=C3=A9=20UE=20bon?=
 =?UTF-8?q?us=20/=20semestre?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/scodoc/sco_edit_module.py       |  5 ++++-
 app/scodoc/sco_formsemestre_edit.py | 27 +++++++++++++++++++++++----
 2 files changed, 27 insertions(+), 5 deletions(-)

diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py
index 7bcfcd78..ab5292a7 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_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index 6de171d1..619e2d07 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(
-                """<p class="help">Seuls les modules cochés font partie de ce semestre. Pour les retirer, les décocher et appuyer sur le bouton "modifier".
-</p>
-<p class="help">Attention : s'il y a déjà des évaluations dans un module, il ne peut pas être supprimé !</p>
-<p class="help">Les modules ont toujours un responsable. Par défaut, c'est le directeur des études.</p>"""
+                """<p class="help">Seuls les modules cochés font partie de ce semestre. 
+                Pour les retirer, les décocher et appuyer sur le bouton "modifier".
+                </p>
+                <p class="help">Attention : s'il y a déjà des évaluations dans un module, 
+                il ne peut pas être supprimé !</p>
+                <p class="help">Les modules ont toujours un responsable. 
+                Par défaut, c'est le directeur des études.</p>
+                <p class="help">Un semestre ne peut comporter qu'une seule UE "bonus
+                sport/culture"</p>
+            """
             )
 
     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)