diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py
index 918d14fd2e3320beae12d6fdf2ae6b665e66e7b5..87b175468fce6eaa78a950002a592b4b7c79a8c8 100644
--- a/app/but/bulletin_but.py
+++ b/app/but/bulletin_but.py
@@ -104,9 +104,11 @@ class BulletinBUT:
             "competence": None,  # XXX TODO lien avec référentiel
             "moyenne": None,
             # Le bonus sport appliqué sur cette UE
-            "bonus": fmt_note(res.bonus_ues[ue.id][etud.id])
-            if res.bonus_ues is not None and ue.id in res.bonus_ues
-            else fmt_note(0.0),
+            "bonus": (
+                fmt_note(res.bonus_ues[ue.id][etud.id])
+                if res.bonus_ues is not None and ue.id in res.bonus_ues
+                else fmt_note(0.0)
+            ),
             "malus": fmt_note(res.malus[ue.id][etud.id]),
             "capitalise": None,  # "AAAA-MM-JJ" TODO #sco93
             "ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
@@ -181,14 +183,16 @@ class BulletinBUT:
                     "is_external": ue_capitalisee.is_external,
                     "date_capitalisation": ue_capitalisee.event_date,
                     "formsemestre_id": ue_capitalisee.formsemestre_id,
-                    "bul_orig_url": url_for(
-                        "notes.formsemestre_bulletinetud",
-                        scodoc_dept=g.scodoc_dept,
-                        etudid=etud.id,
-                        formsemestre_id=ue_capitalisee.formsemestre_id,
-                    )
-                    if ue_capitalisee.formsemestre_id
-                    else None,
+                    "bul_orig_url": (
+                        url_for(
+                            "notes.formsemestre_bulletinetud",
+                            scodoc_dept=g.scodoc_dept,
+                            etudid=etud.id,
+                            formsemestre_id=ue_capitalisee.formsemestre_id,
+                        )
+                        if ue_capitalisee.formsemestre_id
+                        else None
+                    ),
                     "ressources": {},  # sans détail en BUT
                     "saes": {},
                 }
@@ -227,13 +231,15 @@ class BulletinBUT:
                     "id": modimpl.id,
                     "titre": modimpl.module.titre,
                     "code_apogee": modimpl.module.code_apogee,
-                    "url": url_for(
-                        "notes.moduleimpl_status",
-                        scodoc_dept=g.scodoc_dept,
-                        moduleimpl_id=modimpl.id,
-                    )
-                    if has_request_context()
-                    else "na",
+                    "url": (
+                        url_for(
+                            "notes.moduleimpl_status",
+                            scodoc_dept=g.scodoc_dept,
+                            moduleimpl_id=modimpl.id,
+                        )
+                        if has_request_context()
+                        else "na"
+                    ),
                     "moyenne": {
                         # # moyenne indicative de module: moyenne des UE,
                         # # ignorant celles sans notes (nan)
@@ -242,18 +248,20 @@ class BulletinBUT:
                         # "max": fmt_note(moyennes_etuds.max()),
                         # "moy": fmt_note(moyennes_etuds.mean()),
                     },
-                    "evaluations": [
-                        self.etud_eval_results(etud, e)
-                        for e in modimpl.evaluations
-                        if (e.visibulletin or version == "long")
-                        and (e.id in modimpl_results.evaluations_etat)
-                        and (
-                            modimpl_results.evaluations_etat[e.id].is_complete
-                            or self.prefs["bul_show_all_evals"]
-                        )
-                    ]
-                    if version != "short"
-                    else [],
+                    "evaluations": (
+                        [
+                            self.etud_eval_results(etud, e)
+                            for e in modimpl.evaluations
+                            if (e.visibulletin or version == "long")
+                            and (e.id in modimpl_results.evaluations_etat)
+                            and (
+                                modimpl_results.evaluations_etat[e.id].is_complete
+                                or self.prefs["bul_show_all_evals"]
+                            )
+                        ]
+                        if version != "short"
+                        else []
+                    ),
                 }
         return d
 
@@ -274,9 +282,11 @@ class BulletinBUT:
             poids = collections.defaultdict(lambda: 0.0)
         d = {
             "id": e.id,
-            "coef": fmt_note(e.coefficient)
-            if e.evaluation_type == scu.EVALUATION_NORMALE
-            else None,
+            "coef": (
+                fmt_note(e.coefficient)
+                if e.evaluation_type == Evaluation.EVALUATION_NORMALE
+                else None
+            ),
             "date_debut": e.date_debut.isoformat() if e.date_debut else None,
             "date_fin": e.date_fin.isoformat() if e.date_fin else None,
             "description": e.description,
@@ -291,18 +301,20 @@ class BulletinBUT:
                 "moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
             },
             "poids": poids,
-            "url": url_for(
-                "notes.evaluation_listenotes",
-                scodoc_dept=g.scodoc_dept,
-                evaluation_id=e.id,
-            )
-            if has_request_context()
-            else "na",
+            "url": (
+                url_for(
+                    "notes.evaluation_listenotes",
+                    scodoc_dept=g.scodoc_dept,
+                    evaluation_id=e.id,
+                )
+                if has_request_context()
+                else "na"
+            ),
             # deprecated (supprimer avant #sco9.7)
             "date": e.date_debut.isoformat() if e.date_debut else None,
