diff --git a/app/comp/jury.py b/app/comp/jury.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb7cd0d4e308d4112be92df6c38bd872a6e680f0
--- /dev/null
+++ b/app/comp/jury.py
@@ -0,0 +1,147 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Stockage des décisions de jury
+"""
+import pandas as pd
+
+from app import db
+from app.models import FormSemestre, ScolarFormSemestreValidation
+from app.comp.res_cache import ResultatsCache
+from app.scodoc import sco_cache
+from app.scodoc import sco_codes_parcours
+
+
+class ValidationsSemestre(ResultatsCache):
+    """ """
+
+    _cached_attrs = (
+        "decisions_jury",
+        "decisions_jury_ues",
+        "ue_capitalisees",
+    )
+
+    def __init__(self, formsemestre: FormSemestre):
+        super().__init__(formsemestre, sco_cache.ValidationsSemestreCache)
+
+        self.decisions_jury = {}
+        """Décisions prises dans ce semestre: 
+        { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}"""
+        self.decisions_jury_ues = {}
+        """Décisions sur des UEs dans ce semestre:
+        { etudid : { ue_id : { 'code' : Note|ADM|CMP, 'event_date' }}}
+        """
+
+        if not self.load_cached():
+            self.compute()
+            self.store()
+
+    def compute(self):
+        "Charge les résultats de jury et UEs capitalisées"
+        self.ue_capitalisees = formsemestre_get_ue_capitalisees(self.formsemestre)
+        self.comp_decisions_jury()
+
+    def comp_decisions_jury(self):
+        """Cherche les decisions du jury pour le semestre (pas les UE).
+        Calcule les attributs:
+        decisions_jury = { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}
+        decision_jury_ues={ etudid : { ue_id : { 'code' : Note|ADM|CMP, 'event_date' }}}
+        Si la décision n'a pas été prise, la clé etudid n'est pas présente.
+        Si l'étudiant est défaillant, pas de décisions d'UE.
+        """
+        # repris de NotesTable.comp_decisions_jury pour la compatibilité
+        decisions_jury_q = ScolarFormSemestreValidation.query.filter_by(
+            formsemestre_id=self.formsemestre.id
+        )
+        decisions_jury = {}
+        for decision in decisions_jury_q.filter(
+            ScolarFormSemestreValidation.ue_id == None  # slt dec. sem.
+        ):
+            decisions_jury[decision.etudid] = {
+                "code": decision.code,
+                "assidu": decision.assidu,
+                "compense_formsemestre_id": decision.compense_formsemestre_id,
+                "event_date": decision.event_date.strftime("%d/%m/%Y"),
+            }
+        self.decisions_jury = decisions_jury
+
+        # UEs:
+        decisions_jury_ues = {}
+        for decision in decisions_jury_q.filter(
+            ScolarFormSemestreValidation.ue_id != None  # slt dec. sem.
+        ):
+            if decision.etudid not in decisions_jury_ues:
+                decisions_jury_ues[decision.etudid] = {}
+            # Calcul des ECTS associés à cette UE:
+            if sco_codes_parcours.code_ue_validant(decision.code):
+                ects = decision.ue.ects or 0.0  # 0 if None
+            else:
+                ects = 0.0
+
+            decisions_jury_ues[decision.etudid][decision.ue.id] = {
+                "code": decision.code,
+                "ects": ects,  # 0. si UE non validée
+                "event_date": decision.event_date.strftime("%d/%m/%Y"),
+            }
+
+        self.decisions_jury_ues = decisions_jury_ues
+
+
+def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame:
+    """Liste des UE capitalisées (ADM) utilisables dans ce formsemestre
+
+    Recherche dans les semestres des formations de même code, avec le même semestre_id
+    et une date de début antérieure que celle du formsemestre.
+    Prend aussi les UE externes validées.
+
+    Attention: fonction très coûteuse, cacher le résultat.
+
+    Résultat: DataFrame avec
+        etudid (index)
+        formsemestre_id : origine de l'UE capitalisée
+        is_external : vrai si validation effectuée dans un semestre extérieur
+        ue_id :  dans le semestre origine (pas toujours de la même formation)
+        ue_code : code de l'UE
+        moy_ue :
+        event_date :
+    } ]
+    """
+    query = """
+    SELECT DISTINCT SFV.*, ue.ue_code
+    FROM 
+    notes_ue ue, 
+    notes_formations nf,
+    notes_formations nf2, 
+    scolar_formsemestre_validation SFV, 
+    notes_formsemestre sem,
+    notes_formsemestre_inscription ins
+
+    WHERE ue.formation_id = nf.id
+    and nf.formation_code = nf2.formation_code
+    and nf2.id=%(formation_id)s
+    and ins.etudid = SFV.etudid
+    and ins.formsemestre_id = %(formsemestre_id)s
+
+    and SFV.ue_id = ue.id
+    and SFV.code = 'ADM'
+
+    and ( (sem.id = SFV.formsemestre_id
+        and sem.date_debut < %(date_debut)s
+        and sem.semestre_id = %(semestre_id)s )
+        or (
+            ((SFV.formsemestre_id is NULL) OR (SFV.is_external)) -- les UE externes ou "anterieures"
+            AND (SFV.semestre_id is NULL OR SFV.semestre_id=%(semestre_id)s)
+           ) )
+    """
+    params = {
+        "formation_id": formsemestre.formation.id,
+        "formsemestre_id": formsemestre.id,
+        "semestre_id": formsemestre.semestre_id,
+        "date_debut": formsemestre.date_debut,
+    }
+
+    df = pd.read_sql_query(query, db.engine, params=params, index_col="etudid")
+    return df
diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index f890b6a615f358790abcb6070cd30421e8c247da..f30f04912341f92cceb5d7596a79816b9bfeb1d0 100644
--- a/app/comp/moy_mod.py
+++ b/app/comp/moy_mod.py
@@ -77,6 +77,8 @@ class ModuleImplResults:
         "{ evaluation.id : bool } indique si à prendre en compte ou non."
         self.evaluations_etat = {}
         "{ evaluation_id: EvaluationEtat }"
+        self.en_attente = False
+        "Vrai si au moins une évaluation a une note en attente"
         #
         self.evals_notes = None
         """DataFrame, colonnes: EVALS, Lignes: etudid (inscrits au SEMESTRE)
@@ -133,7 +135,7 @@ class ModuleImplResults:
         evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
         self.evaluations_completes = []
         self.evaluations_completes_dict = {}
-
+        self.en_attente = False
         for evaluation in moduleimpl.evaluations:
             eval_df = self._load_evaluation_notes(evaluation)
             # is_complete ssi tous les inscrits (non dem) au semestre ont une note
@@ -160,6 +162,8 @@ class ModuleImplResults:
             self.evaluations_etat[evaluation.id] = EvaluationEtat(
                 evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete
             )
+            if nb_att > 0:
+                self.en_attente = True
 
         # Force columns names to integers (evaluation ids)
         evals_notes.columns = pd.Int64Index(
@@ -209,6 +213,13 @@ class ModuleImplResults:
             * self.evaluations_completes
         ).reshape(-1, 1)
 
