diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py
new file mode 100644
index 000000000..690f70990
--- /dev/null
+++ b/app/comp/res_compat.py
@@ -0,0 +1,456 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Classe résultats pour compatibilité avec le code ScoDoc 7
+"""
+from functools import cached_property
+
+from flask import g, flash
+
+from app import log
+from app.comp import moy_sem
+from app.comp.aux_stats import StatsMoyenne
+from app.comp.res_common import ResultatsSemestre
+from app.comp import res_sem
+from app.models import FormSemestre
+from app.models import Identite
+from app.models import ModuleImpl
+from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
+from app.scodoc import sco_utils as scu
+
+# Pour raccorder le code des anciens codes qui attendent une NoteTable
+class NotesTableCompat(ResultatsSemestre):
+    """Implementation partielle de NotesTable
+
+    Les méthodes définies dans cette classe sont là
+    pour conserver la compatibilité abvec les codes anciens et
+    il n'est pas recommandé de les utiliser dans de nouveaux
+    développements (API malcommode et peu efficace).
+    """
+
+    _cached_attrs = ResultatsSemestre._cached_attrs + (
+        "bonus",
+        "bonus_ues",
+        "malus",
+        "etud_moy_gen_ranks",
+        "etud_moy_gen_ranks_int",
+        "ue_rangs",
+    )
+
+    def __init__(self, formsemestre: FormSemestre):
+        super().__init__(formsemestre)
+
+        nb_etuds = len(self.etuds)
+        self.bonus = None  # virtuel
+        self.bonus_ues = None  # virtuel
+        self.ue_rangs = {u.id: (None, nb_etuds) for u in self.ues}
+        self.mod_rangs = None  # sera surchargé en Classic, mais pas en APC
+        """{ modimpl_id : (rangs, effectif) }"""
+        self.moy_min = "NA"
+        self.moy_max = "NA"
+        self.moy_moy = "NA"
+        self.expr_diagnostics = ""
+        self.parcours = self.formsemestre.formation.get_parcours()
+
+    def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]:
+        """Liste des étudiants inscrits
+        order_by = False|'nom'|'moy' tri sur nom ou sur moyenne générale (indicative)
+
+        Note: pour récupérer les etudids des inscrits, non triés, il est plus efficace
+        d'utiliser `[ ins.etudid for ins in nt.formsemestre.inscriptions ]`
+        """
+        etuds = self.formsemestre.get_inscrits(
+            include_demdef=include_demdef, order=(order_by == "nom")
+        )
+        if order_by == "moy":
+            etuds.sort(
+                key=lambda e: (
+                    self.etud_moy_gen_ranks_int.get(e.id, 100000),
+                    e.sort_key,
+                )
+            )
+        return etuds
+
+    def get_etudids(self) -> list[int]:
+        """(deprecated)
+        Liste des etudids inscrits, incluant les démissionnaires.
+        triée par ordre alphabetique de NOM
+        (à éviter: renvoie les etudids, mais est moins efficace que get_inscrits)
+        """
+        # Note: pour avoir les inscrits non triés,
+        # utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ]
+        return [x["etudid"] for x in self.inscrlist]
+
+    @cached_property
+    def sem(self) -> dict:
+        """le formsemestre, comme un gros et gras dict (nt.sem)"""
+        return self.formsemestre.get_infos_dict()
+
+    @cached_property
+    def inscrlist(self) -> list[dict]:  # utilisé par PE
+        """Liste des inscrits au semestre (avec DEM et DEF),
+        sous forme de dict etud,
+        classée dans l'ordre alphabétique de noms.
+        """
+        etuds = self.formsemestre.get_inscrits(include_demdef=True, order=True)
+        return [e.to_dict_scodoc7() for e in etuds]
+
+    @cached_property
+    def stats_moy_gen(self):
+        """Stats (moy/min/max) sur la moyenne générale"""
+        return StatsMoyenne(self.etud_moy_gen)
+
+    def get_ues_stat_dict(
+        self, filter_sport=False, check_apc_ects=True
+    ) -> list[dict]:  # was get_ues()
+        """Liste des UEs, ordonnée par numero.
+        Si filter_sport, retire les UE de type SPORT.
+        Résultat: liste de dicts { champs UE U stats moyenne UE }
+        """
+        ues = self.formsemestre.query_ues(with_sport=not filter_sport)
+        ues_dict = []
+        for ue in ues:
+            d = ue.to_dict()
+            if ue.type != UE_SPORT:
+                moys = self.etud_moy_ue[ue.id]
+            else:
+                moys = None
+            d.update(StatsMoyenne(moys).to_dict())
+            ues_dict.append(d)
+        if check_apc_ects and self.is_apc and not hasattr(g, "checked_apc_ects"):
+            g.checked_apc_ects = True
+            if None in [ue.ects for ue in ues if ue.type != UE_SPORT]:
+                flash(
+                    """Calcul moyenne générale impossible: ECTS des UE manquants !""",
+                    category="danger",
+                )
+        return ues_dict
+
+    def get_modimpls_dict(self, ue_id=None) -> list[dict]:
+        """Liste des modules pour une UE (ou toutes si ue_id==None),
+        triés par numéros (selon le type de formation)
+        """
+        modimpls_dict = []
+        for modimpl in self.formsemestre.modimpls_sorted:
+            if ue_id == None or modimpl.module.ue.id == ue_id:
+                d = modimpl.to_dict()
+                # compat ScoDoc < 9.2: ajoute matières
+                d["mat"] = modimpl.module.matiere.to_dict()
+                modimpls_dict.append(d)
+        return modimpls_dict
+
+    def compute_rangs(self):
+        """Calcule les classements
+        Moyenne générale: etud_moy_gen_ranks
+        Par UE (sauf ue bonus)
+        """
+        (
+            self.etud_moy_gen_ranks,
+            self.etud_moy_gen_ranks_int,
+        ) = moy_sem.comp_ranks_series(self.etud_moy_gen)
+        for ue in self.formsemestre.query_ues():
+            moy_ue = self.etud_moy_ue[ue.id]
+            self.ue_rangs[ue.id] = (
+                moy_sem.comp_ranks_series(moy_ue)[0],  # juste en chaine
+                int(moy_ue.count()),
+            )
+            # .count() -> nb of non NaN values
+
+    def get_etud_ue_rang(self, ue_id, etudid) -> tuple[str, int]:
+        """Le rang de l'étudiant dans cette ue
+        Result: rang:str, effectif:str
+        """
+        rangs, effectif = self.ue_rangs[ue_id]
+        if rangs is not None:
+            rang = rangs[etudid]
+        else:
+            return "", ""
+        return rang, effectif
+
+    def etud_check_conditions_ues(self, etudid):
+        """Vrai si les conditions sur les UE sont remplies.
+        Ne considère que les UE ayant des notes (moyenne calculée).
+        (les UE sans notes ne sont pas comptées comme sous la barre)
+        Prend en compte les éventuelles UE capitalisées.
+
+        Pour les parcours habituels, cela revient à vérifier que
+        les moyennes d'UE sont toutes > à leur barre (sauf celles sans notes)
+
+        Pour les parcours non standards (LP2014), cela peut être plus compliqué.
+
+        Return: True|False, message explicatif
+        """
+        ue_status_list = []
+        for ue in self.formsemestre.query_ues():
+            ue_status = self.get_etud_ue_status(etudid, ue.id)
+            if ue_status:
+                ue_status_list.append(ue_status)
+        return self.parcours.check_barre_ues(ue_status_list)
+
+    def all_etuds_have_sem_decisions(self):
+        """True si tous les étudiants du semestre ont une décision de jury.
+        Ne regarde pas les décisions d'UE.
+        """
+        for ins in self.formsemestre.inscriptions:
+            if ins.etat != scu.INSCRIT:
+                continue  # skip démissionnaires
+            if self.get_etud_decision_sem(ins.etudid) is None:
+                return False
+        return True
+
+    def etud_has_decision(self, etudid):
+        """True s'il y a une décision de jury pour cet étudiant"""
+        return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid)
+
+    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 }
+        Si état défaillant, force le code a DEF
+        """
+        if self.get_etud_etat(etudid) == DEF:
+            return {
+                "code": DEF,
+                "assidu": False,
+                "event_date": "",
+                "compense_formsemestre_id": None,
+            }
+        else:
+            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)"
+        ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
+        if ins is None:
+            return ""
+        return ins.etat
+
+    def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str:
+        """moyenne d'un étudiant dans une matière (ou NA si pas de notes)"""
+        if not self.moyennes_matieres:
+            return "nd"
+        return (
+            self.moyennes_matieres[matiere_id].get(etudid, "-")
+            if matiere_id in self.moyennes_matieres
+            else "-"
+        )
+
+    def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float:
+        """La moyenne de l'étudiant dans le moduleimpl
+        En APC, il s'agira d'une moyenne indicative sans valeur.
+        Result: valeur float (peut être naN) ou chaîne "NI" (non inscrit ou DEM)
+        """
+        raise NotImplementedError()  # virtual method
+
+    def get_etud_moy_gen(self, etudid):  # -> float | str
+        """Moyenne générale de cet etudiant dans ce semestre.
+        Prend en compte les UE capitalisées.
+        Si apc, moyenne indicative.
+        Si pas de notes: 'NA'
+        """
+        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_pot_fond": 0.0,  # not implemented (anciennemment pour école ingé)
+        }
+
+    def get_etud_rang(self, etudid: int):
+        return self.etud_moy_gen_ranks.get(etudid, 99999)
+
+    def get_etud_rang_group(self, etudid: int, group_id: int):
+        return (None, 0)  # XXX unimplemented TODO
+
+    def get_evals_in_mod(self, moduleimpl_id: int) -> list[dict]:
+        """Liste d'informations (compat NotesTable) sur évaluations completes
+        de ce module.
+        Évaluation "complete" ssi toutes notes saisies ou en attente.
+        """
+        modimpl = ModuleImpl.query.get(moduleimpl_id)
+        modimpl_results = self.modimpls_results.get(moduleimpl_id)
+        if not modimpl_results:
+            return []  # safeguard
+        evals_results = []
+        for e in modimpl.evaluations:
+            if modimpl_results.evaluations_completes_dict.get(e.id, False):
+                d = e.to_dict()
+                d["heure_debut"] = e.heure_debut  # datetime.time
+                d["heure_fin"] = e.heure_fin
+                d["jour"] = e.jour  # datetime
+                d["notes"] = {
+                    etud.id: {
+                        "etudid": etud.id,
+                        "value": modimpl_results.evals_notes[e.id][etud.id],
+                    }
+                    for etud in self.etuds
+                }
+                d["etat"] = {
+                    "evalattente": modimpl_results.evaluations_etat[e.id].nb_attente,
+                }
+                evals_results.append(d)
+            elif e.id not in modimpl_results.evaluations_completes_dict:
+                # ne devrait pas arriver ? XXX
+                log(
+                    f"Warning: 220213 get_evals_in_mod {e.id} not in mod {moduleimpl_id} ?"
+                )
+        return evals_results
+
+    def get_evaluations_etats(self):
+        """[ {...evaluation et son etat...} ]"""
+        # TODO: à moderniser
+        from app.scodoc import sco_evaluations
+
+        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 données 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):
+        """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
+        Vide en APC
+        """
+        return {
+            "moy": "-",
+            "max": "-",
+            "min": "-",
+            "nb_notes": "-",
+            "nb_missing": "-",
+            "nb_valid_evals": "-",
+        }
+
+    def get_nom_short(self, etudid):
+        "formatte nom d'un etud (pour table recap)"
+        etud = self.identdict[etudid]
+        return (
+            (etud["nom_usuel"] or etud["nom"]).upper()
+            + " "
+            + etud["prenom"].capitalize()[:2]
+            + "."
+        )
+
+    @cached_property
+    def T(self):
+        return self.get_table_moyennes_triees()
+
+    def get_table_moyennes_triees(self) -> list:
+        """Result: liste de tuples
+        moy_gen, moy_ue_0, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid
+        """
+        table_moyennes = []
+        etuds_inscriptions = self.formsemestre.etuds_inscriptions
+        ues = self.formsemestre.query_ues(with_sport=True)  # avec bonus
+        for etudid in etuds_inscriptions:
+            moy_gen = self.etud_moy_gen.get(etudid, False)
+            if moy_gen is False:
+                # pas de moyenne: démissionnaire ou def
+                t = (
+                    ["-"]
+                    + ["0.00"] * len(self.ues)
+                    + ["NI"] * len(self.formsemestre.modimpls_sorted)
+                )
+            else:
+                moy_ues = []
+                ue_is_cap = {}
+                for ue in ues:
+                    ue_status = self.get_etud_ue_status(etudid, ue.id)
+                    if ue_status:
+                        moy_ues.append(ue_status["moy"])
+                        ue_is_cap[ue.id] = ue_status["is_capitalized"]
+                    else:
+                        moy_ues.append("?")
+                t = [moy_gen] + list(moy_ues)
+                # Moyennes modules:
+                for modimpl in self.formsemestre.modimpls_sorted:
+                    if ue_is_cap.get(modimpl.module.ue.id, False):
+                        val = "-c-"
+                    else:
+                        val = self.get_etud_mod_moy(modimpl.id, etudid)
+                    t.append(val)
+            t.append(etudid)
+            table_moyennes.append(t)
+        # tri par moyennes décroissantes,
+        # en laissant les démissionnaires à la fin, par ordre alphabetique
+        etuds = [ins.etud for ins in etuds_inscriptions.values()]
+        etuds.sort(key=lambda e: e.sort_key)
+        self._rang_alpha = {e.id: i for i, e in enumerate(etuds)}
+        table_moyennes.sort(key=self._row_key)
+        return table_moyennes
+
+    def _row_key(self, x):
+        """clé de tri par moyennes décroissantes,
+        en laissant les demissionnaires à la fin, par ordre alphabetique.
+        (moy_gen, rang_alpha)
+        """
+        try:
+            moy = -float(x[0])
+        except (ValueError, TypeError):
+            moy = 1000.0
+        return (moy, self._rang_alpha[x[-1]])
+
+    @cached_property
+    def identdict(self) -> dict:
+        """{ etudid : etud_dict } pour tous les inscrits au semestre"""
+        return {
+            ins.etud.id: ins.etud.to_dict_scodoc7()
+            for ins in self.formsemestre.inscriptions
+        }