diff --git a/app/api/jury.py b/app/api/jury.py
index 6103d11649a7130373c4a11ea207eea842aaa57e..6f0710770d3333343ddd434e7380a29b75cd69c9 100644
--- a/app/api/jury.py
+++ b/app/api/jury.py
@@ -114,16 +114,16 @@ def _validation_ue_delete(etudid: int, validation_id: int):
     # rattachées à un formsemestre)
     if not g.scodoc_dept:  # accès API
         if not current_user.has_permission(Permission.ScoEtudInscrit):
-            return json_error(403, "validation_delete: non autorise")
+            return json_error(403, "opération non autorisée (117)")
     else:
         if validation.formsemestre:
             if (
                 validation.formsemestre.dept_id != g.scodoc_dept_id
             ) or not validation.formsemestre.can_edit_jury():
-                return json_error(403, "validation_delete: non autorise")
+                return json_error(403, "opération non autorisée (123)")
         elif not current_user.has_permission(Permission.ScoEtudInscrit):
             # Validation non rattachée à un semestre: on doit être chef
-            return json_error(403, "validation_delete: non autorise")
+            return json_error(403, "opération non autorisée (126)")
 
     log(f"validation_ue_delete: etuid={etudid} {validation}")
     db.session.delete(validation)
diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py
index 5eda000dcaad1143da74a5cc7ca292ab88e36984..97a555ca60151439b9a1e93b9fb644fa508d4259 100644
--- a/app/but/cursus_but.py
+++ b/app/but/cursus_but.py
@@ -43,7 +43,7 @@ from app.models.formsemestre import FormSemestre, FormSemestreInscription
 from app.models.ues import UniteEns
 from app.models.validations import ScolarFormSemestreValidation
 from app.scodoc import codes_cursus as sco_codes
-from app.scodoc.codes_cursus import code_ue_validant, RED, UE_STANDARD
+from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES
 from app.scodoc import sco_utils as scu
 from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
 
@@ -102,7 +102,7 @@ class EtudCursusBUT:
         "Liste des inscriptions aux sem. de la formation, triées par indice et chronologie"
         self.parcour: ApcParcours = self.inscriptions[-1].parcour
         "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
-        self.niveaux_by_annee = {}
+        self.niveaux_by_annee: dict[int, list[ApcNiveau]] = {}
         "{ annee:int : liste des niveaux à valider }"
         self.niveaux: dict[int, ApcNiveau] = {}
         "cache les niveaux"
@@ -364,10 +364,33 @@ class FormSemestreCursusBUT:
         "cache { competence_id : competence }"
 
 
+def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
+    """Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué.
+    Ne prend que les UE associées à des niveaux de compétences,
+    et ne les compte qu'une fois même en cas de redoublement avec re-validation.
+    """
+    validations = (
+        ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
+        .filter(ScolarFormSemestreValidation.ue_id != None)
+        .join(UniteEns)
+        .join(ApcNiveau)
+        .join(ApcCompetence)
+        .filter_by(referentiel_id=referentiel_competence_id)
+    )
+
+    ects_dict = {}
+    for v in validations:
+        key = (v.ue.semestre_idx, v.ue.niveau_competence.id)
+        if v.code in CODES_UE_VALIDES:
+            ects_dict[key] = v.ue.ects
+
+    return sum(ects_dict.values()) if ects_dict else 0.0
+
+
 def etud_ues_de_but1_non_validees(
     etud: Identite, formation: Formation, parcour: ApcParcours
 ) -> list[UniteEns]:
-    """Vrai si cet étudiant a validé toutes ses UEs de S1 et S2, dans son parcours"""
+    """Liste des UEs de S1 et S2 non validées, dans son parcours"""
     # Les UEs avec décisions, dans les S1 ou S2 d'une formation de même code:
     validations = (
         ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
@@ -377,9 +400,9 @@ def etud_ues_de_but1_non_validees(
         .join(Formation)
         .filter_by(formation_code=formation.formation_code)
     )
-    codes_validations_by_ue = collections.defaultdict(list)
+    codes_validations_by_ue_code = collections.defaultdict(list)
     for v in validations:
-        codes_validations_by_ue[v.ue_id].append(v.code)
+        codes_validations_by_ue_code[v.ue.ue_code].append(v.code)
 
     # Les UEs du parcours en S1 et S2:
     ues = formation.query_ues_parcour(parcour).filter(
@@ -390,8 +413,11 @@ def etud_ues_de_but1_non_validees(
         [
             ue
             for ue in ues
-            if any(
-                (not code_ue_validant(code) for code in codes_validations_by_ue[ue.id])
+            if not any(
+                (
+                    code_ue_validant(code)
+                    for code in codes_validations_by_ue_code[ue.ue_code]
+                )
             )
         ],
         key=attrgetter("numero", "acronyme"),
diff --git a/app/but/jury_but.py b/app/but/jury_but.py
index 97ab95acadac558476041c941bce155537c2bf7b..1ec6eeeafe604e1da4b6ea42e980d23a0d0f6c31 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -284,15 +284,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
         # ---- 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,
-            )
-            .join(Formation)
-            .filter_by(formation_code=self.formsemestre.formation.formation_code)
-            .first()
-        )
+        self.validation = ApcValidationAnnee.query.filter_by(
+            etudid=self.etud.id,
+            ordre=self.annee_but,
+            referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
+        ).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"
@@ -346,21 +342,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
         # 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, self.formsemestre.formation, self.parcour
-                )
-                self.passage_de_droit = not ues_but1_non_validees
-                explanation += (
-                    f"""UEs de BUT1 non validées: <b>{
-                    ', '.join(ue.acronyme for ue in ues_but1_non_validees)
-                    }</b>. """
-                    if ues_but1_non_validees
-                    else ""
-                )
-            else:
+            if not inscription or inscription.etat != scu.INSCRIT:
                 # pas inscrit dans le semestre courant ???
                 self.passage_de_droit = False
+            else:
+                self.passage_de_droit, explanation = self.passage_de_droit_en_but3()
 
         # Enfin calcule les codes des UEs:
         for dec_ue in self.decisions_ues.values():
@@ -427,6 +413,53 @@ class DecisionsProposeesAnnee(DecisionsProposees):
             )
         self.codes = [self.codes[0]] + sorted(self.codes[1:])
 
+    def passage_de_droit_en_but3(self) -> tuple[bool, str]:
+        """Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites"""
+        cursus: EtudCursusBUT = EtudCursusBUT(self.etud, self.formsemestre.formation)
+        niveaux_but1 = cursus.niveaux_by_annee[1]
+
+        niveaux_but1_non_valides = []
+        for niveau in niveaux_but1:
+            ok = False
+            validation_par_annee = cursus.validation_par_competence_et_annee.get(
+                niveau.competence_id
+            )
+            if validation_par_annee:
+                validation_niveau = validation_par_annee.get("BUT1")
+                if validation_niveau and validation_niveau.code in CODES_RCUE_VALIDES:
+                    ok = True
+            if not ok:
+                niveaux_but1_non_valides.append(niveau)
+
+        # Les niveaux de BUT1 manquants passent-ils en ADSUP ?
+        # en vertu de l'article 4.3,
+        # "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."
+        explanation = ""
+        ok = True
+        for niveau_but1 in niveaux_but1_non_valides:
+            niveau_but2 = niveau_but1.competence.niveaux.filter_by(annee="BUT2").first()
+            if niveau_but2:
+                rcue = self.rcue_by_niveau.get(niveau_but2.id)
+                if (rcue is None) or (
+                    not rcue.est_validable() and not rcue.code_valide()
+                ):
+                    # le RCUE de BUT2 n'est ni validable (avec les notes en cours) ni déjà validé
+                    ok = False
+                    explanation += (
+                        f"Compétence {niveau_but1} de BUT 1 non validée.<br> "
+                    )
+                else:
+                    explanation += (
+                        f"Compétence {niveau_but1} de BUT 1 validée par ce BUT2.<br> "
+                    )
+            else:
+                ok = False
+                explanation += f"""Compétence {
+                    niveau_but1} de BUT 1 non validée et non existante en BUT2.<br> """
+
+        return ok, explanation
+
     # WIP TODO XXX def get_moyenne_annuelle(self)
 
     def infos(self) -> str:
@@ -689,7 +722,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
                     self.validation = ApcValidationAnnee(
                         etudid=self.etud.id,
                         formsemestre=self.formsemestre_impair,
-                        formation_id=self.formsemestre.formation_id,
+                        referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
                         ordre=self.annee_but,
                         annee_scolaire=self.annee_scolaire(),
                         code=code,
@@ -852,13 +885,10 @@ class DecisionsProposeesAnnee(DecisionsProposees):
 
         # Efface les validations concernant l'année BUT
         # de ce semestre
-        validations = (
-            ApcValidationAnnee.query.filter_by(
-                etudid=self.etud.id,
-                ordre=self.annee_but,
-            )
-            .join(Formation)
-            .filter_by(formation_code=self.formsemestre.formation.formation_code)
+        validations = ApcValidationAnnee.query.filter_by(
+            etudid=self.etud.id,
+            ordre=self.annee_but,
+            referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
         )
         for validation in validations:
             db.session.delete(validation)
@@ -935,9 +965,17 @@ class DecisionsProposeesAnnee(DecisionsProposees):
                         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 !"
-                                )
+                                if (
+                                    dec_ue.ue_status
+                                    and dec_ue.ue_status["was_capitalized"]
+                                ):
+                                    messages.append(
+                                        f"Information: l'UE {ue.acronyme} capitalisée est utilisée pour un RCUE cette année"
+                                    )
+                                else:
+                                    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'a pas décision (???)"
@@ -1207,6 +1245,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
                     ue1_id=ue1.id,
                     ue2_id=ue2.id,
                     code=sco_codes.ADSUP,
+                    formsemestre_id=self.deca.formsemestre.id,  # origine
                 )
                 db.session.add(validation_rcue)
                 db.session.commit()
@@ -1233,13 +1272,16 @@ class DecisionsProposeesRCUE(DecisionsProposees):
         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)
+        semestre_id : l'indice du semestre concerné (le pair ou l'impair du niveau courant)
         """
-        # Les validations d'UE impaires existantes pour ce niveau inférieur ?
+        semestre_id_inferieur = semestre_id - 2
+        if semestre_id_inferieur < 1:
+            return
+        # Les validations d'UE 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)
+            .filter_by(semestre_idx=semestre_id_inferieur)
             .join(ApcNiveau)
             .filter_by(ordre=ordre_inferieur)
             .join(ApcCompetence)
@@ -1254,13 +1296,14 @@ class DecisionsProposeesRCUE(DecisionsProposees):
             # 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)
+            ue = self._get_ue_inferieure(
+                semestre_id_inferieur, 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}
+                    f"""Impossible de valider l'UE inférieure de la compétence {
+                        competence.titre} (niveau {ordre_inferieur})
                     car elle n'existe pas dans la formation
                     """,
                     "warning",
@@ -1287,15 +1330,11 @@ class DecisionsProposeesRCUE(DecisionsProposees):
         if annee_inferieure < 1:
             return
         # Garde-fou: Année déjà validée ?
-        validations_annee: ApcValidationAnnee = (
-            ApcValidationAnnee.query.filter_by(
-                etudid=self.etud.id,
-                ordre=annee_inferieure,
-            )
-            .join(Formation)
-            .filter_by(formation_code=self.deca.formsemestre.formation.formation_code)
-            .all()
-        )
+        validations_annee: ApcValidationAnnee = ApcValidationAnnee.query.filter_by(
+            etudid=self.etud.id,
+            ordre=annee_inferieure,
+            referentiel_competence_id=self.deca.formsemestre.formation.referentiel_competence_id,
+        ).all()
         if len(validations_annee) > 1:
             log(
                 f"warning: {len(validations_annee)} validations d'année\n{validations_annee}"
@@ -1332,8 +1371,8 @@ class DecisionsProposeesRCUE(DecisionsProposees):
             validation_annee = ApcValidationAnnee(
                 etudid=self.etud.id,
                 ordre=annee_inferieure,
+                referentiel_competence_id=self.deca.formsemestre.formation.referentiel_competence_id,
                 code=sco_codes.ADSUP,
-                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(),
             )
@@ -1575,16 +1614,11 @@ class DecisionsProposeesUE(DecisionsProposees):
 
 #     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
+#                 referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id
 #             )
 #             .count()
 #             > 0
diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py
index 29630af045c4ff719c5bc4d52fb2a12167009f0c..d0933c7a6c3b9817462264136d3fe10754dbcf88 100644
--- a/app/but/jury_but_pv.py
+++ b/app/but/jury_but_pv.py
@@ -12,6 +12,7 @@ from openpyxl.styles import Font, Border, Side, Alignment, PatternFill
 
 from app import log
 from app.but import jury_but
+from app.but.cursus_but import but_ects_valides
 from app.models.etudiants import Identite
 from app.models.formsemestre import FormSemestre
 from app.scodoc.gen_tables import GenTable
@@ -109,6 +110,11 @@ def pvjury_table_but(
     """
     # remplace pour le BUT la fonction sco_pv_forms.pvjury_table
     annee_but = (formsemestre.semestre_id + 1) // 2