+    # was _list_notes_evals_titles
+    def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list:
+        "Liste des évaluations complètes"
+        return [
+            e for e in moduleimpl.evaluations if self.evaluations_completes_dict[e.id]
+        ]
+
     def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array:
         """Les notes des évaluations,
         remplace les  ATT, EXC, ABS, NaN par zéro et mets les notes sur 20.
diff --git a/app/comp/res_cache.py b/app/comp/res_cache.py
new file mode 100644
index 0000000000000000000000000000000000000000..47c40b7e4281fde81ad4028e2a3277ddfc2f096d
--- /dev/null
+++ b/app/comp/res_cache.py
@@ -0,0 +1,34 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Cache pour résultats (super classe)
+"""
+
+from app.models import FormSemestre
+
+
+class ResultatsCache:
+    _cached_attrs = ()  # virtual
+
+    def __init__(self, formsemestre: FormSemestre, cache_class=None):
+        self.formsemestre: FormSemestre = formsemestre
+        self.cache_class = cache_class
+
+    def load_cached(self) -> bool:
+        "Load cached dataframes, returns False si pas en cache"
+        data = self.cache_class.get(self.formsemestre.id)
+        if not data:
+            return False
+        for attr in self._cached_attrs:
+            setattr(self, attr, data[attr])
+        return True
+
+    def store(self):
+        "Cache our data"
+        self.cache_class.set(
+            self.formsemestre.id,
+            {attr: getattr(self, attr) for attr in self._cached_attrs},
+        )
diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index 601f76a99bd51e11ed87c0b6baa0c6750a9622d8..3c408257da72d6873278c5551d1b4a6cc09c0603 100644
--- a/app/comp/res_common.py
+++ b/app/comp/res_common.py
@@ -9,10 +9,13 @@ from functools import cached_property
 import numpy as np
 import pandas as pd
 from app.comp.aux_stats import StatsMoyenne
+from app.comp.res_cache import ResultatsCache
+from app.comp import res_sem
 from app.comp.moy_mod import ModuleImplResults
 from app.models import FormSemestre, Identite, ModuleImpl
 from app.models.ues import UniteEns
 from app.scodoc import sco_utils as scu
+from app.scodoc import sco_evaluations
 from app.scodoc.sco_cache import ResultatsSemestreCache
 from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF
 
@@ -25,7 +28,7 @@ from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF
 #      (durée de vie de l'instance de ResultatsSemestre)
 #      qui sont notamment les attributs décorés par `@cached_property``
 #
-class ResultatsSemestre:
+class ResultatsSemestre(ResultatsCache):
     _cached_attrs = (
         "etud_moy_gen_ranks",
         "etud_moy_gen",
@@ -36,7 +39,7 @@ class ResultatsSemestre:
     )
 
     def __init__(self, formsemestre: FormSemestre):
-        self.formsemestre: FormSemestre = formsemestre
+        super().__init__(formsemestre, ResultatsSemestreCache)
         # BUT ou standard ? (apc == "approche par compétences")
         self.is_apc = formsemestre.formation.is_apc()
         # Attributs "virtuels", définis dans les sous-classes
@@ -46,26 +49,9 @@ class ResultatsSemestre:
         self.etud_moy_gen = {}
         self.etud_moy_gen_ranks = {}
         self.modimpls_results: ModuleImplResults = None
+        "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }"
         self.etud_coef_ue_df = None
-        """coefs d'UE effectifs pour chaque etudiant (pour form. classiques)"""
-
-        # TODO ?
-
-    def load_cached(self) -> bool:
-        "Load cached dataframes, returns False si pas en cache"
-        data = ResultatsSemestreCache.get(self.formsemestre.id)
-        if not data:
-            return False
-        for attr in self._cached_attrs:
-            setattr(self, attr, data[attr])
-        return True
-
-    def store(self):
-        "Cache our data"
-        ResultatsSemestreCache.set(
-            self.formsemestre.id,
-            {attr: getattr(self, attr) for attr in self._cached_attrs},
-        )
+        """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)"""
 
     def compute(self):
         "Charge les notes et inscriptions et calcule toutes les moyennes"
@@ -101,7 +87,8 @@ class ResultatsSemestre:
     @cached_property
     def ues(self) -> list[UniteEns]:
         """Liste des UEs du semestre (avec les UE bonus sport)
-        (indices des DataFrames)
+        (indices des DataFrames).
+        Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs.
         """
         return self.formsemestre.query_ues(with_sport=True).all()
 
@@ -123,15 +110,34 @@ class ResultatsSemestre:
             if m.module.module_type == scu.ModuleType.SAE
         ]
 
-    @cached_property
-    def ue_validables(self) -> list:
-        """Liste des UE du semestre qui doivent être validées
-        (toutes sauf le sport)
+    def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]:
+        """Liste des UEs du semestre qui doivent être validées
+
+        Rappel: l'étudiant est inscrit à des modimpls et non à des UEs.
+
+        - En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules
+        du parcours. XXX notion à implémenter, pour l'instant toutes les UE du semestre.
+
+        - En classique: toutes les UEs des modimpls auxquels l'étufdiant est inscrit sont
+        susceptibles d'être validées.
+
+        Les UE "bonus" (sport) ne sont jamais "validables".
         """
-        return self.formsemestre.query_ues().filter(UniteEns.type != UE_SPORT).all()
+        if self.is_apc:
+            # TODO: introduire la notion de parcours (#sco93)
+            return self.formsemestre.query_ues().filter(UniteEns.type != UE_SPORT).all()
+        else:
+            # restreint aux UE auxquelles l'étudiant est inscrit (dans l'un des modimpls)
+            ues = {
+                modimpl.module.ue
+                for modimpl in self.formsemestre.modimpls_sorted
+                if self.modimpl_inscr_df[modimpl.id][etudid]
+            }
+            ues = sorted(list(ues), key=lambda x: x.numero or 0)
+            return ues
 
-    def modimpls_in_ue(self, ue_id, etudid):
-        """Liste des modimpl de cet ue auxquels l'étudiant est inscrit"""
+    def modimpls_in_ue(self, ue_id, etudid) -> list[ModuleImpl]:
+        """Liste des modimpl de cette UE auxquels l'étudiant est inscrit"""
         # sert pour l'affichage ou non de l'UE sur le bulletin
         return [
             modimpl
@@ -180,6 +186,7 @@ class NotesTableCompat(ResultatsSemestre):
         self.moy_moy = "NA"
         self.expr_diagnostics = ""
         self.parcours = self.formsemestre.formation.get_parcours()
+        self.validations = None
 
     def get_etudids(self, sorted=False) -> list[int]:
         """Liste des etudids inscrits, incluant les démissionnaires.
@@ -243,6 +250,21 @@ class NotesTableCompat(ResultatsSemestre):
             modimpls_dict.append(d)
         return modimpls_dict
 
+    def get_etud_decision_ues(self, etudid: int) -> dict:
+        """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu.
+        Ne tient pas compte des UE capitalisées.
+        { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : }
+        Ne renvoie aucune decision d'UE pour les défaillants
+        """
+        if self.get_etud_etat(etudid) == DEF:
+            return {}
+        else:
+            if not self.validations:
+                self.validations = res_sem.load_formsemestre_validations(
+                    self.formsemestre
+                )
+            return self.validations.decisions_jury_ues.get(etudid, None)
+
     def get_etud_decision_sem(self, etudid: int) -> dict:
         """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu.
         { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id }
@@ -256,12 +278,11 @@ class NotesTableCompat(ResultatsSemestre):
                 "compense_formsemestre_id": None,
             }
         else:
