diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py
index 5b5b6f29c26a1f3eacef8ea0914d8df2f1e883e8..5eda000dcaad1143da74a5cc7ca292ab88e36984 100644
--- a/app/but/cursus_but.py
+++ b/app/but/cursus_but.py
@@ -36,7 +36,6 @@ from app.models import Scolog, ScolarAutorisationInscription
 from app.models.but_validations import (
     ApcValidationAnnee,
     ApcValidationRCUE,
-    RegroupementCoherentUE,
 )
 from app.models.etudiants import Identite
 from app.models.formations import Formation
diff --git a/app/but/jury_but.py b/app/but/jury_but.py
index bb73af4311b9854965384c611121393b35e1661f..d4e7dd8cf2c133167e618f4602ecbe1e21211bee 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -25,8 +25,8 @@ Utilisation:
             - on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
                 ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
             - Si RCUE validé, on déclenche d'éventuelles validations: 
-            ("La validation des deux UE du niveau d’une compétence emporte la validation 
-            de l’ensemble des UE du niveau inférieur de cette même compétence.")
+            ("La validation des deux UE du niveau d'une compétence emporte la validation 
+            de l'ensemble des UE du niveau inférieur de cette même compétence.")
         
 Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une 
 autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`.
@@ -60,7 +60,6 @@ DecisionsProposeesUE: décisions de jury sur une UE du BUT
 """
 from datetime import datetime
 import html
-from operator import attrgetter
 import re
 from typing import Union
 
@@ -71,6 +70,7 @@ from app import db
 from app import log
 from app.but import cursus_but
 from app.but.cursus_but import EtudCursusBUT
+from app.but.rcue import RegroupementCoherentUE
 from app.comp.res_but import ResultatsSemestreBUT
 from app.comp import res_sem
 
@@ -83,7 +83,6 @@ from app.models import Scolog, ScolarAutorisationInscription
 from app.models.but_validations import (
     ApcValidationAnnee,
     ApcValidationRCUE,
-    RegroupementCoherentUE,
 )
 from app.models.etudiants import Identite
 from app.models.formations import Formation
@@ -186,10 +185,10 @@ class DecisionsProposeesAnnee(DecisionsProposees):
     """Décisions de jury sur une année (ETP) du BUT
 
     Le texte:
-    La poursuite d'études dans un semestre pair d’une même année est de droit
-    pour tout étudiant. La poursuite d’études dans un semestre impair est
-    possible si et seulement si l’étudiant a obtenu :
-        - la moyenne à plus de la moitié des regroupements cohérents d’UE;
+    La poursuite d'études dans un semestre pair d'une même année est de droit
+    pour tout étudiant. La poursuite d'études dans un semestre impair est
+    possible si et seulement si l'étudiant a obtenu :
+        - la moyenne à plus de la moitié des regroupements cohérents d'UE;
         - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
     La poursuite d'études dans le semestre 5 nécessite de plus la validation
     de toutes les UE des semestres 1 et 2 dans les conditions de validation
@@ -220,129 +219,102 @@ class DecisionsProposeesAnnee(DecisionsProposees):
             raise ScoNoReferentielCompetences(formation=formsemestre.formation)
         super().__init__(etud=etud)
         self.formsemestre = formsemestre
-        "le formsemestre utilisé pour construire ce deca"
-        self.formsemestre_id = formsemestre.id
-        "l'id du formsemestre utilisé pour construire ce deca"
-        formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre)
-        assert (
-            (formsemestre_pair is None)
-            or (formsemestre_impair is None)
-            or (
-                ((formsemestre_pair.semestre_id - formsemestre_impair.semestre_id) == 1)
-                and (
-                    formsemestre_pair.formation.referentiel_competence_id
-                    == formsemestre_impair.formation.referentiel_competence_id
-                )
-            )
-        )
-        # Si les années scolaires sont distinctes, on est "à cheval"
-        self.a_cheval = (
-            formsemestre_impair
-            and formsemestre_pair
-            and formsemestre_impair.annee_scolaire()
-            != formsemestre_pair.annee_scolaire()
-        )
-        "vrai si on groupe deux semestres d'années scolaires différentes"
+        "le formsemestre d'origine, utilisé pour construire ce deca"
         # Si on part d'un semestre IMPAIR, il n'y aura pas de décision année proposée
         # (mais on pourra évidemment valider des UE et même des RCUE)
         self.jury_annuel: bool = formsemestre.semestre_id in (2, 4, 6)
         "vrai si jury de fin d'année scolaire (sem. pair, propose code annuel)"
-
-        self.formsemestre_impair = formsemestre_impair
-        "le 1er semestre du groupement (S1, S3, S5)"
-        self.formsemestre_pair = formsemestre_pair
-        "le second formsemestre (S2, S4, S6), de la même année scolaire ou d'une précédente"
-        formsemestre_last = formsemestre_pair or formsemestre_impair
-        "le formsemestre le plus avancé (en indice de semestre) dans le groupement"
-
-        self.annee_but = (formsemestre_last.semestre_id + 1) // 2
+        self.annee_but = (formsemestre.semestre_id + 1) // 2
         "le rang de l'année dans le BUT: 1, 2, 3"
         assert self.annee_but in (1, 2, 3)
-        self.autorisations_recorded = False
-        "vrai si on a enregistré l'autorisation de passage"
-        self.rcues_annee = []
-        """RCUEs de l'année 
-        (peuvent concerner l'année scolaire antérieur pour les redoublants 
-        avec UE capitalisées)
-        """
-        self.inscription_etat = etud.inscription_etat(formsemestre_last.id)
-        "état de l'inscription dans le semestre le plus avancé (pair si année complète)"
-        self.inscription_etat_pair = (
-            etud.inscription_etat(formsemestre_pair.id)
-            if formsemestre_pair is not None
+        # ---- inscription et parcours
+        inscription = formsemestre.etuds_inscriptions.get(etud.id)
+        if inscription is None:
+            raise ValueError("Etudiant non inscrit au semestre")
+        self.inscription_etat = inscription.etat
+        "état de l'inscription dans le semestre origine"
+        self.parcour = inscription.parcour
+        "Le parcours considéré, qui est celui de l'étudiant dans le formsemestre origine"
+        self.formsemestre_impair, self.formsemestre_pair = self.comp_formsemestres(
+            formsemestre
+        )
+        # ---- résultats et UEs en cours cette année:
+        self.res_impair: ResultatsSemestreBUT = (
+            res_sem.load_formsemestre_results(self.formsemestre_impair)
+            if self.formsemestre_impair
             else None
         )
-        self.inscription_etat_impair = (
-            etud.inscription_etat(formsemestre_impair.id)
-            if formsemestre_impair is not None
+        self.res_pair: ResultatsSemestreBUT = (
+            res_sem.load_formsemestre_results(self.formsemestre_pair)
+            if self.formsemestre_pair
             else None
         )
-
-        if self.formsemestre_impair is not None:
-            self.validation = (
-                ApcValidationAnnee.query.filter_by(
-                    etudid=self.etud.id,
-                    ordre=self.annee_but,
-                )
-                .join(Formation)
-                .filter_by(formation_code=self.formsemestre.formation.formation_code)
-                .first()
+        self.cur_ues_impair = (
+            list_ue_parcour_etud(
+                self.formsemestre_impair, self.etud, self.parcour, self.res_impair
             )
-        else:
-            self.validation = None
-        if self.validation is not None:
-            self.code_valide = self.validation.code
-        self.parcour = None
-        "Le parcours considéré (celui du semestre pair, ou à défaut impair)"
-        if self.formsemestre_pair is not None:
-            self.res_pair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
-                self.formsemestre_pair
+            if self.formsemestre_impair
+            else []
+        )
+        self.cur_ues_pair = (
+            list_ue_parcour_etud(
+                self.formsemestre_pair, self.etud, self.parcour, self.res_pair
             )
-        else:
-            self.res_pair = None
-        if self.formsemestre_impair is not None:
-            self.res_impair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
-                self.formsemestre_impair
+            if self.formsemestre_pair
+            else []
+        )
+        # ---- Niveaux et RCUEs
+        niveaux_by_parcours = (
+            formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
+                self.annee_but, [self.parcour] if self.parcour else None
+            )[1]
+        )
+        self.niveaux_competences = niveaux_by_parcours["TC"] + (
+            niveaux_by_parcours[self.parcour.id] if self.parcour else []
+        )
+        """Les niveaux à valider pour cet étudiant dans cette année, compte tenu de son parcours.
+        Liste non triée des niveaux de compétences associés à cette année pour cet étudiant.
+        = niveaux du tronc commun + niveau du parcours de l'étudiant.
+        """
+        self.rcue_by_niveau = self._compute_rcues_annee()
+        """RCUEs de l'année 
+        (peuvent être construits avec des UEs validées antérieurement: redoublants 
+        avec UEs capitalisées, validation "antérieures")
+        """
+        # ---- Décision année et autorisation
+        self.autorisations_recorded = False
+        "vrai si on a enregistré l'autorisation de passage"
+        self.validation = (
+            ApcValidationAnnee.query.filter_by(
+                etudid=self.etud.id,
+                ordre=self.annee_but,
             )
-        else:
-            self.res_impair = None
+            .join(Formation)
+            .filter_by(formation_code=self.formsemestre.formation.formation_code)
+            .first()
+        )
+        "Validation actuellement enregistrée pour cette année BUT"
+        self.code_valide = self.validation.code if self.validation is not None else None
+        "Le code jury annuel enregistré, ou None"
 
-        self.ues_impair, self.ues_pair = self.compute_ues_annee()  # pylint: disable=all
+        # ---- Décisions d'UEs
         self.decisions_ues = {
-            ue.id: DecisionsProposeesUE(
-                etud, formsemestre_impair, ue, self.inscription_etat_impair
+            rcue.ue_1.id: DecisionsProposeesUE(
+                etud, self.formsemestre_impair, rcue, False, self.inscription_etat
             )
-            for ue in self.ues_impair
+            for rcue in self.rcue_by_niveau.values()
+            if rcue.ue_1
         }
-        "{ue_id : DecisionsProposeesUE} pour toutes les UE de l'année"
         self.decisions_ues.update(
             {
-                ue.id: DecisionsProposeesUE(
-                    etud, formsemestre_pair, ue, self.inscription_etat_pair
+                rcue.ue_2.id: DecisionsProposeesUE(
+                    etud, self.formsemestre_pair, rcue, True, self.inscription_etat
                 )
-                for ue in self.ues_pair
+                for rcue in self.rcue_by_niveau.values()
+                if rcue.ue_2
             }
         )
-        self.rcues_annee = self.compute_rcues_annee()
-
-        formation = (
-            self.formsemestre_impair.formation
-            if self.formsemestre_impair
-            else self.formsemestre_pair.formation
-        )
-        (
-            parcours,
-            niveaux_by_parcours,
-        ) = formation.referentiel_competence.get_niveaux_by_parcours(
-            self.annee_but, [self.parcour] if self.parcour else None
-        )
-        self.niveaux_competences = niveaux_by_parcours["TC"] + (
-            niveaux_by_parcours[self.parcour.id] if self.parcour else []
-        )
-        """liste non triée des niveaux de compétences associés à cette année pour cet étudiant.
-        = niveaux du tronc commun + niveau du parcours de l'étudiant.
-        """
-        self.decisions_rcue_by_niveau = self.compute_decisions_niveaux()
+        self.decisions_rcue_by_niveau = self._compute_decisions_niveaux()
         "les décisions rcue associées aux niveau_id"
         self.dec_rcue_by_ue = self._dec_rcue_by_ue()
         "{ ue_id : DecisionsProposeesRCUE } pour toutes les UE associées à un niveau"
@@ -371,12 +343,12 @@ class DecisionsProposeesAnnee(DecisionsProposees):
         self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
         "Vrai si peut passer dans l'année BUT suivante: plus de la moitié validables et tous > 8"
         explanation = ""
-        # Cas particulier du passage en BUT 3: nécessité d’avoir validé toutes les UEs du BUT 1.
+        # Cas particulier du passage en BUT 3: nécessité d'avoir validé toutes les UEs du BUT 1.
         if self.passage_de_droit and self.annee_but == 2:
             inscription = formsemestre.etuds_inscriptions.get(etud.id)
             if inscription:
                 ues_but1_non_validees = cursus_but.etud_ues_de_but1_non_validees(
-                    etud, formation, inscription.parcour
+                    etud, self.formsemestre.formation, self.parcour
                 )
                 self.passage_de_droit = not ues_but1_non_validees
                 explanation += (
@@ -434,7 +406,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
                 sco_codes.ADJ,
                 sco_codes.PASD,  # voir #488 (discutable, conventions locales)
             ] + self.codes
-            explanation += f""" et {self.nb_rcues_under_8} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
+            explanation += f""" et {self.nb_rcues_under_8
+                                    } niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
 
         # Si l'un des semestres est extérieur, propose ADM
         if (
@@ -487,7 +460,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
             text += "</li>"
 
         text += f"""
-            <li>RCUEs: {html.escape(str(self.rcues_annee))}</li>
+            <li>RCUEs: {html.escape(str(self.rcue_by_niveau))}</li>
             <li>nb_competences: {getattr(self, "nb_competences", "-")}</li>
             <li>nb_validables: {getattr(self, "nb_validables", "-")}</li>
             <li>codes: {self.codes}</li>
@@ -509,177 +482,107 @@ class DecisionsProposeesAnnee(DecisionsProposees):
     def comp_formsemestres(
         self, formsemestre: FormSemestre
     ) -> tuple[FormSemestre, FormSemestre]:
-        """Les deux formsemestres du niveau auquel appartient formsemestre.
-        Complète le niveau avec le formsemestre antérieur le plus récent.
-        L'"autre" formsemestre peut ainsi appartenir à l'année scolaire
-        antérieure (redoublants).
+        """Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF)
+        du niveau auquel appartient formsemestre.
+
         -> S_impair, S_pair
+
+        Si l'origine est impair, S_impair est l'origine et S_pair est None
+        Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur
+        suivi par cet étudiant (ou None).
         """
         if not formsemestre.formation.is_apc():  # garde fou
             return None, None
-        if formsemestre.semestre_id % 2 == 0:
-            other_semestre_id = formsemestre.semestre_id - 1
+
+        if formsemestre.semestre_id % 2:
+            idx_autre = formsemestre.semestre_id + 1
         else:
-            other_semestre_id = formsemestre.semestre_id + 1
+            idx_autre = formsemestre.semestre_id - 1
 
-        other_formsemestre = None
+        # Cherche l'autre semestre de la même année scolaire:
+        autre_formsemestre = None
         for inscr in self.etud.formsemestre_inscriptions:
             if (
+                (inscr.etat == scu.INSCRIT)
+                and
                 # Même spécialité BUT (tolère ainsi des variantes de formation)
                 (
                     inscr.formsemestre.formation.referentiel_competence
                     == formsemestre.formation.referentiel_competence
                 )
                 # L'autre semestre
-                and (inscr.formsemestre.semestre_id == other_semestre_id)
-                # Antérieur
-                and inscr.formsemestre.date_debut < formsemestre.date_debut
-                # Et plus le récent possible
-                and (
-                    (other_formsemestre is None)
-                    or (other_formsemestre.date_debut < inscr.formsemestre.date_debut)
-                )
+                and (inscr.formsemestre.semestre_id == idx_autre)
+                # de la même année scolaire
+                and inscr.formsemestre.annee_scolaire() == formsemestre.annee_scolaire()
             ):
-                other_formsemestre = inscr.formsemestre
-        if formsemestre.semestre_id % 2 == 0:
-            return other_formsemestre, formsemestre
-        return formsemestre, other_formsemestre
-
-    def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]:
-        """UEs à valider cette année pour cet étudiant, selon son parcours.
-        Affecte self.parcour suivant l'inscription de l'étudiant et
-        ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
-        """
-        ues_sems = []
-        for formsemestre, res in (
-            (self.formsemestre_impair, self.res_impair),
-            (self.formsemestre_pair, self.res_pair),
-        ):
-            if (formsemestre is None) or (not formsemestre.formation.is_apc()):
-                ues = []
-            else:
-                parcour, ues = list_ue_parcour_etud(formsemestre, self.etud, res)
-                if parcour is not None:
-                    self.parcour = parcour
-            ues_sems.append(ues)
-        return ues_sems
-
-    def check_ues_ready_jury(self) -> list[str]:
-        """Vérifie que les toutes les UEs (hors bonus) de l'année sont
-        bien associées à des niveaux de compétences.
-        Renvoie liste vide si ok, sinon liste de message explicatifs
-        """
-        messages = []
-        for ue in self.ues_impair + self.ues_pair:
-            if ue.niveau_competence is None:
-                messages.append(
-                    f"UE {ue.acronyme} non associée à un niveau de compétence"
-                )
-            if ue.semestre_idx is None:
-                messages.append(
-                    f"UE {ue.acronyme} n'a pas d'indice de semestre dans la formation"
-                )
-        return messages
+                autre_formsemestre = inscr.formsemestre
+                break
+        # autre_formsemestre peut être None
+        if formsemestre.semestre_id % 2:
+            return formsemestre, autre_formsemestre
+        else:
+            return autre_formsemestre, formsemestre
+
+    def get_decisions_rcues_annee(self) -> list["DecisionsProposeesRCUE"]:
+        "Liste des DecisionsProposeesRCUE de l'année, tirée par numéro d'UE"
+        return self.decisions_rcue_by_niveau.values()
+
+    def _compute_rcues_annee(self) -> dict[int, RegroupementCoherentUE]:
+        "calcule tous les RCUEs: { niveau_id : rcue }"
+        semestre_id_impair = ((self.formsemestre.semestre_id - 1) // 2) * 2 + 1
+        return {
+            niveau.id: RegroupementCoherentUE(
+                self.etud,
+                niveau,
+                self.res_pair,
+                self.res_impair,
+                semestre_id_impair,
+                self.cur_ues_pair,
+                self.cur_ues_impair,
+            )
+            for niveau in self.niveaux_competences
+        }
 
-    def compute_rcues_annee(self) -> list[RegroupementCoherentUE]:
-        """Liste des regroupements d'UE à considérer cette année.
-        On peut avoir un RCUE à cheval sur plusieurs années (redoublants avec UE capitalisées).
-        Si on n'a pas les deux semestres, aucun RCUE.
-        """
-        if self.formsemestre_pair is None or self.formsemestre_impair is None:
-            return []
-        rcues_annee = []
-        ues_impair_sans_rcue = {ue.id for ue in self.ues_impair}
-        for ue_pair in self.ues_pair:
-            rcue = None
-            for ue_impair in self.ues_impair:
-                if self.a_cheval:
-                    # l'UE paire DOIT être capitalisée pour être utilisée
-                    if (
-                        self.decisions_ues[ue_pair.id].code_valide
-                        not in CODES_UE_CAPITALISANTS
-                    ):
-                        continue  # ignore cette UE antérieure non capitalisée
-                    # et l'UE impaire doit être actuellement meilleure que
-                    # celle éventuellement capitalisée
-                    if (
-                        self.decisions_ues[ue_impair.id].ue_status
-                        and self.decisions_ues[ue_impair.id].ue_status["is_capitalized"]
-                    ):
-                        continue  # ignore cette UE car capitalisée et actuelle moins bonne
-                if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
-                    rcue = RegroupementCoherentUE(
-                        self.etud,
-                        self.formsemestre_impair,
-                        self.decisions_ues[ue_impair.id],
-                        self.formsemestre_pair,
-                        self.decisions_ues[ue_pair.id],
-                        self.inscription_etat,
-                    )
-                    ues_impair_sans_rcue.discard(ue_impair.id)
-                    break
-            # if rcue is None and not self.a_cheval:
-            #    raise NoRCUEError(deca=self, ue=ue_pair)
-            if rcue is not None:
-                rcues_annee.append(rcue)
-        # Si jury annuel (pas à cheval), on doit avoir tous les RCUEs:
-        # if len(ues_impair_sans_rcue) > 0 and not self.a_cheval:
-        #    ue = UniteEns.query.get(ues_impair_sans_rcue.pop())
-        #    raise NoRCUEError(deca=self, ue=ue)
-        return rcues_annee
-
-    def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
+    def _compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
         """Pour chaque niveau de compétence de cette année, construit
-        le DecisionsProposeesRCUE, ou None s'il n'y en a pas
-            (ne devrait pas arriver car compute_rcues_annee vérifie déjà cela).
-
+        le DecisionsProposeesRCUE à partir du rcue déjà calculé.
         Appelé à la construction du deca, donc avant décisions manuelles.
         Return: { niveau_id : DecisionsProposeesRCUE }
         """
-        # Retrouve le RCUE associé à chaque niveau
-        rc_niveaux = []
-        for niveau in self.niveaux_competences:
-            rcue = None
-            for rc in self.rcues_annee:
-                if rc.ue_1.niveau_competence_id == niveau.id:
-                    rcue = rc
-                    break
-            if rcue is not None:
-                dec_rcue = DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
-                rc_niveaux.append((dec_rcue, niveau.id))
-                # prévient les UE concernées :-)
-                self.decisions_ues[dec_rcue.rcue.ue_1.id].set_rcue(dec_rcue.rcue)
-                self.decisions_ues[dec_rcue.rcue.ue_2.id].set_rcue(dec_rcue.rcue)
         # Ordonne par numéro d'UE
-        rc_niveaux.sort(key=lambda x: x[0].rcue.ue_1.numero)
-        decisions_rcue_by_niveau = {x[1]: x[0] for x in rc_niveaux}
-        return decisions_rcue_by_niveau
+        niv_rcue = sorted(
+            self.rcue_by_niveau.items(),
+            key=lambda x: x[1].ue_1.numero
+            if x[1].ue_1
+            else x[1].ue_2.numero
+            if x[1].ue_2
+            else 0,
+        )
+        return {
+            niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
+            for (niveau_id, rcue) in niv_rcue
+        }
 
     def _dec_rcue_by_ue(self) -> dict[int, "DecisionsProposeesRCUE"]:
         """construit dict { ue_id : DecisionsProposeesRCUE }
         à partir de self.decisions_rcue_by_niveau"""
         d = {}
         for dec_rcue in self.decisions_rcue_by_niveau.values():
-            d[dec_rcue.rcue.ue_1.id] = dec_rcue
-            d[dec_rcue.rcue.ue_2.id] = dec_rcue
+            if dec_rcue.rcue.ue_1:
+                d[dec_rcue.rcue.ue_1.id] = dec_rcue
+            if dec_rcue.rcue.ue_2:
+                d[dec_rcue.rcue.ue_2.id] = dec_rcue
         return d
 
-    def formsemestre_ects(self) -> float:
-        "ECTS validés dans le formsemestre de départ du deca"
-        ues = self.ues_impair if self.formsemestre.semestre_id % 2 else self.ues_pair
-        return sum(
-            [
-                self.decisions_ues[ue.id].ects_acquis()
-                for ue in ues
-                if ue.id in self.decisions_ues
-            ]
-        )
+    def ects_annee(self) -> float:
+        "ECTS validés dans l'année BUT courante"
+        return sum([dec_ue.ects_acquis() for dec_ue in self.decisions_ues.values()])
 
     def next_semestre_ids(self, code: str) -> set[int]:
         """Les indices des semestres dans lequels l'étudiant est autorisé
         à poursuivre après le semestre courant.
         """
-        # La poursuite d'études dans un semestre pair d’une même année
+        # La poursuite d'études dans un semestre pair d'une même année
         # est de droit pour tout étudiant.
         # Pas de redoublements directs de S_impair vers S_impair
         # (pourront être traités manuellement)
@@ -687,9 +590,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
             self.formsemestre.semestre_id % 2
         ) and self.formsemestre.semestre_id < sco_codes.CursusBUT.NB_SEM:
             return {self.formsemestre.semestre_id + 1}
-        # La poursuite d’études dans un semestre impair est possible si
-        # et seulement si l’étudiant a obtenu :
-        #  - la moyenne à plus de la moitié des regroupements cohérents d’UE ;
+        # La poursuite d'études dans un semestre impair est possible si
+        # et seulement si l'étudiant a obtenu :
+        #  - la moyenne à plus de la moitié des regroupements cohérents d'UE ;
         #  - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
         #
         # La condition a paru trop stricte à de nombreux collègues.
@@ -914,20 +817,20 @@ class DecisionsProposeesAnnee(DecisionsProposees):
         pour cette année: décisions d'UE, de RCUE, d'année,
         et autorisations d'inscription émises.
         Efface même si étudiant DEM ou DEF.
-        Si à cheval ou only_one_sem, n'efface que les décisions UE et les
+        Si only_one_sem, n'efface que les décisions UE et les
         autorisations de passage du semestre d'origine du deca.
 
         Dans tous les cas, efface les validations de l'année en cours.
         (commite la session.)
         """
-        if only_one_sem or self.a_cheval:
+        if only_one_sem:
             # N'efface que les autorisations venant de ce semestre,
             # et les validations de ses UEs
             ScolarAutorisationInscription.delete_autorisation_etud(
-                self.etud.id, self.formsemestre_id
+                self.etud.id, self.formsemestre.id
             )
             for dec_ue in self.decisions_ues.values():
-                if dec_ue.formsemestre.id == self.formsemestre_id:
+                if dec_ue.formsemestre.id == self.formsemestre.id:
                     dec_ue.erase()
         else:
             for dec_ue in self.decisions_ues.values():
@@ -968,7 +871,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
         # et autres UEs (en cas de changement d'architecture de formation depuis le jury ?)
         #
         for validation in ScolarFormSemestreValidation.query.filter_by(
-            etudid=self.etud.id, formsemestre_id=self.formsemestre_id
+            etudid=self.etud.id, formsemestre_id=self.formsemestre.id
         ):
             db.session.delete(validation)
 
@@ -1026,14 +929,17 @@ class DecisionsProposeesAnnee(DecisionsProposees):
         for dec_rcue in self.decisions_rcue_by_niveau.values():
             if dec_rcue.code_valide in CODES_RCUE_VALIDES:
                 for ue in (dec_rcue.rcue.ue_1, dec_rcue.rcue.ue_2):
-                    dec_ue = self.decisions_ues.get(ue.id)
-                    if dec_ue:
-                        if dec_ue.code_valide not in CODES_UE_VALIDES:
+                    if ue:
+                        dec_ue = self.decisions_ues.get(ue.id)
+                        if dec_ue:
+                            if dec_ue.code_valide not in CODES_UE_VALIDES:
+                                messages.append(
+                                    f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !"
+                                )
+                        else:
                             messages.append(
-                                f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !"
+                                f"L'UE {ue.acronyme} n'a pas décision (???)"
                             )
-                    else:
-                        messages.append(f"L'UE {ue.acronyme} n'a pas décision (???)")
         return messages
 
     def valide_diplome(self) -> bool:
@@ -1042,26 +948,25 @@ class DecisionsProposeesAnnee(DecisionsProposees):
 
 
 def list_ue_parcour_etud(
-    formsemestre: FormSemestre, etud: Identite, res: ResultatsSemestreBUT
-) -> tuple[ApcParcours, list[UniteEns]]:
-    """Parcour dans lequel l'étudiant est inscrit,
-    et liste des UEs à valider pour ce semestre (sans les UE "dispensées")
-    """
-    if res.etuds_parcour_id[etud.id] is None:
-        parcour = None
+    formsemestre: FormSemestre,
+    etud: Identite,
+    parcour: ApcParcours,
+    res: ResultatsSemestreBUT,
+) -> list[UniteEns]:
+    """Liste des UEs suivies ce semestre (sans les UE "dispensées")"""
+
+    if parcour is None:
         # pas de parcour: prend toutes les UEs (non bonus)
         ues = [ue for ue in res.etud_ues(etud.id) if ue.type == UE_STANDARD]
         ues.sort(key=lambda u: u.numero)
     else:
-        parcour = ApcParcours.query.get(res.etuds_parcour_id[etud.id])
         ues = (
             formsemestre.formation.query_ues_parcour(parcour)
             .filter(UniteEns.semestre_idx == formsemestre.semestre_id)
             .order_by(UniteEns.numero)
             .all()
         )
-    ues = [ue for ue in ues if (etud.id, ue.id) not in res.dispense_ues]
-    return parcour, ues
+    return [ue for ue in ues if (etud.id, ue.id) not in res.dispense_ues]
 
 
 class DecisionsProposeesRCUE(DecisionsProposees):
@@ -1117,8 +1022,11 @@ class DecisionsProposeesRCUE(DecisionsProposees):
             self.codes.insert(0, sco_codes.AJ)
         # Si au moins l'un des semestres est extérieur, propose ADM au cas où
         if (
-            dec_prop_annee.formsemestre_impair.modalite == "EXT"
-            or dec_prop_annee.formsemestre_pair.modalite == "EXT"
+            dec_prop_annee.formsemestre_impair
+            and dec_prop_annee.formsemestre_impair.modalite == "EXT"
+        ) or (
+            dec_prop_annee.formsemestre_pair
+            and dec_prop_annee.formsemestre_pair.modalite == "EXT"
         ):
             self.codes.insert(0, sco_codes.ADM)
         # S'il y a une décision enregistrée: si elle est plus favorable que celle que l'on
@@ -1148,11 +1056,13 @@ class DecisionsProposeesRCUE(DecisionsProposees):
         """
         if self.rcue is None:
             return False  # pas de RCUE a enregistrer
+        if not (self.rcue.ue_1 and self.rcue.ue_2):
+            return False  # on n'a pas les deux UEs
         if self.inscription_etat != scu.INSCRIT:
             return False
         if code and not code in self.codes:
             raise ScoValueError(
-                f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
+                f"code RCUE invalide pour {self.rcue}: {html.escape(code)}"
             )
         if code == self.code_valide:
             self.recorded = True
@@ -1166,7 +1076,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
         else:
             self.validation = ApcValidationRCUE(
                 etudid=self.etud.id,
-                formsemestre_id=self.rcue.formsemestre_2.id,
+                formsemestre_id=self.deca.formsemestre.id,  # origine
                 ue1_id=self.rcue.ue_1.id,
                 ue2_id=self.rcue.ue_2.id,
                 parcours_id=parcours_id,
@@ -1190,7 +1100,9 @@ class DecisionsProposeesRCUE(DecisionsProposees):
                     if dec_ue and dec_ue.code_valide not in CODES_UE_VALIDES:
                         log(f"rcue.record: force ADJR sur {dec_ue}")
                         flash(
-                            f"""UEs du RCUE "{dec_ue.ue.niveau_competence.competence.titre}" passées en ADJR"""
+                            f"""UEs du RCUE "{
+                                dec_ue.ue.niveau_competence.competence.titre
+                            }" passées en ADJR"""
                         )
                         dec_ue.record(sco_codes.ADJR)
 
@@ -1198,13 +1110,13 @@ class DecisionsProposeesRCUE(DecisionsProposees):
             if code in CODES_RCUE_VALIDES:
                 self.valide_niveau_inferieur()
 
-        if self.rcue.formsemestre_1 is not None:
+        if self.rcue.res_impair is not None:
             sco_cache.invalidate_formsemestre(
-                formsemestre_id=self.rcue.formsemestre_1.id
+                formsemestre_id=self.rcue.res_impair.formsemestre.id
             )
-        if self.rcue.formsemestre_2 is not None:
+        if self.rcue.res_pair is not None:
             sco_cache.invalidate_formsemestre(
-                formsemestre_id=self.rcue.formsemestre_2.id
+                formsemestre_id=self.rcue.res_pair.formsemestre.id
             )
         self.code_valide = code  # mise à jour état
         self.recorded = True
@@ -1236,13 +1148,13 @@ class DecisionsProposeesRCUE(DecisionsProposees):
 
     def valide_niveau_inferieur(self) -> None:
         """Appelé juste après la validation d'un RCUE.
-        *La validation des deux UE du niveau d’une compétence emporte la validation de
-        l’ensemble des UEs du niveau inférieur de cette même compétence.*
+        *La validation des deux UE du niveau d'une compétence emporte la validation de
+        l'ensemble des UEs du niveau inférieur de cette même compétence.*
         """
-        if not self.rcue or not self.rcue.ue_1 or not self.rcue.ue_1.niveau_competence:
+        if not self.rcue:
             return
-        competence: ApcCompetence = self.rcue.ue_1.niveau_competence.competence
-        ordre_inferieur = self.rcue.ue_1.niveau_competence.ordre - 1
+        competence: ApcCompetence = self.rcue.niveau.competence
+        ordre_inferieur = self.rcue.niveau.ordre - 1
         if ordre_inferieur < 1:
             return  # pas de niveau inferieur
 
@@ -1259,43 +1171,14 @@ class DecisionsProposeesRCUE(DecisionsProposees):
         if [v for v in validations_rcue if code_rcue_validant(v.code)]:
             return  # déjà validé
 
-        # --- Validations des UEs
-        ues, ue1, ue2 = self._get_ues_inferieures(competence, ordre_inferieur)
-        # Pour chaque UE inférieure non validée, valide:
-        for ue in ues:
-            validations_ue = ScolarFormSemestreValidation.query.filter_by(
-                etudid=self.etud.id, ue_id=ue.id
-            ).all()
-            if [
-                validation
-                for validation in validations_ue
-                if sco_codes.code_ue_validant(validation.code)
-            ]:
-                continue  # on a déjà une validation
-            # aucune validation validante
-            validation_ue = validations_ue[0] if validations_ue else None
-            if validation_ue:
-                # Modifie validation existante
-                validation_ue.code = sco_codes.ADSUP
-                validation_ue.event_date = datetime.now()
-                if validation_ue.formsemestre_id is not None:
-                    sco_cache.invalidate_formsemestre(
-                        formsemestre_id=validation_ue.formsemestre_id
-                    )
-                log(f"updating {validation_ue}")
-            else:
-                # Ajoute une validation,
-                # pas de formsemestre ni de note car pas une capitalisation
-                validation_ue = ScolarFormSemestreValidation(
-                    etudid=self.etud.id,
-                    code=sco_codes.ADSUP,
-                    ue_id=ue.id,
-                    is_external=True,  # pas rattachée à un formsemestre
-                )
-                log(f"recording {validation_ue}")
-            db.session.add(validation_ue)
-
-        # Valide le RCUE inférieur
+        # --- Validations des UEs du niveau inférieur
+        self.valide_ue_inferieures(
+            self.rcue.semestre_id_impair, ordre_inferieur, competence
+        )
+        self.valide_ue_inferieures(
+            self.rcue.semestre_id_pair, ordre_inferieur, competence
+        )
+        # --- Valide le RCUE inférieur
         if validations_rcue:
             # Met à jour validation existante
             validation_rcue = validations_rcue[0]
@@ -1308,20 +1191,93 @@ class DecisionsProposeesRCUE(DecisionsProposees):
                 sco_cache.invalidate_formsemestre(
                     formsemestre_id=validation_rcue.formsemestre_id
                 )
-        elif ue1 and ue2:
+        else:
             # Crée nouvelle validation
-            validation_rcue = ApcValidationRCUE(
-                etudid=self.etud.id, ue1_id=ue1.id, ue2_id=ue2.id, code=sco_codes.ADSUP
+            ue1 = self._get_ue_inferieure(
+                self.rcue.semestre_id_impair, ordre_inferieur, competence
             )
-            db.session.add(validation_rcue)
-            db.session.commit()
-            log(f"recording {validation_rcue}")
+            ue2 = self._get_ue_inferieure(
+                self.rcue.semestre_id_pair, ordre_inferieur, competence
+            )
+            if ue1 and ue2:
+                validation_rcue = ApcValidationRCUE(
+                    etudid=self.etud.id,
+                    ue1_id=ue1.id,
+                    ue2_id=ue2.id,
+                    code=sco_codes.ADSUP,
+                )
+                db.session.add(validation_rcue)
+                db.session.commit()
+                log(f"recording {validation_rcue}")
+
         self.valide_annee_inferieure()
 
+    def _get_ue_inferieure(
+        self, semestre_id: int, ordre_inferieur: int, competence: ApcCompetence
+    ) -> UniteEns:
+        "L'UE de la formation associée au semestre indiqué diu niveau de compétence"
+        return (
+            UniteEns.query.filter_by(
+                formation_id=self.deca.formsemestre.formation_id,
+                semestre_idx=semestre_id,
+            )
+            .join(ApcNiveau)
+            .filter_by(ordre=ordre_inferieur)
+            .join(ApcCompetence)
+            .filter_by(id=competence.id)
+        ).first()
+
+    def valide_ue_inferieures(
+        self, semestre_id: int, ordre_inferieur: int, competence: ApcCompetence
+    ):
+        """Au besoin, enregistre une validation d'UE ADSUP pour le niveau de compétence
+        semestre_id : l'indice du semestre concerné (le pair ou l'impair)
+        """
+        # Les validations d'UE impaires existantes pour ce niveau inférieur ?
+        validations_ues: list[ScolarFormSemestreValidation] = (
+            ScolarFormSemestreValidation.query.filter_by(etudid=self.etud.id)
+            .join(UniteEns)
+            .filter_by(semestre_idx=semestre_id)
+            .join(ApcNiveau)
+            .filter_by(ordre=ordre_inferieur)
+            .join(ApcCompetence)
+            .filter_by(id=competence.id)
+        ).all()
+        validations_ues_validantes = [
+            validation
+            for validation in validations_ues
+            if sco_codes.code_ue_validant(validation.code)
+        ]
+        if not validations_ues_validantes:
+            # Il faut créer une validation d'UE
+            # cherche l'UE de notre formation associée à ce niveau
+            # et warning si il n'y en a pas
+            ue = self._get_ue_inferieure(semestre_id, ordre_inferieur, competence)
+            if not ue:
+                # programme incomplet ou mal paramétré
+                flash(
+                    f"""Impossible de valider l'UE inférieure du niveau {
+                      ordre_inferieur
+                    } de la compétence {competence.titre}
+                    car elle n'existe pas dans la formation
+                    """,
+                    "warning",
+                )
+                log("valide_ue_inferieures: UE manquante dans la formation")
+            else:
+                validation_ue = ScolarFormSemestreValidation(
+                    etudid=self.etud.id,
+                    code=sco_codes.ADSUP,
+                    ue_id=ue.id,
+                    is_external=True,  # pas rattachée à un formsemestre
+                )
+                db.session.add(validation_ue)
+                log(f"recording {validation_ue}")
+
     def valide_annee_inferieure(self) -> None:
         """Si tous les RCUEs de l'année inférieure sont validés, la valide"""
         # Indice de l'année inférieure:
-        annee_courante = self.rcue.ue_1.niveau_competence.annee  # "BUT2"
+        annee_courante = self.rcue.niveau.annee  # "BUT2"
         if not re.match(r"^BUT\d$", annee_courante):
             log("Warning: valide_annee_inferieure invalid annee_courante")
             return
@@ -1335,7 +1291,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
                 ordre=annee_inferieure,
             )
             .join(Formation)
-            .filter_by(formation_code=self.rcue.formsemestre_1.formation.formation_code)
+            .filter_by(formation_code=self.deca.formsemestre.formation.formation_code)
             .all()
         )
         if len(validations_annee) > 1:
@@ -1352,7 +1308,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
         # Liste des niveaux à valider:
         # ici on sort l'artillerie lourde
         cursus: EtudCursusBUT = EtudCursusBUT(
-            self.etud, self.rcue.formsemestre_1.formation
+            self.etud, self.deca.formsemestre.formation
         )
         niveaux_a_valider = cursus.niveaux_by_annee[annee_inferieure]
         # Pour chaque niveau, cherche validation RCUE
@@ -1375,54 +1331,14 @@ class DecisionsProposeesRCUE(DecisionsProposees):
                 etudid=self.etud.id,
                 ordre=annee_inferieure,
                 code=sco_codes.ADSUP,
-                formation_id=self.rcue.formsemestre_1.formation_id,
-                # met cette validation sur l'année scolaire actuelle, pas la précédente (??)
-                annee_scolaire=self.rcue.formsemestre_1.annee_scolaire(),
+                formation_id=self.deca.formsemestre.formation_id,
+                # met cette validation sur l'année scolaire actuelle, pas la précédente
+                annee_scolaire=self.deca.formsemestre.annee_scolaire(),
             )
             log(f"recording {validation_annee}")
         db.session.add(validation_annee)
         db.session.commit()
 
-    def _get_ues_inferieures(
-        self, competence: ApcCompetence, ordre_inferieur: int
-    ) -> tuple[list[UniteEns], UniteEns, UniteEns]:
-        """Les UEs de cette formation associées au niveau de compétence inférieur ?
-        Note: on ne cherche que dans la formation courante, pas les UEs de
-        même code d'autres formations.
-        """
-        formation: Formation = self.rcue.formsemestre_1.formation
-        ues: list[UniteEns] = (
-            UniteEns.query.filter_by(formation_id=formation.id)
-            .filter(UniteEns.semestre_idx != None)
-            .join(ApcNiveau)
-            .filter_by(ordre=ordre_inferieur)
-            .join(ApcCompetence)
-            .filter_by(id=competence.id)
-            .all()
-        )
-        log(f"valide_niveau_inferieur: {competence} UEs inférieures: {ues}")
-        if len(ues) != 2:  # on n'a pas 2 UE associées au niveau inférieur !
-            flash(
-                "Impossible de valider le niveau de compétence inférieur: pas 2 UEs associées'",
-                "warning",
-            )
-            return [], None, None
-        ues_impaires = [ue for ue in ues if ue.semestre_idx % 2]
-        if len(ues_impaires) != 1:
-            flash(
-                "Impossible de valider le niveau de compétence inférieur: pas d'UE impaire associée"
-            )
-            return [], None, None
-        ue1 = ues_impaires[0]
-        ues_paires = [ue for ue in ues if not ue.semestre_idx % 2]
-        if len(ues_paires) != 1:
-            flash(
-                "Impossible de valider le niveau de compétence inférieur: pas d'UE paire associée"
-            )
-            return [], None, None
-        ue2 = ues_paires[0]
-        return ues, ue1, ue2
-
 
 class DecisionsProposeesUE(DecisionsProposees):
     """Décisions de jury sur une UE du BUT
@@ -1437,6 +1353,10 @@ class DecisionsProposeesUE(DecisionsProposees):
     sinon: ADJ, AJ
 
     et proposer toujours: RAT, DEF, ABAN, ADJR, DEM, UEBSL (codes_communs)
+
+
+    Le DecisionsProposeesUE peut concerner une UE du formsemestre, ou une validation
+    antérieure non éditable.
     """
 
     # Codes toujours proposés sauf si include_communs est faux:
@@ -1454,51 +1374,59 @@ class DecisionsProposeesUE(DecisionsProposees):
         self,
         etud: Identite,
         formsemestre: FormSemestre,
-        ue: UniteEns,
+        rcue: RegroupementCoherentUE = None,
+        paire: bool = False,
         inscription_etat: str = scu.INSCRIT,
     ):
-        # Une UE peut être validée plusieurs fois en cas de redoublement (qu'elle soit capitalisée ou non)
+        self.paire = paire
+        self.ue: UniteEns = rcue.ue_2 if paire else rcue.ue_1
+        self.inscription_etat = inscription_etat
+        # Une UE peut être validée plusieurs fois en cas de redoublement
+        # (qu'elle soit capitalisée ou non)
         # mais ici on a restreint au formsemestre donc une seule (prend la première)
         validation = ScolarFormSemestreValidation.query.filter_by(
-            etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
+            etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=self.ue.id << XXX
         ).first()
         super().__init__(
             etud=etud,
             code_valide=validation.code if validation is not None else None,
         )
         self.validation = validation
+        "validation dans le formsemestre courant"
         self.formsemestre = formsemestre
-        self.ue: UniteEns = ue
-        self.rcue: RegroupementCoherentUE = None
+        self.rcue: RegroupementCoherentUE = rcue
         "Le rcue auquel est rattaché cette UE, ou None"
-        self.inscription_etat = inscription_etat
-        "inscription: I, DEM, DEF dans le semestre de cette UE"
+        # Editable ou pas ?
+        # si ue courante, éditable.
+        self.editable = (
+            (self.rcue.ue_cur_pair is not None)
+            if paire
+            else (self.rcue.ue_cur_impair is not None)
+        )
+        res: ResultatsSemestreBUT = (
+            self.rcue.res_pair if paire else self.rcue.res_impair
+        )
         self.moy_ue = np.NaN
         self.moy_ue_with_cap = np.NaN
         self.ue_status = {}
 
-        if ue.type == sco_codes.UE_SPORT:
-            self.explanation = "UE bonus, pas de décision de jury"
+        if self.ue.type != sco_codes.UE_STANDARD:
+            self.explanation = "UE non standard, pas de décision de jury BUT"
             self.codes = []  # aucun code proposé
             return
-        if inscription_etat != scu.INSCRIT:
+
+        if res and res.get_etud_etat(etud.id) != scu.INSCRIT:
             self.validation = None  # cache toute validation
-            self.explanation = "non incrit (dem. ou déf.)"
+            self.explanation = "non inscrit (dem. ou déf.)"
             self.codes = [
-                sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF
+                sco_codes.DEM
+                if res.get_etud_etat(etud.id) == scu.DEMISSION
+                else sco_codes.DEF
             ]
             return
 
         # Moyenne de l'UE ?
-        res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
-        # Safety checks:
-        if not ue.id in res.etud_moy_ue:
-            self.explanation = "UE sans résultat"
-            return
-        if not etud.id in res.etud_moy_ue[ue.id]:
-            self.explanation = "Étudiant sans résultat dans cette UE"
-            return
-        ue_status = res.get_etud_ue_status(etud.id, ue.id)
+        ue_status = self.rcue.ue_status_pair if paire else self.rcue.ue_status_impair
         self.moy_ue = ue_status["cur_moy_ue"]
         self.moy_ue_with_cap = ue_status["moy"]
         self.ue_status = ue_status
@@ -1508,11 +1436,6 @@ class DecisionsProposeesUE(DecisionsProposees):
         return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide
         } codes={self.codes} explanation={self.explanation}>"""
 
-    def set_rcue(self, rcue: RegroupementCoherentUE):
-        """Rattache cette UE à un RCUE. Cela peut modifier les codes
-        proposés par compute_codes() (si compensation)"""
-        self.rcue = rcue
-
     def compute_codes(self):
         """Calcul des .codes attribuables et de l'explanation associée"""
         if self.inscription_etat != scu.INSCRIT:
@@ -1607,91 +1530,91 @@ class DecisionsProposeesUE(DecisionsProposees):
         return 0.0
 
 
-class BUTCursusEtud:  # WIP TODO
-    """Validation du cursus d'un étudiant"""
-
-    def __init__(self, formsemestre: FormSemestre, etud: Identite):
-        if formsemestre.formation.referentiel_competence is None:
-            raise ScoNoReferentielCompetences(formation=formsemestre.formation)
-        assert len(etud.formsemestre_inscriptions) > 0
-        self.formsemestre = formsemestre
-        self.etud = etud
-        #
-        # La dernière inscription en date va donner le parcours (donc les compétences à valider)
-        self.last_inscription = sorted(
-            etud.formsemestre_inscriptions, key=attrgetter("formsemestre.date_debut")
-        )[-1]
-
-    def est_diplomable(self) -> bool:
-        """Vrai si toutes les compétences sont validables"""
-        return all(
-            self.competence_validable(competence)
-            for competence in self.competences_du_parcours()
-        )
-
-    def est_annee_validee(self, ordre: int) -> bool:
-        """Vrai si l'année BUT ordre est validée"""
-        # On cherche les validations d'annee avec le même
-        # code formation que nous.
-        return (
-            ApcValidationAnnee.query.filter_by(
-                etudid=self.etud.id,
-                ordre=ordre,
-            )
-            .join(Formation)
-            .filter(
-                Formation.formation_code == self.formsemestre.formation.formation_code
-            )
-            .count()
-            > 0
-        )
-
-    def est_diplome(self) -> bool:
-        """Vrai si BUT déjà validé"""
-        # vrai si la troisième année est validée
-        return self.est_annee_validee(3)
-
-    def competences_du_parcours(self) -> list[ApcCompetence]:
-        """Construit liste des compétences du parcours, qui doivent être
-        validées pour obtenir le diplôme.
-        Le parcours est celui de la dernière inscription.
-        """
-        parcour = self.last_inscription.parcour
-        query = self.formsemestre.formation.formation.query_competences_parcour(parcour)
-        if query is None:
-            return []
-        return query.all()
-
-    def competence_validee(self, competence: ApcCompetence) -> bool:
-        """Vrai si la compétence est validée, c'est à dire que tous ses
-        niveaux sont validés (ApcValidationRCUE).
-        """
-        # XXX A REVOIR
-        validations = (
-            ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
-            .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
-            .join(ApcNiveau, ApcNiveau.id == UniteEns.niveau_competence_id)
-            .join(ApcCompetence, ApcCompetence.id == ApcNiveau.competence_id)
-        )
-
-    def competence_validable(self, competence: ApcCompetence):
-        """Vrai si la compétence est "validable" automatiquement, c'est à dire
-        que les conditions de notes sont satisfaites pour l'acquisition de
-        son niveau le plus élevé, qu'il ne manque que l'enregistrement de la décision.
-
-        En vertu de la règle "La validation des deux UE du niveau d’une compétence
-        emporte la validation de l'ensemble des UE du niveau inférieur de cette
-        même compétence.",
-        il suffit de considérer le dernier niveau dans lequel l'étudiant est inscrit.
-        """
-        pass
-
-    def ues_emportees(self, niveau: ApcNiveau) -> list[tuple[FormSemestre, UniteEns]]:
-        """La liste des UE à valider si on valide ce niveau.
-        Ne liste que les UE qui ne sont pas déjà acquises.
-
-        Selon la règle donnée par l'arrêté BUT:
-        * La validation des deux UE du niveau d’une compétence emporte la validation de
-        l'ensemble des UE du niveau inférieur de cette même compétence.
-        """
-        pass
+# class BUTCursusEtud:  # WIP TODO
+#     """Validation du cursus d'un étudiant"""
+
+#     def __init__(self, formsemestre: FormSemestre, etud: Identite):
+#         if formsemestre.formation.referentiel_competence is None:
+#             raise ScoNoReferentielCompetences(formation=formsemestre.formation)
+#         assert len(etud.formsemestre_inscriptions) > 0
+#         self.formsemestre = formsemestre
+#         self.etud = etud
+#         #
+#         # La dernière inscription en date va donner le parcours (donc les compétences à valider)
+#         self.last_inscription = sorted(
+#             etud.formsemestre_inscriptions, key=attrgetter("formsemestre.date_debut")
+#         )[-1]
+
+#     def est_diplomable(self) -> bool:
+#         """Vrai si toutes les compétences sont validables"""
+#         return all(
+#             self.competence_validable(competence)
+#             for competence in self.competences_du_parcours()
+#         )
+
+#     def est_annee_validee(self, ordre: int) -> bool:
+#         """Vrai si l'année BUT ordre est validée"""
+#         # On cherche les validations d'annee avec le même
+#         # code formation que nous.
+#         return (
+#             ApcValidationAnnee.query.filter_by(
+#                 etudid=self.etud.id,
+#                 ordre=ordre,
+#             )
+#             .join(Formation)
+#             .filter(
+#                 Formation.formation_code == self.formsemestre.formation.formation_code
+#             )
+#             .count()
+#             > 0
+#         )
+
+#     def est_diplome(self) -> bool:
+#         """Vrai si BUT déjà validé"""
+#         # vrai si la troisième année est validée
+#         return self.est_annee_validee(3)
+
+#     def competences_du_parcours(self) -> list[ApcCompetence]:
+#         """Construit liste des compétences du parcours, qui doivent être
+#         validées pour obtenir le diplôme.
+#         Le parcours est celui de la dernière inscription.
+#         """
+#         parcour = self.last_inscription.parcour
+#         query = self.formsemestre.formation.formation.query_competences_parcour(parcour)
+#         if query is None:
+#             return []
+#         return query.all()
+
+#     def competence_validee(self, competence: ApcCompetence) -> bool:
+#         """Vrai si la compétence est validée, c'est à dire que tous ses
+#         niveaux sont validés (ApcValidationRCUE).
+#         """
+#         # XXX A REVOIR
+#         validations = (
+#             ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
+#             .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
+#             .join(ApcNiveau, ApcNiveau.id == UniteEns.niveau_competence_id)
+#             .join(ApcCompetence, ApcCompetence.id == ApcNiveau.competence_id)
+#         )
+
+#     def competence_validable(self, competence: ApcCompetence):
+#         """Vrai si la compétence est "validable" automatiquement, c'est à dire
+#         que les conditions de notes sont satisfaites pour l'acquisition de
+#         son niveau le plus élevé, qu'il ne manque que l'enregistrement de la décision.
+
+#         En vertu de la règle "La validation des deux UE du niveau d'une compétence
+#         emporte la validation de l'ensemble des UE du niveau inférieur de cette
+#         même compétence.",
+#         il suffit de considérer le dernier niveau dans lequel l'étudiant est inscrit.
+#         """
+#         pass
+
+#     def ues_emportees(self, niveau: ApcNiveau) -> list[tuple[FormSemestre, UniteEns]]:
+#         """La liste des UE à valider si on valide ce niveau.
+#         Ne liste que les UE qui ne sont pas déjà acquises.
+
+#         Selon la règle donnée par l'arrêté BUT:
+#         * La validation des deux UE du niveau d'une compétence emporte la validation de
+#         l'ensemble des UE du niveau inférieur de cette même compétence.
+#         """
+#         pass
diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py
index e3119b276467c4f7e2ddcd4437089291b4103bb6..29630af045c4ff719c5bc4d52fb2a12167009f0c 100644
--- a/app/but/jury_but_pv.py
+++ b/app/but/jury_but_pv.py
@@ -153,7 +153,7 @@ def pvjury_table_but(
                 etudid=etud.id,
             ),
             "cursus": _descr_cursus_but(etud),
-            "ects": f"{deca.formsemestre_ects():g}",
+            "ects": f"{deca.ects_annee():g}",
             "ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
             "niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
             if deca
diff --git a/app/but/jury_but_results.py b/app/but/jury_but_results.py
index 00aa649ad819fdd9022138fad29a42fed1f001fe..089b2e772a040477fcb3b0b1963a19ed6d02f02f 100644
--- a/app/but/jury_but_results.py
+++ b/app/but/jury_but_results.py
@@ -48,9 +48,9 @@ def _get_jury_but_etud_result(
     # --- Les RCUEs
     rcue_list = []
     if deca:
-        for rcue in deca.rcues_annee:
-            dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
-            if dec_rcue is not None:  # None si l'UE n'est pas associée à un niveau
+        for dec_rcue in deca.get_decisions_rcues_annee():
+            rcue = dec_rcue.rcue
+            if rcue.complete:  # n'exporte que les RCUEs complets
                 dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
                 dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
                 rcue_dict = {
diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py
index 4af88cae5738e0e384168915bffe7774268d419f..cfb778152b2794f6e8ec94684f6d46ab1ec18370 100644
--- a/app/but/jury_but_view.py
+++ b/app/but/jury_but_view.py
@@ -93,35 +93,25 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
     <div class="titre">RCUE</div>
     """
     )
-    for niveau in deca.niveaux_competences:
+    for dec_rcue in deca.get_decisions_rcues_annee():
+        rcue = dec_rcue.rcue
+        niveau = rcue.niveau
         H.append(
             f"""<div class="but_niveau_titre">
             <div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
             </div>"""
         )
-        dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id)  # peut être None
-        ues = [
-            ue
-            for ue in deca.ues_impair
-            if ue.niveau_competence and ue.niveau_competence.id == niveau.id
-        ]
-        ue_impair = ues[0] if ues else None
-        ues = [
-            ue
-            for ue in deca.ues_pair
-            if ue.niveau_competence and ue.niveau_competence.id == niveau.id
-        ]
-        ue_pair = ues[0] if ues else None
+        ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
         # Les UEs à afficher,
-        #  qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
+        #  qui
         ues_ro = [
             (
                 ue_impair,
-                (deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id),
+                rcue.ue_cur_impair is None,
             ),
             (
                 ue_pair,
-                deca.a_cheval and deca.formsemestre_id != deca.formsemestre_pair.id,
+                rcue.ue_cur_pair is None,
             ),
         ]
         # Ordonne selon les dates des 2 semestres considérés:
@@ -155,12 +145,13 @@ def _gen_but_select(
     code_valide: str,
     disabled: bool = False,
     klass: str = "",
-    data: dict = {},
+    data: dict = None,
     code_valide_label: str = "",
 ) -> str:
     "Le menu html select avec les codes"
     # if disabled:  # mauvaise idée car le disabled est traité en JS
     #    return f"""<div class="but_code {klass}">{code_valide}</div>"""
+    data = data or {}
     options_htm = "\n".join(
         [
             f"""<option value="{code}"
@@ -220,8 +211,14 @@ def _gen_but_niveau_ue(
         else:
             scoplement = ""
 
-    return f"""<div class="but_niveau_ue {
-        'recorded' if dec_ue.code_valide is not None else ''}
+    ue_class = ""  # 'recorded' if dec_ue.code_valide is not None else ''
+    if dec_ue.code_valide is not None and dec_ue.codes:
+        if dec_ue.code_valide == dec_ue.codes[0]:
+            ue_class = "recorded"
+        else:
+            ue_class = "recorded_different"
+
+    return f"""<div class="but_niveau_ue {ue_class}
         {'annee_prec' if annee_prec else ''}
         ">
     <div title="{ue.titre}">{ue.acronyme}</div>
@@ -242,7 +239,7 @@ def _gen_but_niveau_ue(
 
 
 def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
-    if dec_rcue is None:
+    if dec_rcue is None or not dec_rcue.rcue.complete:
         return """
         <div class="but_niveau_rcue niveau_vide with_scoplement">
                 <div></div>
diff --git a/app/models/but_validations.py b/app/models/but_validations.py
index 4054c9f43b751baadb73cb7192a19366594b7c34..00dc61952fa7f698df72d343f76414876aeccc4d 100644
--- a/app/models/but_validations.py
+++ b/app/models/but_validations.py
@@ -4,7 +4,6 @@
 """
 from typing import Union
 
-from flask_sqlalchemy.query import Query
 
 from app import db
 from app.models import CODE_STR_LEN
@@ -13,8 +12,6 @@ from app.models.etudiants import Identite
 from app.models.formations import Formation
 from app.models.formsemestre import FormSemestre
 from app.models.ues import UniteEns
-from app.scodoc import codes_cursus as sco_codes
-from app.scodoc import sco_utils as scu
 
 
 class ApcValidationRCUE(db.Model):
@@ -22,7 +19,7 @@ class ApcValidationRCUE(db.Model):
 
     aka "regroupements cohérents d'UE" dans le jargon BUT.
 
-    Le formsemestre est celui du semestre PAIR du niveau de compétence
+    Le formsemestre est l'origine, utilisé pour effacer
     """
 
     __tablename__ = "apc_validation_rcue"
@@ -109,139 +106,6 @@ class ApcValidationRCUE(db.Model):
         }
 
 
-# Attention: ce n'est pas un modèle mais une classe ordinaire:
-class RegroupementCoherentUE:
-    """Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
-    de la même année (BUT1,2,3) liées au *même niveau de compétence*.
-
-    La moyenne (10/20) au RCUE déclenche la compensation des UE.
-    """
-
-    def __init__(
-        self,
-        etud: Identite,
-        formsemestre_1: FormSemestre,
-        dec_ue_1: "DecisionsProposeesUE",
-        formsemestre_2: FormSemestre,
-        dec_ue_2: "DecisionsProposeesUE",
-        inscription_etat: str,
-    ):
-        ue_1 = dec_ue_1.ue
-        ue_2 = dec_ue_2.ue
-        # Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
-        if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
-            (ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
-                (ue_2, formsemestre_2),
-                (ue_1, formsemestre_1),
-            )
-        assert formsemestre_1.semestre_id % 2 == 1
-        assert formsemestre_2.semestre_id % 2 == 0
-        assert abs(formsemestre_1.semestre_id - formsemestre_2.semestre_id) == 1
-        assert ue_1.niveau_competence_id == ue_2.niveau_competence_id
-        self.etud = etud
-        self.formsemestre_1 = formsemestre_1
-        "semestre impair"
-        self.ue_1 = ue_1
-        self.formsemestre_2 = formsemestre_2
-        "semestre pair"
-        self.ue_2 = ue_2
-        # Stocke les moyennes d'UE
-        if inscription_etat != scu.INSCRIT:
-            self.moy_rcue = None
-            self.moy_ue_1 = self.moy_ue_2 = "-"
-            self.moy_ue_1_val = self.moy_ue_2_val = 0.0
-            return
-        self.moy_ue_1 = dec_ue_1.moy_ue_with_cap
-        self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
-        self.moy_ue_2 = dec_ue_2.moy_ue_with_cap
-        self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
-
-        # Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées)
-        if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
-            # Moyenne RCUE (les pondérations par défaut sont 1.)
-            self.moy_rcue = (
-                self.moy_ue_1 * ue_1.coef_rcue + self.moy_ue_2 * ue_2.coef_rcue
-            ) / (ue_1.coef_rcue + ue_2.coef_rcue)
-        else:
-            self.moy_rcue = None
-
-    def __repr__(self) -> str:
-        return f"""<{self.__class__.__name__} {
-            self.ue_1.acronyme}({self.moy_ue_1}) {
-            self.ue_2.acronyme}({self.moy_ue_2})>"""
-
-    def __str__(self) -> str:
-        return f"""RCUE {
-            self.ue_1.acronyme}({self.moy_ue_1}) + {
-            self.ue_2.acronyme}({self.moy_ue_2})"""
-
-    def query_validations(
-        self,
-    ) -> Query:  # list[ApcValidationRCUE]
-        """Les validations de jury enregistrées pour ce RCUE"""
-        niveau = self.ue_2.niveau_competence
-
-        return (
-            ApcValidationRCUE.query.filter_by(
-                etudid=self.etud.id,
-            )
-            .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
-            .join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
-            .filter(ApcNiveau.id == niveau.id)
-        )
-
-    def other_ue(self, ue: UniteEns) -> UniteEns:
-        """L'autre UE du regroupement. Si ue ne fait pas partie du regroupement, ValueError"""
-        if ue.id == self.ue_1.id:
-            return self.ue_2
-        elif ue.id == self.ue_2.id:
-            return self.ue_1
-        raise ValueError(f"ue {ue} hors RCUE {self}")
-
-    def est_enregistre(self) -> bool:
-        """Vrai si ce RCUE, donc le niveau de compétences correspondant
-        a une décision jury enregistrée
-        """
-        return self.query_validations().count() > 0
-
-    def est_compensable(self):
-        """Vrai si ce RCUE est validable (uniquement) par compensation
-        c'est à dire que sa moyenne est > 10 avec une UE < 10.
-        Note: si ADM, est_compensable est faux.
-        """
-        return (
-            (self.moy_rcue is not None)
-            and (self.moy_rcue > sco_codes.BUT_BARRE_RCUE)
-            and (
-                (self.moy_ue_1_val < sco_codes.NOTES_BARRE_GEN)
-                or (self.moy_ue_2_val < sco_codes.NOTES_BARRE_GEN)
-            )
-        )
-
-    def est_suffisant(self) -> bool:
-        """Vrai si ce RCUE est > 8"""
-        return (self.moy_rcue is not None) and (
-            self.moy_rcue > sco_codes.BUT_RCUE_SUFFISANT
-        )
-
-    def est_validable(self) -> bool:
-        """Vrai si ce RCUE satisfait les conditions pour être validé,
-        c'est à dire que la moyenne des UE qui le constituent soit > 10
-        """
-        return (self.moy_rcue is not None) and (
-            self.moy_rcue > sco_codes.BUT_BARRE_RCUE
-        )
-
-    def code_valide(self) -> Union[ApcValidationRCUE, None]:
-        "Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None"
-        validation = self.query_validations().first()
-        if (validation is not None) and (
-            validation.code in sco_codes.CODES_RCUE_VALIDES
-        ):
-            return validation
-        return None
-
-
 # unused
 # def find_rcues(
 #     formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
@@ -319,7 +183,7 @@ class ApcValidationAnnee(db.Model):
     formsemestre_id = db.Column(
         db.Integer, db.ForeignKey("notes_formsemestre.id"), nullable=True
     )
-    "le semestre IMPAIR (le 1er) de l'année"
+    "le semestre origine, normalement l'IMPAIR (le 1er) de l'année"
     formation_id = db.Column(
         db.Integer,
         db.ForeignKey("notes_formations.id"),
diff --git a/app/models/events.py b/app/models/events.py
index d3e7709761262dc6db73f1465eec99862d3fed5b..de93a24c0f608ad1a6f642c1c1a3a220822298ad 100644
--- a/app/models/events.py
+++ b/app/models/events.py
@@ -265,11 +265,8 @@ class ScolarNews(db.Model):
 
         # Informations générales
         H.append(
-            f"""<div>
-        Pour être informé des évolutions de ScoDoc,
-        vous pouvez vous
-        <a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">
-        abonner à la liste de diffusion</a>.
+            f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
+        Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
         </div>
         """
         )
diff --git a/app/models/validations.py b/app/models/validations.py
index 10791758a0d68e525f7694e29537abdf7fea8f22..9e2cf5e27d58bc96e73d42800cc8c57cac261aaa 100644
--- a/app/models/validations.py
+++ b/app/models/validations.py
@@ -68,7 +68,7 @@ class ScolarFormSemestreValidation(db.Model):
         if self.ue_id:
             # Note: si l'objet vient d'être créé, ue_id peut exister mais pas ue !
             return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id
-                                        }: {self.code}"""
+                                        } ({self.ue_id}): {self.code}"""
         return f"""décision sur semestre {self.formsemestre.titre_mois()} du {
                 self.event_date.strftime("%d/%m/%Y")}"""
 
diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index 4a94f0bde8ff9877d287ee681ff6df46e03473fd..67410b5c145f9c923672a28f6f42669909a92e3b 100644
--- a/app/scodoc/sco_formsemestre_edit.py
+++ b/app/scodoc/sco_formsemestre_edit.py
@@ -793,7 +793,13 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
             {tf[1]}
             """
     elif tf[0] == -1:
-        return "<h4>annulation</h4>"
+        return redirect(
+            url_for(
+                "notes.formsemestre_status",
+                scodoc_dept=g.scodoc_dept,
+                formsemestre_id=formsemestre.id,
+            )
+        )
     else:
         if tf[2]["gestion_compensation_lst"]:
             tf[2]["gestion_compensation"] = True
diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py
index c163806eebc2d0fdbd110537db3e54649286cd15..17dd909f8fc46c50eb8f9876407b9fad2703ab0d 100644
--- a/app/scodoc/sco_utils.py
+++ b/app/scodoc/sco_utils.py
@@ -520,7 +520,7 @@ SCO_DEFAULT_SQL_USERS_CNX = "dbname=SCOUSERS port=%s" % SCO_DEFAULT_SQL_PORT
 # Valeurs utilisées pour affichage seulement, pas de requetes ni de mails envoyés:
 SCO_WEBSITE = "https://scodoc.org"
 SCO_USER_MANUAL = "https://scodoc.org/GuideUtilisateur"
-SCO_ANNONCES_WEBSITE = "https://listes.univ-paris13.fr/mailman/listinfo/scodoc-annonces"
+SCO_ANNONCES_WEBSITE = "https://scodoc.org/Contact"
 SCO_DEVEL_LIST = "scodoc-devel@listes.univ-paris13.fr"
 SCO_USERS_LIST = "notes@listes.univ-paris13.fr"
 SCO_LISTS_URL = "https://scodoc.org/Contact"
diff --git a/app/static/js/scodoc.js b/app/static/js/scodoc.js
index 44b0352be303b045ac583f206111a4874ae3f93b..b7de540872f8a4741d23f6eb01a5fefaa88e73f4 100644
--- a/app/static/js/scodoc.js
+++ b/app/static/js/scodoc.js
@@ -1,291 +1,316 @@
 // JS for all ScoDoc pages (using jQuery UI)
 
-
 $(function () {
-    // Autocomplete recherche etudiants par nom
-    $(".in-expnom").autocomplete(
-        {
-            delay: 300, // wait 300ms before suggestions
-            minLength: 2, // min nb of chars before suggest
-            position: { collision: 'flip' }, // automatic menu position up/down
-            source: SCO_URL + "/search_etud_by_name",
-            select: function (event, ui) {
-                $(".in-expnom").val(ui.item.value);
-                $("#form-chercheetud").submit();
-            }
-        });
+  // Autocomplete recherche etudiants par nom
+  $(".in-expnom").autocomplete({
+    delay: 300, // wait 300ms before suggestions
+    minLength: 2, // min nb of chars before suggest
+    position: { collision: "flip" }, // automatic menu position up/down
+    source: SCO_URL + "/search_etud_by_name",
+    select: function (event, ui) {
+      $(".in-expnom").val(ui.item.value);
+      $("#form-chercheetud").submit();
+    },
+  });
 
-    // Date picker
-    $(".datepicker").datepicker({
-        showOn: 'button',
-        buttonImage: '/ScoDoc/static/icons/calendar_img.png',
-        buttonImageOnly: true,
-        dateFormat: 'dd/mm/yy',
-        duration: 'fast',
-    });
-    $('.datepicker').datepicker('option', $.extend({ showMonthAfterYear: false },
-        $.datepicker.regional['fr']));
+  // Date picker
+  $(".datepicker").datepicker({
+    showOn: "button",
+    buttonImage: "/ScoDoc/static/icons/calendar_img.png",
+    buttonImageOnly: true,
+    dateFormat: "dd/mm/yy",
+    duration: "fast",
+  });
+  $(".datepicker").datepicker(
+    "option",
+    $.extend({ showMonthAfterYear: false }, $.datepicker.regional["fr"])
+  );
 
-    /* Barre menu */
-    var sco_menu_position = { my: "left top", at: "left bottom" };
-    $("#sco_menu").menu({
-        position: sco_menu_position,
-        blur: function () {
-            $(this).menu("option", "position", sco_menu_position);
-        },
-        focus: function (e, ui) {
-            if ($("#sco_menu").get(0) !== $(ui).get(0).item.parent().get(0)) {
-                $(this).menu("option", "position", { my: "left top", at: "right top" });
-            }
+  /* Barre menu */
+  var sco_menu_position = { my: "left top", at: "left bottom" };
+  $("#sco_menu")
+    .menu({
+      position: sco_menu_position,
+      blur: function () {
+        $(this).menu("option", "position", sco_menu_position);
+      },
+      focus: function (e, ui) {
+        if ($("#sco_menu").get(0) !== $(ui).get(0).item.parent().get(0)) {
+          $(this).menu("option", "position", {
+            my: "left top",
+            at: "right top",
+          });
         }
-    }).mouseleave(function (x, y) {
-        $("#sco_menu").menu('collapseAll');
+      },
+    })
+    .mouseleave(function (x, y) {
+      $("#sco_menu").menu("collapseAll");
     });
 
-    $("#sco_menu > li > a > span").switchClass("ui-icon-carat-1-e", "ui-icon-carat-1-s");
+  $("#sco_menu > li > a > span").switchClass(
+    "ui-icon-carat-1-e",
+    "ui-icon-carat-1-s"
+  );
 
-    /* Les menus isoles dropdown */
-    $(".sco_dropdown_menu").menu({
-        position: sco_menu_position
-    }).mouseleave(function (x, y) {
-        $(".sco_dropdown_menu").menu('collapseAll');
-    }
-    );
-    $(".sco_dropdown_menu > li > a > span").switchClass("ui-icon-carat-1-e", "ui-icon-carat-1-s");
+  /* Les menus isoles dropdown */
+  $(".sco_dropdown_menu")
+    .menu({
+      position: sco_menu_position,
+    })
+    .mouseleave(function (x, y) {
+      $(".sco_dropdown_menu").menu("collapseAll");
+    });
+  $(".sco_dropdown_menu > li > a > span").switchClass(
+    "ui-icon-carat-1-e",
+    "ui-icon-carat-1-s"
+  );
 
-    /* up-to-date status */
-    var update_div = document.getElementById("update_warning");
-    if (update_div) {
-        fetch('install_info').then(
-            response => response.text()
-        ).then(text => {
-            update_div.innerHTML = text;
-            if (text) {
-                update_div.style.display = "block";
-            }
-        });
-    }
+  /* up-to-date status */
+  var update_div = document.getElementById("update_warning");
+  if (update_div) {
+    fetch("install_info")
+      .then((response) => response.text())
+      .then((text) => {
+        update_div.innerHTML = text;
+        if (text) {
+          update_div.style.display = "block";
+        }
+      });
+  }
 });
 
 function sco_capitalize(string) {
-    return string[0].toUpperCase() + string.slice(1).toLowerCase();
+  return string[0].toUpperCase() + string.slice(1).toLowerCase();
 }
 
 // Affiche un message transitoire (duration milliseconds, 0 means infinity)
 function sco_message(msg, className = "message_custom", duration = 0) {
-    var div = document.createElement("div");
-    div.className = className;
-    div.innerHTML = msg;
-    document.querySelector("body").appendChild(div);
-    if (duration) {
-        setTimeout(() => {
-            div.remove();
-        }, 3000);
-    }
+  var div = document.createElement("div");
+  div.className = className;
+  div.innerHTML = msg;
+  document.querySelector("body").appendChild(div);
+  if (duration) {
+    setTimeout(() => {
+      div.remove();
+    }, 8000);
+  }
 }
 
 function sco_error_message(msg) {
-    sco_message(msg, className = "message_error", duration = 0);
+  sco_message(msg, (className = "message_error"), (duration = 0));
 }
 
-
 function get_query_args() {
-    var s = window.location.search; // eg "?x=1&y=2"
-    var vars = {};
-    s.replace(
-        /[?&]+([^=&]+)=?([^&]*)?/gi, // regexp
-        function (m, key, value) { // callback
-            vars[key] = value !== undefined ? value : '';
-        }
-    );
-    return vars;
+  var s = window.location.search; // eg "?x=1&y=2"
+  var vars = {};
+  s.replace(
+    /[?&]+([^=&]+)=?([^&]*)?/gi, // regexp
+    function (m, key, value) {
+      // callback
+      vars[key] = value !== undefined ? value : "";
+    }
+  );
+  return vars;
 }
 
-
 // Tables (gen_tables)
 $(function () {
-    if ($('table.gt_table').length > 0) {
-        var table_options = {
-            "paging": false,
-            "searching": false,
-            "info": false,
-            /* "autoWidth" : false, */
-            "fixedHeader": {
-                "header": true,
-                "footer": true
-            },
-            "orderCellsTop": true, // cellules ligne 1 pour tri 
-            "aaSorting": [], // Prevent initial sorting
-        };
-        $('table.gt_table').DataTable(table_options);
-        table_options["searching"] = true;
-        $('table.gt_table_searchable').DataTable(table_options);
-    }
+  if ($("table.gt_table").length > 0) {
+    var table_options = {
+      paging: false,
+      searching: false,
+      info: false,
+      /* "autoWidth" : false, */
+      fixedHeader: {
+        header: true,
+        footer: true,
+      },
+      orderCellsTop: true, // cellules ligne 1 pour tri
+      aaSorting: [], // Prevent initial sorting
+    };
+    $("table.gt_table").DataTable(table_options);
+    table_options["searching"] = true;
+    $("table.gt_table_searchable").DataTable(table_options);
+  }
 });
 
-
 // Show tags (readonly)
 function readOnlyTags(nodes) {
-    // nodes are textareas, hide them and create a span showing tags
-    for (var i = 0; i < nodes.length; i++) {
-        var node = $(nodes[i]);
-        node.hide();
-        var tags = nodes[i].value.split(',');
-        node.after('<span class="ro_tags"><span class="ro_tag">' + tags.join('</span><span class="ro_tag">') + '</span></span>');
-    }
+  // nodes are textareas, hide them and create a span showing tags
+  for (var i = 0; i < nodes.length; i++) {
+    var node = $(nodes[i]);
+    node.hide();
+    var tags = nodes[i].value.split(",");
+    node.after(
+      '<span class="ro_tags"><span class="ro_tag">' +
+        tags.join('</span><span class="ro_tag">') +
+        "</span></span>"
+    );
+  }
 }
 
 /* Editeur pour champs
  *   Usage: créer un élément avec data-oid (object id)
- *  La méthode d'URL save sera appelée en POST avec deux arguments: oid et value, 
+ *  La méthode d'URL save sera appelée en POST avec deux arguments: oid et value,
  *  value contenant la valeur du champs.
  * Inspiré par les codes et conseils de Seb. L.
  */
 class ScoFieldEditor {
-    constructor(selector, save_url, read_only) {
-        this.save_url = save_url;
-        this.read_only = read_only;
-        this.selector = selector;
-        this.installListeners();
+  constructor(selector, save_url, read_only) {
+    this.save_url = save_url;
+    this.read_only = read_only;
+    this.selector = selector;
+    this.installListeners();
+  }
+  // Enregistre l'élément obj
+  save(obj) {
+    var value = obj.innerText.trim();
+    if (value.length == 0) {
+      value = "";
     }
-    // Enregistre l'élément obj
-    save(obj) {
-        var value = obj.innerText.trim();
-        if (value.length == 0) {
-            value = "";
-        }
-        if (value == obj.dataset.value) {
-            return true;	// Aucune modification, pas d'enregistrement mais on continue normalement
-        }
-        obj.classList.add("sco_wait");
-        // DEBUG
-        // console.log(`
-        //     data : ${value},
-        //     id: ${obj.dataset.oid}
-        // `);
+    if (value == obj.dataset.value) {
+      return true; // Aucune modification, pas d'enregistrement mais on continue normalement
+    }
+    obj.classList.add("sco_wait");
+    // DEBUG
+    // console.log(`
+    //     data : ${value},
+    //     id: ${obj.dataset.oid}
+    // `);
 
-        $.post(this.save_url,
-            {
-                oid: obj.dataset.oid,
-                value: value,
-            },
-            function (result) {
-                obj.classList.remove("sco_wait");
-                obj.classList.add("sco_modified");
-            }
-        );
-        return true;
+    $.post(
+      this.save_url,
+      {
+        oid: obj.dataset.oid,
+        value: value,
+      },
+      function (result) {
+        obj.classList.remove("sco_wait");
+        obj.classList.add("sco_modified");
+      }
+    );
+    return true;
+  }
+  /*****************************/
+  /* Gestion des évènements    */
+  /*****************************/
+  installListeners() {
+    if (this.read_only) {
+      return;
     }
-    /*****************************/
-    /* Gestion des évènements    */
-    /*****************************/
-    installListeners() {
-        if (this.read_only) {
-            return;
-        }
-        document.body.addEventListener("keydown", this.key);
-        let editor = this;
-        this.handleSelectCell = (event) => { editor.selectCell(event) };
-        this.handleModifCell = (event) => { editor.modifCell(event) };
-        this.handleBlur = (event) => { editor.blurCell(event) };
-        this.handleKeyCell = (event) => { editor.keyCell(event) };
-        document.querySelectorAll(this.selector).forEach(cellule => {
-            cellule.addEventListener("click", this.handleSelectCell);
-            cellule.addEventListener("dblclick", this.handleModifCell);
-            cellule.addEventListener("blur", this.handleBlur);
-        });
+    document.body.addEventListener("keydown", this.key);
+    let editor = this;
+    this.handleSelectCell = (event) => {
+      editor.selectCell(event);
+    };
+    this.handleModifCell = (event) => {
+      editor.modifCell(event);
+    };
+    this.handleBlur = (event) => {
+      editor.blurCell(event);
+    };
+    this.handleKeyCell = (event) => {
+      editor.keyCell(event);
+    };
+    document.querySelectorAll(this.selector).forEach((cellule) => {
+      cellule.addEventListener("click", this.handleSelectCell);
+      cellule.addEventListener("dblclick", this.handleModifCell);
+      cellule.addEventListener("blur", this.handleBlur);
+    });
+  }
+  /*********************************/
+  /* Interaction avec les cellules */
+  /*********************************/
+  blurCell(event) {
+    let currentModif = document.querySelector(".sco_modifying");
+    if (currentModif) {
+      if (!this.save(currentModif)) {
+        return;
+      }
     }
-    /*********************************/
-    /* Interaction avec les cellules */
-    /*********************************/
-    blurCell(event) {
-        let currentModif = document.querySelector(".sco_modifying");
-        if (currentModif) {
-            if (!this.save(currentModif)) {
-                return;
-            }
+  }
+  selectCell(event) {
+    let obj = event.currentTarget;
+    if (obj) {
+      if (obj.classList.contains("sco_modifying")) {
+        return; // Cellule en cours de modification, ne pas sélectionner.
+      }
+      let currentModif = document.querySelector(".sco_modifying");
+      if (currentModif) {
+        if (!this.save(currentModif)) {
+          return;
         }
-    }
-    selectCell(event) {
-        let obj = event.currentTarget;
-        if (obj) {
-            if (obj.classList.contains("sco_modifying")) {
-                return; // Cellule en cours de modification, ne pas sélectionner.
-            }
-            let currentModif = document.querySelector(".sco_modifying");
-            if (currentModif) {
-                if (!this.save(currentModif)) {
-                    return;
-                }
-            }
+      }
 
-            this.unselectCell();
-            obj.classList.add("sco_selected");
-        }
+      this.unselectCell();
+      obj.classList.add("sco_selected");
     }
-    unselectCell() {
-        document.querySelectorAll(".sco_selected, .sco_modifying").forEach(cellule => {
-            cellule.classList.remove("sco_selected", "sco_modifying");
-            cellule.removeAttribute("contentEditable");
-            cellule.removeEventListener("keydown", this.handleKeyCell);
-        });
+  }
+  unselectCell() {
+    document
+      .querySelectorAll(".sco_selected, .sco_modifying")
+      .forEach((cellule) => {
+        cellule.classList.remove("sco_selected", "sco_modifying");
+        cellule.removeAttribute("contentEditable");
+        cellule.removeEventListener("keydown", this.handleKeyCell);
+      });
+  }
+  modifCell(event) {
+    let obj = event.currentTarget;
+    if (obj) {
+      obj.classList.add("sco_modifying");
+      obj.contentEditable = true;
+      obj.addEventListener("keydown", this.handleKeyCell);
+      obj.focus();
     }
-    modifCell(event) {
-        let obj = event.currentTarget;
-        if (obj) {
-            obj.classList.add("sco_modifying");
-            obj.contentEditable = true;
-            obj.addEventListener("keydown", this.handleKeyCell);
-            obj.focus();
-        }
-    }
-    key(event) {
-        switch (event.key) {
-            case "Enter":
-                this.modifCell(document.querySelector(".sco_selected"));
-                event.preventDefault();
-                break;
-        }
+  }
+  key(event) {
+    switch (event.key) {
+      case "Enter":
+        this.modifCell(document.querySelector(".sco_selected"));
+        event.preventDefault();
+        break;
     }
-    keyCell(event) {
-        let obj = event.currentTarget;
-        if (obj) {
-            if (event.key == "Enter") {
-                event.preventDefault();
-                event.stopPropagation();
-                if (!this.save(obj)) {
-                    return
-                }
-                obj.classList.remove("sco_modifying");
-                // ArrowMove(0, 1);
-                // modifCell(document.querySelector(".sco_selected"));
-                this.unselectCell();
-            }
+  }
+  keyCell(event) {
+    let obj = event.currentTarget;
+    if (obj) {
+      if (event.key == "Enter") {
+        event.preventDefault();
+        event.stopPropagation();
+        if (!this.save(obj)) {
+          return;
         }
+        obj.classList.remove("sco_modifying");
+        // ArrowMove(0, 1);
+        // modifCell(document.querySelector(".sco_selected"));
+        this.unselectCell();
+      }
     }
+  }
 }
 
 function getCurrentScriptPath() {
-    // Get all the script elements on the page
-    var scripts = document.getElementsByTagName('script');
+  // Get all the script elements on the page
+  var scripts = document.getElementsByTagName("script");
 
-    // Find the last script element (which is the currently executing script)
-    var currentScript = scripts[scripts.length - 1];
+  // Find the last script element (which is the currently executing script)
+  var currentScript = scripts[scripts.length - 1];
 
-    // Retrieve the src attribute of the script element
-    var scriptPath = currentScript.src;
+  // Retrieve the src attribute of the script element
+  var scriptPath = currentScript.src;
 
-    return scriptPath;
+  return scriptPath;
 }
 function removeLastTwoComponents(path) {
-    // Split the path into individual components
-    var components = path.split('/');
+  // Split the path into individual components
+  var components = path.split("/");
 
-    // Remove the last two components (filename and enclosing directory)
-    components.splice(-2);
+  // Remove the last two components (filename and enclosing directory)
+  components.splice(-2);
 
-    // Join the remaining components back into a path
-    var newPath = components.join('/');
+  // Join the remaining components back into a path
+  var newPath = components.join("/");
 
-    return newPath;
+  return newPath;
 }
diff --git a/app/tables/jury_recap.py b/app/tables/jury_recap.py
index 3e6f5d24da1b26a81dc908b2c54528c77e524808..aca2491399ea67dd192e3ee29073c0ebdb5d2ecd 100644
--- a/app/tables/jury_recap.py
+++ b/app/tables/jury_recap.py
@@ -74,9 +74,8 @@ class TableJury(TableRecap):
                 self.freq_codes_annuels[deca.code_valide] += 1
             row.add_nb_rcues_cell()
             # --- Les RCUEs
-            for rcue in deca.rcues_annee:
-                dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
-                if dec_rcue is not None:  # None si l'UE n'est pas associée à un niveau
+            for dec_rcue in deca.get_decisions_rcues_annee():
+                if dec_rcue.rcue.complete:
                     row.add_rcue_cols(dec_rcue)
         self.freq_codes_annuels["total"] = len(self.rows)
 
@@ -205,7 +204,7 @@ class RowJury(RowRecap):
         else:
             classes.append("moy_ue_valid")
 
-        if len(deca.rcues_annee) > 0:
+        if len(deca.get_decisions_rcues_annee()) > 0:
             # permet un tri par nb de niveaux validables + moyenne gen indicative S_pair
             if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:
                 moy = deca.res_pair.etud_moy_gen[deca.etud.id]
@@ -260,9 +259,11 @@ class RowJury(RowRecap):
 
     def add_rcue_cols(self, dec_rcue: DecisionsProposeesRCUE):
         "2 cells: moyenne du RCUE, code enregistré"
-        self.table.group_titles["rcue"] = "RCUEs en cours"
         rcue = dec_rcue.rcue
-        col_id = f"moy_rcue_{rcue.ue_1.niveau_competence_id}"  # le niveau_id
+        if not rcue.complete:
+            return
+        col_id = f"moy_rcue_{rcue.niveau.id}"  # le niveau_id
+        self.table.group_titles["rcue"] = "RCUEs en cours"
         note_class = ""
         val = rcue.moy_rcue
         if isinstance(val, float):
diff --git a/app/templates/but/documentation_codes_jury.j2 b/app/templates/but/documentation_codes_jury.j2
index 3ea9c80decaf7581d7567f56874d3d1a69bbc3f3..25d051e57becf2bea967297ebb59b328e2dcd859 100644
--- a/app/templates/but/documentation_codes_jury.j2
+++ b/app/templates/but/documentation_codes_jury.j2
@@ -130,6 +130,12 @@
                 <td class="amue">CODJ</td>
                 <td>Acquis par décision du jury</td>
             </tr>
+            <tr>
+                <td>ADSUP</td>
+                <td>{{codes["ADSUP"]}}</td>
+                <td class="amue"></td>
+                <td>Acquis parce que le niveau de compétence supérieur est acquis</td>
+            </tr>
             <tr>
                 <td>AJ</td>
                 <td>{{codes["AJ"]}}</td>
@@ -200,6 +206,12 @@
                 <td class="amue"></td>
                 <td>Acquis par décision de jury sur le RCUE (ECTS acquis)</td>
             </tr>
+            <tr>
+                <td>ADSUP</td>
+                <td>{{codes["ADSUP"]}}</td>
+                <td class="amue"></td>
+                <td>Acquis parce que le niveau de compétence supérieur est acquis</td>
+            </tr>
             <tr>
                 <td>AJ</td>
                 <td>{{codes["AJ"]}}</td>
diff --git a/app/templates/pn/ue_infos.j2 b/app/templates/pn/ue_infos.j2
index 071b1b8731e0c0f468e7645ca844291d8a1606d7..61ead8e01182579a70e8b3e9108e574b4f753848 100644
--- a/app/templates/pn/ue_infos.j2
+++ b/app/templates/pn/ue_infos.j2
@@ -13,6 +13,7 @@
             <ul>
                 <li>Semestre: {{ue.semestre_idx}}</li>
                 <li>Code: <tt>{{ue.ue_code}}</tt></li>
+                <li>ECTS: <b>{{ue.ects or 0}}</b></li>
                 <li>Type: {{ue.type}}</li>
                 <li>Externe: {{ "oui" if ue.is_external else "non" }}</li>
                 <li>Code Apogée: {{ue.code_apogee or "aucun"}}</li>
diff --git a/app/views/notes.py b/app/views/notes.py
index 3b1772982de5b56e833ad63d0ed3fce058aac10e..6483c510218b6bdde6807f111278b32dac5e2d03 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -2430,7 +2430,7 @@ def formsemestre_validation_but(
         )
 
     deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
-    if len(deca.rcues_annee) == 0:
+    if len(deca.get_decisions_rcues_annee()) == 0:
         return jury_but_view.jury_but_semestriel(
             formsemestre, etud, read_only, navigation_div=navigation_div
         )
@@ -2459,22 +2459,25 @@ def formsemestre_validation_but(
 
     warning = ""
     if len(deca.niveaux_competences) != len(deca.decisions_rcue_by_niveau):
-        if deca.a_cheval:
-            warning += """<div class="warning">Attention: regroupements RCUE
-                entre années (redoublement).</div>"""
-        else:
-            warning += f"""<div class="warning">Attention: {len(deca.niveaux_competences)}
+        warning += f"""<div class="warning">Attention: {len(deca.niveaux_competences)}
                 niveaux mais {len(deca.decisions_rcue_by_niveau)} regroupements RCUE.</div>"""
     if (deca.parcour is None) and len(formsemestre.parcours) > 0:
         warning += (
             """<div class="warning">L'étudiant n'est pas inscrit à un parcours.</div>"""
         )
-    if deca.formsemestre_impair and deca.inscription_etat_impair != scu.INSCRIT:
-        etat_ins = scu.ETATS_INSCRIPTION.get(deca.inscription_etat_impair, "inconnu?")
-        warning += f"""<div class="warning">{etat_ins} en S{deca.formsemestre_impair.semestre_id}</div>"""
-    if deca.formsemestre_pair and deca.inscription_etat_pair != scu.INSCRIT:
-        etat_ins = scu.ETATS_INSCRIPTION.get(deca.inscription_etat_pair, "inconnu?")
-        warning += f"""<div class="warning">{etat_ins} en S{deca.formsemestre_pair.semestre_id}</div>"""
+
+    if deca.formsemestre_impair:
+        inscription = deca.formsemestre_impair.etuds_inscriptions.get(etud.id)
+        if (not inscription) or inscription.etat != scu.INSCRIT:
+            etat_ins = scu.ETATS_INSCRIPTION.get(inscription.etat, "inconnu?")
+            warning += f"""<div class="warning">{etat_ins} en S{deca.formsemestre_impair.semestre_id}</div>"""
+
+    if deca.formsemestre_pair:
+        inscription = deca.formsemestre_pair.etuds_inscriptions.get(etud.id)
+        if (not inscription) or inscription.etat != scu.INSCRIT:
+            etat_ins = scu.ETATS_INSCRIPTION.get(inscription.etat, "inconnu?")
+            warning += f"""<div class="warning">{etat_ins} en S{deca.formsemestre_pair.semestre_id}</div>"""
+
     if deca.has_notes_en_attente():
         warning += f"""<div class="warning">{etud.nomprenom} a des notes en ATTente.
             Vous devriez régler cela avant de statuer en jury !</div>"""
@@ -2531,7 +2534,7 @@ def formsemestre_validation_but(
     else:
         erase_span = f"""<a href="{
             url_for("notes.formsemestre_jury_but_erase",
-            scodoc_dept=g.scodoc_dept, formsemestre_id=deca.formsemestre_id,
+            scodoc_dept=g.scodoc_dept, formsemestre_id=deca.formsemestre.id,
             etudid=deca.etud.id)}" class="stdlink"
             title="efface décisions issues des jurys de cette année"
             >effacer décisions de ce jury</a>
@@ -2564,15 +2567,7 @@ def formsemestre_validation_but(
         )
     H.append(navigation_div)
     H.append("</form>")
-    if deca.a_cheval:
-        H.append(
-            f"""<div class="but_doc_codes but_warning_rcue_cap">
-        {scu.EMO_WARNING} Rappel: pour les redoublants, seules les UE <b>capitalisées</b> (note > 10)
-        lors d'une année précédente peuvent être prise en compte pour former
-        un RCUE (associé à un niveau de compétence du BUT).
-        </div>
-        """
-        )
+
     # Affichage cursus BUT
     but_cursus = cursus_but.EtudCursusBUT(etud, deca.formsemestre.formation)
     H += [
@@ -2595,7 +2590,14 @@ def formsemestre_validation_but(
             codes=ScoDocSiteConfig.get_codes_apo_dict(),
         )
     )
-
+    H.append(
+        f"""<div class="but_doc_codes but_warning_rcue_cap">
+    {scu.EMO_WARNING} Rappel: pour les redoublants, seules les UE <b>capitalisées</b> (note > 10)
+    lors d'une année précédente peuvent être prise en compte pour former
+    un RCUE (associé à un niveau de compétence du BUT).
+    </div>
+    """
+    )
     return "\n".join(H) + html_sco_header.sco_footer()
 
 
diff --git a/sco_version.py b/sco_version.py
index 282f517e7f105f148f3d89b177fcdb04785748b5..aa68bec3df11ed603a081dd84c417c1654f74c6a 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,7 +1,7 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
-SCOVERSION = "9.4.93"
+SCOVERSION = "9.4.94"
 
 SCONAME = "ScoDoc"