+    referentiel_competence_id = formsemestre.formation.referentiel_competence_id
+    if referentiel_competence_id is None:
+        raise ScoValueError(
+            "pas de référentiel de compétences associé à la formation de ce semestre !"
+        )
     titles = {
         "nom": "Code" if anonymous else "Nom",
         "cursus": "Cursus",
@@ -153,7 +159,7 @@ def pvjury_table_but(
                 etudid=etud.id,
             ),
             "cursus": _descr_cursus_but(etud),
-            "ects": f"{deca.ects_annee():g}",
+            "ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):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/comp/bonus_spo.py b/app/comp/bonus_spo.py
index c9c9348bda2315f4c534716f8327650de7e8bd77..d3e8db03e940afda0b1e0f0a0f4bf208e0d0a80b 100644
--- a/app/comp/bonus_spo.py
+++ b/app/comp/bonus_spo.py
@@ -194,7 +194,7 @@ class BonusSportAdditif(BonusSport):
     # les points au dessus du seuil sont comptés (defaut: seuil_moy_gen):
     seuil_comptage = None
     proportion_point = 0.05  # multiplie les points au dessus du seuil
-    bonux_max = 20.0  # le bonus ne peut dépasser 20 points
+    bonus_max = 20.0  # le bonus ne peut dépasser 20 points
     bonus_min = 0.0  # et ne peut pas être négatif
 
     def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
@@ -435,8 +435,11 @@ class BonusAmiens(BonusSportAdditif):
 class BonusBesanconVesoul(BonusSportAdditif):
     """Bonus IUT Besançon - Vesoul pour les UE libres
 
-    <p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,2 point
-    sur toutes les moyennes d'UE.
+    <p>Le bonus est compris entre 0 et 0,2 points.
+    et est reporté sur les moyennes d'UE.
+    </p>
+    <p>La valeur saisie doit être entre 0 et 0,2: toute valeur
+    supérieure à 0,2 entraine un bonus de 0,2.
     </p>
     """
 
@@ -444,7 +447,7 @@ class BonusBesanconVesoul(BonusSportAdditif):
     displayed_name = "IUT de Besançon - Vesoul"
     classic_use_bonus_ues = True  # s'applique aux UEs en DUT et LP
     seuil_moy_gen = 0.0  # tous les points sont comptés
-    proportion_point = 1e10  # infini
+    proportion_point = 1
     bonus_max = 0.2
 
 
@@ -1057,6 +1060,36 @@ class BonusLyon(BonusSportAdditif):
         )
 
 
+class BonusLyon3(BonusSportAdditif):
+    """IUT de Lyon 3 (septembre 2022)
+
+    <p>Nous avons deux types de bonifications : sport et/ou culture
+    </p>
+    <p>
+    Pour chaque point au-dessus de 10 obtenu en sport ou en culture nous
+    ajoutons 0,03 points à toutes les moyennes d’UE du semestre. Exemple : 16 en
+    sport ajoute 6*0,03 = 0,18 points à toutes les moyennes d’UE du semestre.
+    </p>
+    <p>
+    Les bonifications sport et culture peuvent se cumuler dans la limite de 0,3
+    points ajoutés aux moyennes des UE. Exemple : 17 en sport et 16 en culture
+    conduisent au calcul (7 + 6)*0,03 = 0,39 qui dépasse 0,3. La bonification
+    dans ce cas ne sera que de 0,3 points ajoutés à toutes les moyennes d’UE du
+    semestre.
+    </p>
+    <p>
+    Dans Scodoc on déclarera une UE Sport&Culture dans laquelle on aura un
+    module pour le Sport et un autre pour la Culture avec pour chaque module la
+    note sur 20 obtenue en sport ou en culture par l’étudiant.
+    </p>
+    """
+
+    name = "bonus_lyon3"
+    displayed_name = "IUT de Lyon 3"
+    proportion_point = 0.03
+    bonus_max = 0.3
+
+
 class BonusMantes(BonusSportAdditif):
     """Calcul bonus modules optionnels (investissement, ...), IUT de Mantes en Yvelines.
 
diff --git a/app/comp/jury.py b/app/comp/jury.py
index 1c43158da4ad1d1221a10692622d5f8e36e09022..ee2c1373872ec016f947dd5670a22915b5e31fca 100644
--- a/app/comp/jury.py
+++ b/app/comp/jury.py
@@ -231,12 +231,11 @@ def erase_decisions_annee_formation(
         .all()
     )
     # Année BUT
-    validations += (
-        ApcValidationAnnee.query.filter_by(etudid=etud.id, ordre=annee)
-        .join(Formation)
-        .filter_by(formation_code=formation.formation_code)
-        .all()
-    )
+    validations += ApcValidationAnnee.query.filter_by(
+        etudid=etud.id,
+        ordre=annee,
+        referentiel_competence_id=formation.referentiel_competence_id,
+    ).all()
     # Autorisations vers les semestres suivants ceux de l'année:
     validations += (
         ScolarAutorisationInscription.query.filter_by(
diff --git a/app/comp/res_but.py b/app/comp/res_but.py
index 163d56f1dc12bb29a236b59a4d5b1d8a3f7fe6a5..b2ba3eb5c6f517386f35a608a5d0589dda62683f 100644
--- a/app/comp/res_but.py
+++ b/app/comp/res_but.py
@@ -337,17 +337,15 @@ class ResultatsSemestreBUT(NotesTableCompat):
         if self.validations_annee:
             return self.validations_annee
         annee_but = (self.formsemestre.semestre_id + 1) // 2
-        validations = (
-            ApcValidationAnnee.query.filter_by(ordre=annee_but)
-            .join(Formation)
-            .filter_by(formation_code=self.formsemestre.formation.formation_code)
-            .join(
-                FormSemestreInscription,
-                db.and_(
-                    FormSemestreInscription.etudid == ApcValidationAnnee.etudid,
-                    FormSemestreInscription.formsemestre_id == self.formsemestre.id,
-                ),
-            )
+        validations = ApcValidationAnnee.query.filter_by(
+            ordre=annee_but,
+            referentiel_competence_id=self.formsemestre.formation.referentiel_competence_id,
+        ).join(
+            FormSemestreInscription,
+            db.and_(
+                FormSemestreInscription.etudid == ApcValidationAnnee.etudid,
+                FormSemestreInscription.formsemestre_id == self.formsemestre.id,
+            ),
         )
         validation_by_etud = {}
         for validation in validations:
diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py
index 824e7e3b6c2312b3b022b561ae366977ff252112..b33e2f2bb08f7666c5d2f340afe22180961dd82b 100644
--- a/app/models/but_refcomp.py
+++ b/app/models/but_refcomp.py
@@ -94,6 +94,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
         backref="referentiel_competence",
         order_by="Formation.acronyme, Formation.version",
     )
+    validations_annee = db.relationship(
+        "ApcValidationAnnee",
+        backref="referentiel_competence",
+        lazy="dynamic",
+    )
 
     def __repr__(self):
         return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
@@ -359,6 +364,9 @@ class ApcNiveau(db.Model, XMLModel):
         return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
             self.annee!r} {self.competence!r}>"""
 
+    def __str__(self):
+        return f"""{self.competence.titre} niveau {self.ordre}"""
+
     def to_dict(self, with_app_critiques=True):
         "as a dict, recursif (ou non) sur les AC"
         return {
diff --git a/app/models/but_validations.py b/app/models/but_validations.py
index 997d1a46fdba31763aff0be96eb8992683d56cf2..185ab539875340a5ddabeee0ce7c2b5a54f5adc8 100644
--- a/app/models/but_validations.py
+++ b/app/models/but_validations.py
@@ -2,8 +2,6 @@
 
 """Décisions de jury (validations) des RCUE et années du BUT
 """
-from typing import Union
-
 
 from app import db
 from app.models import CODE_STR_LEN
@@ -38,7 +36,7 @@ class ApcValidationRCUE(db.Model):
     formsemestre_id = db.Column(
         db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
     )
-    "formsemestre pair du RCUE"
+    "formsemestre origine du RCUE (celui d'où a été émis la validation)"
     # Les deux UE associées à ce niveau:
     ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
     ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
@@ -106,73 +104,14 @@ class ApcValidationRCUE(db.Model):
         }
 
 