-            return {
-                "code": ATT,  # XXX TODO
-                "assidu": True,  # XXX TODO
-                "event_date": "",
-                "compense_formsemestre_id": None,
-            }
+            if not self.validations:
+                self.validations = res_sem.load_formsemestre_validations(
+                    self.formsemestre
+                )
+            return self.validations.decisions_jury.get(etudid, None)
 
     def get_etud_etat(self, etudid: int) -> str:
         "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)"
@@ -290,6 +311,31 @@ class NotesTableCompat(ResultatsSemestre):
         """
         return self.etud_moy_gen[etudid]
 
+    def get_etud_ects_pot(self, etudid: int) -> dict:
+        """
+        Un dict avec les champs
+         ects_pot : (float) nb de crédits ECTS qui seraient validés (sous réserve de validation par le jury),
+         ects_pot_fond: (float) nb d'ECTS issus d'UE fondamentales (non électives)
+
+        Ce sont les ECTS des UE au dessus de la barre (10/20 en principe), avant le jury (donc non
+        encore enregistrées).
+        """
+        # was nt.get_etud_moy_infos
+        # XXX pour compat nt, à remplacer ultérieurement
+        ues = self.get_etud_ue_validables(etudid)
+        ects_pot = 0.0
+        for ue in ues:
+            if (
+                ue.id in self.etud_moy_ue
+                and ue.ects is not None
+                and self.etud_moy_ue[ue.id][etudid] > self.parcours.NOTES_BARRE_VALID_UE
+            ):
+                ects_pot += ue.ects
+        return {
+            "ects_pot": ects_pot,
+            "ects_fond": 0.0,  # not implemented (anciennemment pour école ingé)
+        }
+
     def get_etud_ue_status(self, etudid: int, ue_id: int):
         coef_ue = self.etud_coef_ue_df[ue_id][etudid]
         return {
@@ -333,8 +379,32 @@ class NotesTableCompat(ResultatsSemestre):
                 evals_results.append(d)
         return evals_results
 
+    def get_evaluations_etats(self):
+        """[ {...evaluation et son etat...} ]"""
+        # TODO: à moderniser
+        if not hasattr(self, "_evaluations_etats"):
+            self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem(
+                self.formsemestre.id
+            )
+
+        return self._evaluations_etats
+
+    def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
+        """Liste des états des évaluations de ce module"""
+        # XXX TODO à moderniser: lent, recharge des donénes que l'on a déjà...
+        return [
+            e
+            for e in self.get_evaluations_etats()
+            if e["moduleimpl_id"] == moduleimpl_id
+        ]
+
     def get_moduleimpls_attente(self):
-        return []  # XXX TODO
+        """Liste des modimpls du semestre ayant des notes en attente"""
+        return [
+            modimpl
+            for modimpl in self.formsemestre.modimpls_sorted
+            if self.modimpls_results[modimpl.id].en_attente
+        ]
 
     def get_mod_stats(self, moduleimpl_id: int) -> dict:
         """Stats sur les notes obtenues dans un modimpl
diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py
index 8e14d5afe2d990ee6256b3a00f800352232d8a87..5da2c7f061f0e99ecd056f972281d546e68909bd 100644
--- a/app/comp/res_sem.py
+++ b/app/comp/res_sem.py
@@ -8,31 +8,49 @@
 """
 from flask import g
 
+from app.comp.jury import ValidationsSemestre
 from app.comp.res_common import ResultatsSemestre
 from app.comp.res_classic import ResultatsSemestreClassic
 from app.comp.res_but import ResultatsSemestreBUT
 from app.models.formsemestre import FormSemestre
 
 
-def load_formsemestre_result(formsemestre: FormSemestre) -> ResultatsSemestre:
+def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre:
     """Returns ResultatsSemestre for this formsemestre.
     Suivant le type de formation, retour une instance de
     ResultatsSemestreClassic ou de ResultatsSemestreBUT.
 
     Search in local cache (g.formsemestre_result_cache)
-    then global app cache (eg REDIS)
     If not in cache, build it and cache it.
     """
     # --- Try local cache (within the same request context)
-    if not hasattr(g, "formsemestre_result_cache"):
-        g.formsemestre_result_cache = {}  # pylint: disable=C0237
+    if not hasattr(g, "formsemestre_results_cache"):
+        g.formsemestre_results_cache = {}  # pylint: disable=C0237
     else:
-        if formsemestre.id in g.formsemestre_result_cache:
-            return g.formsemestre_result_cache[formsemestre.id]
+        if formsemestre.id in g.formsemestre_results_cache:
+            return g.formsemestre_results_cache[formsemestre.id]
 
     klass = (
         ResultatsSemestreBUT
         if formsemestre.formation.is_apc()
         else ResultatsSemestreClassic
     )
-    return klass(formsemestre)
+    g.formsemestre_results_cache[formsemestre.id] = klass(formsemestre)
+    return g.formsemestre_results_cache[formsemestre.id]
+
+
+def load_formsemestre_validations(formsemestre: FormSemestre) -> ValidationsSemestre:
+    """Charge les résultats de jury de ce semestre.
+    Search in local cache (g.formsemestre_result_cache)
+    If not in cache, build it and cache it.
+    """
+    if not hasattr(g, "formsemestre_validation_cache"):
+        g.formsemestre_validations_cache = {}  # pylint: disable=C0237
+    else:
+        if formsemestre.id in g.formsemestre_validations_cache:
+            return g.formsemestre_validations_cache[formsemestre.id]
+
+    g.formsemestre_validations_cache[formsemestre.id] = ValidationsSemestre(
+        formsemestre
+    )
+    return g.formsemestre_validations_cache[formsemestre.id]
diff --git a/app/models/__init__.py b/app/models/__init__.py
index f110849348d49f7a746271baacb3039d699155fd..d29b6bf3b12668747df443d869136081aa180128 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -49,13 +49,15 @@ from app.models.evaluations import (
 )
 from app.models.groups import Partition, GroupDescr, group_membership
 from app.models.notes import (
-    ScolarEvent,
-    ScolarFormSemestreValidation,
-    ScolarAutorisationInscription,
     BulAppreciations,
     NotesNotes,
     NotesNotesLog,
 )
+from app.models.validations import (
+    ScolarEvent,
+    ScolarFormSemestreValidation,
+    ScolarAutorisationInscription,
+)
 from app.models.preferences import ScoPreference
 
 from app.models.but_refcomp import (
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 2234fdfa11003bd1808226172efb4b25d8d8154c..e4b8fc8c8d81f8e46b3760500c55cc1140fcfdf3 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -158,7 +158,7 @@ class FormSemestre(db.Model):
 
     @cached_property
     def modimpls_sorted(self) -> list[ModuleImpl]:
-        """Liste des modimpls du semestre
+        """Liste des modimpls du semestre (y compris bonus)
         - triée par type/numéro/code en APC
         - triée par numéros d'UE/matières/modules pour les formations standard.
         """
diff --git a/app/models/notes.py b/app/models/notes.py
index fa8dc8d106b0bac8df18e9d87d3b84db50c688ff..7e5583579dd18428ac0978ecd5eaf433c0a8d273 100644
--- a/app/models/notes.py
+++ b/app/models/notes.py
@@ -8,100 +8,6 @@ from app.models import SHORT_STR_LEN
 from app.models import CODE_STR_LEN
 
 