-            "heure_debut": e.date_debut.time().isoformat("minutes")
-            if e.date_debut
-            else None,
+            "heure_debut": (
+                e.date_debut.time().isoformat("minutes") if e.date_debut else None
+            ),
             "heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
         }
         return d
@@ -524,9 +536,9 @@ class BulletinBUT:
 
         d.update(infos)
         # --- Rangs
-        d[
-            "rang_nt"
-        ] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
+        d["rang_nt"] = (
+            f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
+        )
         d["rang_txt"] = "Rang " + d["rang_nt"]
 
         d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))
diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py
index f56b86c0fc7284c6497a67ead72fdf527e01a958..999846f7794966b3bd2e436878f4c858321b3bcc 100644
--- a/app/but/bulletin_but_pdf.py
+++ b/app/but/bulletin_but_pdf.py
@@ -24,7 +24,7 @@ from reportlab.lib.colors import blue
 from reportlab.lib.units import cm, mm
 from reportlab.platypus import Paragraph, Spacer
 
-from app.models import ScoDocSiteConfig
+from app.models import Evaluation, ScoDocSiteConfig
 from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
 from app.scodoc import gen_tables
 from app.scodoc.codes_cursus import UE_SPORT
@@ -422,7 +422,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
     def evaluations_rows(self, rows, evaluations: list[dict], ue_acros=()):
         "lignes des évaluations"
         for e in evaluations:
-            coef = e["coef"] if e["evaluation_type"] == scu.EVALUATION_NORMALE else "*"
+            coef = (
+                e["coef"]
+                if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
+                else "*"
+            )
             t = {
                 "titre": f"{e['description'] or ''}",
                 "moyenne": e["note"]["value"],
@@ -431,7 +435,10 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
                 ),
                 "coef": coef,
                 "_coef_pdf": Paragraph(
-                    f"<para align=right fontSize={self.small_fontsize}><i>{coef}</i></para>"
+                    f"""<para align=right fontSize={self.small_fontsize}><i>{
+                        coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
+                        else "bonus"
+                    }</i></para>"""
                 ),
                 "_pdf_style": [
                     (
diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index 94f056b2f321404eef258da8b8d80ca6cc9ad8d9..a977894d6bf6b0d14b767758e1f324526f458c92 100644
--- a/app/comp/moy_mod.py
+++ b/app/comp/moy_mod.py
@@ -157,8 +157,7 @@ class ModuleImplResults:
 
             etudids_sans_note = inscrits_module - set(eval_df.index)  # sans les dem.
             is_complete = (
-                (evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE)
-                or (evaluation.evaluation_type == scu.EVALUATION_SESSION2)
+                (evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
                 or (evaluation.publish_incomplete)
                 or (not etudids_sans_note)
             )
@@ -240,19 +239,20 @@ class ModuleImplResults:
             ).formsemestre.inscriptions
         ]
 
-    def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
+    def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array:
         """Coefficients des évaluations.
-        Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
-        sont zéro.
+        Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro.
         Résultat: 2d-array of floats, shape (nb_evals, 1)
         """
         return (
             np.array(
                 [
-                    e.coefficient
-                    if e.evaluation_type == scu.EVALUATION_NORMALE
-                    else 0.0
-                    for e in moduleimpl.evaluations
+                    (
+                        e.coefficient
+                        if e.evaluation_type == Evaluation.EVALUATION_NORMALE
+                        else 0.0
+                    )
+                    for e in modimpl.evaluations
                 ],
                 dtype=float,
             )
@@ -285,7 +285,7 @@ class ModuleImplResults:
             for (etudid, x) in self.evals_notes[evaluation_id].items()
         }
 
-    def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl):
+    def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None:
         """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.
@@ -293,25 +293,41 @@ class ModuleImplResults:
         eval_list = [
             e
             for e in moduleimpl.evaluations
-            if e.evaluation_type == scu.EVALUATION_RATTRAPAGE
+            if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
         ]
         if eval_list:
             return eval_list[0]
         return None
 
-    def get_evaluation_session2(self, moduleimpl: ModuleImpl):
+    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.
         """
         eval_list = [
             e
             for e in moduleimpl.evaluations
-            if e.evaluation_type == scu.EVALUATION_SESSION2
+            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."""
+        return [
+            e
+            for e in modimpl.evaluations
+            if e.evaluation_type == Evaluation.EVALUATION_BONUS
+        ]
+
+    def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
+        """Les indices des évaluations bonus"""
+        return [
+            i
+            for (i, e) in enumerate(modimpl.evaluations)
+            if e.evaluation_type == Evaluation.EVALUATION_BONUS
+        ]
+
 
 class ModuleImplResultsAPC(ModuleImplResults):
     "Calcul des moyennes de modules à la mode BUT"
@@ -356,7 +372,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
         #  et dans dans evals_poids_etuds
         #  (rappel: la comparaison est toujours false face à un NaN)
         # shape: (nb_etuds, nb_evals, nb_ues)
-        poids_stacked = np.stack([evals_poids] * nb_etuds)
+        poids_stacked = np.stack([evals_poids] * nb_etuds)  # nb_etuds, nb_evals, nb_ues
         evals_poids_etuds = np.where(
             np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
             poids_stacked,
@@ -364,10 +380,20 @@ class ModuleImplResultsAPC(ModuleImplResults):
         )
         # Calcule la moyenne pondérée sur les notes disponibles:
         evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2)
+        # evals_notes_stacked shape: nb_etuds, nb_evals, nb_ues
         with np.errstate(invalid="ignore"):  # ignore les 0/0 (-> NaN)
             etuds_moy_module = np.sum(
                 evals_poids_etuds * evals_notes_stacked, axis=1
             ) / 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)
@@ -416,6 +442,30 @@ class ModuleImplResultsAPC(ModuleImplResults):
         )
         return self.etuds_moy_module
 
+    def apply_bonus(
+        self,
+        etuds_moy_module: pd.DataFrame,
+        modimpl: ModuleImpl,
+        evals_poids_df: pd.DataFrame,
+        evals_notes_stacked: np.ndarray,
+    ):
+        """Ajoute les points des évaluations bonus.
+        Il peut y avoir un nb quelconque d'évaluations bonus.
+        Les points sont directement ajoutés (ils peuvent être négatifs).
+        """
+        evals_bonus = self.get_evaluations_bonus(modimpl)
+        if not evals_bonus:
+            return etuds_moy_module
+        poids_stacked = np.stack([evals_poids_df.values] * len(etuds_moy_module))
+        for evaluation in evals_bonus:
+            eval_idx = evals_poids_df.index.get_loc(evaluation.id)
+            etuds_moy_module += (
+                evals_notes_stacked[:, eval_idx, :] * poids_stacked[:, eval_idx, :]
+            )
+        # Clip dans [0,20]
+        etuds_moy_module.clip(0, 20, out=etuds_moy_module)
+        return etuds_moy_module
+
 
 def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
     """Charge poids des évaluations d'un module et retourne un dataframe
@@ -532,6 +582,13 @@ 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:
@@ -571,3 +628,22 @@ class ModuleImplResultsClassic(ModuleImplResults):
         )
 
         return self.etuds_moy_module