-# unused
-# def find_rcues(
-#     formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
-# ) -> list[RegroupementCoherentUE]:
-#     """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
-#     ce semestre pour cette UE.
-
-#     Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
-#     En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
-
-#     Résultat: la liste peut être vide.
-#     """
-#     if (ue.niveau_competence is None) or (ue.semestre_idx is None):
-#         return []
-
-#     if ue.semestre_idx % 2:  # S1, S3, S5
-#         other_semestre_idx = ue.semestre_idx + 1
-#     else:
-#         other_semestre_idx = ue.semestre_idx - 1
-
-#     cursor = db.session.execute(
-#         text(
-#             """SELECT
-#             ue.id, formsemestre.id
-#             FROM
-#                 notes_ue ue,
-#                 notes_formsemestre_inscription inscr,
-#                 notes_formsemestre formsemestre
-
-#             WHERE
-#                 inscr.etudid = :etudid
-#             AND inscr.formsemestre_id = formsemestre.id
-
-#             AND formsemestre.semestre_id = :other_semestre_idx
-#             AND ue.formation_id = formsemestre.formation_id
-#             AND ue.niveau_competence_id = :ue_niveau_competence_id
-#             AND ue.semestre_idx = :other_semestre_idx
-#             """
-#         ),
-#         {
-#             "etudid": etud.id,
-#             "other_semestre_idx": other_semestre_idx,
-#             "ue_niveau_competence_id": ue.niveau_competence_id,
-#         },
-#     )
-#     rcues = []
-#     for ue_id, formsemestre_id in cursor:
-#         other_ue = UniteEns.query.get(ue_id)
-#         other_formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
-#         rcues.append(
-#             RegroupementCoherentUE(
-#                 etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
-#             )
-#         )
-#     # safety check: 1 seul niveau de comp. concerné:
-#     assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
-#     return rcues
-
-
 class ApcValidationAnnee(db.Model):
     """Validation des années du BUT"""
 
     __tablename__ = "apc_validation_annee"
     # Assure unicité de la décision:
     __table_args__ = (
-        db.UniqueConstraint("etudid", "ordre", "formation_id"),
-    )  # il aurait été plus intelligent de mettre ici le refcomp
+        db.UniqueConstraint("etudid", "ordre", "referentiel_competence_id"),
+    )
     id = db.Column(db.Integer, primary_key=True)
     etudid = db.Column(
         db.Integer,
@@ -185,11 +124,9 @@ 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"
-    formation_id = db.Column(  # il aurait été plus intelligent de mettre ici le refcomp
-        db.Integer,
-        db.ForeignKey("notes_formations.id"),
-        nullable=False,
+    "le semestre origine, normalement l'IMPAIR (le 1er) de l'année"
+    referentiel_competence_id = db.Column(
+        db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
     )
     annee_scolaire = db.Column(db.Integer, nullable=False)  # eg 2021
     date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
@@ -209,17 +146,30 @@ class ApcValidationAnnee(db.Model):
         "dict pour bulletins"
         return {
             "annee_scolaire": self.annee_scolaire,
-            "date": self.date.isoformat(),
+            "date": self.date.isoformat() if self.date else "",
             "code": self.code,
             "ordre": self.ordre,
         }
 
     def html(self) -> str:
         "Affichage html"
+        date_str = (
+            f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
+            if self.date
+            else "(sans date)"
+        )
+        link = (
+            self.formsemestre.html_link_status(
+                label=f"{self.formsemestre.titre_formation(with_sem_idx=1)}",
+                title=self.formsemestre.titre_annee(),
+            )
+            if self.formsemestre
+            else "externe/antérieure"
+        )
         return f"""Validation <b>année BUT{self.ordre}</b> émise par
-            {self.formsemestre.html_link_status() if self.formsemestre else "-"}
+            {link}
             : <b>{self.code}</b>
-            le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
+            {date_str}
         """
 
 
@@ -261,15 +211,11 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
         decisions["descr_decisions_rcue"] = ""
         decisions["descr_decisions_niveaux"] = ""
     # --- Année: prend la validation pour l'année scolaire de ce semestre
-    validation = (
-        ApcValidationAnnee.query.filter_by(
-            etudid=etud.id,
-            annee_scolaire=formsemestre.annee_scolaire(),
-        )
-        .join(Formation)
-        .filter(Formation.formation_code == formsemestre.formation.formation_code)
-        .first()
-    )
+    validation = ApcValidationAnnee.query.filter_by(
+        etudid=etud.id,
+        annee_scolaire=formsemestre.annee_scolaire(),
+        referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
+    ).first()
     if validation:
         decisions["decision_annee"] = validation.to_dict_bul()
     else:
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 37e09d140fec8d2c2b8d4ccbe61f801e002d9167..d43bb09bd381427ef4d82cd5223c78608ec2ee1d 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -165,12 +165,12 @@ class FormSemestre(db.Model):
     def __repr__(self):
         return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
 
-    def html_link_status(self) -> str:
+    def html_link_status(self, label=None, title=None) -> str:
         "html link to status page"
         return f"""<a class="stdlink" href="{
             url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
                 formsemestre_id=self.id,)
-        }">{self.titre_mois()}</a>
+        }" title="{title or ''}">{label or self.titre_mois()}</a>
         """
 
     @classmethod
@@ -528,6 +528,11 @@ class FormSemestre(db.Model):
             return ""
         return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
 
+    def add_etape(self, etape_apo: str):
+        "Ajoute une étape"
+        etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
+        db.session.add(etape)
+
     def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
         """Calcule la liste des regroupements cohérents d'UE impliquant ce
         formsemestre.
@@ -873,15 +878,12 @@ class FormSemestre(db.Model):
             .order_by(UniteEns.numero)
             .all()
         )
-        vals_annee = (  # issues de ce formsemestre seulement
+        vals_annee = (  # issues de cette année scolaire seulement
             ApcValidationAnnee.query.filter_by(
                 etudid=etudid,
                 annee_scolaire=self.annee_scolaire(),
-            )
-            .join(ApcValidationAnnee.formsemestre)
-            .join(FormSemestre.formation)
-            .filter(Formation.formation_code == self.formation.formation_code)
-            .all()
+                referentiel_competence_id=self.formation.referentiel_competence_id,
+            ).all()
         )
         H = []
         for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
diff --git a/app/models/validations.py b/app/models/validations.py
index 9e2cf5e27d58bc96e73d42800cc8c57cac261aaa..d4ca5bb074fce52bfeeef4e705a11fa7431ea781 100644
--- a/app/models/validations.py
+++ b/app/models/validations.py
@@ -56,6 +56,7 @@ class ScolarFormSemestreValidation(db.Model):
     )
 
     ue = db.relationship("UniteEns", lazy="select", uselist=False)
+    etud = db.relationship("Identite", backref="validations")
     formsemestre = db.relationship(
         "FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
     )
@@ -94,6 +95,14 @@ class ScolarFormSemestreValidation(db.Model):
                 if self.moy_ue is not None
                 else ""
             )
+            link = (
+                self.formsemestre.html_link_status(
+                    label=f"{self.formsemestre.titre_formation(with_sem_idx=1)}",
+                    title=self.formsemestre.titre_annee(),
+                )
+                if self.formsemestre
+                else "externe/antérieure"
+            )
             return f"""Validation
                 {'<span class="redboldtext">externe</span>' if self.is_external else ""}
                 de l'UE <b>{self.ue.acronyme}</b>
@@ -101,9 +110,7 @@ class ScolarFormSemestreValidation(db.Model):
                   + ", ".join([p.code for p in self.ue.parcours]))
                   + "</span>"
                   if self.ue.parcours else ""}
-                de {self.ue.formation.acronyme}
-                {("émise par " + self.formsemestre.html_link_status()) 
-                    if self.formsemestre else "externe/antérieure"}
+                {("émise par " + link)}
                 : <b>{self.code}</b>{moyenne}
                 le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
             """
@@ -149,10 +156,16 @@ class ScolarAutorisationInscription(db.Model):
 
     def html(self) -> str:
         "Affichage html"
+        link = (
+            self.origin_formsemestre.html_link_status(
+                label=f"{self.origin_formsemestre.titre_formation(with_sem_idx=1)}",
+                title=self.origin_formsemestre.titre_annee(),
+            )
+            if self.origin_formsemestre
+            else "externe/antérieure"
+        )
         return f"""Autorisation de passage vers <b>S{self.semestre_id}</b> émise par
-            {self.origin_formsemestre.html_link_status()
-             if self.origin_formsemestre
-             else "-"}
+            {link}
             le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
         """
 
diff --git a/app/scodoc/codes_cursus.py b/app/scodoc/codes_cursus.py
index 85d14b957b48ae55f7eff0fb5c1108d20bafaa37..dff4ead7b3d22f0bbc180e26dd6dc3eb634c4f37 100644
--- a/app/scodoc/codes_cursus.py
+++ b/app/scodoc/codes_cursus.py
@@ -196,6 +196,8 @@ CODES_SEM_ATTENTES = {ATT, ATB, ATJ}  # semestre en attente
 
 CODES_SEM_REO = {NAR}  # reorientation
 
+# Les codes d'UEs
+CODES_JURY_UE = {ADM, CMP, ADJ, ADJR, ADSUP, AJ, ATJ, RAT, DEF, ABAN, DEM, UEBSL}
 CODES_UE_VALIDES_DE_DROIT = {ADM, CMP}  # validation "de droit"
 CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR, ADSUP}
 "UE validée"
diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py
index 436ecde25d46710b188aac71b7e2e90369229b33..2b5e867a9259f235bc02ee9b63cc90e34a18034d 100644
--- a/app/scodoc/gen_tables.py
+++ b/app/scodoc/gen_tables.py
@@ -88,7 +88,7 @@ class DEFAULT_TABLE_PREFERENCES(object):
         return self.values[k]
 
 
-class GenTable(object):
+class GenTable:
     """Simple 2D tables with export to HTML, PDF, Excel, CSV.
     Can be sub-classed to generate fancy formats.
     """
@@ -197,6 +197,9 @@ class GenTable(object):
     def __repr__(self):
         return f"<gen_table( nrows={self.get_nb_rows()}, ncols={self.get_nb_cols()} )>"
 
+    def __len__(self):
+        return len(self.rows)
+
     def get_nb_cols(self):
         return len(self.columns_ids)
 
diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py
index 1348cbe8cf3094411dabf5f41902abc6c58d5a1d..61af26bdd36cefd07a8b7ff5bf4d5e79ab848579 100644
--- a/app/scodoc/sco_apogee_csv.py
+++ b/app/scodoc/sco_apogee_csv.py
@@ -51,7 +51,14 @@ from app import log
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.comp.res_but import ResultatsSemestreBUT
-from app.models import FormSemestre, Identite, ApcValidationAnnee
+from app.models import (
+    ApcValidationAnnee,
+    ApcValidationRCUE,
+    FormSemestre,
+    Identite,
+    ScolarFormSemestreValidation,
+)
+
 from app.models.config import ScoDocSiteConfig
 from app.scodoc.sco_apogee_reader import (
     APO_DECIMAL_SEP,
@@ -64,6 +71,7 @@ from app.scodoc.gen_tables import GenTable
 from app.scodoc.sco_vdi import ApoEtapeVDI
 from app.scodoc.codes_cursus import code_semestre_validant
 from app.scodoc.codes_cursus import (
+    ADSUP,
     DEF,
     DEM,
     NAR,
@@ -216,7 +224,12 @@ class ApoEtud(dict):
                             break
                 self.col_elts[code] = elt
                 if elt is None:
-                    self.new_cols[col_id] = self.cols[col_id]
+                    try:
+                        self.new_cols[col_id] = self.cols[col_id]
+                    except KeyError as exc:
+                        raise ScoFormatError(
+                            f"""Fichier Apogee invalide : ligne mal formatée ? <br>colonne <tt>{col_id}</tt> non déclarée ?"""
+                        ) from exc
                 else:
                     try:
                         self.new_cols[col_id] = sco_elts[code][
@@ -323,14 +336,22 @@ class ApoEtud(dict):
                 x.strip() for x in ue["code_apogee"].split(",")
             }:
                 if self.export_res_ues:
-                    if decisions_ue and ue["ue_id"] in decisions_ue:
+                    if (
+                        decisions_ue and ue["ue_id"] in decisions_ue
+                    ) or self.export_res_sdj:
                         ue_status = res.get_etud_ue_status(etudid, ue["ue_id"])
-                        code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
+                        if decisions_ue and ue["ue_id"] in decisions_ue:
+                            code_decision_ue = decisions_ue[ue["ue_id"]]["code"]
+                            code_decision_ue_apo = ScoDocSiteConfig.get_code_apo(
+                                code_decision_ue
+                            )
+                        else:
+                            code_decision_ue_apo = ""
                         return dict(
                             N=self.fmt_note(ue_status["moy"] if ue_status else ""),
                             B=20,
                             J="",
-                            R=ScoDocSiteConfig.get_code_apo(code_decision_ue),
+                            R=code_decision_ue_apo,
                             M="",
                         )
                     else:
@@ -343,14 +364,17 @@ class ApoEtud(dict):
         module_code_found = False
         for modimpl in modimpls:
             module = modimpl["module"]
-            if module["code_apogee"] and code in {
-                x.strip() for x in module["code_apogee"].split(",")
-            }:
+            if (
+                res.modimpl_inscr_df[modimpl["moduleimpl_id"]][etudid]
+                and module["code_apogee"]
+                and code in {x.strip() for x in module["code_apogee"].split(",")}
+            ):
                 n = res.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid)
                 if n != "NI" and self.export_res_modules:
                     return dict(N=self.fmt_note(n), B=20, J="", R="")
                 else:
                     module_code_found = True
+
         if module_code_found:
             return VOID_APO_RES
         #
@@ -491,15 +515,11 @@ class ApoEtud(dict):
             # ne trouve pas de semestre impair
             self.validation_annee_but = None
             return
-        self.validation_annee_but: ApcValidationAnnee = (
-            ApcValidationAnnee.query.filter_by(
-                formsemestre_id=formsemestre.id,
-                etudid=self.etud["etudid"],
-                formation_id=self.cur_sem[
-                    "formation_id"
-                ],  # XXX utiliser formation_code
-            ).first()
-        )
+        self.validation_annee_but: ApcValidationAnnee = ApcValidationAnnee.query.filter_by(
+            formsemestre_id=formsemestre.id,
+            etudid=self.etud["etudid"],
+            referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
+        ).first()
         self.is_nar = (
             self.validation_annee_but and self.validation_annee_but.code == NAR
         )
@@ -899,6 +919,75 @@ class ApoData:
         )
         return T
 
+    def build_adsup_table(self):
+        """Construit une table listant les ADSUP émis depuis les formsemestres
+        NIP nom prenom nom_formsemestre etape UE
+        """
+        validations_ues, validations_rcue = self.list_adsup()
+        rows = [
+            {
+                "code_nip": v.etud.code_nip,
+                "nom": v.etud.nom,
+                "prenom": v.etud.prenom,
+                "formsemestre": v.formsemestre.titre_formation(with_sem_idx=1),
+                "etape": v.formsemestre.etapes_apo_str(),
+                "ue": v.ue.acronyme,
+            }
+            for v in validations_ues
+        ]
+        rows += [
+            {
+                "code_nip": v.etud.code_nip,
+                "nom": v.etud.nom,
+                "prenom": v.etud.prenom,
+                "formsemestre": v.formsemestre.titre_formation(with_sem_idx=1),
+                "etape": "",  # on ne sait pas à quel étape rattacher le RCUE
+                "rcue": f"{v.ue1.acronyme}/{v.ue2.acronyme}",
+            }
+            for v in validations_rcue
+        ]
+
+        return GenTable(
+            columns_ids=(
+                "code_nip",
+                "nom",
+                "prenom",
+                "formsemestre",
+                "etape",
+                "ue",
+                "rcue",
+            ),
+            titles={
+                "code_nip": "NIP",
+                "nom": "Nom",
+                "prenom": "Prénom",
+                "formsemestre": "Semestre",
+                "etape": "Etape",
+                "ue": "UE",
+                "rcue": "RCUE",
+            },
+            rows=rows,
+            xls_sheet_name="ADSUPs",
+        )
+
+    def list_adsup(
+        self,
+    ) -> tuple[list[ScolarFormSemestreValidation], list[ApcValidationRCUE]]:
+        """Liste les validations ADSUP émises par des formsemestres de cet ensemble"""
+        validations_ues = (
+            ScolarFormSemestreValidation.query.filter_by(code=ADSUP)
+            .filter(ScolarFormSemestreValidation.ue_id != None)
+            .filter(
+                ScolarFormSemestreValidation.formsemestre_id.in_(
+                    self.etape_formsemestre_ids
+                )
+            )
+        )
+        validations_rcue = ApcValidationRCUE.query.filter_by(code=ADSUP).filter(
+            ApcValidationRCUE.formsemestre_id.in_(self.etape_formsemestre_ids)
+        )
+        return validations_ues, validations_rcue
+
 
 def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]:
     """