-class ScolarEvent(db.Model):
-    """Evenement dans le parcours scolaire d'un étudiant"""
-
-    __tablename__ = "scolar_events"
-    id = db.Column(db.Integer, primary_key=True)
-    event_id = db.synonym("id")
-    etudid = db.Column(
-        db.Integer,
-        db.ForeignKey("identite.id"),
-    )
-    event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
-    formsemestre_id = db.Column(
-        db.Integer,
-        db.ForeignKey("notes_formsemestre.id"),
-    )
-    ue_id = db.Column(
-        db.Integer,
-        db.ForeignKey("notes_ue.id"),
-    )
-    # 'CREATION', 'INSCRIPTION', 'DEMISSION',
-    # 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
-    # 'ECHEC_SEM'
-    # 'UTIL_COMPENSATION'
-    event_type = db.Column(db.String(SHORT_STR_LEN))
-    # Semestre compensé par formsemestre_id:
-    comp_formsemestre_id = db.Column(
-        db.Integer,
-        db.ForeignKey("notes_formsemestre.id"),
-    )
-
-
-class ScolarFormSemestreValidation(db.Model):
-    """Décisions de jury"""
-
-    __tablename__ = "scolar_formsemestre_validation"
-    # Assure unicité de la décision:
-    __table_args__ = (db.UniqueConstraint("etudid", "formsemestre_id", "ue_id"),)
-
-    id = db.Column(db.Integer, primary_key=True)
-    formsemestre_validation_id = db.synonym("id")
-    etudid = db.Column(
-        db.Integer,
-        db.ForeignKey("identite.id"),
-        index=True,
-    )
-    formsemestre_id = db.Column(
-        db.Integer,
-        db.ForeignKey("notes_formsemestre.id"),
-        index=True,
-    )
-    ue_id = db.Column(
-        db.Integer,
-        db.ForeignKey("notes_ue.id"),
-        index=True,
-    )
-    code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
-    # NULL pour les UE, True|False pour les semestres:
-    assidu = db.Column(db.Boolean)
-    event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
-    # NULL sauf si compense un semestre:
-    compense_formsemestre_id = db.Column(
-        db.Integer,
-        db.ForeignKey("notes_formsemestre.id"),
-    )
-    moy_ue = db.Column(db.Float)
-    # (normalement NULL) indice du semestre, utile seulement pour
-    # UE "antérieures" et si la formation définit des UE utilisées
-    # dans plusieurs semestres (cas R&T IUTV v2)
-    semestre_id = db.Column(db.Integer)
-    # Si UE validée dans le cursus d'un autre etablissement
-    is_external = db.Column(db.Boolean, default=False, server_default="false")
-
-
-class ScolarAutorisationInscription(db.Model):
-    """Autorisation d'inscription dans un semestre"""
-
-    __tablename__ = "scolar_autorisation_inscription"
-    id = db.Column(db.Integer, primary_key=True)
-    autorisation_inscription_id = db.synonym("id")
-
-    etudid = db.Column(
-        db.Integer,
-        db.ForeignKey("identite.id"),
-    )
-    formation_code = db.Column(db.String(SHORT_STR_LEN), nullable=False)
-    # semestre ou on peut s'inscrire:
-    semestre_id = db.Column(db.Integer)
-    date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
-    origin_formsemestre_id = db.Column(
-        db.Integer,
-        db.ForeignKey("notes_formsemestre.id"),
-    )
-
-
 class BulAppreciations(db.Model):
     """Appréciations sur bulletins"""
 
diff --git a/app/models/validations.py b/app/models/validations.py
new file mode 100644
index 0000000000000000000000000000000000000000..0bf487f3a85b1ac32fdefe243c3e800dc3227489
--- /dev/null
+++ b/app/models/validations.py
@@ -0,0 +1,109 @@
+# -*- coding: UTF-8 -*
+
+"""Notes, décisions de jury, évènements scolaires
+"""
+
+from app import db
+from app.models import SHORT_STR_LEN
+from app.models import CODE_STR_LEN
+
+
+class ScolarFormSemestreValidation(db.Model):
+    """Décisions de jury"""
+
+    __tablename__ = "scolar_formsemestre_validation"
+    # Assure unicité de la décision:
+    __table_args__ = (db.UniqueConstraint("etudid", "formsemestre_id", "ue_id"),)
+
+    id = db.Column(db.Integer, primary_key=True)
+    formsemestre_validation_id = db.synonym("id")
+    etudid = db.Column(
+        db.Integer,
+        db.ForeignKey("identite.id"),
+        index=True,
+    )
+    formsemestre_id = db.Column(
+        db.Integer,
+        db.ForeignKey("notes_formsemestre.id"),
+        index=True,
+    )
+    ue_id = db.Column(
+        db.Integer,
+        db.ForeignKey("notes_ue.id"),
+        index=True,
+    )
+    code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
+    # NULL pour les UE, True|False pour les semestres:
+    assidu = db.Column(db.Boolean)
+    event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
+    # NULL sauf si compense un semestre:
+    compense_formsemestre_id = db.Column(
+        db.Integer,
+        db.ForeignKey("notes_formsemestre.id"),
+    )
+    moy_ue = db.Column(db.Float)
+    # (normalement NULL) indice du semestre, utile seulement pour
+    # UE "antérieures" et si la formation définit des UE utilisées
+    # dans plusieurs semestres (cas R&T IUTV v2)
+    semestre_id = db.Column(db.Integer)
+    # Si UE validée dans le cursus d'un autre etablissement
+    is_external = db.Column(
+        db.Boolean, default=False, server_default="false", index=True
+    )
+
+    ue = db.relationship("UniteEns", lazy="select", uselist=False)
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue_id}, moy_ue={self.moy_ue})"
+
+
+class ScolarAutorisationInscription(db.Model):
+    """Autorisation d'inscription dans un semestre"""
+
+    __tablename__ = "scolar_autorisation_inscription"
+    id = db.Column(db.Integer, primary_key=True)
+    autorisation_inscription_id = db.synonym("id")
+
+    etudid = db.Column(
+        db.Integer,
+        db.ForeignKey("identite.id"),
+    )
+    formation_code = db.Column(db.String(SHORT_STR_LEN), nullable=False)
+    # semestre ou on peut s'inscrire:
+    semestre_id = db.Column(db.Integer)
+    date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
+    origin_formsemestre_id = db.Column(
+        db.Integer,
+        db.ForeignKey("notes_formsemestre.id"),
+    )
+
+
+class ScolarEvent(db.Model):
+    """Evenement dans le parcours scolaire d'un étudiant"""
+
+    __tablename__ = "scolar_events"
+    id = db.Column(db.Integer, primary_key=True)
+    event_id = db.synonym("id")
+    etudid = db.Column(
+        db.Integer,
+        db.ForeignKey("identite.id"),
+    )
+    event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
+    formsemestre_id = db.Column(
+        db.Integer,
+        db.ForeignKey("notes_formsemestre.id"),
+    )
+    ue_id = db.Column(
+        db.Integer,
+        db.ForeignKey("notes_ue.id"),
+    )
+    # 'CREATION', 'INSCRIPTION', 'DEMISSION',
+    # 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
+    # 'ECHEC_SEM'
+    # 'UTIL_COMPENSATION'
+    event_type = db.Column(db.String(SHORT_STR_LEN))
+    # Semestre compensé par formsemestre_id:
+    comp_formsemestre_id = db.Column(
+        db.Integer,
+        db.ForeignKey("notes_formsemestre.id"),
+    )
diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py
index 07cbd13365d08b8256ec78a2e78947e394afba81..b9b4630ceb80830ba94191800c0853321f86cbb6 100644
--- a/app/scodoc/notes_table.py
+++ b/app/scodoc/notes_table.py
@@ -935,7 +935,7 @@ class NotesTable:
         """
         return self.moy_gen[etudid]
 
-    def get_etud_moy_infos(self, etudid):
+    def get_etud_moy_infos(self, etudid):  # XXX OBSOLETE
         """Infos sur moyennes"""
         return self.etud_moy_infos[etudid]
 
@@ -1011,7 +1011,10 @@ class NotesTable:
         cnx = ndb.GetDBConnexion()
         cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
         cursor.execute(
-            "select etudid, code, assidu, compense_formsemestre_id, event_date from scolar_formsemestre_validation where formsemestre_id=%(formsemestre_id)s and ue_id is NULL;",
+            """SELECT etudid, code, assidu, compense_formsemestre_id, event_date 
+            FROM scolar_formsemestre_validation 
+            WHERE formsemestre_id=%(formsemestre_id)s AND ue_id is NULL;
+            """,
             {"formsemestre_id": self.formsemestre_id},
         )
         decisions_jury = {}
@@ -1137,8 +1140,14 @@ class NotesTable:
         """
         self.ue_capitalisees = scu.DictDefault(defaultvalue=[])
         cnx = None