+
+    def apply_bonus(
+        self,
+        etuds_moy_module: np.ndarray,
+        modimpl: ModuleImpl,
+        evals_notes_20: np.ndarray,
+    ):
+        """Ajoute les points des évaluations bonus.
+        Il peut y avoir un nb quelconque d'évaluations bonus.
+        Les points sont directement ajoutés (ils peuvent être négatifs).
+        """
+        evals_bonus_idx = self.get_evaluations_bonus_idx(modimpl)
+        if not evals_bonus_idx:
+            return etuds_moy_module
+        for eval_idx in evals_bonus_idx:
+            etuds_moy_module += evals_notes_20[:, eval_idx]
+        # Clip dans [0,20]
+        etuds_moy_module.clip(0, 20, out=etuds_moy_module)
+        return etuds_moy_module
diff --git a/app/models/evaluations.py b/app/models/evaluations.py
index 7da2d84cc4f0dd2f843b8575fe5040410ca7cc88..37e3ac79487f76970625a77b63eda5a99d3e5d4f 100644
--- a/app/models/evaluations.py
+++ b/app/models/evaluations.py
@@ -23,8 +23,6 @@ MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
 NOON = datetime.time(12, 00)
 DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
 
-VALID_EVALUATION_TYPES = {0, 1, 2}
-
 
 class Evaluation(db.Model):
     """Evaluation (contrôle, examen, ...)"""
@@ -57,6 +55,17 @@ class Evaluation(db.Model):
     numero = db.Column(db.Integer, nullable=False, default=0)
     ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
 