@@ -1025,6 +1114,10 @@ def export_csv_to_apogee(
     cr_table = apo_data.build_cr_table()
     cr_xls = cr_table.excel()
 
+    # ADSUPs
+    adsup_table = apo_data.build_adsup_table()
+    adsup_xls = adsup_table.excel() if len(adsup_table) else None
+
     # Create ZIP
     if not dest_zip:
         data = io.BytesIO()
@@ -1050,6 +1143,7 @@ def export_csv_to_apogee(
     log_filename = "scodoc-" + basename + ".log.txt"
     nar_filename = basename + "-nar" + scu.XLSX_SUFFIX
     cr_filename = basename + "-decisions" + scu.XLSX_SUFFIX
+    adsup_filename = f"{basename}-adsups{scu.XLSX_SUFFIX}"
 
     logf = io.StringIO()
     logf.write(f"export_to_apogee du {time.ctime()}\n\n")
@@ -1086,6 +1180,8 @@ def export_csv_to_apogee(
         "\n\nElements Apogee inconnus dans ces semestres ScoDoc:\n"
         + "\n".join(apo_data.list_unknown_elements())
     )
+    if adsup_xls:
+        logf.write(f"\n\nADSUP générés: {len(adsup_table)}\n")
     log(logf.getvalue())  # sortie aussi sur le log ScoDoc
 
     # Write data to ZIP
@@ -1094,6 +1190,8 @@ def export_csv_to_apogee(
     if nar_xls:
         dest_zip.writestr(nar_filename, nar_xls)
     dest_zip.writestr(cr_filename, cr_xls)
+    if adsup_xls:
+        dest_zip.writestr(adsup_filename, adsup_xls)
 
     if my_zip:
         dest_zip.close()
diff --git a/app/scodoc/sco_apogee_reader.py b/app/scodoc/sco_apogee_reader.py
index 1e798433675922606a19e59c2dc395b45ed9714e..2a56d85d945abc65a05c12a7d9ff2a6565419268 100644
--- a/app/scodoc/sco_apogee_reader.py
+++ b/app/scodoc/sco_apogee_reader.py
@@ -295,8 +295,15 @@ class ApoCSVReadWrite:
                     filename=self.get_filename(),
                 )
             cols = {}  # { col_id : value }
-            for i, field in enumerate(fields):
-                cols[self.col_ids[i]] = field
+            try:
+                for i, field in enumerate(fields):
+                    cols[self.col_ids[i]] = field
+            except IndexError as exc:
+                raise
+                raise ScoFormatError(
+                    f"Fichier Apogee incorrect (colonnes excédentaires ? (<tt>{i}/{field}</tt>))",
+                    filename=self.get_filename(),
+                ) from exc
             etud_tuples.append(
                 ApoEtudTuple(
                     nip=fields[0],  # id etudiant
diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py
index f145b3f2cc8597b873ee54c8fb9bc10e7fd2ab4e..2310944757872409e8d690e1388c4c3b556aae6c 100644
--- a/app/scodoc/sco_formsemestre_validation.py
+++ b/app/scodoc/sco_formsemestre_validation.py
@@ -398,7 +398,7 @@ def formsemestre_validation_etud(
             selected_choice = choice
             break
     if not selected_choice:
-        raise ValueError("code choix invalide ! (%s)" % codechoice)
+        raise ValueError(f"code choix invalide ! ({codechoice})")
     #
     Se.valide_decision(selected_choice)  # enregistre
     return _redirect_valid_choice(
@@ -1132,6 +1132,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
                 },
             )
         )
+    ue_codes = sorted(codes_cursus.CODES_JURY_UE)
     form_descr += [
         (
             "date",
@@ -1152,6 +1153,18 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
                 "title": "Moyenne (/20) obtenue dans cette UE:",
             },
         ),
+        (
+            "code_jury",
+            {
+                "input_type": "menu",
+                "title": "Code jury",
+                "explanation": " code donné par le jury (ADM si validée normalement)",
+                "allow_null": True,
+                "allowed_values": [""] + ue_codes,
+                "labels": ["-"] + ue_codes,
+                "default": ADM,
+            },
+        ),
     ]
     tf = TrivialFormulator(
         request.base_url,
@@ -1173,17 +1186,20 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
     de {etud.html_link_fiche()}
     </h2>
 
-    <p class="help">Utiliser cette page pour enregistrer une UE validée antérieurement,
+    <p class="help">Utiliser cette page pour enregistrer des UEs validées antérieurement,
     <em>dans un semestre hors ScoDoc</em>.</p>
-    <p class="expl"><b>Les UE validées dans ScoDoc sont déjà
-    automatiquement prises en compte</b>. Cette page n'est utile que pour les étudiants ayant
-    suivi un début de cursus dans <b>un autre établissement</b>, ou bien dans un semestre géré
-    <b>sans ScoDoc</b> et qui <b>redouble</b> ce semestre
-    (<em>pour les semestres précédents gérés avec ScoDoc,
-    passer par la page jury normale)</em>).
+    <p class="expl"><b>Les UE validées dans ScoDoc sont 
+    automatiquement prises en compte</b>. 
+    </p>
+    <p>Cette page est surtout utile  pour les étudiants ayant
+    suivi un début de cursus dans <b>un autre établissement</b>, ou qui 
+    ont suivi une UE à l'étranger ou dans un semestre géré <b>sans ScoDoc</b>.
+    </p>
+    <p>Pour les semestres précédents gérés avec ScoDoc, passer par la page jury normale.
+    </p>
+    <p>Notez que l'UE est validée, avec enregistrement immédiat de la décision et
+    l'attribution des ECTS si le code jury est validant (ADM).
     </p>
-    <p>Notez que l'UE est validée (ADM), avec enregistrement immédiat de la décision et
-    l'attribution des ECTS.</p>
     <p>On ne peut valider ici que les UEs du cursus <b>{formation.titre}</b></p>
 
     {_get_etud_ue_cap_html(etud, formsemestre)}
@@ -1221,12 +1237,16 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
     else:
         semestre_id = None
 
+    if tf[2]["code_jury"] not in CODES_JURY_UE:
+        flash("Code UE invalide")
+        return flask.redirect(dest_url)
     do_formsemestre_validate_previous_ue(
         formsemestre,
         etud.id,
         tf[2]["ue_id"],
         tf[2]["moy_ue"],
         tf[2]["date"],
+        code=tf[2]["code_jury"],
         semestre_id=semestre_id,
     )
     flash("Validation d'UE enregistrée")
@@ -1258,7 +1278,7 @@ def _get_etud_ue_cap_html(etud: Identite, formsemestre: FormSemestre) -> str:
         <div class="help">Liste de toutes les UEs validées par {etud.html_link_fiche()}, 
         sur des semestres ou déclarées comme "antérieures" (externes).
         </div>
-        <ul>"""
+        <ul class="liste_validations">"""
     ]
     for validation in validations:
         if validation.formsemestre_id is None:
@@ -1267,17 +1287,20 @@ def _get_etud_ue_cap_html(etud: Identite, formsemestre: FormSemestre) -> str:
             origine = f", du semestre {formsemestre.html_link_status()}"
         if validation.semestre_id is not None:
             origine += f" (<b>S{validation.semestre_id}</b>)"
-        H.append(
-            f"""
-            <li>{validation.html()}
+        H.append(f"""<li>{validation.html()}""")
+        if validation.formsemestre.can_edit_jury():
+            H.append(
+                f"""
                 <form class="inline-form">
                     <button 
                     data-v_id="{validation.id}" data-type="validation_ue" data-etudid="{etud.id}"
                     >effacer</button>
                 </form>
-            </li>
-            """,
-        )
+                """,
+            )
+        else:
+            H.append(scu.icontag("lock_img", border="0", title="Semestre verrouillé"))
+        H.append("</li>")
     H.append("</ul></div>")
     return "\n".join(H)
 
@@ -1300,7 +1323,7 @@ def do_formsemestre_validate_previous_ue(
     ue: UniteEns = UniteEns.query.get_or_404(ue_id)
 
     cnx = ndb.GetDBConnexion()
-    if ue_coefficient != None:
+    if ue_coefficient is not None:
         sco_formsemestre.do_formsemestre_uecoef_edit_or_create(
             cnx, formsemestre.id, ue_id, ue_coefficient
         )
diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py
index e2dbd1cbce675ea4d822b355aaff6541db9f7a88..cbb26f08f4569bca1c9018ca0d85449bf72f0f0e 100644
--- a/app/scodoc/sco_page_etud.py
+++ b/app/scodoc/sco_page_etud.py
@@ -59,11 +59,13 @@ from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_t
 from app.scodoc.sco_permissions import Permission
 
 
-def _menu_scolarite(authuser, sem: dict, etudid: int):
+def _menu_scolarite(
+    authuser, formsemestre: FormSemestre, etudid: int, etat_inscription: str
+):
     """HTML pour menu "scolarite" pour un etudiant dans un semestre.
     Le contenu du menu depend des droits de l'utilisateur et de l'état de l'étudiant.
     """
-    locked = not sem["etat"]
+    locked = not formsemestre.etat
     if locked:
         lockicon = scu.icontag("lock32_img", title="verrouillé", border="0")
         return lockicon  # no menu
@@ -71,10 +73,10 @@ def _menu_scolarite(authuser, sem: dict, etudid: int):
         Permission.ScoEtudInscrit
     ) and not authuser.has_permission(Permission.ScoEtudChangeGroups):
         return ""  # no menu
-    ins = sem["ins"]
-    args = {"etudid": etudid, "formsemestre_id": ins["formsemestre_id"]}
 
-    if ins["etat"] != "D":
+    args = {"etudid": etudid, "formsemestre_id": formsemestre.id}
+
+    if etat_inscription != scu.DEMISSION:
         dem_title = "Démission"
         dem_url = "scolar.form_dem"
     else:
@@ -82,14 +84,14 @@ def _menu_scolarite(authuser, sem: dict, etudid: int):
         dem_url = "scolar.do_cancel_dem"
 
     # Note: seul un etudiant inscrit (I) peut devenir défaillant.
-    if ins["etat"] != codes_cursus.DEF:
+    if etat_inscription != codes_cursus.DEF:
         def_title = "Déclarer défaillance"
         def_url = "scolar.form_def"
-    elif ins["etat"] == codes_cursus.DEF:
+    elif etat_inscription == codes_cursus.DEF:
         def_title = "Annuler la défaillance"
         def_url = "scolar.do_cancel_def"
     def_enabled = (
-        (ins["etat"] != "D")
+        (etat_inscription != scu.DEMISSION)
         and authuser.has_permission(Permission.ScoEtudInscrit)
         and not locked
     )
@@ -128,6 +130,12 @@ def _menu_scolarite(authuser, sem: dict, etudid: int):
             "enabled": authuser.has_permission(Permission.ScoEtudInscrit)
             and not locked,
         },
