diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py
index a3b3eb897c6a13aec43207df758b4f3543e60685..c9c4c00fec908dcb6d67e12d7ef94d6464d798a5 100644
--- a/app/but/bulletin_but.py
+++ b/app/but/bulletin_but.py
@@ -80,6 +80,9 @@ class BulletinBUT:
         """
         res = self.res
 
+        if (etud.id, ue.id) in self.res.dispense_ues:
+            return {}
+
         if ue.type == UE_SPORT:
             modimpls_spo = [
                 modimpl
diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py
index 698851e1151ff321c45bb0a90a9b79ccb7037c41..523cab3f15f1e730045096906b3a98f3efa38c57 100644
--- a/app/comp/moy_ue.py
+++ b/app/comp/moy_ue.py
@@ -32,9 +32,17 @@ import pandas as pd
 
 from app import db
 from app import models
-from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef
+from app.models import (
+    DispenseUE,
+    FormSemestre,
+    FormSemestreInscription,
+    Identite,
+    Module,
+    ModuleImpl,
+    ModuleUECoef,
+    UniteEns,
+)
 from app.comp import moy_mod
-from app.models.formsemestre import FormSemestre
 from app.scodoc import sco_codes_parcours
 from app.scodoc import sco_preferences
 from app.scodoc.sco_codes_parcours import UE_SPORT
@@ -140,7 +148,8 @@ def df_load_modimpl_coefs(
                 mod_coef.ue_id
             ] = mod_coef.coef
         except IndexError:
-            # il peut y avoir en base des coefs sur des modules ou UE qui ont depuis été retirés de la formation
+            # il peut y avoir en base des coefs sur des modules ou UE
+            # qui ont depuis été retirés de la formation
             pass
     # Initialisation des poids non fixés:
     # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
@@ -199,7 +208,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
         modimpls_results[modimpl.id] = mod_results
         modimpls_evals_poids[modimpl.id] = evals_poids
         modimpls_notes.append(etuds_moy_module)
-    if len(modimpls_notes):
+    if len(modimpls_notes) > 0:
         cube = notes_sem_assemble_cube(modimpls_notes)
     else:
         nb_etuds = formsemestre.etuds.count()
@@ -211,14 +220,39 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
     )
 
 
+def load_dispense_ues(
+    formsemestre: FormSemestre, etudids: pd.Index, ues: list[UniteEns]
+) -> set[tuple[int, int]]:
+    """Construit l'ensemble des
+    etudids = modimpl_inscr_df.index,  # les etudids
+    ue_ids : modimpl_coefs_df.index,  # les UE du formsemestre sans les UE bonus sport
+
+    Résultat: set de (etudid, ue_id).
+    """
+    dispense_ues = set()
+    ue_sem_by_code = {ue.ue_code: ue for ue in ues}
+    # Prend toutes les dispenses obtenues par des étudiants de ce formsemestre,
+    # puis filtre sur inscrits et code d'UE UE
+    for dispense_ue in DispenseUE.query.join(
+        Identite, FormSemestreInscription
+    ).filter_by(formsemestre_id=formsemestre.id):
+        if dispense_ue.etudid in etudids:
+            # UE dans le semestre avec même code ?
+            ue = ue_sem_by_code.get(dispense_ue.ue.ue_code)
+            if ue is not None:
+                dispense_ues.add((dispense_ue.etudid, ue.id))
+
+    return dispense_ues
+
+
 def compute_ue_moys_apc(
     sem_cube: np.array,
     etuds: list,
     modimpls: list,
-    ues: list,
     modimpl_inscr_df: pd.DataFrame,
     modimpl_coefs_df: pd.DataFrame,
     modimpl_mask: np.array,
+    dispense_ues: set[tuple[int, int]],
     block: bool = False,
 ) -> pd.DataFrame:
     """Calcul de la moyenne d'UE en mode APC (BUT).