+    EVALUATION_NORMALE = 0  # valeurs stockées en base, ne pas changer !
+    EVALUATION_RATTRAPAGE = 1
+    EVALUATION_SESSION2 = 2
+    EVALUATION_BONUS = 3
+    VALID_EVALUATION_TYPES = {
+        EVALUATION_NORMALE,
+        EVALUATION_RATTRAPAGE,
+        EVALUATION_SESSION2,
+        EVALUATION_BONUS,
+    }
+
     def __repr__(self):
         return f"""<Evaluation {self.id} {
             self.date_debut.isoformat() if self.date_debut else ''} "{
@@ -546,7 +555,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
     # --- evaluation_type
     try:
         data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
-        if not data["evaluation_type"] in VALID_EVALUATION_TYPES:
+        if not data["evaluation_type"] in Evaluation.VALID_EVALUATION_TYPES:
             raise ScoValueError("invalid evaluation_type value")
     except ValueError as exc:
         raise ScoValueError("invalid evaluation_type value") from exc
diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py
index d7d91ba22a81581191aa2cc079d941eff6477147..69c13df36f9c88e01e9864749b5a689d39916afc 100644
--- a/app/scodoc/sco_bulletins.py
+++ b/app/scodoc/sco_bulletins.py
@@ -610,16 +610,19 @@ def _ue_mod_bulletin(
                     e_dict["coef_txt"] = ""
                 else:
                     e_dict["coef_txt"] = scu.fmt_coef(e.coefficient)
-                if e.evaluation_type == scu.EVALUATION_RATTRAPAGE:
+                if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
                     e_dict["coef_txt"] = "rat."
-                elif e.evaluation_type == scu.EVALUATION_SESSION2:
+                elif e.evaluation_type == Evaluation.EVALUATION_SESSION2:
                     e_dict["coef_txt"] = "Ses. 2"
 
                 if modimpl_results.evaluations_etat[e.id].nb_attente:
                     mod_attente = True  # une eval en attente dans ce module
 
                 if ((not is_malus) or (val != "NP")) and (
-                    (e.evaluation_type == scu.EVALUATION_NORMALE or not np.isnan(val))
+                    (
+                        e.evaluation_type == Evaluation.EVALUATION_NORMALE
+                        or not np.isnan(val)
+                    )
                 ):
                     # ne liste pas les eval malus sans notes
                     # ni les rattrapages et sessions 2 si pas de note
diff --git a/app/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py
index a35a021e26a2045ac32258322234c4bb9e35583e..f2309e941ccbe333afd7d9fcf54ff0269275493f 100644
--- a/app/scodoc/sco_bulletins_standard.py
+++ b/app/scodoc/sco_bulletins_standard.py
@@ -51,7 +51,7 @@ from reportlab.lib.colors import Color, blue
 from reportlab.lib.units import cm, mm
 from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table
 
-from app.models import BulAppreciations
+from app.models import BulAppreciations, Evaluation
 import app.scodoc.sco_utils as scu
 from app.scodoc import (
     gen_tables,
@@ -715,9 +715,15 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
                     eval_style = ""
                 t = {
                     "module": '<bullet indent="2mm">&bull;</bullet>&nbsp;' + e["name"],
-                    "coef": ("<i>" + e["coef_txt"] + "</i>")
-                    if prefs["bul_show_coef"]
-                    else "",
+                    "coef": (
+                        (
+                            f"<i>{e['coef_txt']}</i>"
+                            if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
+                            else "bonus"
+                        )
+                        if prefs["bul_show_coef"]
+                        else ""
+                    ),
                     "_hidden": hidden,
                     "_module_target": e["target_html"],
                     # '_module_help' : ,
diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py
index b26ed9fbe3108816ed314c93a85b74a5cccd310b..c7b0dd676f3bdaacb7c450c796c64aadfe04ab77 100644
--- a/app/scodoc/sco_evaluation_edit.py
+++ b/app/scodoc/sco_evaluation_edit.py
@@ -183,7 +183,8 @@ def evaluation_create_form(
                 {
                     "size": 6,
                     "type": "float",  # peut être négatif (!)
-                    "explanation": "coef. dans le module (choisi librement par l'enseignant, non utilisé pour rattrapage et 2ème session)",
+                    "explanation": """coef. dans le module (choisi librement par
+                        l'enseignant, non utilisé pour rattrapage, 2ème session et bonus)""",
                     "allow_null": False,
                 },
             )