+        {
+            "title": "Gérer les validations d'UEs antérieures",
+            "endpoint": "notes.formsemestre_validate_previous_ue",
+            "args": args,
+            "enabled": formsemestre.can_edit_jury(),
+        },
         {
             "title": "Inscrire à un autre semestre",
             "endpoint": "notes.formsemestre_inscription_with_modules_form",
@@ -250,8 +258,8 @@ def ficheEtud(etudid=None):
         info["last_formsemestre_id"] = ""
     sem_info = {}
     for sem in info["sems"]:
+        formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
         if sem["ins"]["etat"] != scu.INSCRIT:
-            formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
             descr, _ = etud_descr_situation_semestre(
                 etudid,
                 formsemestre,
@@ -283,7 +291,7 @@ def ficheEtud(etudid=None):
                 )
             grlink = ", ".join(grlinks)
         # infos ajoutées au semestre dans le parcours (groupe, menu)
-        menu = _menu_scolarite(authuser, sem, etudid)
+        menu = _menu_scolarite(authuser, formsemestre, etudid, sem["ins"]["etat"])
         if menu:
             sem_info[sem["formsemestre_id"]] = (
                 "<table><tr><td>" + grlink + "</td><td>" + menu + "</td></tr></table>"
diff --git a/app/scodoc/sco_semset.py b/app/scodoc/sco_semset.py
index c028429356269b73bf348a5208b85787a8fef330..f3d8c6d3a5de815476e95ab8e0357012c450b39c 100644
--- a/app/scodoc/sco_semset.py
+++ b/app/scodoc/sco_semset.py
@@ -42,6 +42,7 @@ sem_set_list()
 import flask
 from flask import g, url_for
 
+from app import log
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.models import FormSemestre
@@ -52,7 +53,6 @@ from app.scodoc import sco_formsemestre_status
 from app.scodoc import sco_portal_apogee
 from app.scodoc import sco_preferences
 from app.scodoc.gen_tables import GenTable
-from app import log
 from app.scodoc.sco_etape_bilan import EtapeBilan
 from app.scodoc.sco_exceptions import ScoValueError
 from app.scodoc.sco_vdi import ApoEtapeVDI
diff --git a/app/static/css/jury_delete_manual.css b/app/static/css/jury_delete_manual.css
index d41e4f802b16655c24fcf231c7c6ebc5ffbde8ac..b685384a4286a4d19ea3e01244444d2516aa7287 100644
--- a/app/static/css/jury_delete_manual.css
+++ b/app/static/css/jury_delete_manual.css
@@ -1,9 +1,12 @@
-
 div.jury_decisions_list div {
     font-size: 120%;
     font-weight: bold;
 }
 
 span.parcours {
-    color:blueviolet;
+    color: blueviolet;
 }
+
+div.ue_list_etud_validations ul.liste_validations li {
+    margin-bottom: 8px;
+}
\ No newline at end of file
diff --git a/app/static/js/validate_previous_ue.js b/app/static/js/validate_previous_ue.js
index 22655f498e23016a74dc2ec745e0359b145e731c..c2fe3c88882ab4def85ce1d36b50e6f4b78edb4b 100644
--- a/app/static/js/validate_previous_ue.js
+++ b/app/static/js/validate_previous_ue.js
@@ -11,27 +11,30 @@ document.addEventListener("DOMContentLoaded", () => {
       // Handle button click event here
       event.preventDefault();
       const etudid = event.target.dataset.etudid;
-      const v_id = event.target.dataset.v_id;
+      const validation_id = event.target.dataset.v_id;
       const validation_type = event.target.dataset.type;
       if (confirm("Supprimer cette validation ?")) {
-        fetch(
-          `${SCO_URL}/../api/etudiant/${etudid}/jury/${validation_type}/${v_id}/delete`,
-          {
-            method: "POST",
-          }
-        ).then((response) => {
-          // Handle the response
-          if (response.ok) {
-            location.reload();
-          } else {
-            throw new Error("Request failed");
-          }
-        });
+        delete_validation(etudid, validation_type, validation_id);
       }
     });
   });
 });
 
+async function delete_validation(etudid, validation_type, validation_id) {
+  const response = await fetch(
+    `${SCO_URL}/../api/etudiant/${etudid}/jury/${validation_type}/${validation_id}/delete`,
+    {
+      method: "POST",
+    }
+  );
+  if (response.ok) {
+    location.reload();
+  } else {
+    const data = await response.json();
+    sco_error_message("erreur: " + data.message);
+  }
+}
+
 function update_ue_list() {
   var ue_id = $("#tf_ue_id")[0].value;
   if (ue_id) {
diff --git a/app/templates/but/formsemestre_validation_auto_but.j2 b/app/templates/but/formsemestre_validation_auto_but.j2
index 5bf69b397d06fccef9839ac1a0595a1c22147671..0f8e827b692c632e9513fd7a6dd80623b48712bf 100644
--- a/app/templates/but/formsemestre_validation_auto_but.j2
+++ b/app/templates/but/formsemestre_validation_auto_but.j2
@@ -9,38 +9,41 @@
 {% block app_content %}
 
 <div class="sco_help">
-<h2>Calcul automatique des décisions de jury du BUT</h2>
-<ul>
-    <li>N'enregistre jamais de décisions de l'année scolaire précédente, même
-        si on a des RCUE "à cheval" sur deux années.
-    </li>
-
-    <li><b>Attention: peut modifier des décisions déjà enregistrées</b>, si la
-    validation de droit est calculée. Par exemple, vous aviez saisi <b>RAT</b>
-    pour un étudiant dont les moyennes d'UE dépassent 10 mais qui pour une
-    raison particulière ne valide pas son année. Le calcul automatique peut
-    remplacer ce <b>RAT</b> par un <b>ADM</b>, ScoDoc considérant que les
-    conditions sont satisfaites. On peut éviter cela en laissant une note de
-    l'étudiant en ATTente.
-    </li>
-
-    <li>N'enregistre que les décisions <b>validantes de droit: ADM ou CMP</b>.
-    </li>
-    <li>N'enregistre pas de décision si l'étudiant a une ou plusieurs notes en ATTente.
-    </li>
-    <li>L'assiduité n'est <b>pas</b> prise en compte. </li>
-</ul>
-<p>
-    En conséquence, saisir ensuite <b>manuellement les décisions manquantes</b>,
-    notamment sur les UEs en dessous de 10.
-</p>
-<div class="warning">
+    <h2>Calcul automatique des décisions de jury du BUT</h2>
     <ul>
-    <li>Ne jamais lancer ce calcul avant que toutes les notes ne soient saisies !
-    (verrouiller le semestre ensuite)
-    </li>
-    <li>Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure !</li>
-</div>
+        <li>N'enregistre jamais de décisions de l'année scolaire précédente, même
+            si on a des RCUE "à cheval" sur deux années.
+        </li>
+
+        <li><b>Attention: peut modifier des décisions déjà enregistrées</b>, si la
+            validation de droit est calculée.
+            Ce calcul <b>n'utilise que les notes, et pas les décisions manuelles déjà saisies.</b>
+            <br>
+            Par exemple, vous aviez saisi <b>ATJ</b> ou <b>RAT</b>
+            pour un étudiant dont les moyennes d'UE dépassent 10 mais qui pour une
+            raison particulière ne valide pas son année. Le calcul automatique peut
+            remplacer ce <b>RAT</b> par un <b>ADM</b>, ScoDoc considérant que les
+            conditions sont satisfaites. On peut éviter cela en laissant une note de
+            l'étudiant en ATTente.
+        </li>
+
+        <li>N'enregistre que les décisions <b>validantes de droit: ADM ou CMP</b>.
+        </li>
+        <li>N'enregistre pas de décision si l'étudiant a une ou plusieurs notes en ATTente.
+        </li>
+        <li>L'assiduité n'est <b>pas</b> prise en compte. </li>
+    </ul>
+    <p>
+        En conséquence, saisir ensuite <b>manuellement les décisions manquantes</b>,
+        notamment sur les UEs en dessous de 10.
+    </p>
+    <div class="warning">
+        <ul>
+            <li>Ne jamais lancer ce calcul avant que toutes les notes ne soient saisies !
+                (verrouiller le semestre ensuite)
+            </li>
+            <li>Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure !</li>
+    </div>
 
 </div>
 
diff --git a/app/templates/jury/erase_decisions_annee_formation.j2 b/app/templates/jury/erase_decisions_annee_formation.j2
index 0c231b5325c4fde38f3bb68ce31ae862f4b3a949..4549688b510d4e40ad76099763991b47f83f9bb6 100644
--- a/app/templates/jury/erase_decisions_annee_formation.j2
+++ b/app/templates/jury/erase_decisions_annee_formation.j2
@@ -4,8 +4,8 @@
 
 {% if not validations %}
 <p>Aucune validation de jury enregistrée pour <b>{{etud.html_link_fiche()|safe}}</b>
-sur <b>l'année {{annee}}</b>
-de la formation <em>{{ formation.html() }}</em>
+    sur <b>l'année {{annee}}</b>
+    de la formation <em>{{ formation.html() }}</em>
 </p>
 
 <div style="margin-top: 16px;">
@@ -16,7 +16,7 @@ de la formation <em>{{ formation.html() }}</em>
 <h2>Effacer les décisions de jury pour l'année {{annee}} de {{etud.html_link_fiche()|safe}} ?</h2>
 
 <p class="help">Affectera toutes les décisions concernant l'année {{annee}} de la formation,
-quelle que soit leur origine.</p>
+    quelle que soit leur origine.</p>
 
 <p>Les décisions concernées sont:</p>
 <ul>
@@ -34,8 +34,34 @@ quelle que soit leur origine.</p>
         {% endif %}
     </form>
 </div>
+
 {% endif %}
 
+<div class="sco_box">
+    <div class="sco_box_title">Autres actions:</div>
+    <ul>
+        <li><a class="stdlink" href="{{
+        url_for('notes.jury_delete_manual',
+                    scodoc_dept=g.scodoc_dept,
+                    etudid=etud.id
+                )
+    }}">effacer les décisions une à une</a>
+        </li>
+        {% if formsemestre_origine is not none %}
+        <li><a class="stdlink" href="{{
+            url_for('notes.formsemestre_jury_but_erase',
+                    scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_origine.id,
+                    etudid=etud.id, only_one_sem=1)
+            }}">
+                effacer seulement les décisions émises par le semestre
+                {{formsemestre_origine.titre_formation(with_sem_idx=1)|safe}}
+                (efface aussi la décision annuelle)
+            </a>
+        </li>
+        {% endif %}
+    </ul>
+</div>
+
 
 
 {% endblock %}