+        semestre_id = self.sem["semestre_id"]
         for etudid in self.get_etudids():
-            capital = formsemestre_get_etud_capitalisation(self.sem, etudid)
+            capital = formsemestre_get_etud_capitalisation(
+                self.formation["id"],
+                semestre_id,
+                ndb.DateDMYtoISO(self.sem["date_debut"]),
+                etudid,
+            )
             for ue_cap in capital:
                 # Si la moyenne d'UE n'avait pas été stockée (anciennes versions de ScoDoc)
                 # il faut la calculer ici et l'enregistrer
@@ -1308,7 +1317,7 @@ class NotesTable:
         """Liste des evaluations de ce semestre, avec leur etat"""
         return self.get_evaluations_etats()
 
-    def get_mod_evaluation_etat_list(self, moduleimpl_id):
+    def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
         """Liste des évaluations de ce module"""
         return [
             e
diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py
index 23833f88caa9a496b4dac9adf9b04113d31c3a48..d1127110c4143b818aaa746197a41b0c0cb15e31 100644
--- a/app/scodoc/sco_bulletins.py
+++ b/app/scodoc/sco_bulletins.py
@@ -142,7 +142,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
     prefs = sco_preferences.SemPreferences(formsemestre_id)
     # nt = sco_cache.NotesTableCache.get(formsemestre_id)  # > toutes notes
     formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
-    nt: NotesTableCompat = res_sem.load_formsemestre_result(formsemestre)
+    nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
     if not nt.get_etud_etat(etudid):
         raise ScoValueError("Etudiant non inscrit à ce semestre")
     I = scu.DictDefault(defaultvalue="")
diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py
index 59ebab2d5b1de7a015d1cd9dfb74ae050de014b8..5fd0cec686d0d10d379d64ca16b08a9cbbee3008 100644
--- a/app/scodoc/sco_cache.py
+++ b/app/scodoc/sco_cache.py
@@ -59,9 +59,9 @@ import traceback
 
 from flask import g
 
+from app import log
 from app.scodoc import notesdb as ndb
 from app.scodoc import sco_utils as scu
-from app import log
 
 CACHE = None  # set in app.__init__.py
 
@@ -293,6 +293,7 @@ def invalidate_formsemestre(  # was inval_cache(formsemestre_id=None, pdfonly=Fa
 
     SemBulletinsPDFCache.invalidate_sems(formsemestre_ids)
     ResultatsSemestreCache.delete_many(formsemestre_ids)
+    ValidationsSemestreCache.delete_many(formsemestre_ids)
 
 
 class DefferedSemCacheManager:
@@ -319,10 +320,20 @@ class DefferedSemCacheManager:
 
 # ---- Nouvelles classes ScoDoc 9.2
 class ResultatsSemestreCache(ScoDocCache):
-    """Cache pour les résultats ResultatsSemestre.
+    """Cache pour les résultats ResultatsSemestre (notes et moyennes)
     Clé: formsemestre_id
     Valeur: { un paquet de dataframes }
     """
 
     prefix = "RSEM"
     timeout = 60 * 60  # ttl 1 heure (en phase de mise au point)
+
+
+class ValidationsSemestreCache(ScoDocCache):
+    """Cache pour les résultats de jury d'un semestre
+    Clé: formsemestre_id
+    Valeur: un paquet de DataFrames
+    """
+
+    prefix = "VSC"
+    timeout = 60 * 60  # ttl 1 heure (en phase de mise au point)
diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py
index 823dd19fcdb4dd600c91f721568fed1042d058d0..59372b8d62f57290bd013c4f8cd22d595f4167f8 100644
--- a/app/scodoc/sco_codes_parcours.py
+++ b/app/scodoc/sco_codes_parcours.py
@@ -170,18 +170,18 @@ CODES_SEM_REO = {NAR: 1}  # reorientation
 CODES_UE_VALIDES = {ADM: True, CMP: True}  # UE validée
 
 
-def code_semestre_validant(code):
+def code_semestre_validant(code: str) -> bool:
     "Vrai si ce CODE entraine la validation du semestre"
     return CODES_SEM_VALIDES.get(code, False)
 
 
-def code_semestre_attente(code):
+def code_semestre_attente(code: str) -> bool:
     "Vrai si ce CODE est un code d'attente (semestre validable plus tard par jury ou compensation)"
     return CODES_SEM_ATTENTES.get(code, False)
 
 
-def code_ue_validant(code):
-    "Vrai si ce code entraine la validation de l'UE"
+def code_ue_validant(code: str) -> bool:
+    "Vrai si ce code entraine la validation des UEs du semestre."
     return CODES_UE_VALIDES.get(code, False)
 
 
@@ -259,6 +259,7 @@ class TypeParcours(object):
     )  # par defaut, autorise tous les types d'UE
     APC_SAE = False  # Approche par compétences avec ressources et SAÉs
     USE_REFERENTIEL_COMPETENCES = False  # Lien avec ref. comp.
+    ECTS_FONDAMENTAUX_PER_YEAR = 0.0  # pour ISCID, deprecated
 
     def check(self, formation=None):
         return True, ""  # status, diagnostic_message
diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py
index c1c75319bd81a91cb6fb7078a4adb860491635b1..c6b0151d6981bdd23746d67676c3b66b330c2aa9 100644
--- a/app/scodoc/sco_edit_apc.py
+++ b/app/scodoc/sco_edit_apc.py
@@ -32,7 +32,7 @@ from flask_login import current_user
 
 from app import db
 from app.models import Formation, UniteEns, Matiere, Module, FormSemestre, ModuleImpl
-from app.models.notes import ScolarFormSemestreValidation
+from app.models.validations import ScolarFormSemestreValidation
 from app.scodoc.sco_codes_parcours import UE_SPORT
 import app.scodoc.sco_utils as scu
 from app.scodoc import sco_groups
diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py
index 15b690a3ca5e343537a7c15445a966e809dce5b9..c54806e23efb512bacd6688b61383cf9dbe13572 100644
--- a/app/scodoc/sco_evaluations.py
+++ b/app/scodoc/sco_evaluations.py
@@ -393,9 +393,8 @@ def do_evaluation_etat_in_mod(nt, moduleimpl_id):
     """"""
     evals = nt.get_mod_evaluation_etat_list(moduleimpl_id)
     etat = _eval_etat(evals)
-    etat["attente"] = moduleimpl_id in [
-        m["moduleimpl_id"] for m in nt.get_moduleimpls_attente()
-    ]  # > liste moduleimpl en attente
+    # Il y a-t-il des notes en attente dans ce module ?
+    etat["attente"] = nt.modimpls_results[moduleimpl_id].en_attente
     return etat
 
 
diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
index 1f440c8f21e52bae86f9afba4a2c06670d44761f..fcab0e7d9463962c2cdaa86741d5a28dbd93b347 100644
--- a/app/scodoc/sco_formsemestre_status.py
+++ b/app/scodoc/sco_formsemestre_status.py
@@ -991,9 +991,9 @@ def formsemestre_status(formsemestre_id=None):
     modimpls = sco_moduleimpl.moduleimpl_withmodule_list(
         formsemestre_id=formsemestre_id
     )
-    nt = sco_cache.NotesTableCache.get(formsemestre_id)
-    # WIP formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
-    # WIP nt = res_sem.load_formsemestre_result(formsemestre)
+    # nt = sco_cache.NotesTableCache.get(formsemestre_id)
+    formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
+    nt = res_sem.load_formsemestre_results(formsemestre)
 
     # Construit la liste de tous les enseignants de ce semestre:
     mails_enseignants = set(
diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py
index ed5325ce8997d383cc6190b929090b5297c1b625..e835f1dd82c0c32bdeb13b13c3454f2a1887e74a 100644
--- a/app/scodoc/sco_formsemestre_validation.py
+++ b/app/scodoc/sco_formsemestre_validation.py
@@ -549,7 +549,7 @@ def formsemestre_recap_parcours_table(
             ass = ""
 
         formsemestre = FormSemestre.query.get(sem["formsemestre_id"])
-        nt: NotesTableCompat = res_sem.load_formsemestre_result(formsemestre)
+        nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
         # nt = sco_cache.NotesTableCache.get(sem["formsemestre_id"])
         if is_cur:
             type_sem = "*"  # now unused
@@ -692,7 +692,7 @@ def formsemestre_recap_parcours_table(
             sco_preferences.get_preference("bul_show_ects", sem["formsemestre_id"])
             or nt.parcours.ECTS_ONLY
         ):
-            etud_moy_infos = nt.get_etud_moy_infos(etudid)
+            etud_ects_infos = nt.get_etud_ects_pot(etudid)
             H.append(
                 '<tr class="%s rcp_l2 sem_%s">' % (class_sem, sem["formsemestre_id"])
             )
@@ -703,7 +703,7 @@ def formsemestre_recap_parcours_table(
             # total ECTS (affiché sous la moyenne générale)
             H.append(
                 '<td class="sem_ects_tit"><a title="crédit potentiels (dont nb de fondamentaux)">ECTS:</a></td><td class="sem_ects">%g <span class="ects_fond">%g</span></td>'
-                % (etud_moy_infos["ects_pot"], etud_moy_infos["ects_pot_fond"])
+                % (etud_ects_infos["ects_pot"], etud_ects_infos["ects_pot_fond"])
             )
             H.append('<td class="rcp_abs"></td>')
             # ECTS validables dans chaque UE
@@ -1062,7 +1062,7 @@ def formsemestre_validate_previous_ue(formsemestre_id, etudid):
                     "title": "Indice du semestre",
                     "explanation": "Facultatif: indice du semestre dans la formation",
                     "allow_null": True,
-                    "allowed_values": [""] + [str(x) for x in range(11)],
+                    "allowed_values": [""] + [x for x in range(11)],
                     "labels": ["-"] + list(range(11)),
                 },
             ),
diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py
index a342e3126ac43774a5203b42f290b24a41d05662..5d2871af69423e958f231a0ea0deb9532b4f8dcb 100644
--- a/app/scodoc/sco_liste_notes.py
+++ b/app/scodoc/sco_liste_notes.py
@@ -837,7 +837,7 @@ def _add_apc_columns(
     # => On recharge tout dans les nouveaux modèles
     # rows est une liste de dict avec une clé "etudid"
     # on va y ajouter une clé par UE du semestre
-    nt: NotesTableCompat = res_sem.load_formsemestre_result(modimpl.formsemestre)
+    nt: NotesTableCompat = res_sem.load_formsemestre_results(modimpl.formsemestre)
     modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl.id]
 
     # XXX A ENLEVER TODO
diff --git a/app/scodoc/sco_parcours_dut.py b/app/scodoc/sco_parcours_dut.py
index 49c78263a691d570984c5b7963f1936308caad2e..0d682c0170a1cefb6067e3d5b23fe0e6d04966ab 100644
--- a/app/scodoc/sco_parcours_dut.py
+++ b/app/scodoc/sco_parcours_dut.py
@@ -28,6 +28,7 @@
 """Semestres: gestion parcours DUT (Arreté du 13 août 2005)
 """
 
+from app.models.ues import UniteEns
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app import log
@@ -678,10 +679,10 @@ class SituationEtudParcoursECTS(SituationEtudParcoursGeneric):
 
         Dans ce type de parcours, on n'utilise que ADM, AJ, et ADJ (?).
         """
-        etud_moy_infos = self.nt.get_etud_moy_infos(self.etudid)
+        etud_ects_infos = self.nt.get_etud_ects_pot(self.etudid)
         if (
-            etud_moy_infos["ects_pot"] >= self.parcours.ECTS_BARRE_VALID_YEAR
-            and etud_moy_infos["ects_pot"] >= self.parcours.ECTS_FONDAMENTAUX_PER_YEAR
+            etud_ects_infos["ects_pot"] >= self.parcours.ECTS_BARRE_VALID_YEAR
+            and etud_ects_infos["ects_pot"] >= self.parcours.ECTS_FONDAMENTAUX_PER_YEAR
         ):
             choices = [
                 DecisionSem(
@@ -954,6 +955,9 @@ def do_formsemestre_validate_ue(
     is_external=False,
 ):
     """Ajoute ou change validation UE"""
+    if semestre_id is None:
+        ue = UniteEns.query.get_or_404(ue_id)
+        semestre_id = ue.semestre_idx
     args = {
         "formsemestre_id": formsemestre_id,
         "etudid": etudid,
@@ -971,7 +975,8 @@ def do_formsemestre_validate_ue(
         if formsemestre_id:
             cond += " and formsemestre_id=%(formsemestre_id)s"
         if semestre_id:
-            cond += " and semestre_id=%(semestre_id)s"
+            cond += " and (semestre_id=%(semestre_id)s or semestre_id is NULL)"
+        log(f"formsemestre_validate_ue: deleting where {cond}, args={args})")
         cursor.execute("delete from scolar_formsemestre_validation where " + cond, args)
         # insert
         args["code"] = code
@@ -980,7 +985,7 @@ def do_formsemestre_validate_ue(
                 # stocke la moyenne d'UE capitalisée:
                 moy_ue = nt.get_etud_ue_status(etudid, ue_id)["moy"]
             args["moy_ue"] = moy_ue
-        log("formsemestre_validate_ue: %s" % args)
+        log("formsemestre_validate_ue: create %s" % args)
         if code != None:
             scolar_formsemestre_validation_create(cnx, args)
         else:
@@ -1039,7 +1044,9 @@ def formsemestre_get_autorisation_inscription(etudid, origin_formsemestre_id):
     )
 
 
-def formsemestre_get_etud_capitalisation(sem, etudid):
+def formsemestre_get_etud_capitalisation(
+    formation_id: int, semestre_idx: int, date_debut, etudid: int
+) -> list[dict]:
     """Liste des UE capitalisées (ADM) correspondant au semestre sem et à l'étudiant.
 
     Recherche dans les semestres de la même formation (code) avec le même
@@ -1057,30 +1064,32 @@ def formsemestre_get_etud_capitalisation(sem, etudid):
     cnx = ndb.GetDBConnexion()
     cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
     cursor.execute(
-        """select distinct SFV.*, ue.ue_code from notes_ue ue, notes_formations nf, 
-        notes_formations nf2, scolar_formsemestre_validation SFV, notes_formsemestre sem
+        """
+    SELECT DISTINCT SFV.*, ue.ue_code
+    FROM notes_ue ue, notes_formations nf,
+    notes_formations nf2, scolar_formsemestre_validation SFV, notes_formsemestre sem
 
-    WHERE ue.formation_id = nf.id   
+    WHERE ue.formation_id = nf.id
     and nf.formation_code = nf2.formation_code
     and nf2.id=%(formation_id)s
 
     and SFV.ue_id = ue.id
     and SFV.code = 'ADM'
     and SFV.etudid = %(etudid)s
-    
-    and (  (sem.id = SFV.formsemestre_id
-           and sem.date_debut < %(date_debut)s
-           and sem.semestre_id = %(semestre_id)s )
-         or (
-             ((SFV.formsemestre_id is NULL) OR (SFV.is_external)) -- les UE externes ou "anterieures"
-             AND (SFV.semestre_id is NULL OR SFV.semestre_id=%(semestre_id)s)
+
+    and ( (sem.id = SFV.formsemestre_id
+        and sem.date_debut < %(date_debut)s
+        and sem.semestre_id = %(semestre_id)s )
+        or (
+            ((SFV.formsemestre_id is NULL) OR (SFV.is_external)) -- les UE externes ou "anterieures"
+            AND (SFV.semestre_id is NULL OR SFV.semestre_id=%(semestre_id)s)
            ) )
     """,
         {
             "etudid": etudid,
-            "formation_id": sem["formation_id"],
-            "semestre_id": sem["semestre_id"],
-            "date_debut": ndb.DateDMYtoISO(sem["date_debut"]),
+            "formation_id": formation_id,
+            "semestre_id": semestre_idx,
+            "date_debut": date_debut,
         },
     )
 
diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py
index ea43cb2d2a492ced862bc4142983f2848a226862..292c618a23a49c3fd2d66d247d7ee7b900d08824 100644
--- a/app/scodoc/sco_pvjury.py
+++ b/app/scodoc/sco_pvjury.py
@@ -140,23 +140,23 @@ def descr_autorisations(autorisations):
     return ", ".join(alist)
 
 
-def _comp_ects_by_ue_code_and_type(nt, decision_ues):
+def _comp_ects_by_ue_code(nt, decision_ues):
     """Calcul somme des ECTS validés dans ce semestre (sans les UE capitalisées)
     decision_ues est le resultat de nt.get_etud_decision_ues
     Chaque resultat est un dict: { ue_code : ects }
     """
+    raise NotImplementedError()  # XXX #sco92
+    # ré-écrire en utilisant
     if not decision_ues:
-        return {}, {}
+        return {}
 
     ects_by_ue_code = {}
-    ects_by_ue_type = scu.DictDefault(defaultvalue=0)  # { ue_type : ects validés }
     for ue_id in decision_ues:
         d = decision_ues[ue_id]
         ue = nt.uedict[ue_id]
         ects_by_ue_code[ue["ue_code"]] = d["ects"]
-        ects_by_ue_type[ue["type"]] += d["ects"]
 
-    return ects_by_ue_code, ects_by_ue_type
+    return ects_by_ue_code
 
 
 def _comp_ects_capitalises_by_ue_code(nt, etudid):
@@ -249,11 +249,8 @@ def dict_pvjury(
 
         ects_capitalises_by_ue_code = _comp_ects_capitalises_by_ue_code(nt, etudid)
         d["sum_ects_capitalises"] = sum(ects_capitalises_by_ue_code.values())
-        ects_by_ue_code, ects_by_ue_type = _comp_ects_by_ue_code_and_type(
-            nt, d["decisions_ue"]
-        )
+        ects_by_ue_code = _comp_ects_by_ue_code(nt, d["decisions_ue"])
         d["sum_ects"] = _sum_ects_dicts(ects_capitalises_by_ue_code, ects_by_ue_code)
-        d["sum_ects_by_type"] = ects_by_ue_type
 
         if d["decision_sem"] and sco_codes_parcours.code_semestre_validant(
             d["decision_sem"]["code"]
diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py
index 997e835eac52078accb154b2cdc00adfa716255f..87ab8c26a01ad67a0de75dbba5f883d2b1528388 100644
--- a/app/scodoc/sco_recapcomplet.py
+++ b/app/scodoc/sco_recapcomplet.py
@@ -25,7 +25,7 @@
 #
 ##############################################################################
 
-"""Tableau recapitulatif des notes d'un semestre
+"""Tableau récapitulatif des notes d'un semestre
 """
 import datetime
 import json
@@ -41,6 +41,7 @@ from app.comp import res_sem
 from app.comp.res_common import NotesTableCompat
 from app.models import FormSemestre
 from app.models.etudiants import Identite
+from app.models.evaluations import Evaluation
 
 import app.scodoc.sco_utils as scu
 from app.scodoc import html_sco_header
@@ -308,8 +309,8 @@ def make_formsemestre_recapcomplet(
 
     # nt = sco_cache.NotesTableCache.get(formsemestre_id) # sco91
     # sco92 :
-    nt: NotesTableCompat = res_sem.load_formsemestre_result(formsemestre)
-    modimpls = nt.get_modimpls_dict()
+    nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
+    modimpls = formsemestre.modimpls_sorted
     ues = nt.get_ues_stat_dict()  # incluant le(s) UE de sport
     #
     # if formsemestre.formation.is_apc():
@@ -367,15 +368,16 @@ def make_formsemestre_recapcomplet(
             pass
         if not hidemodules and not ue["is_external"]:
             for modimpl in modimpls:
-                if modimpl["module"]["ue_id"] == ue["ue_id"]:
-                    code = modimpl["module"]["code"]
+                if modimpl.module.ue_id == ue["ue_id"]:
+                    code = modimpl.module.code
                     h.append(code)
                     cod2mod[code] = modimpl  # pour fabriquer le lien
                     if format == "xlsall":
-                        evals = nt.get_mod_evaluation_etat_list(
-                            modimpl["moduleimpl_id"]
-                        )
-                        mod_evals[modimpl["moduleimpl_id"]] = evals
+                        evals = nt.modimpls_results[
+                            modimpl.id
+                        ].get_evaluations_completes(modimpl)
+                        # evals = nt.get_mod_evaluation_etat_list(...
+                        mod_evals[modimpl.id] = evals
                         h += _list_notes_evals_titles(code, evals)
 
     h += admission_extra_cols
@@ -483,7 +485,7 @@ def make_formsemestre_recapcomplet(
             if not hidemodules and not ue["is_external"]:
                 j = 0
                 for modimpl in modimpls:
-                    if modimpl["module"]["ue_id"] == ue["ue_id"]:
+                    if modimpl.module.ue_id == ue["ue_id"]:
                         l.append(
                             fmtnum(
                                 scu.fmt_note(
@@ -492,9 +494,7 @@ def make_formsemestre_recapcomplet(
                             )
                         )  # moyenne etud dans module
                         if format == "xlsall":
-                            l += _list_notes_evals(
-                                mod_evals[modimpl["moduleimpl_id"]], etudid
-                            )
+                            l += _list_notes_evals(mod_evals[modimpl.id], etudid)
                     j += 1
         if not hidebac:
             for k in admission_extra_cols:
@@ -509,9 +509,7 @@ def make_formsemestre_recapcomplet(
     if not hidemodules:  # moy/min/max dans chaque module
         mods_stats = {}  # moduleimpl_id : stats
         for modimpl in modimpls:
-            mods_stats[modimpl["moduleimpl_id"]] = nt.get_mod_stats(
-                modimpl["moduleimpl_id"]
-            )
+            mods_stats[modimpl.id] = nt.get_mod_stats(modimpl.id)
 
     def add_bottom_stat(key, title, corner_value=""):
         l = ["", title]
@@ -551,16 +549,16 @@ def make_formsemestre_recapcomplet(
             # ue_index.append(len(l) - 1)
             if not hidemodules and not ue["is_external"]:
                 for modimpl in modimpls:
-                    if modimpl["module"]["ue_id"] == ue["ue_id"]:
+                    if modimpl.module.ue_id == ue["ue_id"]:
                         if key == "coef":
-                            coef = modimpl["module"]["coefficient"]
+                            coef = modimpl.module.coefficient
                             if format[:3] != "xls":
                                 coef = str(coef)
                             l.append(coef)
                         elif key == "ects":
                             l.append("")  # ECTS module ?
                         else:
-                            val = mods_stats[modimpl["moduleimpl_id"]][key]
+                            val = mods_stats[modimpl.id][key]
                             if key == "nb_valid_evals":
                                 if (
                                     format[:3] != "xls"
@@ -571,9 +569,7 @@ def make_formsemestre_recapcomplet(
                             l.append(val)
 
                         if format == "xlsall":
-                            l += _list_notes_evals_stats(
-                                mod_evals[modimpl["moduleimpl_id"]], key
-                            )
+                            l += _list_notes_evals_stats(mod_evals[modimpl.id], key)
         if modejury:
             l.append("")  # case vide sur ligne "Moyennes"
 
@@ -595,7 +591,7 @@ def make_formsemestre_recapcomplet(
     add_bottom_stat("nb_valid_evals", "Nb évals")
     add_bottom_stat("ects", "ECTS")
 
-    # Generation table au format demandé
+    # Génération de la table au format demandé
     if format == "html":
         # Table format HTML
         H = [
@@ -838,63 +834,50 @@ def make_formsemestre_recapcomplet(
         raise ValueError("unknown format %s" % format)
 
 
-def _list_notes_evals(evals, etudid):
+def _list_notes_evals(evals: list[Evaluation], etudid: int) -> list[str]:
     """Liste des notes des evaluations completes de ce module
     (pour table xls avec evals)
     """
     L = []
     for e in evals:
-        if (
-            e["etat"]["evalcomplete"]
-            or e["etat"]["evalattente"]
-            or e["publish_incomplete"]
-        ):
-            notes_db = sco_evaluation_db.do_evaluation_get_all_notes(e["evaluation_id"])
-            if etudid in notes_db:
-                val = notes_db[etudid]["value"]
-            else:
-                # Note manquante mais prise en compte immédiate: affiche ATT
-                val = scu.NOTES_ATTENTE
-            val_fmt = scu.fmt_note(val, keep_numeric=True)
-            L.append(val_fmt)
+        notes_db = sco_evaluation_db.do_evaluation_get_all_notes(e.evaluation_id)
+        if etudid in notes_db:
+            val = notes_db[etudid]["value"]
+        else:
+            # Note manquante mais prise en compte immédiate: affiche ATT
+            val = scu.NOTES_ATTENTE
+        val_fmt = scu.fmt_note(val, keep_numeric=True)
+        L.append(val_fmt)
     return L
 
 
-def _list_notes_evals_titles(codemodule, evals):
+def _list_notes_evals_titles(codemodule: str, evals: list[Evaluation]) -> list[str]:
     """Liste des titres des evals completes"""
     L = []
     eval_index = len(evals) - 1
     for e in evals:
-        if (
-            e["etat"]["evalcomplete"]
-            or e["etat"]["evalattente"]
-            or e["publish_incomplete"]
-        ):
-            L.append(codemodule + "-" + str(eval_index) + "-" + e["jour"].isoformat())
+        L.append(codemodule + "-" + str(eval_index) + "-" + e.jour.isoformat())
         eval_index -= 1
     return L
 
 
-def _list_notes_evals_stats(evals, key):
+def _list_notes_evals_stats(evals: list[Evaluation], key: str) -> list[str]:
     """Liste des stats (moy, ou rien!) des evals completes"""
     L = []
     for e in evals:
-        if (
-            e["etat"]["evalcomplete"]
-            or e["etat"]["evalattente"]
-            or e["publish_incomplete"]
-        ):
-            if key == "moy":
-                val = e["etat"]["moy_num"]
-                L.append(scu.fmt_note(val, keep_numeric=True))
-            elif key == "max":
-                L.append(e["note_max"])
-            elif key == "min":
-                L.append(0.0)
-            elif key == "coef":
-                L.append(e["coefficient"])
-            else:
-                L.append("")  # on n'a pas sous la main min/max
+        if key == "moy":
+            # TODO #sco92
+            # val = e["etat"]["moy_num"]
+            # L.append(scu.fmt_note(val, keep_numeric=True))
+            L.append("")
+        elif key == "max":
+            L.append(e.note_max)
+        elif key == "min":
+            L.append(0.0)
+        elif key == "coef":
+            L.append(e.coefficient)
+        else:
+            L.append("")  # on n'a pas sous la main min/max
     return L
 
 
diff --git a/app/views/notes.py b/app/views/notes.py
index 4b299ee522b8bcd751fe140a8468eca49f71841a..0762a5cee7e0bc51b98124b091f0cbc2e1ab8976 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -2140,7 +2140,7 @@ def formsemestre_validation_etud_manu(
     )
 
 
-@bp.route("/formsemestre_validate_previous_ue")
+@bp.route("/formsemestre_validate_previous_ue", methods=["GET", "POST"])
 @scodoc
 @permission_required(Permission.ScoView)
 @scodoc7func