@@ -195,7 +196,7 @@ def evaluation_create_form(
                 "size": 4,
                 "type": "float",
                 "title": "Notes de 0 à",
-                "explanation": f"barème (note max actuelle: {min_note_max_str})",
+                "explanation": f"""barème (note max actuelle: {min_note_max_str}).""",
                 "allow_null": False,
                 "max_value": scu.NOTES_MAX,
                 "min_value": min_note_max,
@@ -206,7 +207,8 @@ def evaluation_create_form(
             {
                 "size": 36,
                 "type": "text",
-                "explanation": """type d'évaluation, apparait sur le bulletins longs. Exemples: "contrôle court", "examen de TP", "examen final".""",
+                "explanation": """type d'évaluation, apparait sur le bulletins longs.
+                    Exemples: "contrôle court", "examen de TP", "examen final".""",
             },
         ),
         (
@@ -230,16 +232,20 @@ def evaluation_create_form(
             {
                 "input_type": "menu",
                 "title": "Modalité",
-                "allowed_values": (
-                    scu.EVALUATION_NORMALE,
-                    scu.EVALUATION_RATTRAPAGE,
-                    scu.EVALUATION_SESSION2,
-                ),
+                "allowed_values": Evaluation.VALID_EVALUATION_TYPES,
                 "type": "int",
                 "labels": (
                     "Normale",
                     "Rattrapage (remplace si meilleure note)",
                     "Deuxième session (remplace toujours)",
+                    (
+                        "Bonus "
+                        + (
+                            "(pondéré par poids et ajouté aux moyennes de ce module)"
+                            if is_apc
+                            else "(ajouté à la moyenne de ce module)"
+                        )
+                    ),
                 ),
             },
         ),
@@ -251,7 +257,8 @@ def evaluation_create_form(
                 {
                     "size": 6,
                     "type": "float",
-                    "explanation": "importance de l'évaluation (multiplie les poids ci-dessous)",
+                    "explanation": """importance de l'évaluation (multiplie les poids ci-dessous).
+                        Non utilisé pour les bonus.""",
                     "allow_null": False,
                 },
             ),
diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py
index 610cb255a9e97f0c39810a690d8a5a1a0d416b27..d157e0a30ed865fe11678e45b273c71a366d2a62 100644
--- a/app/scodoc/sco_evaluations.py
+++ b/app/scodoc/sco_evaluations.py
@@ -217,19 +217,9 @@ def do_evaluation_etat(
 
     gr_incomplets = list(group_nb_missing.keys())
     gr_incomplets.sort()
-    if (
-        (total_nb_missing > 0)
-        and (E["evaluation_type"] != scu.EVALUATION_RATTRAPAGE)
-        and (E["evaluation_type"] != scu.EVALUATION_SESSION2)
-    ):
-        complete = False
-    else:
-        complete = True
 
-    complete = (
-        (total_nb_missing == 0)
-        or (E["evaluation_type"] == scu.EVALUATION_RATTRAPAGE)
-        or (E["evaluation_type"] == scu.EVALUATION_SESSION2)
+    complete = (total_nb_missing == 0) or (
+        E["evaluation_type"] != Evaluation.EVALUATION_NORMALE
     )
     evalattente = (total_nb_missing > 0) and (
         (total_nb_missing == total_nb_att) or E["publish_incomplete"]
@@ -498,13 +488,14 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
     """Experimental: un tableau indiquant pour chaque évaluation
     le nombre de jours avant la publication des notes.
 
-    N'indique pas les évaluations de rattrapage ni celles des modules de bonus/malus.
+    N'indique que les évaluations "normales" (pas rattrapage, ni bonus, ni session2,
+    ni celles des modules de bonus/malus).
     """
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     evaluations = formsemestre.get_evaluations()
     rows = []
     for e in evaluations:
-        if (e.evaluation_type != scu.EVALUATION_NORMALE) or (
+        if (e.evaluation_type != Evaluation.EVALUATION_NORMALE) or (
             e.moduleimpl.module.module_type == ModuleType.MALUS
         ):
             continue
@@ -610,13 +601,17 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True)
         # Indique l'UE
         ue = modimpl.module.ue
         H.append(f"<p><b>UE : {ue.acronyme}</b></p>")
+    if (
+        modimpl.module.module_type == ModuleType.MALUS
+        or evaluation.evaluation_type == Evaluation.EVALUATION_BONUS
+    ):
         # store min/max values used by JS client-side checks:
         H.append(
             """<span id="eval_note_min" class="sco-hidden">-20.</span>
             <span id="eval_note_max" class="sco-hidden">20.</span>"""
         )
     else:
-        # date et absences (pas pour evals de malus)
+        # date et absences (pas pour evals bonus ni des modules de malus)
         if evaluation.date_debut is not None:
             H.append(f"<p>Réalisée le <b>{evaluation.descr_date()}</b> ")
             group_id = sco_groups.get_default_group(modimpl.formsemestre_id)
diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py
index 4405847460a7ccf0df69aec79d42e290a60584d8..9e668c9027fca3db3c4b31ac17a4ecd3e0360ce1 100644
--- a/app/scodoc/sco_liste_notes.py
+++ b/app/scodoc/sco_liste_notes.py
@@ -490,9 +490,9 @@ def _make_table_notes(
         rlinks = {"_table_part": "head"}
         for e in evaluations:
             rlinks[e.id] = "afficher"
-            rlinks[
-                "_" + str(e.id) + "_help"
-            ] = "afficher seulement les notes de cette évaluation"
+            rlinks["_" + str(e.id) + "_help"] = (
+                "afficher seulement les notes de cette évaluation"
+            )
             rlinks["_" + str(e.id) + "_target"] = url_for(
                 "notes.evaluation_listenotes",
                 scodoc_dept=g.scodoc_dept,
@@ -709,9 +709,9 @@ def _add_eval_columns(
     notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
 
     if evaluation.date_debut:
-        titles[
-            evaluation.id
-        ] = f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})"
+        titles[evaluation.id] = (
+            f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})"
+        )
     else:
         titles[evaluation.id] = f"{evaluation.description} "
 
@@ -820,14 +820,17 @@ def _add_eval_columns(
             row_moys[evaluation.id] = scu.fmt_note(
                 sum_notes / nb_notes, keep_numeric=keep_numeric
             )
-            row_moys[
-                "_" + str(evaluation.id) + "_help"
-            ] = "moyenne sur %d notes (%s le %s)" % (
-                nb_notes,
-                evaluation.description,
-                evaluation.date_debut.strftime("%d/%m/%Y")
-                if evaluation.date_debut
-                else "",
+            row_moys["_" + str(evaluation.id) + "_help"] = (
+                "moyenne sur %d notes (%s le %s)"
+                % (
+                    nb_notes,
+                    evaluation.description,
+                    (
+                        evaluation.date_debut.strftime("%d/%m/%Y")
+                        if evaluation.date_debut
+                        else ""
+                    ),
+                )
             )
         else:
             row_moys[evaluation.id] = ""
@@ -884,8 +887,9 @@ def _add_moymod_column(
         row["_" + col_id + "_td_attrs"] = ' class="moyenne" '
         if etudid in inscrits and not isinstance(val, str):
             notes.append(val)
-            nb_notes = nb_notes + 1
-            sum_notes += val
+            if not np.isnan(val):
+                nb_notes = nb_notes + 1
+                sum_notes += val
     row_coefs[col_id] = "(avec abs)"
     if is_apc:
         row_poids[col_id] = "à titre indicatif"
diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py
index 9ee69aec3d33a80ceea0e27da747336d6b838cc9..28021e13f00c6c8de7b2bc71448666c86a1557ad 100644
--- a/app/scodoc/sco_moduleimpl_status.py
+++ b/app/scodoc/sco_moduleimpl_status.py
@@ -519,13 +519,15 @@ def _ligne_evaluation(
         partition_id=partition_id,
         select_first_partition=True,
     )
-    if evaluation.evaluation_type in (
-        scu.EVALUATION_RATTRAPAGE,
-        scu.EVALUATION_SESSION2,
-    ):
+    if evaluation.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
         tr_class = "mievr mievr_rattr"
+    elif evaluation.evaluation_type == Evaluation.EVALUATION_SESSION2:
+        tr_class = "mievr mievr_session2"
+    elif evaluation.evaluation_type == Evaluation.EVALUATION_BONUS:
+        tr_class = "mievr mievr_bonus"
     else:
         tr_class = "mievr"
+
     if not evaluation.visibulletin:
         tr_class += " non_visible_inter"
     tr_class_1 = "mievr"
@@ -563,13 +565,17 @@ def _ligne_evaluation(
                 }" class="mievr_evalnodate">Évaluation sans date</a>"""
         )
     H.append(f"&nbsp;&nbsp;&nbsp; <em>{evaluation.description or ''}</em>")
-    if evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE:
+    if evaluation.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
         H.append(
             """<span class="mievr_rattr" title="remplace si meilleure note">rattrapage</span>"""
         )
-    elif evaluation.evaluation_type == scu.EVALUATION_SESSION2:
+    elif evaluation.evaluation_type == Evaluation.EVALUATION_SESSION2:
+        H.append(
+            """<span class="mievr_session2" title="remplace autres notes">session 2</span>"""
+        )
+    elif evaluation.evaluation_type == Evaluation.EVALUATION_BONUS:
         H.append(
-            """<span class="mievr_rattr" title="remplace autres notes">session 2</span>"""
+            """<span class="mievr_bonus" title="s'ajoute aux moyennes de ce module">bonus</span>"""
         )
     #
     if etat["last_modif"]:
diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py
index 8f84e8c6c6eb8f42645078b3799a369be82a7185..a9195d29b2beb2964777518299941b0edb2ac45c 100644
--- a/app/scodoc/sco_saisie_notes.py
+++ b/app/scodoc/sco_saisie_notes.py
@@ -134,12 +134,12 @@ def _displayNote(val):
     return val
 
 
-def _check_notes(notes: list[(int, float)], evaluation: Evaluation):
-    # XXX typehint : float or str
+def _check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
     """notes is a list of tuples (etudid, value)
     mod is the module (used to ckeck type, for malus)
     returns list of valid notes (etudid, float value)
-    and 4 lists of etudid: etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress
+    and 4 lists of etudid:
+        etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress
     """
     note_max = evaluation.note_max or 0.0
     module: Module = evaluation.moduleimpl.module
@@ -148,7 +148,10 @@ def _check_notes(notes: list[(int, float)], evaluation: Evaluation):
         scu.ModuleType.RESSOURCE,
         scu.ModuleType.SAE,
     ):
-        note_min = scu.NOTES_MIN
+        if evaluation.evaluation_type == Evaluation.EVALUATION_BONUS:
+            note_min, note_max = -20, 20
+        else:
+            note_min = scu.NOTES_MIN
     elif module.module_type == ModuleType.MALUS:
         note_min = -20.0
     else:
diff --git a/app/scodoc/sco_ue_external.py b/app/scodoc/sco_ue_external.py
index 4d32a4053005204ea05a056cdf6efce19bf6a7da..f1840386bf12cb29872b26860899266a143498a2 100644
--- a/app/scodoc/sco_ue_external.py
+++ b/app/scodoc/sco_ue_external.py
@@ -175,7 +175,7 @@ def external_ue_inscrit_et_note(
             note_max=20.0,
             coefficient=1.0,
             publish_incomplete=True,
-            evaluation_type=scu.EVALUATION_NORMALE,
+            evaluation_type=Evaluation.EVALUATION_NORMALE,
             visibulletin=False,
             description="note externe",
         )
diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py
index 6b3850997565b4bd31458ea95e7638b05ac9acf0..7e03f38f711613ea852f4acbf74064a4f689d1bd 100644
--- a/app/scodoc/sco_utils.py
+++ b/app/scodoc/sco_utils.py
@@ -454,10 +454,6 @@ NOTES_MENTIONS_LABS = (
     "Excellent",
 )
 
-EVALUATION_NORMALE = 0
-EVALUATION_RATTRAPAGE = 1
-EVALUATION_SESSION2 = 2
-
 # Dates et années scolaires
 # Ces dates "pivot" sont paramétrables dans les préférences générales
 # on donne ici les valeurs par défaut.
diff --git a/app/static/css/releve-but.css b/app/static/css/releve-but.css
index 25a31c972e2f55701e947aab84a5aba79f1a2f40..b0c0f53328688ec8f084505b8998d7dabdee4476 100644
--- a/app/static/css/releve-but.css
+++ b/app/static/css/releve-but.css
@@ -273,6 +273,10 @@ section>div:nth-child(1) {
     min-width: 80px;
     display: inline-block;
 }
+div.eval-bonus {
+    color: #197614;
+    background-color: pink;
+}
 
 .ueBonus,
 .ueBonus h3 {
@@ -280,7 +284,7 @@ section>div:nth-child(1) {
     color: #000 !important;
 }
 /* UE Capitalisée */
-.synthese .ue.capitalisee, 
+.synthese .ue.capitalisee,
 .ue.capitalisee>h3{
 	background: var(--couleurFondTitresUECapitalisee);;
 }
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 4bfb81864667cc52975bb75a37e6df710d993b40..a5e8d173d93b50625eaa7be2794adc6b85cedff4 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -2103,11 +2103,11 @@ tr.mievr {
   background-color: #eeeeee;
 }
 
-tr.mievr_rattr {
+tr.mievr_rattr, tr.mievr_session2,  tr.mievr_bonus {
   background-color: #dddddd;
 }
 
-span.mievr_rattr {
+span.mievr_rattr, span.mievr_session2, span.mievr_bonus {
   display: inline-block;
   font-weight: bold;
   font-size: 80%;
@@ -4743,6 +4743,10 @@ table.table_recap th.col_malus {
   font-weight: bold;
   color: rgb(165, 0, 0);
 }
+table.table_recap td.col_eval_bonus,
+table.table_recap th.col_eval_bonus {
+  color: #90c;
+}
 
 table.table_recap tr.ects td {
   color: rgb(160, 86, 3);
diff --git a/app/static/js/releve-but.js b/app/static/js/releve-but.js
index d76ec53599b2f2f6df64a5b248fcb1caaa4666b3..2523b227f7ee0f36b51d660142c8896607ed4c8d 100644
--- a/app/static/js/releve-but.js
+++ b/app/static/js/releve-but.js
@@ -491,14 +491,15 @@ class releveBUT extends HTMLElement {
     let output = "";
     evaluations.forEach((evaluation) => {
       output += `
-				<div class=eval>
+				<div class="eval ${evaluation.evaluation_type == 3 ? "eval-bonus" : ""}">
 					<div>${this.URL(evaluation.url, evaluation.description || "Évaluation")}</div>
 					<div>
 						${evaluation.note.value}
-						<em>Coef.&nbsp;${evaluation.coef ?? "*"}</em>
+						<em>${evaluation.evaluation_type == 0 ? "Coef." : evaluation.evaluation_type == 3 ? "Bonus" : ""
+          }&nbsp;${evaluation.coef ?? ""}</em>
 					</div>
 					<div class=complement>
-						<div>Coef</div><div>${evaluation.coef}</div>
+						<div>${evaluation.evaluation_type == 0 ? "Coef." : ""}</div><div>${evaluation.coef ?? ""}</div>
 						<div>Max. promo.</div><div>${evaluation.note.max}</div>
 						<div>Moy. promo.</div><div>${evaluation.note.moy}</div>
 						<div>Min. promo.</div><div>${evaluation.note.min}</div>
diff --git a/app/tables/recap.py b/app/tables/recap.py
index f4882983cb18185b2dac9cc4295bae25580778de..0e2872037f5d7ec1f255b9b69d5f9487f755e3ec 100644
--- a/app/tables/recap.py
+++ b/app/tables/recap.py
@@ -13,7 +13,7 @@ import numpy as np
 from app import db
 from app.auth.models import User
 from app.comp.res_common import ResultatsSemestre
-from app.models import Identite, FormSemestre, UniteEns
+from app.models import Identite, Evaluation, FormSemestre, UniteEns
 from app.scodoc.codes_cursus import UE_SPORT, DEF
 from app.scodoc import sco_evaluation_db
 from app.scodoc import sco_groups
@@ -405,15 +405,22 @@ class TableRecap(tb.Table):
                             val = notes_db[etudid]["value"]
                         else:
                             # Note manquante mais prise en compte immédiate: affiche ATT
-                            val = scu.NOTES_ATTENTE
+                            val = (
+                                scu.NOTES_ATTENTE
+                                if e.evaluation_type != Evaluation.EVALUATION_BONUS
+                                else ""
+                            )
                         content = self.fmt_note(val)
-                        classes = col_classes + [
-                            {
-                                "ABS": "abs",
-                                "ATT": "att",
-                                "EXC": "exc",
-                            }.get(content, "")
-                        ]
+                        if e.evaluation_type != Evaluation.EVALUATION_BONUS:
+                            classes = col_classes + [
+                                {
+                                    "ABS": "abs",
+                                    "ATT": "att",
+                                    "EXC": "exc",
+                                }.get(content, "")
+                            ]
+                        else:
+                            classes = col_classes + ["col_eval_bonus"]
                         row.add_cell(
                             col_id, title, content, group="eval", classes=classes
                         )
diff --git a/app/templates/scodoc/help/evaluations.j2 b/app/templates/scodoc/help/evaluations.j2
index ea844fd8d95cf50429c22ca40ca89728a4289ac8..1f133896fa65403c902e6cdeeadc7b48f6b69101 100644
--- a/app/templates/scodoc/help/evaluations.j2
+++ b/app/templates/scodoc/help/evaluations.j2
@@ -8,13 +8,15 @@
     </p>
     {%if is_apc%}
     <p class="help help_but">
-        Dans le BUT, une évaluation peut évaluer différents apprentissages critiques... (à compléter)
-        Le coefficient est multiplié par les poids vers chaque UE.
+        Dans le BUT, une évaluation peut évaluer différents apprentissages critiques,
+        et les poids permettent de moduler l'importance de l'évaluation pour
+        chaque compétence (UE).
+        Le coefficient de l'évaluation est multiplié par les poids vers chaque UE.
     </p>
     {%endif%}
     <p class="help">
         Ne pas confondre ce coefficient avec le coefficient du module, qui est
-        lui fixé par le programme pédagogique (le PPN pour les DUT) et pondère
+        lui fixé par le programme pédagogique (le PN pour les BUT) et pondère
         les moyennes de chaque module pour obtenir les moyennes d'UE et la
         moyenne générale.
     </p>
@@ -22,17 +24,31 @@
         L'option <em>Visible sur bulletins</em> indique que la note sera
         reportée sur les bulletins en version dite "intermédiaire" (dans cette
         version, on peut ne faire apparaitre que certaines notes, en sus des
-        moyennes de modules. Attention, cette option n'empêche pas la
+        moyennes de modules). Attention, cette option n'empêche pas la
         publication sur les bulletins en version "longue" (la note est donc
         visible par les étudiants sur le portail).
     </p>
+    <p class="help">
+        Les évaluations bonus sont particulières:
+    </p>
+    <ul>
+        <li>la valeur est ajoutée à la moyenne du module;</li>
+        <li>le bonus peut être négatif (malus);
+        </li>
+        <li>le bonus ne s'applique pas aux notes de rattrapage et deuxième session;
+        </li>
+        <li>le coefficient est ignoré, mais en BUT le bonus vers une UE est multiplié
+        par le poids correspondant (par défaut égal à 1);
+        </li>
+        <li>les notes de bonus sont prises en compte même si incomplètes.</li>
+    </ul>
     <p class="help">
         Les modalités "rattrapage" et "deuxième session" définissent des
         évaluations prises en compte de façon spéciale:
     </p>
     <ul>
         <li>les notes d'une évaluation de "rattrapage" remplaceront les moyennes
-            du module <em>si elles sont meilleures que celles calculées</em>.
+            du module <em>si elles sont meilleures que celles calculées;</em>.
         </li>
         <li>les notes de "deuxième session" remplacent, lorsqu'elles sont
             saisies, la moyenne de l'étudiant à ce module, même si la note de
diff --git a/sco_version.py b/sco_version.py
index 2b38021dc6ef0499ece139aaba8aa9d300dbb596..ff1b244fb60d17e7b36c9e07b42b57c3daaa2e1a 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,19 +1,20 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
-SCOVERSION = "9.6.944"
+SCOVERSION = "9.6.945"
 
 SCONAME = "ScoDoc"
 
 SCONEWS = """
-<h4>Année 2023</h4>
+<h4>Année 2023-2024</h4>
 <ul>
 
-<li>ScoDoc 9.6 (juillet 2023)</li>
+<li>ScoDoc 9.6 (2023-2024)</li>
 <ul>
       <li>Nouveaux bulletins BUT compacts</li>
       <li>Nouvelle gestion des absences et assiduité</li>
       <li>Mise à jour logiciels: Debian 12, Python 3.11, ...</li>
+      <li>Evaluations bonus</li>
 </ul>
 
 <li>ScoDoc 9.5 (juillet 2023)</li>
diff --git a/tests/unit/test_notes_rattrapage.py b/tests/unit/test_notes_rattrapage.py
index 918a357c5b87522c4fab3efc6e2bbdf839097ed1..4dfaee3361e01d5c07887e4db4090db0cda15f88 100644
--- a/tests/unit/test_notes_rattrapage.py
+++ b/tests/unit/test_notes_rattrapage.py
@@ -1,5 +1,6 @@
 """Test calculs rattrapages
 """
+
 import datetime
 
 import app
@@ -68,7 +69,7 @@ def test_notes_rattrapage(test_client):
         date_debut=datetime.datetime(2020, 1, 2),
         description="evaluation rattrapage",
         coefficient=1.0,
-        evaluation_type=scu.EVALUATION_RATTRAPAGE,
+        evaluation_type=Evaluation.EVALUATION_RATTRAPAGE,
     )
     etud = etuds[0]
     _, _, _ = G.create_note(evaluation_id=e["id"], etudid=etud["etudid"], note=12.0)
@@ -144,7 +145,7 @@ def test_notes_rattrapage(test_client):
         date_debut=datetime.datetime(2020, 1, 2),
         description="evaluation session 2",
         coefficient=1.0,
-        evaluation_type=scu.EVALUATION_SESSION2,
+        evaluation_type=Evaluation.EVALUATION_SESSION2,
     )
 
     res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)