\ No newline at end of file
diff --git a/app/views/notes.py b/app/views/notes.py
index d6a77c614dba19381265486951b95ba96ee044f9..4bc79ebae849674de8d078e5fca46f72870aa66f 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -2534,21 +2534,20 @@ def formsemestre_validation_but(
             </div>"""
         )
     else:
-        erase_span = f"""<a href="{
-            url_for("notes.formsemestre_jury_but_erase",
-            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>
-            
+        erase_span = f"""
             <a style="margin-left: 16px;" class="stdlink"
             href="{
                 url_for("notes.erase_decisions_annee_formation",
                 scodoc_dept=g.scodoc_dept, formation_id=deca.formsemestre.formation.id,
-                etudid=deca.etud.id, annee=deca.annee_but)}"
-            title="efface toutes décisions concernant le BUT{deca.annee_but}
-                de cet étudiant (même extérieures ou issues d'un redoublement)"
-            >effacer toutes ses décisions de BUT{deca.annee_but}</a>
+                etudid=deca.etud.id, annee=deca.annee_but, formsemestre_id=formsemestre_id)}"
+            >effacer des décisions de jury</a>
+
+            <a style="margin-left: 16px;" class="stdlink"
+            href="{
+                url_for("notes.formsemestre_validate_previous_ue",
+                scodoc_dept=g.scodoc_dept, 
+                etudid=deca.etud.id, formsemestre_id=formsemestre_id)}"
+            >enregistrer des UEs antérieures</a>
             """
         H.append(
             f"""<div class="but_settings">
@@ -2966,6 +2965,12 @@ def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int):
             )
         )
     validations = jury.erase_decisions_annee_formation(etud, formation, annee)
+    formsemestre_origine_id = request.args.get("formsemestre_id")
+    formsemestre_origine = (
+        FormSemestre.query.get_or_404(formsemestre_origine_id)
+        if formsemestre_origine_id
+        else None
+    )
     return render_template(
         "jury/erase_decisions_annee_formation.j2",
         annee=annee,
@@ -2974,6 +2979,7 @@ def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int):
         ),
         etud=etud,
         formation=formation,
+        formsemestre_origine=formsemestre_origine,
         validations=validations,
         sco=ScoData(),
         title=f"Effacer décisions de jury {etud.nom} - année {annee}",
diff --git a/migrations/versions/829683efddc4_change_apcvalidationannee.py b/migrations/versions/829683efddc4_change_apcvalidationannee.py
index 8955a1a3dfd70f039fe2aa246703cf4c49fe937b..79152d570a057ec5d96ec6511909bf0b45d1f737 100644
--- a/migrations/versions/829683efddc4_change_apcvalidationannee.py
+++ b/migrations/versions/829683efddc4_change_apcvalidationannee.py
@@ -7,7 +7,7 @@ Create Date: 2023-06-28 09:47:16.591028
 """
 from alembic import op
 import sqlalchemy as sa
-
+from sqlalchemy.orm import sessionmaker  # added by ev
 
 # revision identifiers, used by Alembic.
 revision = "829683efddc4"
@@ -15,30 +15,96 @@ down_revision = "c701224fa255"
 branch_labels = None
 depends_on = None
 
+Session = sessionmaker()
+
+
+# Voir https://stackoverflow.com/questions/24082542/check-if-a-table-column-exists-in-the-database-using-sqlalchemy-and-alembic
+from sqlalchemy import inspect
+
+
+def column_exists(table_name, column_name):
+    bind = op.get_context().bind
+    insp = inspect(bind)
+    columns = insp.get_columns(table_name)
+    return any(c["name"] == column_name for c in columns)
+
 
 def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
+    if column_exists("apc_validation_annee", "referentiel_competence_id"):
+        return  # utile durant developpement
+    # Enleve la contrainte erronée
     with op.batch_alter_table("apc_validation_annee", schema=None) as batch_op:
         batch_op.drop_constraint(
             "apc_validation_annee_etudid_annee_scolaire_ordre_key", type_="unique"
         )
-        # batch_op.create_unique_constraint(
-        #     "apc_validation_annee_etudid_formation_ordre_key",
-        #     ["etudid", "ordre", "formation_id"],
-        # )
+        # Ajoute colonne referentiel, nullable pour l'instant
+        batch_op.add_column(
+            sa.Column("referentiel_competence_id", sa.Integer(), nullable=True)
+        )
 
-    # ### end Alembic commands ###
+    # Affecte le referentiel des anciennes validations
+    bind = op.get_bind()
+    session = Session(bind=bind)
+    session.execute(
+        sa.text(
+            """
+        UPDATE apc_validation_annee AS a
+        SET referentiel_competence_id = (
+            SELECT f.referentiel_competence_id
+            FROM notes_formations f
+            WHERE f.id = a.formation_id
+        )
+        """
+        )
+    )
+    # En principe, on n'a pas pu entrer de validation sur des formations sans referentiel
+    # par prudence, on les supprime avant d'ajouter la contrainte
+    session.execute(
+        sa.text(
+            "DELETE FROM apc_validation_annee WHERE referentiel_competence_id is NULL"
+        )
+    )
+    op.alter_column(
+        "apc_validation_annee",
+        "referentiel_competence_id",
+        nullable=False,
+    )
+    op.create_foreign_key(
+        "apc_validation_annee_refcomp_fkey",
+        "apc_validation_annee",
+        "apc_referentiel_competences",
+        ["referentiel_competence_id"],
+        ["id"],
+    )
+    # Efface les validations d'année dupliquées
+    # (garde la validation la plus récente)
+    session.execute(
+        sa.text(
+            """
+        DELETE FROM apc_validation_annee t1
+        WHERE t1.id <> (SELECT max(t2.id)
+            FROM apc_validation_annee t2
+            WHERE t1.etudid = t2.etudid
+            AND t1.referentiel_competence_id = t2.referentiel_competence_id
+            AND t1.ordre = t2.ordre
+        )
+        """
+        )
+    )
+    # Et ajoute la contrainte unicité de décision année par étudiant/ref. comp.:
+    op.create_unique_constraint(
+        "apc_validation_annee_etudid_ordre_refcomp_key",
+        "apc_validation_annee",
+        ["etudid", "ordre", "referentiel_competence_id"],
+    )
+    op.drop_column("apc_validation_annee", "formation_id")
 
 
 def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
+    # Se contente de ré-ajouter la colonne formation_id sans re-générer son contenu
     with op.batch_alter_table("apc_validation_annee", schema=None) as batch_op:
         # batch_op.drop_constraint(
-        #     "apc_validation_annee_etudid_formation_ordre_key", type_="unique"
+        #     "apc_validation_annee_etudid_ordre_refcomp_key", type_="unique"
         # )
-        batch_op.create_unique_constraint(
-            "apc_validation_annee_etudid_annee_scolaire_ordre_key",
-            ["etudid", "annee_scolaire", "ordre"],
-        )
-
-    # ### end Alembic commands ###
+        # batch_op.drop_column("referentiel_competence_id")
+        batch_op.add_column(sa.Column("formation_id", sa.Integer(), nullable=True))
diff --git a/pytest.ini b/pytest.ini
index e4d9d0bed917f719356dc533a8601cbe92ce6350..e92885fe63fe30bfed4a86ca6f88b86a7ea3a8bf 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,6 +1,7 @@
 [pytest]
 markers =
     slow: marks tests as slow (deselect with '-m "not slow"')
+    apo
     but_gb
     but_gccd
     but_mlt
diff --git a/sco_version.py b/sco_version.py
index aa68bec3df11ed603a081dd84c417c1654f74c6a..832f5c16dc0818fe6856122368ca9be2e74938dc 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,13 +1,24 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
-SCOVERSION = "9.4.94"
+SCOVERSION = "9.4.96"
 
 SCONAME = "ScoDoc"
 
 SCONEWS = """
 <h4>Année 2023</h4>
 <ul>
+
+<li>ScoDoc 9.6 (juillet 2023)</li>
+<ul>
+      <li>Nouvelle gestion des absences et assiduité</li>
+</ul>
+
+<li>ScoDoc 9.5 (juillet 2023)</li>
+   <ul>
+      <li>Version de maintenance (sécurité et correctifs critiques) sur Debian 11: fin de vie: 1/11/2023</li>
+   </ul>
+
 <li>ScoDoc 9.4</li>
 <ul>
     <li>Connexion avec service CAS</li>
diff --git a/tests/ressources/apogee/BUT-INFO-S2.txt b/tests/ressources/apogee/BUT-INFO-S2.txt
new file mode 100644
index 0000000000000000000000000000000000000000..1970f0fef925fc1bc26f5b2ab43416b355315a23
--- /dev/null
+++ b/tests/ressources/apogee/BUT-INFO-S2.txt
@@ -0,0 +1,76 @@
+XX-APO_TITRES-XX
+apoC_annee	2021/2022
+apoC_cod_dip	DIPTIS2
+apoC_Cod_Exp	2
+apoC_cod_vdi	17
+apoC_Fichier_Exp	export.txt
+apoC_lib_dip	BUT INFO TEST
+apoC_Titre1	Maquette pour tests unitaires sur un BUT Info S2
+apoC_Titre2	
+							
+XX-APO_TYP_RES-XX
+10	AB1	AB2	ABI	ABJ	ADM	AJ	AJRO	C1	DEF	DIF	
+18	AB1	AB2	ABI	ABJ	ADM	ADMC	ADMD	AJ	AJAC	AJAR	AJRO	ATT	B1	C1	COMP	DEF	DIF	NAR	
+45	ABI	ABJ	ADAC	ADM	ADMC	ADMD	AIR	AJ	AJAR	AJCP	AJRO	AJS	ATT	B1	B2	C1	COMP	CRED	DEF	DES	DETT	DIF	ENER	ENRA	EXC	INFA	INFO	INST	LC	MACS	N1	N2	NAR	NON	NSUI	NVAL	OUI	SUIV	SUPS	TELE	TOEF	TOIE	VAL	VALC	VALR	
+10	ABI	ABJ	ADMC	COMP	DEF	DIS	NVAL	VAL	VALC	VALR	
+AB1 : Ajourné en B2 mais admis en B1	AB2 : ADMIS en B1 mais ajourné en B2	ABI : Absence	ABJ : Absence justifiée	ADM : Admis	AJ : Ajourné	AJRO : Ajourné - Réorientation Obligatoire	C1 : Niveau C1	DEF : Défaillant	DIF : Décision différée	
+AB1 : Ajourné en B2 mais admis en B1	AB2 : ADMIS en B1 mais ajourné en B2	ABI : Absence	ABJ : Absence justifiée	ADM : Admis	ADMC : Admis avec compensation	ADMD : Admis (passage avec dette)	AJ : Ajourné	AJAC : Ajourné mais accès autorisé à étape sup.	AJAR : Ajourné et Admis A Redoubler	AJRO : Ajourné - Réorientation Obligatoire	ATT : En attente de décison	B1 : Niveau B1	C1 : Niveau C1	COMP : Compensé	DEF : Défaillant	DIF : Décision différée	NAR : Ajourné non admis à redoubler	
+ABI : Absence	ABJ : Absence justifiée	ADAC : Admis avant choix	ADM : Admis	ADMC : Admis avec compensation	ADMD : Admis (passage avec dette)	AIR : Ingénieur spécialité Informatique appr	AJ : Ajourné	AJAR : Ajourné et Admis A Redoubler	AJCP : Ajourné mais autorisé à compenser	AJRO : Ajourné - Réorientation Obligatoire	AJS : Ajourné (note éliminatoire)	ATT : En attente de décison	B1 : Niveau B1	B2 : Niveau B2	C1 : Niveau C1	COMP : Compensé	CRED : Eléments en crédits	DEF : Défaillant	DES : Désistement	DETT : Eléments en dettes	DIF : Décision différée	ENER : Ingénieur spécialité Energétique	ENRA : Ingénieur spécialité Energétique appr	EXC : Exclu	INFA : Ingénieur spécialité Informatique appr	INFO : Ingénieur spécialié Informatique	INST : Ingénieur spécialité Instrumentation	LC : Liste complémentaire	MACS : Ingénieur spécialité MACS	N1 : Compétences CLES	N2 : Niveau N2	NAR : Ajourné non admis à redoubler	NON : Non	NSUI : Non suivi(e)	NVAL : Non Validé(e)	OUI : Oui	SUIV : Suivi(e)	SUPS : Supérieur au seuil	TELE : Ingénieur spéciailté Télécommunications	TOEF : TOEFL	TOIE : TOEIC	VAL : Validé(e)	VALC : Validé(e) par compensation	VALR : Validé(e) Retrospectivement	
+ABI : Absence	ABJ : Absence justifiée	ADMC : Admis avec compensation	COMP : Compensé	DEF : Défaillant	DIS : Dispense examen	NVAL : Non Validé(e)	VAL : Validé(e)	VALC : Validé(e) par compensation	VALR : Validé(e) Retrospectivement	
+							
+XX-APO_COLONNES-XX
+apoL_a01_code	Type Objet	Code	Version	Année	Session	Admission/Admissibilité	Type Rés.			Etudiant	Numéro
+apoL_a02_nom											Nom
+apoL_a03_prenom											Prénom
+apoL_a04_naissance									Session	Admissibilité	Naissance
+APO_COL_VAL_DEB
+apoL_c0001	ELP	V1INFU21		2021	0	1	N	V1INFU21 - UE 2.1 Réaliser	0	1	Note
+apoL_c0002	ELP	V1INFU21		2021	0	1	B		0	1	Barème
+apoL_c0003	ELP	V1INFU21		2021	0	1	J		0	1	Pts Jury
+apoL_c0004	ELP	V1INFU21		2021	0	1	R		0	1	Résultat
+apoL_c0005	ELP	V1INFU22		2021	0	1	N	V1INFU22 - UE 2.2 Optimiser	0	1	Note
+apoL_c0006	ELP	V1INFU22		2021	0	1	B		0	1	Barème
+apoL_c0007	ELP	V1INFU22		2021	0	1	J		0	1	Pts Jury
+apoL_c0008	ELP	V1INFU22		2021	0	1	R		0	1	Résultat
+apoL_c0009	ELP	V1INFU23		2021	0	1	N	V1INFU23 - UE 2.3 Administrer	0	1	Note
+apoL_c0010	ELP	V1INFU23		2021	0	1	B		0	1	Barème
+apoL_c0011	ELP	V1INFU23		2021	0	1	J		0	1	Pts Jury
+apoL_c0012	ELP	V1INFU23		2021	0	1	R		0	1	Résultat
+apoL_c0013	ELP	V1INFU24		2021	0	1	N	V1INFU24 - UE 2.4 Gérer	0	1	Note
+apoL_c0014	ELP	V1INFU24		2021	0	1	B		0	1	Barème
+apoL_c0015	ELP	V1INFU24		2021	0	1	J		0	1	Pts Jury
+apoL_c0016	ELP	V1INFU24		2021	0	1	R		0	1	Résultat
+apoL_c0017	ELP	V1INFU25		2021	0	1	N	V1INFU25 - UE 2.5 Conduire	0	1	Note
+apoL_c0018	ELP	V1INFU25		2021	0	1	B		0	1	Barème
+apoL_c0019	ELP	V1INFU25		2021	0	1	J		0	1	Pts Jury
+apoL_c0020	ELP	V1INFU25		2021	0	1	R		0	1	Résultat
+apoL_c0021	ELP	V1INFU26		2021	0	1	N	V1INFU26 - UE 2.6 Travailler	0	1	Note
+apoL_c0022	ELP	V1INFU26		2021	0	1	B		0	1	Barème
+apoL_c0023	ELP	V1INFU26		2021	0	1	J		0	1	Pts Jury
+apoL_c0024	ELP	V1INFU26		2021	0	1	R		0	1	Résultat
+apoL_c0025	ELP	VINFR201		2021	0	1	N	VINFR201 - Développement orienté objets	0	1	Note
+apoL_c0026	ELP	VINFR201		2021	0	1	B		0	1	Barème
+apoL_c0027	ELP	VINFR207		2021	0	1	N	VINFR207 - Graphes	0	1	Note
+apoL_c0028	ELP	VINFR207		2021	0	1	B		0	1	Barème
+apoL_c0029	ELP	VINFPOR2		2021	0	1	N	VINFPOR2 - Portfolio	0	1	Note
+apoL_c0030	ELP	VINFPOR2		2021	0	1	B		0	1	Barème
+apoL_c0031	ELP	TIRW2		2021	0	1	N	TIRW2 - Semestre 2 BUT INFO 2	0	1	Note
+apoL_c0032	ELP	TIRW2		2021	0	1	B		0	1	Barème
+apoL_c0033	ELP	TIRW2		2021	0	1	J		0	1	Pts Jury
+apoL_c0034	ELP	TIRW2		2021	0	1	R		0	1	Résultat
+apoL_c0035	ELP	TIRO		2021	0	1	N	TIRO - Année BUT 1 RT	0	1	Note
+apoL_c0036	ELP	TIRO		2021	0	1	B		0	1	Barème
+apoL_c0037	VET	TI1	117	2021	0	1	N	TI1 - BUT INFO an1	0	1	Note
+apoL_c0038	VET	TI1	117	2021	0	1	B		0	1	Barème
+apoL_c0039	VET	TI1	117	2021	0	1	J		0	1	Pts Jury
+apoL_c0040	VET	TI1	117	2021	0	1	R		0	1	Résultat
+APO_COL_VAL_FIN
+apoL_c0041	APO_COL_VAL_FIN							  	
+							
+XX-APO_VALEURS-XX
+apoL_a01_code	apoL_a02_nom	apoL_a03_prenom	apoL_a04_naissance	apoL_c0001	apoL_c0002	apoL_c0003	apoL_c0004	apoL_c0005	apoL_c0006	apoL_c0007	apoL_c0008	apoL_c0009	apoL_c0010	apoL_c0011	apoL_c0012	apoL_c0013	apoL_c0014	apoL_c0015	apoL_c0016	apoL_c0017	apoL_c0018	apoL_c0019	apoL_c0020	apoL_c0021	apoL_c0022	apoL_c0023	apoL_c0024	apoL_c0025	apoL_c0026	apoL_c0027	apoL_c0028	apoL_c0029	apoL_c0030	apoL_c0031	apoL_c0032	apoL_c0033	apoL_c0034	apoL_c0035	apoL_c0036	apoL_c0037	apoL_c0038	apoL_c0039	apoL_c0040
+
+1001	ex_a1	Jean	10/01/2003																																								
+1002	ex_a2	Lucie	11/01/2003																																								
+1003	ex_b1	Hélène	11/01/2003																																								
+1004	ex_b2	Rose	11/01/2003																																								
diff --git a/tests/ressources/yaml/cursus_but_geii_lyon.yaml b/tests/ressources/yaml/cursus_but_geii_lyon.yaml
index 7ae5ecaaea14a3bc30fa7a6d20c1c4f963ef8653..fefdc9b6bd3bd4a31acbd8c450affab984cd82c3 100644
--- a/tests/ressources/yaml/cursus_but_geii_lyon.yaml
+++ b/tests/ressources/yaml/cursus_but_geii_lyon.yaml
@@ -109,6 +109,16 @@ FormSemestres:
     idx: 1
     date_debut: 2022-09-02
     date_fin: 2023-01-12
+  S3:
+    idx: 3
+    codes_parcours: ['AII']
+    date_debut: 2022-09-01
+    date_fin: 2023-01-15
+  S4:
+    idx: 4
+    codes_parcours: ['AII']
+    date_debut: 2023-01-16
+    date_fin: 2023-07-10
 
 Etudiants:
   geii8:
@@ -1265,3 +1275,135 @@ Etudiants:
                 moy_ue: 13.5000
             # decisions_rcues: aucun RCUE en S1-red
           decision_annee: AJ
+  geii89:
+    prenom: etugeii89
+    civilite: M
+    formsemestres:
+      S1:
+        notes_modules: # on joue avec les SAE seulement car elles sont "diagonales"
+          "S1.1": 13.5000
+          "S1.2": 13.0000
+        attendu: # les codes jury que l'on doit vérifier
+          deca:
+            passage_de_droit: False
+            nb_competences: 2
+            nb_rcue_annee: 0
+            decisions_ues:
+              "UE11":
+                codes: [ "ADM", "..." ]
+                code_valide: ADM
+                decision_jury: ATJ # A cause des absences
+                moy_ue: 13.5000
+              "UE12":
+                codes: [ "ADM", "..." ]
+                code_valide: ADM
+                decision_jury: ATJ # A cause des absences
+                moy_ue: 13.0000
+      S2:
+        notes_modules: # on joue avec les SAE seulement car elles sont "diagonales"
+          "S2.1": 14.5000
+          "S2.2": 14.0000
+        attendu: # les codes jury que l'on doit vérifier
+          deca:
+            passage_de_droit: True # d'apres les notes, on *pourrait* passer
+            autorisations_inscription: [2] # et le jury manuel nous fait passer
+            nb_competences: 2
+            nb_rcue_annee: 2
+            valide_moitie_rcue: False
+            codes: [ "ATJ", "..." ]
+            decisions_ues:
+              "UE21":
+                codes: [ "ADM", "..." ]
+                code_valide: ADM
+                decision_jury: ATJ
+                moy_ue: 14.5000
+              "UE22":
+                codes: [ "ADM", "..." ]
+                code_valide: ADM
+                decision_jury: ATJ
+                moy_ue: 14.0000
+            decisions_rcues: # on repère ici les RCUE par l'acronyme de leur 1ere UE (donc du S1)
+              "UE11":
+                code_valide: ADM # le code proposé en auto
+                decision_jury: ATJ # le code forcé manuellement par le jury
+                rcue:
+                  # moy_rcue: 14.0000  #  Pas de moyenne calculée
+                  est_compensable: False
+              "UE12":
+                code_valide: ADM # le code proposé en auto
+                decision_jury: ATJ # le code forcé manuellement par le jury
+                rcue:
+                  # moy_rcue: 13.5000	#  Pas de moyenne calculée
+                  est_compensable: False
+          decision_annee: ATJ # Passage tout de même en S3
+  #
+  # -----------------------  geii90 : ADSUP envoyés par BUT2 vers BUT1
+  #
+  geii90:
+    prenom: etugeii90
+    civilite: M
+    code_nip: geii90
+    formsemestres:
+      S1: # 2 UEs, les deux en AJ
+        notes_modules: # on joue avec les SAE seulement car elles sont "diagonales"
+          "S1.1": 9.5000
+          "S1.2": 8.5000
+        attendu: # les codes jury que l'on doit vérifier
+          deca:
+            passage_de_droit: False
+            nb_competences: 2
+            nb_rcue_annee: 0
+            decisions_ues:
+              "UE11":
+                codes: [ "AJ", "..." ]
+              "UE12":
+                codes: [ "AJ", "..." ]
+      S2: # pareil, mais le jury le fait passer en S3
+        notes_modules: # on joue avec les SAE seulement car elles sont "diagonales"
+          "S2.1": 9.8000
+          "S2.2": 9.9000
+        attendu: # les codes jury que l'on doit vérifier
+          deca:
+            passage_de_droit: False # d'apres les notes, on ne peut pas passer
+            autorisations_inscription: [2] # et le jury manuel nous fait passer
+            nb_competences: 2
+            nb_rcue_annee: 2
+            valide_moitie_rcue: False
+            codes: [ "ADJ", "ATJ", "RED", "..." ]
+            code_valide: RED # le code proposé en auto
+            decisions_ues:
+              "UE21":
+                codes: [ "AJ", "..." ]
+                code_valide: AJ
+                moy_ue: 9.8
+              "UE22":
+                code_valide: AJ
+                moy_ue: 9.9
+            decisions_rcues: # on repère ici les RCUE par l'acronyme de leur 1ere UE (donc du S1)
+              "UE11":
+                code_valide: AJ # le code proposé en auto
+                rcue:
+                  # moy_rcue: 14.0000  #  Pas de moyenne calculée
+                  est_compensable: False
+              "UE12":
+                code_valide: AJ # le code proposé en auto
+                rcue:
+                  # moy_rcue: 13.5000	#  Pas de moyenne calculée
+                  est_compensable: False
+          decision_annee: ADJ # Passage tout de même en S3 !
+      S3: # le S3 avec 4 niveaux
+        parcours: AII
+        notes_modules: # combinaison pour avoir ADM AJ AJ AJ
+            "AII3": 9
+            "ER3": 10.75
+            "AU3": 8
+        attendu: # les codes jury que l'on doit vérifier
+          deca:
+            passage_de_droit: False # d'apres les notes, on ne peut pas passer
+            autorisations_inscription: [4] # passe en S4
+            nb_competences: 4
+      S4: # le S4 avec 4 niveaux
+        parcours: AII
+        notes_modules: # combinaison pour avoir ADM ADM ADM AJ
+          "PF4": 12
+          "SAE4AII": 8
diff --git a/tests/ressources/yaml/cursus_but_info.yaml b/tests/ressources/yaml/cursus_but_info.yaml
index f735e1e563f1241761c55ff00a13c5156ed111ac..52ab37599a61e5f5033ddf893db65f7c8fa3d9b1 100644
--- a/tests/ressources/yaml/cursus_but_info.yaml
+++ b/tests/ressources/yaml/cursus_but_info.yaml
@@ -1,4 +1,4 @@
-# Tests unitaires 
+# Tests unitaires
 # Le BUT Info a 4 parcours qui partagent certains niveaux de compétences
 # mais à ces niveaux sont associés des UEs dont les coefficients des ressources
 # varient selon le parcours.
@@ -14,58 +14,58 @@ Formation:
   # nota: les associations UE/Niveaux sont déjà données dans ce fichier XML.
   ues:
     # S1
-    'UE11':
+    "UE11":
       annee: BUT1
-    'UE12':
+    "UE12":
       annee: BUT1
-    'UE13':
+    "UE13":
       annee: BUT1
-    'UE14':
+    "UE14":
       annee: BUT1
-    'UE15':
+    "UE15":
       annee: BUT1
-    'UE16':
+    "UE16":
       annee: BUT1
     # S2
-    'UE21':
+    "UE21":
       annee: BUT1
-    'UE22':
+    "UE22":
       annee: BUT1
-    'UE23':
+    "UE23":
       annee: BUT1
-    'UE24':
+    "UE24":
       annee: BUT1
-    'UE25':
+    "UE25":
       annee: BUT1
-    'UE26':
+    "UE26":
       annee: BUT1
     # S3
-    'UE31':
+    "UE31":
       annee: BUT2
-    'UE32':
+    "UE32":
       annee: BUT2
-    'UE33':
+    "UE33":
       annee: BUT2
-    'UE34':
+    "UE34":
       annee: BUT2
-    'UE35':
+    "UE35":
       annee: BUT2
-    'UE36':
+    "UE36":
       annee: BUT2
     # S4
-    'UE41-A': # UE pour le parcours A
+    "UE41-A": # UE pour le parcours A
       annee: BUT2
-    'UE41-B': # UE pour le parcours B (même contenu, coefs différents)
+    "UE41-B": # UE pour le parcours B (même contenu, coefs différents)
       annee: BUT2
-    'UE42':
+    "UE42":
       annee: BUT2
-    'UE43':
+    "UE43":
       annee: BUT2
-    'UE44':
+    "UE44":
       annee: BUT2
-    'UE45':
+    "UE45":
       annee: BUT2
-    'UE46':
+    "UE46":
       annee: BUT2
 
 FormSemestres:
@@ -74,37 +74,41 @@ FormSemestres:
     idx: 1
     date_debut: 2021-09-01
     date_fin: 2022-01-15
-    codes_parcours: ['A', 'B']
+    codes_parcours: ["A", "B"]
   S2:
     idx: 2
     date_debut: 2022-01-16
     date_fin: 2022-06-30
-    codes_parcours: ['A', 'B']
+    codes_parcours: ["A", "B"]
+    elt_sem_apo: TIRW2
+    elt_annee_apo: TIRO
+    etape_apo: TI1!117
   S3:
     idx: 3
     date_debut: 2022-09-01
     date_fin: 2023-01-15
-    codes_parcours: ['A', 'B']
+    codes_parcours: ["A", "B"]
   S4:
     idx: 4
     date_debut: 2023-01-16
     date_fin: 2023-06-30
-    codes_parcours: ['A', 'B']
+    codes_parcours: ["A", "B"]
   S5:
     idx: 5
     date_debut: 2023-09-01
     date_fin: 2024-01-15
-    codes_parcours: ['A', 'B']
+    codes_parcours: ["A", "B"]
   S6:
     idx: 6
     date_debut: 2024-01-16
     date_fin: 2024-06-30
-    codes_parcours: ['A', 'B']
+    codes_parcours: ["A", "B"]
 
 Etudiants:
   ex_a1: # cursus S1 -> S6, valide tout
     prenom: Jean
     civilite: M
+    code_nip: 1001
     formsemestres:
       # on ne note que le portfolio, qui affecte toutes les UEs
       S1:
@@ -115,6 +119,7 @@ Etudiants:
         parcours: A
         notes_modules:
           "P2": 12
+          "R2.04-A": 16
       S3:
         parcours: A
         notes_modules:
@@ -135,6 +140,7 @@ Etudiants:
   ex_a2: # cursus S1 -> S6, valide tout sauf S5
     prenom: Lucie
     civilite: F
+    code_nip: 1002
     formsemestres:
       # on ne note que le portfolio, qui affecte toutes les UEs
       S1:
@@ -145,6 +151,7 @@ Etudiants:
         parcours: A
         notes_modules:
           "P2": 12
+          "R2.04-A": 17
       S3:
         parcours: A
         notes_modules:
@@ -161,10 +168,11 @@ Etudiants:
         parcours: A
         notes_modules:
           "P6-A": 16
-  
+
   ex_b1: # cursus S1 -> S6, valide tout
     prenom: Hélène
     civilite: F
+    code_nip: 1003
     formsemestres:
       # on ne note que le portfolio, qui affecte toutes les UEs
       S1:
@@ -175,6 +183,7 @@ Etudiants:
         parcours: B
         notes_modules:
           "P2": 12
+          "R2.04-B": 18
       S3:
         parcours: B
         notes_modules:
@@ -191,10 +200,11 @@ Etudiants:
         parcours: B
         notes_modules:
           "P6-B": 16
-  
+
   ex_b2: # cursus S1 -> S6, valide tout sauf S6
     prenom: Rose
     civilite: F
+    code_nip: 1004
     formsemestres:
       # on ne note que le portfolio, qui affecte toutes les UEs
       S1:
@@ -205,6 +215,7 @@ Etudiants:
         parcours: B
         notes_modules:
           "P2": 12
+          "R2.04-B": 19
       S3:
         parcours: B
         notes_modules:
diff --git a/tests/unit/test_apogee_export.py b/tests/unit/test_apogee_export.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b84f0a86478c6e70180a76c4647dd0033ed1fda
--- /dev/null
+++ b/tests/unit/test_apogee_export.py
@@ -0,0 +1,54 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2023 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+""" Test export Apogéee
+
+Ces tests sont généralement lents (construction de la base),
+et donc marqués par `@pytest.mark.slow`.
+
+Certains sont aussi marqués par @pytest.mark.lemans ou @pytest.mark.lyon
+pour lancer certains tests spécifiques seulement.
+
+Exemple utilisation spécifique:
+# test sur "apo" seulement:
+pytest --pdb -m apo tests/unit/test_apogee_export.py
+
+Elements Apogée simulés:
+
+- UEs : TIU2x
+- Ressources: R2.xy : TIRxy  (VRETR201 -> TIR201)
+"""
+
+import pytest
+from tests.unit import yaml_setup, yaml_setup_but
+
+import app
+from app.but.jury_but_validation_auto import formsemestre_validation_auto_but
+from app.models import Formation, FormSemestre, UniteEns
+from config import TestConfig
+
+DEPT = TestConfig.DEPT_TEST
+
+
+@pytest.mark.skip  # Ce "test" est utilisé comme setup pour développer, pas comme test unitaire routinier
+@pytest.mark.slow
+@pytest.mark.apo
+def test_refcomp_niveaux_info(test_client):
+    """Test niveaux / parcours / UE pour un BUT INFO
+    avec parcours A et B, même compétences mais coefs différents
+    selon le parcours.
+    """
+    # WIP
+    # pour le moment juste le chargement de la formation, du ref. comp, et des UE du S4.
+    app.set_sco_dept(DEPT)
+    doc, formation, formsemestre_titres = yaml_setup.setup_from_yaml(
+        "tests/ressources/yaml/cursus_but_info.yaml"
+    )
+    for formsemestre_titre in formsemestre_titres:
+        formsemestre = yaml_setup.create_formsemestre_with_etuds(
+            doc, formation, formsemestre_titre
+        )
+    #
diff --git a/tests/unit/test_but_cursus.py b/tests/unit/test_but_cursus.py
index 12b54be1b5080e1db1e88134413c835eb1190377..293e6d24272d7c8b09d6cf6477ee64fa71b39669 100644
--- a/tests/unit/test_but_cursus.py
+++ b/tests/unit/test_but_cursus.py
@@ -31,7 +31,13 @@ def test_cursus_but_jury_gb(test_client):
     app.set_sco_dept(DEPT)
     # login_user(User.query.filter_by(user_name="admin").first()) # XXX pour tests manuels
     # ctx.push() # XXX
-    doc = yaml_setup.setup_from_yaml("tests/ressources/yaml/cursus_but_gb.yaml")
+    doc, formation, formsemestre_titres = yaml_setup.setup_from_yaml(
+        "tests/ressources/yaml/cursus_but_gb.yaml"
+    )
+    for formsemestre_titre in formsemestre_titres:
+        formsemestre = yaml_setup.create_formsemestre_with_etuds(
+            doc, formation, formsemestre_titre
+        )
     formsemestre: FormSemestre = FormSemestre.query.filter_by(titre="S3").first()
     res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
     cursus = FormSemestreCursusBUT(res)
@@ -72,7 +78,13 @@ def test_refcomp_niveaux_info(test_client):
     # WIP
     # pour le moment juste le chargement de la formation, du ref. comp, et des UE du S4.
     app.set_sco_dept(DEPT)
-    doc = yaml_setup.setup_from_yaml("tests/ressources/yaml/cursus_but_info.yaml")
+    doc, formation, formsemestre_titres = yaml_setup.setup_from_yaml(
+        "tests/ressources/yaml/cursus_but_info.yaml"
+    )
+    for formsemestre_titre in formsemestre_titres:
+        formsemestre = yaml_setup.create_formsemestre_with_etuds(
+            doc, formation, formsemestre_titre
+        )
     formsemestre: FormSemestre = FormSemestre.query.filter_by(titre="S4").first()
     assert formsemestre
     res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
diff --git a/tests/unit/test_formsemestre.py b/tests/unit/test_formsemestre.py
index 029450444ef428905e7037c9b6548b7c09c7d5d0..1af482eb1c6a0f3f303494dabde9af47e7055e19 100644
--- a/tests/unit/test_formsemestre.py
+++ b/tests/unit/test_formsemestre.py
@@ -44,9 +44,18 @@ def test_formsemestres_associate_new_version(test_client):
     app.set_sco_dept(DEPT)
     # Construit la base de test GB une seule fois
     # puis lance les tests de jury
-    yaml_setup.setup_from_yaml("tests/ressources/yaml/simple_formsemestres.yaml")
-    formation = Formation.query.filter_by(acronyme="BUT GEII", version=1).first()
+    doc, formation, formsemestre_titres = yaml_setup.setup_from_yaml(
+        "tests/ressources/yaml/simple_formsemestres.yaml"
+    )
+    for formsemestre_titre in formsemestre_titres:
+        formsemestre = yaml_setup.create_formsemestre_with_etuds(
+            doc, formation, formsemestre_titre
+        )
+        assert formsemestre
+    formation_geii = Formation.query.filter_by(acronyme="BUT GEII", version=1).first()
+    assert formation_geii.id == formation.id
     formsemestres = formation.formsemestres.all()
+    assert len(formsemestres) == len(formsemestre_titres)
     # On a deux S1:
     assert len(formsemestres) == 2
     assert {s.semestre_id for s in formsemestres} == {1}
@@ -70,7 +79,14 @@ def test_formsemestre_misc_views(test_client):
     Note: les anciennes vues renvoient souvent des str au lieu de Response.
     """
     app.set_sco_dept(DEPT)
-    yaml_setup.setup_from_yaml("tests/ressources/yaml/simple_formsemestres.yaml")
+    doc, formation, formsemestre_titres = yaml_setup.setup_from_yaml(
+        "tests/ressources/yaml/simple_formsemestres.yaml"
+    )
+    for formsemestre_titre in formsemestre_titres:
+        formsemestre = yaml_setup.create_formsemestre_with_etuds(
+            doc, formation, formsemestre_titre
+        )
+        assert formsemestre
     formsemestre: FormSemestre = FormSemestre.query.first()
 
     # ----- MENU SEMESTRE
diff --git a/tests/unit/yaml_setup.py b/tests/unit/yaml_setup.py
index bf4dc70e4c1aaa113797a25c904c3383fa2ba2b3..277728e479e156468ff2a192b292ce8cff26113a 100644
--- a/tests/unit/yaml_setup.py
+++ b/tests/unit/yaml_setup.py
@@ -99,6 +99,9 @@ def create_formsemestre(
     titre: str,
     date_debut: str,
     date_fin: str,
+    elt_sem_apo: str = None,
+    elt_annee_apo: str = None,
+    etape_apo: str = None,
 ) -> FormSemestre:
     "Création d'un formsemestre, avec ses modimpls et évaluations"
     assert formation.is_apc() or not parcours  # parcours seulement si APC
@@ -110,11 +113,15 @@ def create_formsemestre(
         semestre_id=semestre_id,
         date_debut=date_debut,
         date_fin=date_fin,
+        elt_sem_apo=elt_sem_apo,
+        elt_annee_apo=elt_annee_apo,
     )
     # set responsable (list)
     a_user = User.query.first()
     formsemestre.responsables = [a_user]
     db.session.add(formsemestre)
+    db.session.flush()
+    formsemestre.add_etape(etape_apo)
     # Ajoute tous les modules du semestre sans parcours OU avec l'un des parcours indiqués
     sem_parcours_ids = {p.id for p in parcours}
     modules = [
@@ -228,6 +235,10 @@ def setup_formsemestre(
         assert parcour is not None
         parcours.append(parcour)
 
+    elt_sem_apo = infos.get("elt_sem_apo")
+    elt_annee_apo = infos.get("elt_annee_apo")
+    etape_apo = infos.get("etape_apo")
+
     formsemestre = create_formsemestre(
         formation,
         parcours,
@@ -235,6 +246,9 @@ def setup_formsemestre(
         formsemestre_titre,
         infos["date_debut"],
         infos["date_fin"],
+        elt_sem_apo=elt_sem_apo,
+        elt_annee_apo=elt_annee_apo,
+        etape_apo=etape_apo,
     )
 
     db.session.flush()
@@ -257,6 +271,7 @@ def inscrit_les_etudiants(doc: dict, formsemestre_titre: str = ""):
         # Création des étudiants (sauf si déjà existants)
         prenom = infos.get("prenom", "prénom")
         civilite = infos.get("civilite", "X")
+        code_nip = infos.get("code_nip", None)
         etud = Identite.query.filter_by(
             nom=nom, prenom=prenom, civilite=civilite
         ).first()
@@ -266,6 +281,7 @@ def inscrit_les_etudiants(doc: dict, formsemestre_titre: str = ""):
                 nom=nom,
                 prenom=prenom,
                 civilite=civilite,
+                code_nip=code_nip,
             )
             db.session.add(etud)
             db.session.commit()