@@ -230,7 +264,7 @@ def compute_ue_moys_apc(
     etuds : liste des étudiants (dim. 0 du cube)
     modimpls : liste des module_impl (dim. 1 du cube)
     ues : liste des UE (dim. 2 du cube)
-    modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
+    modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl)
     modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport
     modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas.
                     (utilisé pour éliminer les bonus, et pourra servir à cacluler
@@ -239,7 +273,6 @@ def compute_ue_moys_apc(
     Résultat: DataFrame columns UE (sans bonus), rows etudid
     """
     nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape
-    nb_ues_tot = len(ues)
     assert len(modimpls) == nb_modules
     if block or nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0:
         return pd.DataFrame(
@@ -278,11 +311,16 @@ def compute_ue_moys_apc(
         etud_moy_ue = np.sum(
             modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1
         ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1)
-    return pd.DataFrame(
+    etud_moy_ue_df = pd.DataFrame(
         etud_moy_ue,
         index=modimpl_inscr_df.index,  # les etudids
         columns=modimpl_coefs_df.index,  # les UE sans les UE bonus sport
     )
+    # Les "dispenses" sont très peu nombreuses et traitées en python:
+    for dispense_ue in dispense_ues:
+        etud_moy_ue_df[dispense_ue[1]][dispense_ue[0]] = 0.0
+
+    return etud_moy_ue_df
 
 
 def compute_ue_moys_classic(
@@ -435,7 +473,7 @@ def compute_mat_moys_classic(
     Résultat:
      - moyennes: pd.Series, index etudid
     """
-    if (not len(modimpl_mask)) or (
+    if (0 == len(modimpl_mask)) or (
         sem_matrix.shape[0] == 0
     ):  # aucun module ou aucun étudiant
         # etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
diff --git a/app/comp/res_but.py b/app/comp/res_but.py
index 6ae6285f59154bd85592da0638e997334afe3e27..bd8891116a16f6f169796035372ded31e20ef307 100644
--- a/app/comp/res_but.py
+++ b/app/comp/res_but.py
@@ -39,6 +39,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
         """ndarray (etuds x modimpl x ue)"""
         self.etuds_parcour_id = None
         """Parcours de chaque étudiant { etudid : parcour_id }"""
+
         if not self.load_cached():
             t0 = time.time()
             self.compute()
@@ -71,14 +72,17 @@ class ResultatsSemestreBUT(NotesTableCompat):
             modimpl.module.ue.type != UE_SPORT
             for modimpl in self.formsemestre.modimpls_sorted
         ]
+        self.dispense_ues = moy_ue.load_dispense_ues(
+            self.formsemestre, self.modimpl_inscr_df.index, self.ues
+        )
         self.etud_moy_ue = moy_ue.compute_ue_moys_apc(
             self.sem_cube,
             self.etuds,
             self.formsemestre.modimpls_sorted,
-            self.ues,
             self.modimpl_inscr_df,
             self.modimpl_coefs_df,
             modimpls_mask,
+            self.dispense_ues,
             block=self.formsemestre.block_moyennes,
         )
         # Les coefficients d'UE ne sont pas utilisés en APC
diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index cd7e5f93cc697ea2cad4a61cb9fc5444afb8e860..567c658d323b97ddc360883d1e2ab69ca6a95a76 100644
--- a/app/comp/res_common.py
+++ b/app/comp/res_common.py
@@ -48,12 +48,13 @@ class ResultatsSemestre(ResultatsCache):
     _cached_attrs = (
         "bonus",
         "bonus_ues",
+        "dispense_ues",
+        "etud_coef_ue_df",
         "etud_moy_gen_ranks",
         "etud_moy_gen",
         "etud_moy_ue",
         "modimpl_inscr_df",
         "modimpls_results",
-        "etud_coef_ue_df",
         "moyennes_matieres",
     )
 
@@ -66,6 +67,8 @@ class ResultatsSemestre(ResultatsCache):
         "Bonus sur moy. gen. Series de float, index etudid"
         self.bonus_ues: pd.DataFrame = None  # virtuel
         "DataFrame de float, index etudid, columns: ue.id"
+        self.dispense_ues: set[tuple[int, int]] = set()
+        """set des dispenses d'UE: (etudid, ue_id), en APC seulement."""
         #  ResultatsSemestreBUT ou ResultatsSemestreClassic
         self.etud_moy_ue = {}
         "etud_moy_ue: DataFrame columns UE, rows etudid"
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 23c677c8632c5f0c6acaf344c44cf92e7e359182..a832ad74f0a5c946ca51b944dfbdf55571845c0e 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -36,7 +36,7 @@ from app.models.etudiants import (
 from app.models.events import Scolog, ScolarNews
 from app.models.formations import Formation, Matiere
 from app.models.modules import Module, ModuleUECoef, NotesTag, notes_modules_tags
-from app.models.ues import UniteEns
+from app.models.ues import DispenseUE, UniteEns
 from app.models.formsemestre import (
     FormSemestre,
     FormSemestreEtape,
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 89bb90de84002a9b75842a576a3eb163db4e30c3..5d7052afee9ddc32817090427347269c4c8006c4 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -58,6 +58,12 @@ class Identite(db.Model):
     billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
     #
     admission = db.relationship("Admission", backref="identite", lazy="dynamic")
+    dispense_ues = db.relationship(
+        "DispenseUE",
+        back_populates="etud",
+        cascade="all, delete",
+        passive_deletes=True,
+    )
 
     def __repr__(self):
         return (
diff --git a/app/models/ues.py b/app/models/ues.py
index a832375b18f147a1fe5d17931662432042a72bdc..964dfae28b541620047dec3fff88944e48ec50bb 100644
--- a/app/models/ues.py
+++ b/app/models/ues.py
@@ -5,6 +5,7 @@ from app import db, log
 from app.models import APO_CODE_STR_LEN
 from app.models import SHORT_STR_LEN
 from app.models.but_refcomp import ApcNiveau, ApcParcours
+from app.models.modules import Module
 from app.scodoc.sco_exceptions import ScoFormationConflict
 from app.scodoc import sco_utils as scu
 
@@ -57,6 +58,12 @@ class UniteEns(db.Model):
     # relations
     matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
     modules = db.relationship("Module", lazy="dynamic", backref="ue")
+    dispense_ues = db.relationship(
+        "DispenseUE",
+        back_populates="ue",
+        cascade="all, delete",
+        passive_deletes=True,
+    )
 
     def __repr__(self):
         return f"""<{self.__class__.__name__}(id={self.id}, formation_id={
@@ -237,3 +244,31 @@ class UniteEns(db.Model):
         db.session.add(self)
         db.session.commit()
         log(f"ue.set_parcour( {self}, {parcour} )")
+
+
+class DispenseUE(db.Model):
+    """Dispense d'UE
+    Utilisé en PCC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée
+    qu'ils ne refont pas.
+    """
+
+    __table_args__ = (db.UniqueConstraint("ue_id", "etudid"),)
+    id = db.Column(db.Integer, primary_key=True)
+    ue_id = db.Column(
+        db.Integer,
+        db.ForeignKey(UniteEns.id, ondelete="CASCADE"),
+        index=True,
+        nullable=False,
+    )
+    ue = db.relationship("UniteEns", back_populates="dispense_ues")
+    etudid = db.Column(
+        db.Integer,
+        db.ForeignKey("identite.id", ondelete="CASCADE"),
+        index=True,
+        nullable=False,
+    )
+    etud = db.relationship("Identite", back_populates="dispense_ues")
+
+    def __repr__(self) -> str:
+        return f"""<{self.__class__.__name__} {self.id} etud={
+                repr(self.etud)} ue={repr(self.ue)}>"""
diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py
index 09873bbe17ee1be226c8d92c31cd459055c71123..d3e27dae2dd2e41fcb3ae10f2a4610189c7764ea 100644
--- a/app/scodoc/sco_evaluation_db.py
+++ b/app/scodoc/sco_evaluation_db.py
@@ -36,7 +36,7 @@ from flask_login import current_user
 
 from app import log
 
-from app.models import ScolarNews
+from app.models import ModuleImpl, ScolarNews
 from app.models.evaluations import evaluation_enrich_dict, check_evaluation_args
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
@@ -126,10 +126,13 @@ def do_evaluation_create(
     """Create an evaluation"""
     if not sco_permissions_check.can_edit_evaluation(moduleimpl_id=moduleimpl_id):
         raise AccessDenied(
-            "Modification évaluation impossible pour %s" % current_user.get_nomplogin()
+            f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
         )
     args = locals()
     log("do_evaluation_create: args=" + str(args))
+    modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
+    if modimpl is None:
+        raise ValueError("module not found")
     check_evaluation_args(args)
     # Check numeros
     module_evaluation_renumber(moduleimpl_id, only_if_unumbered=True)
@@ -172,16 +175,18 @@ def do_evaluation_create(
     r = _evaluationEditor.create(cnx, args)
 
     # news
-    M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
-    sco_cache.invalidate_formsemestre(formsemestre_id=M["formsemestre_id"])
-    mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
-    mod["moduleimpl_id"] = M["moduleimpl_id"]
-    mod["url"] = "Notes/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod
+    sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id)
+    url = url_for(
+        "notes.moduleimpl_status",
+        scodoc_dept=g.scodoc_dept,
+        moduleimpl_id=moduleimpl_id,
+    )
     ScolarNews.add(
         typ=ScolarNews.NEWS_NOTE,
         obj=moduleimpl_id,
-        text='Création d\'une évaluation dans <a href="%(url)s">%(titre)s</a>' % mod,
-        url=mod["url"],
+        text=f"""Création d'une évaluation dans <a href="{url}">{
+                modimpl.module.titre or '(module sans titre)'}</a>""",
+        url=url,
     )
 
     return r
diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py
index a6d037ae19d7114d282d1d6919af416aa35190d5..75aad0aa484a4d84f502329eef8639fa1ba28989 100644
--- a/app/scodoc/sco_moduleimpl_inscriptions.py
+++ b/app/scodoc/sco_moduleimpl_inscriptions.py
@@ -262,6 +262,7 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
     """
     authuser = current_user
     formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
+    res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
     is_apc = formsemestre.formation.is_apc()
     inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
         args={"formsemestre_id": formsemestre_id}
@@ -390,65 +391,80 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
         H.append("</table>")
 
     # Etudiants "dispensés" d'une UE (capitalisée)
-    UECaps = get_etuds_with_capitalized_ue(formsemestre_id)
-    if UECaps:
-        H.append('<h3>Etudiants avec UEs capitalisées:</h3><ul class="ue_inscr_list">')
-        ues = [sco_edit_ue.ue_list({"ue_id": ue_id})[0] for ue_id in UECaps.keys()]
+    ues_cap_info = get_etuds_with_capitalized_ue(formsemestre_id)
+    if ues_cap_info:
+        H.append('<h3>Étudiants avec UEs capitalisées:</h3><ul class="ue_inscr_list">')
+        ues = [
+            sco_edit_ue.ue_list({"ue_id": ue_id})[0] for ue_id in ues_cap_info.keys()
+        ]
         ues.sort(key=lambda u: u["numero"])
         for ue in ues:
             H.append(
-                '<li class="tit"><span class="tit">%(acronyme)s: %(titre)s</span>' % ue
+                f"""<li class="tit"><span class="tit">{ue['acronyme']}: {ue['titre']}</span>"""
             )
             H.append("<ul>")
-            for info in UECaps[ue["ue_id"]]:
+            for info in ues_cap_info[ue["ue_id"]]:
                 etud = sco_etud.get_etud_info(etudid=info["etudid"], filled=True)[0]
                 H.append(
-                    '<li class="etud"><a class="discretelink" href="%s">%s</a>'
-                    % (
+                    f"""<li class="etud"><a class="discretelink" href="{
                         url_for(
                             "scolar.ficheEtud",
                             scodoc_dept=g.scodoc_dept,
                             etudid=etud["etudid"],
-                        ),
-                        etud["nomprenom"],
-                    )
+                        )
+                    }">{etud["nomprenom"]}</a>"""
                 )
                 if info["ue_status"]["event_date"]:
                     H.append(
-                        "(cap. le %s)"
-                        % (info["ue_status"]["event_date"]).strftime("%d/%m/%Y")
-                    )
-
-                if info["is_ins"]:
-                    dm = ", ".join(
-                        [
-                            m["code"] or m["abbrev"] or "pas_de_code"
-                            for m in info["is_ins"]
-                        ]
-                    )
-                    H.append(
-                        'actuellement inscrit dans <a title="%s" class="discretelink">%d modules</a>'
-                        % (dm, len(info["is_ins"]))
+                        f"""(cap. le {info["ue_status"]["event_date"].strftime("%d/%m/%Y")})"""
                     )
+                if is_apc:
+                    is_inscrit_ue = (etud["etudid"], ue["id"]) not in res.dispense_ues
+                else:
+                    # CLASSIQUE
+                    is_inscrit_ue = info["is_ins"]
+                    if is_inscrit_ue:
+                        dm = ", ".join(
+                            [
+                                m["code"] or m["abbrev"] or "pas_de_code"
+                                for m in info["is_ins"]
+                            ]
+                        )
+                        H.append(
+                            f"""actuellement inscrit dans <a title="{dm}" class="discretelink"
+                            >{len(info["is_ins"])} modules</a>"""
+                        )
+                if is_inscrit_ue:
                     if info["ue_status"]["is_capitalized"]:
                         H.append(
-                            """<div><em style="font-size: 70%">UE actuelle moins bonne que l'UE capitalisée</em></div>"""
+                            """<div><em style="font-size: 70%">UE actuelle moins bonne que
+                            l'UE capitalisée</em>
+                            </div>"""
                         )
                     else:
                         H.append(
-                            """<div><em style="font-size: 70%">UE actuelle meilleure que l'UE capitalisée</em></div>"""
+                            """<div><em style="font-size: 70%">UE actuelle meilleure que
+                            l'UE capitalisée</em>
+                            </div>"""
                         )
                     if can_change:
                         H.append(
-                            '<div><a class="stdlink" href="etud_desinscrit_ue?etudid=%s&formsemestre_id=%s&ue_id=%s">désinscrire des modules de cette UE</a></div>'
-                            % (etud["etudid"], formsemestre_id, ue["ue_id"])
+                            f"""<div><a class="stdlink" href="{
+                                url_for("notes.etud_desinscrit_ue",
+                                scodoc_dept=g.scodoc_dept, etudid=etud["etudid"],
+                                formsemestre_id=formsemestre_id, ue_id=ue["ue_id"])
+                            }">désinscrire {"des modules" if not is_apc else ""} de cette UE</a></div>
+                            """
                         )
                 else:
                     H.append("(non réinscrit dans cette UE)")
                     if can_change:
                         H.append(
-                            '<div><a class="stdlink" href="etud_inscrit_ue?etudid=%s&formsemestre_id=%s&ue_id=%s">inscrire à tous les modules de cette UE</a></div>'
-                            % (etud["etudid"], formsemestre_id, ue["ue_id"])
+                            f"""<div><a class="stdlink" href="{
+                                url_for("notes.etud_inscrit_ue", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"],
+                                formsemestre_id=formsemestre_id, ue_id=ue["ue_id"])
+                            }">inscrire à {"" if is_apc else "tous les modules de"} cette UE</a></div>
+                            """
                         )
                 H.append("</li>")
             H.append("</ul></li>")
@@ -524,11 +540,11 @@ def _fmt_etud_set(ins, max_list_size=7):
     )
 
 
-def get_etuds_with_capitalized_ue(formsemestre_id):
+def get_etuds_with_capitalized_ue(formsemestre_id: int) -> list[dict]:
     """For each UE, computes list of students capitalizing the UE.
     returns { ue_id : [ { infos } ] }
     """
-    UECaps = scu.DictDefault(defaultvalue=[])
+    ues_cap_info = scu.DictDefault(defaultvalue=[])
     formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
     nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
 
@@ -540,21 +556,22 @@ def get_etuds_with_capitalized_ue(formsemestre_id):
         for etud in inscrits:
             ue_status = nt.get_etud_ue_status(etud["etudid"], ue["ue_id"])
             if ue_status and ue_status["was_capitalized"]:
-                UECaps[ue["ue_id"]].append(
+                ues_cap_info[ue["ue_id"]].append(
                     {
                         "etudid": etud["etudid"],
                         "ue_status": ue_status,
-                        "is_ins": is_inscrit_ue(
+                        "is_ins": etud_modules_ue_inscr(
                             etud["etudid"], formsemestre_id, ue["ue_id"]
                         ),
                     }
                 )
-    return UECaps
+    return ues_cap_info
 
 
-def is_inscrit_ue(etudid, formsemestre_id, ue_id):
+def etud_modules_ue_inscr(etudid, formsemestre_id, ue_id) -> list[int]:
     """Modules de cette UE dans ce semestre
     auxquels l'étudiant est inscrit.
+    Utile pour formations classiques seulement.
     """
     r = ndb.SimpleDictFetch(
         """SELECT mod.id AS module_id, mod.*
@@ -573,8 +590,10 @@ def is_inscrit_ue(etudid, formsemestre_id, ue_id):
     return r
 
 
-def do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
-    """Desincrit l'etudiant de tous les modules de cette UE dans ce semestre."""
+def do_etud_desinscrit_ue_classic(etudid, formsemestre_id, ue_id):
+    """Désinscrit l'etudiant de tous les modules de cette UE dans ce semestre.
+    N'utiliser que pour les formations classiques, pas APC.
+    """
     cnx = ndb.GetDBConnexion()
     cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
     cursor.execute(
@@ -597,7 +616,7 @@ def do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
         cnx,
         method="etud_desinscrit_ue",
         etudid=etudid,
-        msg="desinscription UE %s" % ue_id,
+        msg=f"desinscription UE {ue_id}",
         commit=False,
     )
     sco_cache.invalidate_formsemestre(
diff --git a/app/views/notes.py b/app/views/notes.py
index a104bd99d5e57fc60f98c47f2a723cf65b501bb5..f7e5473ff227385bb5dceda006de44d3fc8d58c1 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -58,7 +58,7 @@ from app.models.formsemestre import FormSemestre
 from app.models.formsemestre import FormSemestreUEComputationExpr
 from app.models.moduleimpls import ModuleImpl
 from app.models.modules import Module
-from app.models.ues import UniteEns
+from app.models.ues import DispenseUE, UniteEns
 from app.scodoc.sco_exceptions import ScoFormationConflict
 from app.views import notes_bp as bp
 
@@ -1588,12 +1588,30 @@ sco_publish(
 @permission_required(Permission.ScoEtudInscrit)
 @scodoc7func
 def etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
-    """Desinscrit l'etudiant de tous les modules de cette UE dans ce semestre."""
-    sco_moduleimpl_inscriptions.do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id)
+    """
+    - En classique: désinscrit l'etudiant de tous les modules de cette UE dans ce semestre.
+    - En APC: dispense de l'UE indiquée.
+    """
+    etud = Identite.query.get_or_404(etudid)
+    ue = UniteEns.query.get_or_404(ue_id)
+    formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
+    if ue.formation.is_apc():
+        if DispenseUE.query.filter_by(etudid=etudid, ue_id=ue_id).count() == 0:
+            disp = DispenseUE(ue_id=ue_id, etudid=etudid)
+            db.session.add(disp)
+            db.session.commit()
+        sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
+    else:
+        sco_moduleimpl_inscriptions.do_etud_desinscrit_ue_classic(
+            etudid, formsemestre_id, ue_id
+        )
+    flash(f"{etud.nomprenom} déinscrit de {ue.acronyme}")
     return flask.redirect(
-        scu.ScoURL()
-        + "/Notes/moduleimpl_inscriptions_stats?formsemestre_id="
-        + str(formsemestre_id)
+        url_for(
+            "notes.moduleimpl_inscriptions_stats",
+            scodoc_dept=g.scodoc_dept,
+            formsemestre_id=formsemestre_id,
+        )
     )
 
 
diff --git a/migrations/versions/f95656fdd3ef_dispenseue.py b/migrations/versions/f95656fdd3ef_dispenseue.py
new file mode 100644
index 0000000000000000000000000000000000000000..7da77b91009d9c9b51c6f6d15dfe2054f878d9d8
--- /dev/null
+++ b/migrations/versions/f95656fdd3ef_dispenseue.py
@@ -0,0 +1,43 @@
+"""DispenseUE
+
+Revision ID: f95656fdd3ef
+Revises: 5542cac8c34a
+Create Date: 2022-11-30 22:22:05.045255
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "f95656fdd3ef"
+down_revision = "5542cac8c34a"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "dispenseUE",
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("ue_id", sa.Integer(), nullable=False),
+        sa.Column("etudid", sa.Integer(), nullable=False),
+        sa.ForeignKeyConstraint(["etudid"], ["identite.id"], ondelete="CASCADE"),
+        sa.ForeignKeyConstraint(["ue_id"], ["notes_ue.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("ue_id", "etudid"),
+    )
+    op.create_index(
+        op.f("ix_dispenseUE_etudid"), "dispenseUE", ["etudid"], unique=False
+    )
+    op.create_index(op.f("ix_dispenseUE_ue_id"), "dispenseUE", ["ue_id"], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f("ix_dispenseUE_ue_id"), table_name="dispenseUE")
+    op.drop_index(op.f("ix_dispenseUE_etudid"), table_name="dispenseUE")
+    op.drop_table("dispenseUE")
+    # ### end Alembic commands ###
diff --git a/sco_version.py b/sco_version.py
index 6ef4172ca82aaae260406063345ac4ec7ec41400..d3d81902fcc0c89fde0d3881bd791e455d084c16 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,13 +1,17 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
-SCOVERSION = "9.4.6"
+SCOVERSION = "9.4.7"
 
 SCONAME = "ScoDoc"
 
 SCONEWS = """
 <h4>Année 2022</h4>
 <ul>
+<li>ScoDoc 9.4</li>
+ <ul>
+    <li>Jury BUT2 avec parcours BUT</li>
+ </ul>
 <li>ScoDoc 9.3</li>
 <ul>
  <li>Nouvelle API REST pour connecter ScoDoc à d'autres applications<li>
diff --git a/tests/unit/test_but_ues.py b/tests/unit/test_but_ues.py
index bc0ad62252a247bf99662e38bb3e802702f7d938..8585ec60fb6eb4605b7944d9dacdce523d823235 100644
--- a/tests/unit/test_but_ues.py
+++ b/tests/unit/test_but_ues.py
@@ -74,7 +74,6 @@ def test_ue_moy(test_client):
             sem_cube,
             etuds,
             modimpls,
-            ues,
             modimpl_inscr_df,
             modimpl_coefs_df,
             modimpl_mask,
@@ -123,7 +122,7 @@ def test_ue_moy(test_client):
         modimpl.module.ue.type != UE_SPORT for modimpl in formsemestre.modimpls_sorted
     ]
     etud_moy_ue = moy_ue.compute_ue_moys_apc(
-        sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df, modimpl_mask
+        sem_cube, etuds, modimpls, modimpl_inscr_df, modimpl_coefs_df, modimpl_mask
     )
     assert etud_moy_ue[ue1.id][etudid] == n1
     assert etud_moy_ue[ue2.id][etudid] == n1