diff --git a/app/pe/pe_affichage.py b/app/pe/pe_affichage.py
new file mode 100644
index 0000000000000000000000000000000000000000..50f2e2ab71d8a166ee0f1c30a8487a23b4962854
--- /dev/null
+++ b/app/pe/pe_affichage.py
@@ -0,0 +1,44 @@
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+"""Affichages, debug
+"""
+
+from flask import g
+from app import log
+
+PE_DEBUG = False
+
+
+# On stocke les logs PE dans g.scodoc_pe_log
+# pour ne pas modifier les nombreux appels à pe_print.
+def pe_start_log() -> list[str]:
+    "Initialize log"
+    g.scodoc_pe_log = []
+    return g.scodoc_pe_log
+
+
+def pe_print(*a):
+    "Log (or print in PE_DEBUG mode) and store in g"
+    lines = getattr(g, "scodoc_pe_log")
+    if lines is None:
+        lines = pe_start_log()
+    msg = " ".join(a)
+    lines.append(msg)
+    if PE_DEBUG:
+        print(msg)
+    else:
+        log(msg)
+
+
+def pe_get_log() -> str:
+    "Renvoie une chaîne avec tous les messages loggués"
+    return "\n".join(getattr(g, "scodoc_pe_log", []))
+
+
+# Affichage dans le tableur pe en cas d'absence de notes
+SANS_NOTE = "-"
+NOM_STAT_GROUPE = "statistiques du groupe"
+NOM_STAT_PROMO = "statistiques de la promo"
diff --git a/app/pe/pe_comp.py b/app/pe/pe_comp.py
new file mode 100644
index 0000000000000000000000000000000000000000..67805dfa49e1f129303725f17cc8a50634cf6e64
--- /dev/null
+++ b/app/pe/pe_comp.py
@@ -0,0 +1,286 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+"""
+Created on Thu Sep  8 09:36:33 2016
+
+@author: barasc
+"""
+
+import os
+import datetime
+import re
+import unicodedata
+
+
+from flask import g
+
+import app.scodoc.sco_utils as scu
+
+from app.models import FormSemestre
+from app.pe.pe_rcs import TYPES_RCS
+from app.scodoc import sco_formsemestre
+from app.scodoc.sco_logos import find_logo
+
+
+# Generated LaTeX files are encoded as:
+PE_LATEX_ENCODING = "utf-8"
+
+# /opt/scodoc/tools/doc_poursuites_etudes
+REP_DEFAULT_AVIS = os.path.join(scu.SCO_TOOLS_DIR, "doc_poursuites_etudes/")
+REP_LOCAL_AVIS = os.path.join(scu.SCODOC_CFG_DIR, "doc_poursuites_etudes/")
+
+PE_DEFAULT_AVIS_LATEX_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_avis.tex"
+PE_LOCAL_AVIS_LATEX_TMPL = REP_LOCAL_AVIS + "local/modeles/un_avis.tex"
+PE_DEFAULT_FOOTER_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_footer.tex"
+PE_LOCAL_FOOTER_TMPL = REP_LOCAL_AVIS + "local/modeles/un_footer.tex"
+
+# ----------------------------------------------------------------------------------------
+
+"""
+Descriptif d'un parcours classique BUT
+
+TODO:: A améliorer si BUT en moins de 6 semestres
+"""
+
+NBRE_SEMESTRES_DIPLOMANT = 6
+AGGREGAT_DIPLOMANT = (
+    "6S"  # aggrégat correspondant à la totalité des notes pour le diplôme
+)
+TOUS_LES_SEMESTRES = TYPES_RCS[AGGREGAT_DIPLOMANT]["aggregat"]
+
+
+# ----------------------------------------------------------------------------------------
+def calcul_age(born: datetime.date) -> int:
+    """Calcule l'age connaissant la date de naissance ``born``. (L'age est calculé
+    à partir de l'horloge système).
+
+    Args:
+        born: La date de naissance
+
+    Return:
+        L'age (au regard de la date actuelle)
+    """
+    if not born or not isinstance(born, datetime.date):
+        return None
+
+    today = datetime.date.today()
+    return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
+
+
+# Nota: scu.suppress_accents fait la même chose mais renvoie un str et non un bytes
+def remove_accents(input_unicode_str: str) -> bytes:
+    """Supprime les accents d'une chaine unicode"""
+    nfkd_form = unicodedata.normalize("NFKD", input_unicode_str)
+    only_ascii = nfkd_form.encode("ASCII", "ignore")
+    return only_ascii
+
+
+def escape_for_latex(s):
+    """Protège les caractères pour inclusion dans du source LaTeX"""
+    if not s:
+        return ""
+    conv = {
+        "&": r"\&",
+        "%": r"\%",
+        "$": r"\$",
+        "#": r"\#",
+        "_": r"\_",
+        "{": r"\{",
+        "}": r"\}",
+        "~": r"\textasciitilde{}",
+        "^": r"\^{}",
+        "\\": r"\textbackslash{}",
+        "<": r"\textless ",
+        ">": r"\textgreater ",
+    }
+    exp = re.compile(
+        "|".join(
+            re.escape(key)
+            for key in sorted(list(conv.keys()), key=lambda item: -len(item))
+        )
+    )
+    return exp.sub(lambda match: conv[match.group()], s)
+
+
+# ----------------------------------------------------------------------------------------
+def list_directory_filenames(path: str) -> list[str]:
+    """List of regular filenames (paths) in a directory (recursive)
+    Excludes files and directories begining with .
+    """
+    paths = []
+    for root, dirs, files in os.walk(path, topdown=True):
+        dirs[:] = [d for d in dirs if d[0] != "."]
+        paths += [os.path.join(root, fn) for fn in files if fn[0] != "."]
+    return paths
+
+
+def add_local_file_to_zip(zipfile, ziproot, pathname, path_in_zip):
+    """Read pathname server file and add content to zip under path_in_zip"""
+    rooted_path_in_zip = os.path.join(ziproot, path_in_zip)
+    zipfile.write(filename=pathname, arcname=rooted_path_in_zip)
+    # data = open(pathname).read()
+    # zipfile.writestr(rooted_path_in_zip, data)
+
+
+def add_refs_to_register(register, directory):
+    """Ajoute les fichiers trouvés dans directory au registre (dictionaire) sous la forme
+    filename => pathname
+    """
+    length = len(directory)
+    for pathname in list_directory_filenames(directory):
+        filename = pathname[length + 1 :]
+        register[filename] = pathname
+
+
+def add_pe_stuff_to_zip(zipfile, ziproot):
+    """Add auxiliary files to (already opened) zip
+    Put all local files found under config/doc_poursuites_etudes/local
+    and config/doc_poursuites_etudes/distrib
+    If a file is present in both subtrees, take the one in local.
+
+    Also copy logos
+    """
+    register = {}
+    # first add standard (distrib references)
+    distrib_dir = os.path.join(REP_DEFAULT_AVIS, "distrib")
+    add_refs_to_register(register=register, directory=distrib_dir)
+    # then add local references (some oh them may overwrite distrib refs)
+    local_dir = os.path.join(REP_LOCAL_AVIS, "local")
+    add_refs_to_register(register=register, directory=local_dir)
+    # at this point register contains all refs (filename, pathname) to be saved
+    for filename, pathname in register.items():
+        add_local_file_to_zip(zipfile, ziproot, pathname, "avis/" + filename)
+
+    # Logos: (add to logos/ directory in zip)
+    logos_names = ["header", "footer"]
+    for name in logos_names:
+        logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id)
+        if logo is not None:
+            add_local_file_to_zip(
+                zipfile, ziproot, logo.filepath, "avis/logos/" + logo.filename
+            )
+
+
+# ----------------------------------------------------------------------------------------
+def get_annee_diplome_semestre(
+    sem_base: FormSemestre | dict, nbre_sem_formation: int = 6
+) -> int:
+    """Pour un semestre ``sem_base`` donné (supposé être un semestre d'une formation BUT
+    à 6 semestres) et connaissant le numéro du semestre, ses dates de début et de fin du
+    semestre, prédit l'année à laquelle sera remis le diplôme BUT des étudiants qui y
+    sont scolarisés (en supposant qu'il n'y ait pas de redoublement à venir).
+
+    **Remarque sur le calcul** : Les semestres de 1ère partie d'année (S1, S3, S5 ou S4,
+    S6 pour des semestres décalés)
+    s'étalent sur deux années civiles ; contrairement au semestre de seconde partie
+    d'année universitaire.
+
+    Par exemple :
+
+    * S5 débutant en 2025 finissant en 2026 : diplome en 2026
+    * S3 debutant en 2025 et finissant en 2026 : diplome en 2027
+
+    La fonction est adaptée au cas des semestres décalés.
+
+    Par exemple :
+
+    * S5 décalé débutant en 2025 et finissant en 2025 : diplome en 2026
+    * S3 décalé débutant en 2025 et finissant en 2025 : diplome en 2027
+
+    Args:
+        sem_base: Le semestre à partir duquel est prédit l'année de diplomation, soit :
+
+                 * un ``FormSemestre`` (Scodoc9)
+                 * un dict (format compatible avec Scodoc7)
+
+        nbre_sem_formation: Le nombre de semestre prévu dans la formation (par défaut 6 pour un BUT)
+    """
+
+    if isinstance(sem_base, FormSemestre):
+        sem_id = sem_base.semestre_id
+        annee_fin = sem_base.date_fin.year
+        annee_debut = sem_base.date_debut.year
+    else:  # sem_base est un dictionnaire (Scodoc 7)
+        sem_id = sem_base["semestre_id"]
+        annee_fin = int(sem_base["annee_fin"])
+        annee_debut = int(sem_base["annee_debut"])
+    if (
+        1 <= sem_id <= nbre_sem_formation
+    ):  # Si le semestre est un semestre BUT => problème si formation BUT en 1 an ??
+        nb_sem_restants = (
+            nbre_sem_formation - sem_id
+        )  # nombre de semestres restant avant diplome
+        nb_annees_restantes = (
+            nb_sem_restants // 2
+        )  # nombre d'annees restant avant diplome
+        # Flag permettant d'activer ou désactiver un increment
+        # à prendre en compte en cas de semestre décalé
+        # avec 1 - delta = 0 si semestre de 1ere partie d'année / 1 sinon
+        delta = annee_fin - annee_debut
+        decalage = nb_sem_restants % 2  # 0 si S4, 1 si S3, 0 si S2, 1 si S1
+        increment = decalage * (1 - delta)
+        return annee_fin + nb_annees_restantes + increment
+
+
+def get_cosemestres_diplomants(annee_diplome: int) -> dict[int, FormSemestre]:
+    """Ensemble des cosemestres donnant lieu à diplomation à l'``annee_diplome``.
+
+    **Définition** : Un co-semestre est un semestre :
+
+    * dont l'année de diplômation prédite (sans redoublement) est la même
+    * dont la formation est la même (optionnel)
+    * qui a des étudiants inscrits
+
+    Args:
+        annee_diplome: L'année de diplomation
+
+    Returns:
+        Un dictionnaire {fid: FormSemestre(fid)} contenant les cosemestres
+    """
+    tous_les_sems = (
+        sco_formsemestre.do_formsemestre_list()
+    )  # tous les semestres memorisés dans scodoc
+
+    cosemestres_fids = {
+        sem["id"]
+        for sem in tous_les_sems
+        if get_annee_diplome_semestre(sem) == annee_diplome
+    }
+
+    cosemestres = {}
+    for fid in cosemestres_fids:
+        cosem = FormSemestre.get_formsemestre(fid)
+        if len(cosem.etuds_inscriptions) > 0:
+            cosemestres[fid] = cosem
+
+    return cosemestres
diff --git a/app/pe/pe_etudiant.py b/app/pe/pe_etudiant.py
new file mode 100644
index 0000000000000000000000000000000000000000..cdf6a5ecd3b5fe55df5ef57e4e68784aa0171306
--- /dev/null
+++ b/app/pe/pe_etudiant.py
@@ -0,0 +1,619 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+"""
+Created on 17/01/2024
+
+@author: barasc
+"""
+import pandas as pd
+
+from app.models import FormSemestre, Identite, Formation
+from app.pe import pe_comp, pe_affichage
+from app.scodoc import codes_cursus
+from app.scodoc import sco_utils as scu
+from app.comp.res_sem import load_formsemestre_results
+
+
+class EtudiantsJuryPE:
+    """Classe centralisant la gestion des étudiants à prendre en compte dans un jury de PE"""
+
+    def __init__(self, annee_diplome: int):
+        """
+        Args:
+            annee_diplome: L'année de diplomation
+        """
+        self.annee_diplome = annee_diplome
+        """L'année du diplôme"""
+
+        self.identites: dict[int, Identite] = {}  # ex. ETUDINFO_DICT
+        "Les identités des étudiants traités pour le jury"
+
+        self.cursus: dict[int, dict] = {}
+        "Les cursus (semestres suivis, abandons) des étudiants"
+
+        self.trajectoires = {}
+        """Les trajectoires/chemins de semestres suivis par les étudiants
+        pour atteindre un aggrégat donné
+        (par ex: 3S=S1+S2+S3 à prendre en compte avec d'éventuels redoublements)"""
+
+        self.etudiants_diplomes = {}
+        """Les identités des étudiants à considérer au jury (ceux qui seront effectivement
+        diplômés)"""
+
+        self.diplomes_ids = {}
+        """Les etudids des étudiants diplômés"""
+
+        self.etudiants_ids = {}
+        """Les etudids des étudiants dont il faut calculer les moyennes/classements
+        (même si d'éventuels abandons).
+        Il s'agit des étudiants inscrits dans les co-semestres (ceux du jury mais aussi
+        d'autres ayant été réorientés ou ayant abandonnés)"""
+
+        self.cosemestres: dict[int, FormSemestre] = None
+        "Les cosemestres donnant lieu à même année de diplome"
+
+        self.abandons = {}
+        """Les étudiants qui ne seront pas diplômés à ce jury (redoublants/réorientés)"""
+        self.abandons_ids = {}
+        """Les etudids des étudiants redoublants/réorientés"""
+
+    def find_etudiants(self):
+        """Liste des étudiants à prendre en compte dans le jury PE, en les recherchant
+        de manière automatique par rapport à leur année de diplomation ``annee_diplome``.
+
+        Les données obtenues sont stockées dans les attributs de EtudiantsJuryPE.
+
+        *Remarque* : ex: JuryPE.get_etudiants_in_jury()
+        """
+        cosemestres = pe_comp.get_cosemestres_diplomants(self.annee_diplome)
+        self.cosemestres = cosemestres
+
+        pe_affichage.pe_print(
+            f"1) Recherche des coSemestres -> {len(cosemestres)} trouvés"
+        )
+
+        pe_affichage.pe_print("2) Liste des étudiants dans les différents co-semestres")
+        self.etudiants_ids = get_etudiants_dans_semestres(cosemestres)
+        pe_affichage.pe_print(
+            f" => {len(self.etudiants_ids)} étudiants trouvés dans les cosemestres"
+        )
+
+        # Analyse des parcours étudiants pour déterminer leur année effective de diplome
+        # avec prise en compte des redoublements, des abandons, ....
+        pe_affichage.pe_print("3) Analyse des parcours individuels des étudiants")
+
+        for etudid in self.etudiants_ids:
+            self.identites[etudid] = Identite.get_etud(etudid)
+
+            # Analyse son cursus
+            self.analyse_etat_etudiant(etudid, cosemestres)
+
+            # Analyse son parcours pour atteindre chaque semestre de la formation
+            self.structure_cursus_etudiant(etudid)
+
+        # Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris
+        self.etudiants_diplomes = self.get_etudiants_diplomes()
+        self.diplomes_ids = set(self.etudiants_diplomes.keys())
+        self.etudiants_ids = set(self.identites.keys())
+
+        # Les abandons (pour debug)
+        self.abandons = self.get_etudiants_redoublants_ou_reorientes()
+        # Les identités des étudiants ayant redoublés ou ayant abandonnés
+
+        self.abandons_ids = set(self.abandons)
+        # Les identifiants des étudiants ayant redoublés ou ayant abandonnés
+
+        # Synthèse
+        pe_affichage.pe_print(
+            f"  => {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}"
+        )
+        nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_diplomes)
+        assert nbre_abandons == len(self.abandons_ids)
+
+        pe_affichage.pe_print(
+            f"  => {nbre_abandons} étudiants non considérés (redoublement, réorientation, abandon"
+        )
+        # pe_affichage.pe_print(
+        #    "  => quelques étudiants futurs diplômés : "
+        #    + ", ".join([str(etudid) for etudid in list(self.etudiants_diplomes)[:10]])
+        # )
+        # pe_affichage.pe_print(
+        #    "  => semestres dont il faut calculer les moyennes : "
+        #    + ", ".join([str(fid) for fid in list(self.formsemestres_jury_ids)])
+        # )
+
+    def get_etudiants_diplomes(self) -> dict[int, Identite]:
+        """Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
+        qui vont être à traiter au jury PE pour
+        l'année de diplômation donnée et n'ayant ni été réorienté, ni abandonné.
+
+
+        Returns:
+            Un dictionnaire `{etudid: Identite(etudid)}`
+        """
+        etudids = [
+            etudid
+            for etudid, cursus_etud in self.cursus.items()
+            if cursus_etud["diplome"] == self.annee_diplome
+            and cursus_etud["abandon"] is False
+        ]
+        etudiants = {etudid: self.identites[etudid] for etudid in etudids}
+        return etudiants
+
+    def get_etudiants_redoublants_ou_reorientes(self) -> dict[int, Identite]:
+        """Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
+        dont les notes seront prises en compte (pour les classements) mais qui n'apparaitront
+        pas dans le jury car diplômé une autre année (redoublants) ou réorienté ou démissionnaire.
+
+        Returns:
+            Un dictionnaire `{etudid: Identite(etudid)}`
+        """
+        etudids = [
+            etudid
+            for etudid, cursus_etud in self.cursus.items()
+            if cursus_etud["diplome"] != self.annee_diplome
+            or cursus_etud["abandon"] is True
+        ]
+        etudiants = {etudid: self.identites[etudid] for etudid in etudids}
+        return etudiants
+
+    def analyse_etat_etudiant(self, etudid: int, cosemestres: dict[int, FormSemestre]):
+        """Analyse le cursus d'un étudiant pouvant être :
+
+        * l'un de ceux sur lesquels le jury va statuer (année de diplômation du jury considéré)
+        * un étudiant qui ne sera pas considéré dans le jury mais qui a participé dans sa scolarité
+          à un (ou plusieurs) semestres communs aux étudiants du jury (et impactera les classements)
+
+        L'analyse consiste :
+
+        * à insérer une entrée dans ``self.cursus`` pour mémoriser son identité,
+          avec son nom, prénom, etc...
+        * à analyser son parcours, pour déterminer s'il n'a (ou non) abandonné l'IUT en cours de
+          route (cf. clé abandon)
+
+        Args:
+            etudid: L'etudid d'un étudiant, à ajouter à ceux traiter par le jury
+            cosemestres: Dictionnaire {fid: Formsemestre(fid)} donnant accès aux cosemestres
+                         de même année de diplomation
+        """
+        identite = Identite.get_etud(etudid)
+
+        # Le cursus global de l'étudiant (restreint aux semestres APC)
+        formsemestres = identite.get_formsemestres()
+
+        semestres_etudiant = {
+            formsemestre.formsemestre_id: formsemestre
+            for formsemestre in formsemestres
+            if formsemestre.formation.is_apc()
+        }
+
+        self.cursus[etudid] = {
+            "etudid": etudid,  # les infos sur l'étudiant
+            "etat_civil": identite.etat_civil,  # Ajout à la table jury
+            "nom": identite.nom,
+            "entree": formsemestres[-1].date_debut.year,  # La date d'entrée à l'IUT
+            "diplome": get_annee_diplome(
+                identite
+            ),  # Le date prévisionnelle de son diplôme
+            "formsemestres": semestres_etudiant,  # les semestres de l'étudiant
+            "nb_semestres": len(
+                semestres_etudiant
+            ),  # le nombre de semestres de l'étudiant
+            "abandon": False,  # va être traité en dessous
+        }
+
+        # Est-il démissionnaire : charge son dernier semestre pour connaitre son état ?
+        dernier_semes_etudiant = formsemestres[0]
+        res = load_formsemestre_results(dernier_semes_etudiant)
+        etud_etat = res.get_etud_etat(etudid)
+        if etud_etat == scu.DEMISSION:
+            self.cursus[etudid]["abandon"] |= True
+        else:
+            # Est-il réorienté ou a-t-il arrêté volontairement sa formation ?
+            self.cursus[etudid]["abandon"] |= arret_de_formation(identite, cosemestres)
+
+    def get_semestres_significatifs(self, etudid: int):
+        """Ensemble des semestres d'un étudiant, qui l'auraient amené à être diplomé
+        l'année visée (supprime les semestres qui conduisent à une diplomation
+        postérieure à celle du jury visé)
+
+        Args:
+            etudid: L'identifiant d'un étudiant
+
+        Returns:
+            Un dictionnaire ``{fid: FormSemestre(fid)`` dans lequel les semestres
+            amènent à une diplomation avant l'annee de diplomation du jury
+        """
+        semestres_etudiant = self.cursus[etudid]["formsemestres"]
+        semestres_significatifs = {}
+        for fid in semestres_etudiant:
+            semestre = semestres_etudiant[fid]
+            if pe_comp.get_annee_diplome_semestre(semestre) <= self.annee_diplome:
+                semestres_significatifs[fid] = semestre
+        return semestres_significatifs
+
+    def structure_cursus_etudiant(self, etudid: int):
+        """Structure les informations sur les semestres suivis par un
+        étudiant, pour identifier les semestres qui seront pris en compte lors de ses calculs
+        de moyennes PE.
+
+        Cette structuration s'appuie sur les numéros de semestre: pour chaque Si, stocke :
+        le dernier semestre (en date) de numéro i qu'il a suivi (1 ou 0 si pas encore suivi).
+        Ce semestre influera les interclassement par semestre dans la promo.
+        """
+        semestres_significatifs = self.get_semestres_significatifs(etudid)
+
+        # Tri des semestres par numéro de semestre
+        for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
+            # les semestres de n°i de l'étudiant:
+            semestres_i = {
+                fid: sem_sig
+                for fid, sem_sig in semestres_significatifs.items()
+                if sem_sig.semestre_id == i
+            }
+            self.cursus[etudid][f"S{i}"] = semestres_i
+
+    def get_formsemestres_terminaux_aggregat(
+        self, aggregat: str
+    ) -> dict[int, FormSemestre]:
+        """Pour un aggrégat donné, ensemble des formsemestres terminaux possibles pour l'aggrégat
+        (pour l'aggrégat '3S' incluant S1+S2+S3, a pour semestre terminal S3).
+        Ces formsemestres traduisent :
+
+        * les différents parcours des étudiants liés par exemple au choix de modalité
+        (par ex: S1 FI + S2 FI + S3 FI ou S1 FI + S2 FI + S3 UFA), en renvoyant les
+        formsemestre_id du S3 FI et du S3 UFA.
+        * les éventuelles situations de redoublement (par ex pour 1 étudiant ayant
+        redoublé sa 2ème année :
+          S1 + S2 + S3 (1ère session) et S1 + S2 + S3 + S4 + S3 (2ème session), en
+          renvoyant les formsemestre_id du S3 (1ère session) et du S3 (2ème session)
+
+        Args:
+            aggregat: L'aggrégat
+
+        Returns:
+            Un dictionnaire ``{fid: FormSemestre(fid)}``
+        """
+        formsemestres_terminaux = {}
+        for trajectoire_aggr in self.trajectoires.values():
+            trajectoire = trajectoire_aggr[aggregat]
+            if trajectoire:
+                # Le semestre terminal de l'étudiant de l'aggrégat
+                fid = trajectoire.formsemestre_final.formsemestre_id
+                formsemestres_terminaux[fid] = trajectoire.formsemestre_final
+        return formsemestres_terminaux
+
+    def nbre_etapes_max_diplomes(self, etudids: list[int]) -> int:
+        """Partant d'un ensemble d'étudiants,
+        nombre de semestres (étapes) maximum suivis par les étudiants du jury.
+
+        Args:
+            etudids: Liste d'étudid d'étudiants
+        """
+        nbres_semestres = []
+        for etudid in etudids:
+            nbres_semestres.append(self.cursus[etudid]["nb_semestres"])
+        if not nbres_semestres:
+            return 0
+        return max(nbres_semestres)
+
+    def df_administratif(self, etudids: list[int]) -> pd.DataFrame:
+        """Synthétise toutes les données administratives d'un groupe
+        d'étudiants fournis par les etudid dans un dataFrame
+
+        Args:
+            etudids: La liste des étudiants à prendre en compte
+        """
+
+        etudids = list(etudids)
+
+        # Récupération des données des étudiants
+        administratif = {}
+        nbre_semestres_max = self.nbre_etapes_max_diplomes(etudids)
+
+        for etudid in etudids:
+            etudiant = self.identites[etudid]
+            cursus = self.cursus[etudid]
+            formsemestres = cursus["formsemestres"]
+
+            if cursus["diplome"]:
+                diplome = cursus["diplome"]
+            else:
+                diplome = "indéterminé"
+
+            administratif[etudid] = {
+                "etudid": etudiant.id,
+                "INE": etudiant.code_ine or "",
+                "NIP": etudiant.code_nip or "",
+                "Nom": etudiant.nom,
+                "Prenom": etudiant.prenom,
+                "Civilite": etudiant.civilite_str,
+                "Age": pe_comp.calcul_age(etudiant.date_naissance),
+                "Date entree": cursus["entree"],
+                "Date diplome": diplome,
+                "Nb semestres": len(formsemestres),
+            }
+
+            # Ajout des noms de semestres parcourus
+            etapes = etapes_du_cursus(formsemestres, nbre_semestres_max)
+            administratif[etudid] |= etapes
+
+        # Construction du dataframe
+        df = pd.DataFrame.from_dict(administratif, orient="index")
+
+        # Tri par nom/prénom
+        df.sort_values(by=["Nom", "Prenom"], inplace=True)
+        return df
+
+
+def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set:
+    """Ensemble d'identifiants des étudiants (identifiés via leur ``etudid``)
+    inscrits à l'un des semestres de la liste de ``semestres``.
+
+    Remarque : Les ``cosemestres`` sont généralement obtenus avec
+    ``sco_formsemestre.do_formsemestre_list()``
+
+    Args:
+        semestres: Un dictionnaire ``{fid: Formsemestre(fid)}`` donnant un
+                   ensemble d'identifiant de semestres
+
+    Returns:
+        Un ensemble d``etudid``
+    """
+
+    etudiants_ids = set()
+    for sem in semestres.values():  # pour chacun des semestres de la liste
+        etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
+
+        pe_affichage.pe_print(f"  --> {sem} : {len(etudiants_du_sem)} etudiants")
+        etudiants_ids = (
+            etudiants_ids | etudiants_du_sem
+        )  # incluant la suppression des doublons
+
+    return etudiants_ids
+
+
+def get_annee_diplome(etud: Identite) -> int | None:
+    """L'année de diplôme prévue d'un étudiant en fonction de ses semestres
+    d'inscription (pour un BUT).
+
+    Args:
+        identite: L'identité d'un étudiant
+
+    Returns:
+        L'année prévue de sa diplômation, ou None si aucun semestre
+    """
+    formsemestres_apc = get_semestres_apc(etud)
+
+    if formsemestres_apc:
+        dates_possibles_diplome = []
+        # Années de diplômation prédites en fonction des semestres
+        # (d'une formation APC) d'un étudiant
+        for sem_base in formsemestres_apc:
+            annee = pe_comp.get_annee_diplome_semestre(sem_base)
+            if annee:
+                dates_possibles_diplome.append(annee)
+        if dates_possibles_diplome:
+            return max(dates_possibles_diplome)
+
+    return None
+
+
+def get_semestres_apc(identite: Identite) -> list:
+    """Liste des semestres d'un étudiant qui corresponde à une formation APC.
+
+    Args:
+        identite: L'identité d'un étudiant
+
+    Returns:
+        Liste de ``FormSemestre`` correspondant à une formation APC
+    """
+    semestres = identite.get_formsemestres()
+    semestres_apc = []
+    for sem in semestres:
+        if sem.formation.is_apc():
+            semestres_apc.append(sem)
+    return semestres_apc
+
+
+def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool:
+    """Détermine si un étudiant a arrêté sa formation.  Il peut s'agir :
+
+    * d'une réorientation à l'initiative du jury de semestre ou d'une démission
+    (on pourrait utiliser les code NAR pour réorienté & DEM pour démissionnaire
+    des résultats du jury renseigné dans la BDD, mais pas nécessaire ici)
+
+    * d'un arrêt volontaire : l'étudiant disparait des listes d'inscrits (sans pour
+    autant avoir été indiqué NAR ou DEM).
+
+    Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas
+    dans l'un des "derniers" cosemestres (semestres conduisant à la même année de diplômation)
+    connu dans Scodoc.
+
+    Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc),
+    l'étudiant doit appartenir à une instance des S5 qui conduisent à la diplomation dans
+    l'année visée. S'il n'est que dans un S4, il a sans doute arrêté. A moins qu'il ne soit
+    parti à l'étranger et là, pas de notes.
+    TODO:: Cas de l'étranger, à coder/tester
+
+    **Attention** : Cela suppose que toutes les instances d'un semestre donné
+    (par ex: toutes les instances de S6 accueillant un étudiant soient créées ; sinon les
+    étudiants non inscrits dans un S6 seront considérés comme ayant abandonnés)
+    TODO:: Peut-être à mettre en regard avec les propositions d'inscriptions d'étudiants dans un nouveau semestre
+
+    Pour chaque étudiant, recherche son dernier semestre en date (validé ou non) et
+    regarde s'il n'existe pas parmi les semestres existants dans Scodoc un semestre :
+    * dont les dates sont postérieures (en terme de date de début)
+    * de n° au moins égal à celui de son dernier semestre valide (S5 -> S5 ou S5 -> S6)
+    dans lequel il aurait pu s'inscrire mais ne l'a pas fait.
+
+    Args:
+        etud: L'identité d'un étudiant
+        cosemestres: Les semestres donnant lieu à diplômation (sans redoublement) en date du jury
+
+    Returns:
+        Est-il réorienté, démissionnaire ou a-t-il arrêté de son propre chef sa formation ?
+
+    TODO:: A reprendre pour le cas des étudiants à l'étranger
+    TODO:: A reprendre si BUT avec semestres décalés
+    """
+
+    # Les semestres APC de l'étudiant
+    semestres = get_semestres_apc(etud)
+    semestres_apc = {sem.semestre_id: sem for sem in semestres}
+    if not semestres_apc:
+        return True
+
+    # Son dernier semestre APC en date
+    dernier_formsemestre = get_dernier_semestre_en_date(semestres_apc)
+    numero_dernier_formsemestre = dernier_formsemestre.semestre_id
+
+    # Les numéro de semestres possible dans lesquels il pourrait s'incrire
+    # semestre impair => passage de droit en semestre pair suivant (effet de l'annualisation)
+    if numero_dernier_formsemestre % 2 == 1:
+        numeros_possibles = list(
+            range(numero_dernier_formsemestre + 1, pe_comp.NBRE_SEMESTRES_DIPLOMANT)
+        )
+    # semestre pair => passage en année supérieure ou redoublement
+    else:  #
+        numeros_possibles = list(
+            range(
+                max(numero_dernier_formsemestre - 1, 1),
+                pe_comp.NBRE_SEMESTRES_DIPLOMANT,
+            )
+        )
+
+    # Y-a-t-il des cosemestres dans lesquels il aurait pu s'incrire ?
+    formsestres_superieurs_possibles = []
+    for fid, sem in cosemestres.items():  # Les semestres ayant des inscrits
+        if (
+            fid != dernier_formsemestre.formsemestre_id
+            and sem.semestre_id in numeros_possibles
+            and sem.date_debut.year >= dernier_formsemestre.date_debut.year
+        ):
+            # date de debut des semestres possibles postérieur au dernier semestre de l'étudiant
+            # et de niveau plus élevé que le dernier semestre valide de l'étudiant
+            formsestres_superieurs_possibles.append(fid)
+
+    if len(formsestres_superieurs_possibles) > 0:
+        return True
+
+    return False
+
+
+def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre:
+    """Renvoie le dernier semestre en **date de fin** d'un dictionnaire
+    de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``.
+
+    Args:
+        semestres: Un dictionnaire de semestres
+
+    Return:
+        Le FormSemestre du semestre le plus récent
+    """
+    if semestres:
+        fid_dernier_semestre = list(semestres.keys())[0]
+        dernier_semestre: FormSemestre = semestres[fid_dernier_semestre]
+        for fid in semestres:
+            if semestres[fid].date_fin > dernier_semestre.date_fin:
+                dernier_semestre = semestres[fid]
+        return dernier_semestre
+    return None
+
+
+def etapes_du_cursus(
+    semestres: dict[int, FormSemestre], nbre_etapes_max: int
+) -> list[str]:
+    """Partant d'un dictionnaire de semestres (qui retrace
+    la scolarité d'un étudiant), liste les noms des
+    semestres (en version abbrégée)
+    qu'un étudiant a suivi au cours de sa scolarité à l'IUT.
+    Les noms des semestres sont renvoyés dans un dictionnaire
+    ``{"etape i": nom_semestre_a_etape_i}``
+    avec i variant jusqu'à nbre_semestres_max. (S'il n'y a pas de semestre à l'étape i,
+    le nom affiché est vide.
+
+    La fonction suppose la liste des semestres triées par ordre
+    décroissant de date.
+
+    Args:
+        semestres: une liste de ``FormSemestre``
+        nbre_etapes_max: le nombre d'étapes max prise en compte
+
+    Returns:
+        Une liste de nom de semestre (dans le même ordre que les ``semestres``)
+
+    See also:
+         app.pe.pe_affichage.nom_semestre_etape
+    """
+    assert len(semestres) <= nbre_etapes_max
+
+    noms = [nom_semestre_etape(sem, avec_fid=False) for (fid, sem) in semestres.items()]
+    noms = noms[::-1]  # trie par ordre croissant
+
+    dico = {f"Etape {i+1}": "" for i in range(nbre_etapes_max)}
+    for i, nom in enumerate(noms):  # Charge les noms de semestres
+        dico[f"Etape {i+1}"] = nom
+    return dico
+
+
+def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str:
+    """Nom d'un semestre à afficher dans le descriptif des étapes de la scolarité
+    d'un étudiant.
+
+    Par ex: Pour un S2, affiche ``"Semestre 2 FI S014-2015 (129)"`` avec :
+
+    * 2 le numéro du semestre,
+    * FI la modalité,
+    * 2014-2015 les dates
+
+    Args:
+        semestre: Un ``FormSemestre``
+        avec_fid: Ajoute le n° du semestre à la description
+
+    Returns:
+        La chaine de caractères décrivant succintement le semestre
+    """
+    formation: Formation = semestre.formation
+    parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
+
+    description = [
+        parcours.SESSION_NAME.capitalize(),
+        str(semestre.semestre_id),
+        semestre.modalite,  # eg FI ou FC
+        f"{semestre.date_debut.year}-{semestre.date_fin.year}",
+    ]
+    if avec_fid:
+        description.append(f"({semestre.formsemestre_id})")
+
+    return " ".join(description)
diff --git a/app/pe/pe_interclasstag.py b/app/pe/pe_interclasstag.py
new file mode 100644
index 0000000000000000000000000000000000000000..895595eddd3e594be5d8f1ee1b899961c0c35b45
--- /dev/null
+++ b/app/pe/pe_interclasstag.py
@@ -0,0 +1,160 @@
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+"""
+Created on Thu Sep  8 09:36:33 2016
+
+@author: barasc
+"""
+
+import pandas as pd
+import numpy as np
+
+from app.pe.pe_tabletags import TableTag, MoyenneTag
+from app.pe.pe_etudiant import EtudiantsJuryPE
+from app.pe.pe_rcs import RCS, RCSsJuryPE
+from app.pe.pe_rcstag import RCSTag
+
+
+class RCSInterclasseTag(TableTag):
+    """
+    Interclasse l'ensemble des étudiants diplômés à une année
+    donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S')
+    en reportant :
+
+    * les moyennes obtenues sur la trajectoire qu'il ont suivi pour atteindre
+    le numéro de semestre de fin de l'aggrégat (indépendamment de son
+    formsemestre)
+    * calculant le classement sur les étudiants diplômes
+    """
+
+    def __init__(
+        self,
+        nom_rcs: str,
+        etudiants: EtudiantsJuryPE,
+        rcss_jury_pe: RCSsJuryPE,
+        rcss_tags: dict[tuple, RCSTag],
+    ):
+        TableTag.__init__(self)
+
+        self.nom_rcs = nom_rcs
+        """Le nom du RCS interclassé"""
+
+        self.nom = self.get_repr()
+
+        """Les étudiants diplômés et leurs rcss"""  # TODO
+        self.diplomes_ids = etudiants.etudiants_diplomes
+        self.etudiants_diplomes = {etudid for etudid in self.diplomes_ids}
+        # pour les exports sous forme de dataFrame
+        self.etudiants = {
+            etudid: etudiants.identites[etudid].etat_civil
+            for etudid in self.diplomes_ids
+        }
+
+        # Les trajectoires (et leur version tagguées), en ne gardant que
+        # celles associées à l'aggrégat
+        self.rcss: dict[int, RCS] = {}
+        """Ensemble des trajectoires associées à l'aggrégat"""
+        for trajectoire_id in rcss_jury_pe.rcss:
+            trajectoire = rcss_jury_pe.rcss[trajectoire_id]
+            if trajectoire_id[0] == nom_rcs:
+                self.rcss[trajectoire_id] = trajectoire
+
+        self.trajectoires_taggues: dict[int, RCS] = {}
+        """Ensemble des trajectoires tagguées associées à l'aggrégat"""
+        for trajectoire_id in self.rcss:
+            self.trajectoires_taggues[trajectoire_id] = rcss_tags[trajectoire_id]
+
+        # Les trajectoires suivies par les étudiants du jury, en ne gardant que
+        # celles associées aux diplomés
+        self.suivi: dict[int, RCS] = {}
+        """Association entre chaque étudiant et la trajectoire tagguée à prendre en
+        compte pour l'aggrégat"""
+        for etudid in self.diplomes_ids:
+            self.suivi[etudid] = rcss_jury_pe.suivi[etudid][nom_rcs]
+
+        self.tags_sorted = self.do_taglist()
+        """Liste des tags (triés par ordre alphabétique)"""
+
+        # Construit la matrice de notes
+        self.notes = self.compute_notes_matrice()
+        """Matrice des notes de l'aggrégat"""
+
+        # Synthétise les moyennes/classements par tag
+        self.moyennes_tags: dict[str, MoyenneTag] = {}
+        for tag in self.tags_sorted:
+            moy_gen_tag = self.notes[tag]
+            self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
+
+        # Est significatif ? (aka a-t-il des tags et des notes)
+        self.significatif = len(self.tags_sorted) > 0
+
+    def get_repr(self) -> str:
+        """Une représentation textuelle"""
+        return f"Aggrégat {self.nom_rcs}"
+
+    def do_taglist(self):
+        """Synthétise les tags à partir des trajectoires_tagguées
+
+        Returns:
+            Une liste de tags triés par ordre alphabétique
+        """
+        tags = []
+        for trajectoire in self.trajectoires_taggues.values():
+            tags.extend(trajectoire.tags_sorted)
+        return sorted(set(tags))
+
+    def compute_notes_matrice(self):
+        """Construit la matrice de notes (etudid x tags)
+        retraçant les moyennes obtenues par les étudiants dans les semestres associés à
+        l'aggrégat (une trajectoire ayant pour numéro de semestre final, celui de l'aggrégat).
+        """
+        # nb_tags = len(self.tags_sorted)   unused ?
+        # nb_etudiants = len(self.diplomes_ids)
+
+        # Index de la matrice (etudids -> dim 0, tags -> dim 1)
+        etudids = list(self.diplomes_ids)
+        tags = self.tags_sorted
+
+        # Partant d'un dataframe vierge
+        df = pd.DataFrame(np.nan, index=etudids, columns=tags)
+
+        for trajectoire in self.trajectoires_taggues.values():
+            # Charge les moyennes par tag de la trajectoire tagguée
+            notes = trajectoire.notes
+            # Etudiants/Tags communs entre la trajectoire_tagguée et les données interclassées
+            etudids_communs = df.index.intersection(notes.index)
+            tags_communs = df.columns.intersection(notes.columns)
+
+            # Injecte les notes par tag
+            df.loc[etudids_communs, tags_communs] = notes.loc[
+                etudids_communs, tags_communs
+            ]
+
+        return df
diff --git a/app/pe/pe_jury.py b/app/pe/pe_jury.py
new file mode 100644
index 0000000000000000000000000000000000000000..ac0c076f6e7ae6e5f9a74a0dfff66f07bf96ac06
--- /dev/null
+++ b/app/pe/pe_jury.py
@@ -0,0 +1,734 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+"""
+Created on Fri Sep  9 09:15:05 2016
+
+@author: barasc
+"""
+
+# ----------------------------------------------------------
+# Ensemble des fonctions et des classes
+# permettant les calculs preliminaires (hors affichage)
+# a l'edition d'un jury de poursuites d'etudes
+# ----------------------------------------------------------
+
+import io
+import os
+import time
+from zipfile import ZipFile
+
+import numpy as np
+import pandas as pd
+
+from app.pe.pe_affichage import NOM_STAT_PROMO, SANS_NOTE, NOM_STAT_GROUPE
+import app.pe.pe_affichage as pe_affichage
+from app.pe.pe_etudiant import *  # TODO A éviter -> pe_etudiant.
+from app.pe.pe_rcs import *  # TODO A éviter
+from app.pe.pe_rcstag import RCSTag
+from app.pe.pe_semtag import SemestreTag
+from app.pe.pe_interclasstag import RCSInterclasseTag
+
+
+class JuryPE(object):
+    """
+    Classe mémorisant toutes les informations nécessaires pour établir un jury de PE, sur la base
+    d'une année de diplôme. De ce semestre est déduit :
+    1. l'année d'obtention du DUT,
+    2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés.
+
+    Args:
+        diplome : l'année d'obtention du diplome BUT et du jury de PE (généralement février XXXX)
+    """
+
+    def __init__(self, diplome):
+        pe_affichage.pe_start_log()
+        self.diplome = diplome
+        "L'année du diplome"
+
+        self.nom_export_zip = f"Jury_PE_{self.diplome}"
+        "Nom du zip où ranger les fichiers générés"
+
+        pe_affichage.pe_print(
+            f"Données de poursuite d'étude générées le {time.strftime('%d/%m/%Y à %H:%M')}\n"
+        )
+        # Chargement des étudiants à prendre en compte dans le jury
+        pe_affichage.pe_print(
+            f"""*** Recherche et chargement des étudiants diplômés en {
+                self.diplome}"""
+        )
+        self.etudiants = EtudiantsJuryPE(self.diplome)  # Les infos sur les étudiants
+        self.etudiants.find_etudiants()
+        self.diplomes_ids = self.etudiants.diplomes_ids
+
+        self.zipdata = io.BytesIO()
+        with ZipFile(self.zipdata, "w") as zipfile:
+            if not self.diplomes_ids:
+                pe_affichage.pe_print("*** Aucun étudiant diplômé")
+            else:
+                self._gen_xls_diplomes(zipfile)
+                self._gen_xls_semestre_taggues(zipfile)
+                self._gen_xls_rcss_tags(zipfile)
+                self._gen_xls_interclassements_rcss(zipfile)
+                self._gen_xls_synthese_jury_par_tag(zipfile)
+                self._gen_xls_synthese_par_etudiant(zipfile)
+            # et le log
+            self._add_log_to_zip(zipfile)
+
+        # Fin !!!! Tada :)
+
+    def _gen_xls_diplomes(self, zipfile: ZipFile):
+        "Intègre le bilan des semestres taggués au zip"
+        output = io.BytesIO()
+        with pd.ExcelWriter(  # pylint: disable=abstract-class-instantiated
+            output, engine="openpyxl"
+        ) as writer:
+            if self.diplomes_ids:
+                onglet = "diplômés"
+                df_diplome = self.etudiants.df_administratif(self.diplomes_ids)
+                df_diplome.to_excel(writer, onglet, index=True, header=True)
+            if self.etudiants.abandons_ids:
+                onglet = "redoublants-réorientés"
+                df_abandon = self.etudiants.df_administratif(
+                    self.etudiants.abandons_ids
+                )
+                df_abandon.to_excel(writer, onglet, index=True, header=True)
+        output.seek(0)
+
+        self.add_file_to_zip(
+            zipfile,
+            f"etudiants_{self.diplome}.xlsx",
+            output.read(),
+            path="details",
+        )
+
+    def _gen_xls_semestre_taggues(self, zipfile: ZipFile):
+        "Génère les semestres taggués (avec le calcul des moyennes) pour le jury PE"
+        pe_affichage.pe_print("*** Génère les semestres taggués")
+        self.sems_tags = compute_semestres_tag(self.etudiants)
+
+        # Intègre le bilan des semestres taggués au zip final
+        output = io.BytesIO()
+        with pd.ExcelWriter(  # pylint: disable=abstract-class-instantiated
+            output, engine="openpyxl"
+        ) as writer:
+            for formsemestretag in self.sems_tags.values():
+                onglet = formsemestretag.nom
+                df = formsemestretag.df_moyennes_et_classements()
+                # écriture dans l'onglet
+                df.to_excel(writer, onglet, index=True, header=True)
+        output.seek(0)
+
+        self.add_file_to_zip(
+            zipfile,
+            f"semestres_taggues_{self.diplome}.xlsx",
+            output.read(),
+            path="details",
+        )
+
+    def _gen_xls_rcss_tags(self, zipfile: ZipFile):
+        """Génère les RCS (combinaisons de semestres suivis
+        par un étudiant)
+        """
+        pe_affichage.pe_print(
+            "*** Génère les trajectoires (différentes combinaisons de semestres) des étudiants"
+        )
+        self.rcss = RCSsJuryPE(self.diplome)
+        self.rcss.cree_rcss(self.etudiants)
+
+        # Génère les moyennes par tags des trajectoires
+        pe_affichage.pe_print("*** Calcule les moyennes par tag des RCS possibles")
+        self.rcss_tags = compute_trajectoires_tag(
+            self.rcss, self.etudiants, self.sems_tags
+        )
+
+        # Intègre le bilan des trajectoires tagguées au zip final
+        output = io.BytesIO()
+        with pd.ExcelWriter(  # pylint: disable=abstract-class-instantiated
+            output, engine="openpyxl"
+        ) as writer:
+            for rcs_tag in self.rcss_tags.values():
+                onglet = rcs_tag.get_repr()
+                df = rcs_tag.df_moyennes_et_classements()
+                # écriture dans l'onglet
+                df.to_excel(writer, onglet, index=True, header=True)
+        output.seek(0)
+
+        self.add_file_to_zip(
+            zipfile,
+            f"RCS_taggues_{self.diplome}.xlsx",
+            output.read(),
+            path="details",
+        )
+
+    def _gen_xls_interclassements_rcss(self, zipfile: ZipFile):
+        """Intègre le bilan des RCS (interclassé par promo) au zip"""
+        # Génère les interclassements (par promo et) par (nom d') aggrégat
+        pe_affichage.pe_print("*** Génère les interclassements par aggrégat")
+        self.interclassements_taggues = compute_interclassements(
+            self.etudiants, self.rcss, self.rcss_tags
+        )
+
+        # Intègre le bilan des aggrégats (interclassé par promo) au zip final
+        output = io.BytesIO()
+        with pd.ExcelWriter(  # pylint: disable=abstract-class-instantiated
+            output, engine="openpyxl"
+        ) as writer:
+            for interclass_tag in self.interclassements_taggues.values():
+                if interclass_tag.significatif:  # Avec des notes
+                    onglet = interclass_tag.get_repr()
+                    df = interclass_tag.df_moyennes_et_classements()
+                    # écriture dans l'onglet
+                    df.to_excel(writer, onglet, index=True, header=True)
+        output.seek(0)
+
+        self.add_file_to_zip(
+            zipfile,
+            f"interclassements_taggues_{self.diplome}.xlsx",
+            output.read(),
+            path="details",
+        )
+
+    def _gen_xls_synthese_jury_par_tag(self, zipfile: ZipFile):
+        """Synthèse des éléments du jury PE tag par tag"""
+        # Synthèse des éléments du jury PE
+        self.synthese = self.synthetise_jury_par_tags()
+
+        # Export des données => mode 1 seule feuille -> supprimé
+        pe_affichage.pe_print("*** Export du jury de synthese par tags")
+        output = io.BytesIO()
+        with pd.ExcelWriter(  # pylint: disable=abstract-class-instantiated
+            output, engine="openpyxl"
+        ) as writer:
+            for onglet, df in self.synthese.items():
+                # écriture dans l'onglet:
+                df.to_excel(writer, onglet, index=True, header=True)
+        output.seek(0)
+
+        self.add_file_to_zip(
+            zipfile, f"synthese_jury_{self.diplome}_par_tag.xlsx", output.read()
+        )
+
+    def _gen_xls_synthese_par_etudiant(self, zipfile: ZipFile):
+        """Synthèse des éléments du jury PE, étudiant par étudiant"""
+        # Synthèse des éléments du jury PE
+        synthese = self.synthetise_jury_par_etudiants()
+
+        # Export des données => mode 1 seule feuille -> supprimé
+        pe_affichage.pe_print("*** Export du jury de synthese par étudiants")
+        output = io.BytesIO()
+        with pd.ExcelWriter(  # pylint: disable=abstract-class-instantiated
+            output, engine="openpyxl"
+        ) as writer:
+            for onglet, df in synthese.items():
+                # écriture dans l'onglet:
+                df.to_excel(writer, onglet, index=True, header=True)
+        output.seek(0)
+
+        self.add_file_to_zip(
+            zipfile, f"synthese_jury_{self.diplome}_par_etudiant.xlsx", output.read()
+        )
+
+    def _add_log_to_zip(self, zipfile):
+        """Add a text file with the log messages"""
+        log_data = pe_affichage.pe_get_log()
+        self.add_file_to_zip(zipfile, "pe_log.txt", log_data)
+
+    def add_file_to_zip(self, zipfile: ZipFile, filename: str, data, path=""):
+        """Add a file to given zip
+        All files under NOM_EXPORT_ZIP/
+        path may specify a subdirectory
+
+        Args:
+             zipfile: ZipFile
+             filename: Le nom du fichier à intégrer au zip
+             data: Les données du fichier
+             path: Un dossier dans l'arborescence du zip
+        """
+        path_in_zip = os.path.join(path, filename)  # self.nom_export_zip,
+        zipfile.writestr(path_in_zip, data)
+
+    def get_zipped_data(self) -> io.BytesIO | None:
+        """returns file-like data with a zip of all generated (CSV) files.
+        Warning: reset stream to the begining.
+        """
+        self.zipdata.seek(0)
+        return self.zipdata
+
+    def do_tags_list(self, interclassements: dict[str, RCSInterclasseTag]):
+        """La liste des tags extraites des interclassements"""
+        tags = []
+        for aggregat in interclassements:
+            interclass = interclassements[aggregat]
+            if interclass.tags_sorted:
+                tags.extend(interclass.tags_sorted)
+        tags = sorted(set(tags))
+        return tags
+
+    # **************************************************************************************************************** #
+    # Méthodes pour la synthèse du juryPE
+    # *****************************************************************************************************************
+
+    def synthetise_jury_par_tags(self) -> dict[pd.DataFrame]:
+        """Synthétise tous les résultats du jury PE dans des dataframes,
+        dont les onglets sont les tags"""
+
+        pe_affichage.pe_print("*** Synthèse finale des moyennes par tag***")
+
+        synthese = {}
+        pe_affichage.pe_print("  -> Synthèse des données administratives")
+        synthese["administratif"] = self.etudiants.df_administratif(self.diplomes_ids)
+
+        tags = self.do_tags_list(self.interclassements_taggues)
+        for tag in tags:
+            pe_affichage.pe_print(f"  -> Synthèse du tag {tag}")
+            synthese[tag] = self.df_tag(tag)
+        return synthese
+
+    def df_tag(self, tag):
+        """Génère le DataFrame synthétisant les moyennes/classements (groupe,
+        interclassement promo) pour tous les aggrégats prévus,
+        tels que fourni dans l'excel final.
+
+        Args:
+            tag: Un des tags (a minima `but`)
+
+        Returns:
+        """
+
+        etudids = list(self.diplomes_ids)
+
+        # Les données des étudiants
+        donnees_etudiants = {}
+        for etudid in etudids:
+            etudiant = self.etudiants.identites[etudid]
+            donnees_etudiants[etudid] = {
+                ("Identité", "", "Civilite"): etudiant.civilite_str,
+                ("Identité", "", "Nom"): etudiant.nom,
+                ("Identité", "", "Prenom"): etudiant.prenom,
+            }
+        df_synthese = pd.DataFrame.from_dict(donnees_etudiants, orient="index")
+
+        # Ajout des aggrégats
+        for aggregat in TOUS_LES_RCS:
+            descr = TYPES_RCS[aggregat]["descr"]
+
+            # Les trajectoires (tagguées) suivies par les étudiants pour l'aggrégat et le tag
+            # considéré
+            trajectoires_tagguees = []
+            for etudid in etudids:
+                trajectoire = self.rcss.suivi[etudid][aggregat]
+                if trajectoire:
+                    tid = trajectoire.rcs_id
+                    trajectoire_tagguee = self.rcss_tags[tid]
+                    if (
+                        tag in trajectoire_tagguee.moyennes_tags
+                        and trajectoire_tagguee not in trajectoires_tagguees
+                    ):
+                        trajectoires_tagguees.append(trajectoire_tagguee)
+
+            # Combien de notes vont être injectées ?
+            nbre_notes_injectees = 0
+            for traj in trajectoires_tagguees:
+                moy_traj = traj.moyennes_tags[tag]
+                inscrits_traj = moy_traj.inscrits_ids
+                etudids_communs = set(etudids) & set(inscrits_traj)
+                nbre_notes_injectees += len(etudids_communs)
+
+            # Si l'aggrégat est significatif (aka il y a des notes)
+            if nbre_notes_injectees > 0:
+                # Ajout des données classements & statistiques
+                nom_stat_promo = f"{NOM_STAT_PROMO} {self.diplome}"
+                donnees = pd.DataFrame(
+                    index=etudids,
+                    columns=[
+                        [descr] * (1 + 4 * 2),
+                        [""] + [NOM_STAT_GROUPE] * 4 + [nom_stat_promo] * 4,
+                        ["note"] + ["class.", "min", "moy", "max"] * 2,
+                    ],
+                )
+
+                for traj in trajectoires_tagguees:
+                    # Les données des trajectoires_tagguees
+                    moy_traj = traj.moyennes_tags[tag]
+
+                    # Les étudiants communs entre tableur de synthèse et trajectoires
+                    inscrits_traj = moy_traj.inscrits_ids
+                    etudids_communs = list(set(etudids) & set(inscrits_traj))
+
+                    # Les notes
+                    champ = (descr, "", "note")
+                    notes_traj = moy_traj.get_notes()
+                    donnees.loc[etudids_communs, champ] = notes_traj.loc[
+                        etudids_communs
+                    ]
+
+                    # Les rangs
+                    champ = (descr, NOM_STAT_GROUPE, "class.")
+                    rangs = moy_traj.get_rangs_inscrits()
+                    donnees.loc[etudids_communs, champ] = rangs.loc[etudids_communs]
+
+                    # Les mins
+                    champ = (descr, NOM_STAT_GROUPE, "min")
+                    mins = moy_traj.get_min()
+                    donnees.loc[etudids_communs, champ] = mins.loc[etudids_communs]
+
+                    # Les max
+                    champ = (descr, NOM_STAT_GROUPE, "max")
+                    maxs = moy_traj.get_max()
+                    donnees.loc[etudids_communs, champ] = maxs.loc[etudids_communs]
+
+                    # Les moys
+                    champ = (descr, NOM_STAT_GROUPE, "moy")
+                    moys = moy_traj.get_moy()
+                    donnees.loc[etudids_communs, champ] = moys.loc[etudids_communs]
+
+                # Ajoute les données d'interclassement
+                interclass = self.interclassements_taggues[aggregat]
+                moy_interclass = interclass.moyennes_tags[tag]
+
+                # Les étudiants communs entre tableur de synthèse et l'interclassement
+                inscrits_interclass = moy_interclass.inscrits_ids
+                etudids_communs = list(set(etudids) & set(inscrits_interclass))
+
+                # Les classements d'interclassement
+                champ = (descr, nom_stat_promo, "class.")
+                rangs = moy_interclass.get_rangs_inscrits()
+                donnees.loc[etudids_communs, champ] = rangs.loc[etudids_communs]
+
+                # Les mins
+                champ = (descr, nom_stat_promo, "min")
+                mins = moy_interclass.get_min()
+                donnees.loc[etudids_communs, champ] = mins.loc[etudids_communs]
+
+                # Les max
+                champ = (descr, nom_stat_promo, "max")
+                maxs = moy_interclass.get_max()
+                donnees.loc[etudids_communs, champ] = maxs.loc[etudids_communs]
+
+                # Les moys
+                champ = (descr, nom_stat_promo, "moy")
+                moys = moy_interclass.get_max()
+                donnees.loc[etudids_communs, champ] = moys.loc[etudids_communs]
+
+                df_synthese = df_synthese.join(donnees)
+            # Fin de l'aggrégat
+
+        # Tri par nom/prénom
+        df_synthese.sort_values(
+            by=[("Identité", "", "Nom"), ("Identité", "", "Prenom")], inplace=True
+        )
+        return df_synthese
+
+    def synthetise_jury_par_etudiants(self) -> dict[pd.DataFrame]:
+        """Synthétise tous les résultats du jury PE dans des dataframes,
+        dont les onglets sont les étudiants"""
+        pe_affichage.pe_print("*** Synthèse finale des moyennes par étudiants***")
+
+        synthese = {}
+        pe_affichage.pe_print("  -> Synthèse des données administratives")
+        synthese["administratif"] = self.etudiants.df_administratif(self.diplomes_ids)
+
+        etudids = list(self.diplomes_ids)
+
+        for etudid in etudids:
+            etudiant = self.etudiants.identites[etudid]
+            nom = etudiant.nom
+            prenom = etudiant.prenom[0]  # initial du prénom
+
+            onglet = f"{nom} {prenom}. ({etudid})"
+            if len(onglet) > 32:  # limite sur la taille des onglets
+                fin_onglet = f"{prenom}. ({etudid})"
+                onglet = f"{nom[:32-len(fin_onglet)-2]}." + fin_onglet
+
+            pe_affichage.pe_print(f"  -> Synthèse de l'étudiant {etudid}")
+            synthese[onglet] = self.df_synthese_etudiant(etudid)
+        return synthese
+
+    def df_synthese_etudiant(self, etudid: int) -> pd.DataFrame:
+        """Créé un DataFrame pour un étudiant donné par son etudid, retraçant
+        toutes ses moyennes aux différents tag et aggrégats"""
+        tags = self.do_tags_list(self.interclassements_taggues)
+
+        donnees = {}
+
+        for tag in tags:
+            # Une ligne pour le tag
+            donnees[tag] = {("", "", "tag"): tag}
+
+            for aggregat in TOUS_LES_RCS:
+                # Le dictionnaire par défaut des moyennes
+                donnees[tag] |= get_defaut_dict_synthese_aggregat(
+                    aggregat, self.diplome
+                )
+
+                # La trajectoire de l'étudiant sur l'aggrégat
+                trajectoire = self.rcss.suivi[etudid][aggregat]
+                if trajectoire:
+                    trajectoire_tagguee = self.rcss_tags[trajectoire.rcs_id]
+                    if tag in trajectoire_tagguee.moyennes_tags:
+                        # L'interclassement
+                        interclass = self.interclassements_taggues[aggregat]
+
+                        # Injection des données dans un dictionnaire
+                        donnees[tag] |= get_dict_synthese_aggregat(
+                            aggregat,
+                            trajectoire_tagguee,
+                            interclass,
+                            etudid,
+                            tag,
+                            self.diplome,
+                        )
+
+            # Fin de l'aggrégat
+        # Construction du dataFrame
+        df = pd.DataFrame.from_dict(donnees, orient="index")
+
+        # Tri par nom/prénom
+        df.sort_values(by=[("", "", "tag")], inplace=True)
+        return df
+
+
+def get_formsemestres_etudiants(etudiants: EtudiantsJuryPE) -> dict:
+    """Ayant connaissance des étudiants dont il faut calculer les moyennes pour
+    le jury PE (attribut `self.etudiant_ids) et de leur cursus (semestres
+    parcourus),
+    renvoie un dictionnaire ``{fid: FormSemestre(fid)}``
+    contenant l'ensemble des formsemestres de leurs cursus, dont il faudra calculer
+    la moyenne.
+
+    Args:
+        etudiants: Les étudiants du jury PE
+
+    Returns:
+        Un dictionnaire de la forme `{fid: FormSemestre(fid)}`
+
+    """
+    semestres = {}
+    for etudid in etudiants.etudiants_ids:
+        for cle in etudiants.cursus[etudid]:
+            if cle.startswith("S"):
+                semestres = semestres | etudiants.cursus[etudid][cle]
+    return semestres
+
+
+def compute_semestres_tag(etudiants: EtudiantsJuryPE) -> dict:
+    """Créé les semestres taggués, de type 'S1', 'S2', ..., pour un groupe d'étudiants donnés.
+    Chaque semestre taggué est rattaché à l'un des FormSemestre faisant partie du cursus scolaire
+    des étudiants (cf. attribut etudiants.cursus).
+    En crééant le semestre taggué, sont calculées les moyennes/classements par tag associé.
+        .
+
+    Args:
+        etudiants: Un groupe d'étudiants participant au jury
+
+    Returns:
+        Un dictionnaire {fid: SemestreTag(fid)}
+    """
+
+    # Création des semestres taggués, de type 'S1', 'S2', ...
+    pe_affichage.pe_print("*** Création des semestres taggués")
+
+    formsemestres = get_formsemestres_etudiants(etudiants)
+
+    semestres_tags = {}
+    for frmsem_id, formsemestre in formsemestres.items():
+        # Crée le semestre_tag et exécute les calculs de moyennes
+        formsemestretag = SemestreTag(frmsem_id)
+        pe_affichage.pe_print(
+            f"  --> Semestre taggué {formsemestretag.nom} sur la base de {formsemestre}"
+        )
+        # Stocke le semestre taggué
+        semestres_tags[frmsem_id] = formsemestretag
+
+    return semestres_tags
+
+
+def compute_trajectoires_tag(
+    trajectoires: RCSsJuryPE,
+    etudiants: EtudiantsJuryPE,
+    semestres_taggues: dict[int, SemestreTag],
+):
+    """Créée les trajectoires tagguées (combinaison aggrégeant plusieurs semestres au sens
+    d'un aggrégat (par ex: '3S')),
+    en calculant les moyennes et les classements par tag pour chacune.
+
+    Pour rappel : Chaque trajectoire est identifiée un nom d'aggrégat et par un formsemestre terminal.
+
+    Par exemple :
+
+    * combinaisons '3S' : S1+S2+S3 en prenant en compte tous les S3 qu'ont fréquenté les
+      étudiants du jury PE. Ces S3 marquent les formsemestre terminal de chaque combinaison.
+
+    * combinaisons 'S2' : 1 seul S2 pour des étudiants n'ayant pas redoublé, 2 pour des redoublants (dont les
+      notes seront moyennées sur leur 2 semestres S2). Ces combinaisons ont pour formsemestre le dernier S2 en
+      date (le S2 redoublé par les redoublants est forcément antérieur)
+
+
+    Args:
+        etudiants: Les données des étudiants
+        semestres_tag: Les semestres tag (pour lesquels des moyennes par tag ont été calculés)
+
+    Return:
+        Un dictionnaire de la forme ``{nom_aggregat: {fid_terminal: SetTag(fid_terminal)} }``
+    """
+    trajectoires_tagguees = {}
+
+    for trajectoire_id, trajectoire in trajectoires.rcss.items():
+        nom = trajectoire.get_repr()
+        pe_affichage.pe_print(f"  --> Aggrégat {nom}")
+        # Trajectoire_tagguee associée
+        trajectoire_tagguee = RCSTag(trajectoire, semestres_taggues)
+        # Mémorise le résultat
+        trajectoires_tagguees[trajectoire_id] = trajectoire_tagguee
+
+    return trajectoires_tagguees
+
+
+def compute_interclassements(
+    etudiants: EtudiantsJuryPE,
+    trajectoires_jury_pe: RCSsJuryPE,
+    trajectoires_tagguees: dict[tuple, RCS],
+):
+    """Interclasse les étudiants, (nom d') aggrégat par aggrégat,
+    pour fournir un classement sur la promo. Le classement est établi au regard du nombre
+    d'étudiants ayant participé au même aggrégat.
+    """
+    aggregats_interclasses_taggues = {}
+    for nom_aggregat in TOUS_LES_RCS:
+        pe_affichage.pe_print(f"   --> Interclassement {nom_aggregat}")
+        interclass = RCSInterclasseTag(
+            nom_aggregat, etudiants, trajectoires_jury_pe, trajectoires_tagguees
+        )
+        aggregats_interclasses_taggues[nom_aggregat] = interclass
+    return aggregats_interclasses_taggues
+
+
+def get_defaut_dict_synthese_aggregat(nom_rcs: str, diplome: int) -> dict:
+    """Renvoie le dictionnaire de synthèse (à intégrer dans
+    un tableur excel) pour décrire les résultats d'un aggrégat
+
+    Args:
+        nom_rcs : Le nom du RCS visé
+        diplôme : l'année du diplôme
+    """
+    # L'affichage de l'aggrégat dans le tableur excel
+    descr = get_descr_rcs(nom_rcs)
+
+    nom_stat_promo = f"{NOM_STAT_PROMO} {diplome}"
+    donnees = {
+        (descr, "", "note"): SANS_NOTE,
+        # Les stat du groupe
+        (descr, NOM_STAT_GROUPE, "class."): SANS_NOTE,
+        (descr, NOM_STAT_GROUPE, "min"): SANS_NOTE,
+        (descr, NOM_STAT_GROUPE, "moy"): SANS_NOTE,
+        (descr, NOM_STAT_GROUPE, "max"): SANS_NOTE,
+        # Les stats de l'interclassement dans la promo
+        (descr, nom_stat_promo, "class."): SANS_NOTE,
+        (
+            descr,
+            nom_stat_promo,
+            "min",
+        ): SANS_NOTE,
+        (
+            descr,
+            nom_stat_promo,
+            "moy",
+        ): SANS_NOTE,
+        (
+            descr,
+            nom_stat_promo,
+            "max",
+        ): SANS_NOTE,
+    }
+    return donnees
+
+
+def get_dict_synthese_aggregat(
+    aggregat: str,
+    trajectoire_tagguee: RCSTag,
+    interclassement_taggue: RCSInterclasseTag,
+    etudid: int,
+    tag: str,
+    diplome: int,
+):
+    """Renvoie le dictionnaire (à intégrer au tableur excel de synthese)
+    traduisant les résultats (moy/class) d'un étudiant à une trajectoire tagguée associée
+    à l'aggrégat donné et pour un tag donné"""
+    donnees = {}
+    # L'affichage de l'aggrégat dans le tableur excel
+    descr = get_descr_rcs(aggregat)
+
+    # La note de l'étudiant (chargement à venir)
+    note = np.nan
+
+    # Les données de la trajectoire tagguée pour le tag considéré
+    moy_tag = trajectoire_tagguee.moyennes_tags[tag]
+
+    # Les données de l'étudiant
+    note = moy_tag.get_note_for_df(etudid)
+
+    classement = moy_tag.get_class_for_df(etudid)
+    nmin = moy_tag.get_min_for_df()
+    nmax = moy_tag.get_max_for_df()
+    nmoy = moy_tag.get_moy_for_df()
+
+    # Statistiques sur le groupe
+    if not pd.isna(note) and note != np.nan:
+        # Les moyennes de cette trajectoire
+        donnees |= {
+            (descr, "", "note"): note,
+            (descr, NOM_STAT_GROUPE, "class."): classement,
+            (descr, NOM_STAT_GROUPE, "min"): nmin,
+            (descr, NOM_STAT_GROUPE, "moy"): nmoy,
+            (descr, NOM_STAT_GROUPE, "max"): nmax,
+        }
+
+    # L'interclassement
+    moy_tag = interclassement_taggue.moyennes_tags[tag]
+
+    classement = moy_tag.get_class_for_df(etudid)
+    nmin = moy_tag.get_min_for_df()
+    nmax = moy_tag.get_max_for_df()
+    nmoy = moy_tag.get_moy_for_df()
+
+    if not pd.isna(note) and note != np.nan:
+        nom_stat_promo = f"{NOM_STAT_PROMO} {diplome}"
+
+        donnees |= {
+            (descr, nom_stat_promo, "class."): classement,
+            (descr, nom_stat_promo, "min"): nmin,
+            (descr, nom_stat_promo, "moy"): nmoy,
+            (descr, nom_stat_promo, "max"): nmax,
+        }
+
+    return donnees
diff --git a/app/pe/pe_rcs.py b/app/pe/pe_rcs.py
new file mode 100644
index 0000000000000000000000000000000000000000..f9ec668836bf6d82530789c30e71a69e776a0c20
--- /dev/null
+++ b/app/pe/pe_rcs.py
@@ -0,0 +1,269 @@
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+"""
+Created on 01-2024
+
+@author: barasc
+"""
+
+import app.pe.pe_comp as pe_comp
+
+from app.models import FormSemestre
+from app.pe.pe_etudiant import EtudiantsJuryPE, get_dernier_semestre_en_date
+
+
+TYPES_RCS = {
+    "S1": {
+        "aggregat": ["S1"],
+        "descr": "Semestre 1 (S1)",
+    },
+    "S2": {
+        "aggregat": ["S2"],
+        "descr": "Semestre 2 (S2)",
+    },
+    "1A": {
+        "aggregat": ["S1", "S2"],
+        "descr": "BUT1 (S1+S2)",
+    },
+    "S3": {
+        "aggregat": ["S3"],
+        "descr": "Semestre 3 (S3)",
+    },
+    "S4": {
+        "aggregat": ["S4"],
+        "descr": "Semestre 4 (S4)",
+    },
+    "2A": {
+        "aggregat": ["S3", "S4"],
+        "descr": "BUT2 (S3+S4)",
+    },
+    "3S": {
+        "aggregat": ["S1", "S2", "S3"],
+        "descr": "Moyenne du semestre 1 au semestre 3 (S1+S2+S3)",
+    },
+    "4S": {
+        "aggregat": ["S1", "S2", "S3", "S4"],
+        "descr": "Moyenne du semestre 1 au semestre 4 (S1+S2+S3+S4)",
+    },
+    "S5": {
+        "aggregat": ["S5"],
+        "descr": "Semestre 5 (S5)",
+    },
+    "S6": {
+        "aggregat": ["S6"],
+        "descr": "Semestre 6 (S6)",
+    },
+    "3A": {
+        "aggregat": ["S5", "S6"],
+        "descr": "3ème année (S5+S6)",
+    },
+    "5S": {
+        "aggregat": ["S1", "S2", "S3", "S4", "S5"],
+        "descr": "Moyenne du semestre 1 au semestre 5 (S1+S2+S3+S4+S5)",
+    },
+    "6S": {
+        "aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"],
+        "descr": "Moyenne globale (S1+S2+S3+S4+S5+S6)",
+    },
+}
+"""Dictionnaire détaillant les différents regroupements cohérents
+de semestres (RCS), en leur attribuant un nom et en détaillant
+le nom des semestres qu'ils regroupent et l'affichage qui en sera fait
+dans les tableurs de synthèse.
+"""
+
+TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS if not cle.startswith("S")]
+TOUS_LES_RCS = list(TYPES_RCS.keys())
+TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS if cle.startswith("S")]
+
+
+class RCS:
+    """Modélise un ensemble de semestres d'étudiants
+    associé à un type de regroupement cohérent de semestres
+    donné (par ex: 'S2', '3S', '2A').
+
+    Si le RCS est un semestre de type Si, stocke le (ou les)
+    formsemestres de numéro i qu'ont suivi l'étudiant pour atteindre le Si
+    (en général 1 si personnes n'a redoublé, mais 2 s'il y a des redoublants)
+
+    Pour le RCS de type iS ou iA (par ex, 3A=S1+S2+S3), elle identifie
+    les semestres que les étudiants ont suivis pour les amener jusqu'au semestre
+    terminal de la trajectoire (par ex: ici un S3).
+
+    Ces semestres peuvent être :
+
+    * des S1+S2+S1+S2+S3 si redoublement de la 1ère année
+    * des S1+S2+(année de césure)+S3 si césure, ...
+
+    Args:
+        nom_rcs: Un nom du RCS (par ex: '5S')
+        semestre_final: Le semestre final du RCS
+    """
+
+    def __init__(self, nom_rcs: str, semestre_final: FormSemestre):
+        self.nom = nom_rcs
+        """Nom du RCS"""
+
+        self.formsemestre_final = semestre_final
+        """FormSemestre terminal du RCS"""
+
+        self.rcs_id = (nom_rcs, semestre_final.formsemestre_id)
+        """Identifiant du RCS sous forme (nom_rcs, id du semestre_terminal)"""
+
+        self.semestres_aggreges = {}
+        """Semestres regroupés dans le RCS"""
+
+    def add_semestres_a_aggreger(self, semestres: dict[int:FormSemestre]):
+        """Ajout de semestres aux semestres à regrouper
+
+        Args:
+            semestres: Dictionnaire ``{fid: FormSemestre(fid)}`` à ajouter
+        """
+        self.semestres_aggreges = self.semestres_aggreges | semestres
+
+    def get_repr(self, verbose=True) -> str:
+        """Représentation textuelle d'un RCS
+        basé sur ses semestres aggrégés"""
+
+        noms = []
+        for fid in self.semestres_aggreges:
+            semestre = self.semestres_aggreges[fid]
+            noms.append(f"S{semestre.semestre_id}({fid})")
+        noms = sorted(noms)
+        title = f"""{self.nom} ({
+            self.formsemestre_final.formsemestre_id}) {self.formsemestre_final.date_fin.year}"""
+        if verbose and noms:
+            title += " - " + "+".join(noms)
+        return title
+
+
+class RCSsJuryPE:
+    """Classe centralisant toutes les regroupements cohérents de
+    semestres (RCS) des étudiants à prendre en compte dans un jury PE
+
+    Args:
+        annee_diplome: L'année de diplomation
+    """
+
+    def __init__(self, annee_diplome: int):
+        self.annee_diplome = annee_diplome
+        """Année de diplômation"""
+
+        self.rcss: dict[tuple:RCS] = {}
+        """Ensemble des RCS recensés : {(nom_RCS, fid_terminal): RCS}"""
+
+        self.suivi: dict[int:str] = {}
+        """Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
+            son RCS : {etudid: {nom_RCS: RCS}}"""
+
+    def cree_rcss(self, etudiants: EtudiantsJuryPE):
+        """Créé tous les RCS, au regard du cursus des étudiants
+        analysés + les mémorise dans les données de l'étudiant
+
+        Args:
+            etudiants: Les étudiants à prendre en compte dans le Jury PE
+        """
+
+        for nom_rcs in pe_comp.TOUS_LES_SEMESTRES + TOUS_LES_RCS_AVEC_PLUSIEURS_SEM:
+            # L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre
+            # terminal (par ex: S3) et son numéro (par ex: 3)
+            noms_semestre_de_aggregat = TYPES_RCS[nom_rcs]["aggregat"]
+            nom_semestre_terminal = noms_semestre_de_aggregat[-1]
+
+            for etudid in etudiants.cursus:
+                if etudid not in self.suivi:
+                    self.suivi[etudid] = {
+                        aggregat: None
+                        for aggregat in pe_comp.TOUS_LES_SEMESTRES
+                        + TOUS_LES_RCS_AVEC_PLUSIEURS_SEM
+                    }
+
+                # Le formsemestre terminal (dernier en date) associé au
+                # semestre marquant la fin de l'aggrégat
+                # (par ex: son dernier S3 en date)
+                semestres = etudiants.cursus[etudid][nom_semestre_terminal]
+                if semestres:
+                    formsemestre_final = get_dernier_semestre_en_date(semestres)
+
+                    # Ajout ou récupération de la trajectoire
+                    trajectoire_id = (nom_rcs, formsemestre_final.formsemestre_id)
+                    if trajectoire_id not in self.rcss:
+                        trajectoire = RCS(nom_rcs, formsemestre_final)
+                        self.rcss[trajectoire_id] = trajectoire
+                    else:
+                        trajectoire = self.rcss[trajectoire_id]
+
+                    # La liste des semestres de l'étudiant à prendre en compte
+                    # pour cette trajectoire
+                    semestres_a_aggreger = get_rcs_etudiant(
+                        etudiants.cursus[etudid], formsemestre_final, nom_rcs
+                    )
+
+                    # Ajout des semestres à la trajectoire
+                    trajectoire.add_semestres_a_aggreger(semestres_a_aggreger)
+
+                    # Mémoire la trajectoire suivie par l'étudiant
+                    self.suivi[etudid][nom_rcs] = trajectoire
+
+
+def get_rcs_etudiant(
+    semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str
+) -> dict[int, FormSemestre]:
+    """Ensemble des semestres parcourus par un étudiant, connaissant
+    les semestres de son cursus,
+    dans le cadre du RCS visé et ayant pour semestre terminal `formsemestre_final`.
+
+    Si le RCS est de type "Si", limite les semestres à ceux de numéro i.
+    Par ex: si formsemestre_terminal est un S3 et nom_agrregat "S3", ne prend en compte que les
+    semestres 3.
+
+    Si le RCS est de type "iA" ou "iS" (incluant plusieurs numéros de semestres), prend en
+    compte les dit numéros de semestres.
+
+    Par ex: si formsemestre_terminal est un S3, ensemble des S1,
+    S2, S3 suivi pour l'amener au S3 (il peut y avoir plusieurs S1,
+    ou S2, ou S3 s'il a redoublé).
+
+    Les semestres parcourus sont antérieurs (en terme de date de fin)
+    au formsemestre_terminal.
+
+    Args:
+        cursus: Dictionnaire {fid: FormSemestre(fid)} donnant l'ensemble des semestres
+                dans lesquels l'étudiant a été inscrit
+        formsemestre_final: le semestre final visé
+        nom_rcs: Nom du RCS visé
+    """
+    numero_semestre_terminal = formsemestre_final.semestre_id
+    # semestres_significatifs = self.get_semestres_significatifs(etudid)
+    semestres_significatifs = {}
+    for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
+        semestres_significatifs = semestres_significatifs | semestres[f"S{i}"]
+
+    if nom_rcs.startswith("S"):  # les semestres
+        numero_semestres_possibles = [numero_semestre_terminal]
+    elif nom_rcs.endswith("A"):  # les années
+        numero_semestres_possibles = [
+            int(sem[-1]) for sem in TYPES_RCS[nom_rcs]["aggregat"]
+        ]
+        assert numero_semestre_terminal in numero_semestres_possibles
+    else:  # les xS = tous les semestres jusqu'à Sx (eg S1, S2, S3 pour un S3 terminal)
+        numero_semestres_possibles = list(range(1, numero_semestre_terminal + 1))
+
+    semestres_aggreges = {}
+    for fid, semestre in semestres_significatifs.items():
+        # Semestres parmi ceux de n° possibles & qui lui sont antérieurs
+        if (
+            semestre.semestre_id in numero_semestres_possibles
+            and semestre.date_fin <= formsemestre_final.date_fin
+        ):
+            semestres_aggreges[fid] = semestre
+    return semestres_aggreges
+
+
+def get_descr_rcs(nom_rcs: str) -> str:
+    """Renvoie la description pour les tableurs de synthèse
+    Excel d'un nom de RCS"""
+    return TYPES_RCS[nom_rcs]["descr"]
diff --git a/app/pe/pe_rcstag.py b/app/pe/pe_rcstag.py
new file mode 100644
index 0000000000000000000000000000000000000000..c3d3a05fd769b3788b1b76acabf51420cf9dcfd7
--- /dev/null
+++ b/app/pe/pe_rcstag.py
@@ -0,0 +1,217 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+"""
+Created on Fri Sep  9 09:15:05 2016
+
+@author: barasc
+"""
+
+from app.comp.res_sem import load_formsemestre_results
+from app.pe.pe_semtag import SemestreTag
+import pandas as pd
+import numpy as np
+from app.pe.pe_rcs import RCS
+
+from app.pe.pe_tabletags import TableTag, MoyenneTag
+
+
+class RCSTag(TableTag):
+    def __init__(
+        self, rcs: RCS, semestres_taggues: dict[int, SemestreTag]
+    ):
+        """Calcule les moyennes par tag d'une combinaison de semestres
+        (RCS), pour extraire les classements par tag pour un
+        groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous
+        participé au semestre terminal.
+
+
+        Args:
+            rcs: Un RCS (identifié par un nom et l'id de son semestre terminal)
+            semestres_taggues: Les données sur les semestres taggués
+        """
+        TableTag.__init__(self)
+
+
+        self.rcs_id = rcs.rcs_id
+        """Identifiant du RCS taggué (identique au RCS sur lequel il s'appuie)"""
+
+        self.rcs = rcs
+        """RCS associé au RCS taggué"""
+
+        self.nom = self.get_repr()
+        """Représentation textuelle du RCS taggué"""
+
+        self.formsemestre_terminal = rcs.formsemestre_final
+        """Le formsemestre terminal"""
+
+        # Les résultats du formsemestre terminal
+        nt = load_formsemestre_results(self.formsemestre_terminal)
+
+        self.semestres_aggreges = rcs.semestres_aggreges
+        """Les semestres aggrégés"""
+
+        self.semestres_tags_aggreges = {}
+        """Les semestres tags associés aux semestres aggrégés"""
+        for frmsem_id in self.semestres_aggreges:
+            try:
+                self.semestres_tags_aggreges[frmsem_id] = semestres_taggues[frmsem_id]
+            except:
+                raise ValueError("Semestres taggués manquants")
+
+        """Les étudiants (état civil + cursus connu)"""
+        self.etuds = nt.etuds
+
+        # assert self.etuds == trajectoire.suivi  # manque-t-il des étudiants ?
+        self.etudiants = {etud.etudid: etud.etat_civil for etud in self.etuds}
+
+        self.tags_sorted = self.do_taglist()
+        """Tags extraits de tous les semestres"""
+
+        self.notes_cube = self.compute_notes_cube()
+        """Cube de notes"""
+
+        etudids = list(self.etudiants.keys())
+        self.notes = compute_tag_moy(self.notes_cube, etudids, self.tags_sorted)
+        """Calcul les moyennes par tag sous forme d'un dataframe"""
+
+        self.moyennes_tags: dict[str, MoyenneTag] = {}
+        """Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)"""
+        for tag in self.tags_sorted:
+            moy_gen_tag = self.notes[tag]
+            self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
+
+    def __eq__(self, other):
+        """Egalité de 2 RCS taggués sur la base de leur identifiant"""
+        return self.rcs_id == other.rcs_id
+
+    def get_repr(self, verbose=False) -> str:
+        """Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
+        est basée)"""
+        return self.rcs.get_repr(verbose=verbose)
+
+    def compute_notes_cube(self):
+        """Construit le cube de notes (etudid x tags x semestre_aggregé)
+        nécessaire au calcul des moyennes de l'aggrégat
+        """
+        # nb_tags = len(self.tags_sorted)
+        # nb_etudiants = len(self.etuds)
+        # nb_semestres = len(self.semestres_tags_aggreges)
+
+        # Index du cube (etudids -> dim 0, tags -> dim 1)
+        etudids = [etud.etudid for etud in self.etuds]
+        tags = self.tags_sorted
+        semestres_id = list(self.semestres_tags_aggreges.keys())
+
+        dfs = {}
+
+        for frmsem_id in semestres_id:
+            # Partant d'un dataframe vierge
+            df = pd.DataFrame(np.nan, index=etudids, columns=tags)
+
+            # Charge les notes du semestre tag
+            notes = self.semestres_tags_aggreges[frmsem_id].notes
+
+            # Les étudiants & les tags commun au dataframe final et aux notes du semestre)
+            etudids_communs = df.index.intersection(notes.index)
+            tags_communs = df.columns.intersection(notes.columns)
+
+            # Injecte les notes par tag
+            df.loc[etudids_communs, tags_communs] = notes.loc[
+                etudids_communs, tags_communs
+            ]
+
+            # Supprime tout ce qui n'est pas numérique
+            for col in df.columns:
+                df[col] = pd.to_numeric(df[col], errors="coerce")
+
+            # Stocke le df
+            dfs[frmsem_id] = df
+
+        """Réunit les notes sous forme d'un cube etdids x tags x semestres"""
+        semestres_x_etudids_x_tags = [dfs[fid].values for fid in dfs]
+        etudids_x_tags_x_semestres = np.stack(semestres_x_etudids_x_tags, axis=-1)
+
+        return etudids_x_tags_x_semestres
+
+    def do_taglist(self):
+        """Synthétise les tags à partir des semestres (taggués) aggrégés
+
+        Returns:
+            Une liste de tags triés par ordre alphabétique
+        """
+        tags = []
+        for frmsem_id in self.semestres_tags_aggreges:
+            tags.extend(self.semestres_tags_aggreges[frmsem_id].tags_sorted)
+        return sorted(set(tags))
+
+
+def compute_tag_moy(set_cube: np.array, etudids: list, tags: list):
+    """Calcul de la moyenne par tag sur plusieurs semestres.
+    La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles
+
+    *Remarque* : Adaptation de moy_ue.compute_ue_moys_apc au cas des moyennes de tag
+    par aggrégat de plusieurs semestres.
+
+    Args:
+        set_cube: notes moyennes aux modules ndarray
+                 (etuds x modimpls x UEs), des floats avec des NaN
+        etudids: liste des étudiants (dim. 0 du cube)
+        tags: liste des tags (dim. 1 du cube)
+    Returns:
+        Un DataFrame avec pour columns les moyennes par tags,
+        et pour rows les etudid
+    """
+    nb_etuds, nb_tags, nb_semestres = set_cube.shape
+    assert nb_etuds == len(etudids)
+    assert nb_tags == len(tags)
+
+    # Quelles entrées du cube contiennent des notes ?
+    mask = ~np.isnan(set_cube)
+
+    # Enlève les NaN du cube pour les entrées manquantes
+    set_cube_no_nan = np.nan_to_num(set_cube, nan=0.0)
+
+    # Les moyennes par tag
+    with np.errstate(invalid="ignore"):  # ignore les 0/0 (-> NaN)
+        etud_moy_tag = np.sum(set_cube_no_nan, axis=2) / np.sum(mask, axis=2)
+
+    # Le dataFrame
+    etud_moy_tag_df = pd.DataFrame(
+        etud_moy_tag,
+        index=etudids,  # les etudids
+        columns=tags,  # les tags
+    )
+
+    etud_moy_tag_df.fillna(np.nan)
+
+    return etud_moy_tag_df
diff --git a/app/pe/pe_semtag.py b/app/pe/pe_semtag.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ed2418b63d45e684aee98e16687ef3e1bc6283c
--- /dev/null
+++ b/app/pe/pe_semtag.py
@@ -0,0 +1,310 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+"""
+Created on Fri Sep  9 09:15:05 2016
+
+@author: barasc
+"""
+import pandas as pd
+
+import app.pe.pe_etudiant
+from app import db, ScoValueError
+from app import comp
+from app.comp.res_sem import load_formsemestre_results
+from app.models import FormSemestre
+from app.models.moduleimpls import ModuleImpl
+import app.pe.pe_affichage as pe_affichage
+from app.pe.pe_tabletags import TableTag, MoyenneTag
+from app.scodoc import sco_tag_module
+from app.scodoc.codes_cursus import UE_SPORT
+
+
+class SemestreTag(TableTag):
+    """
+    Un SemestreTag représente les résultats des étudiants à un semestre, en donnant
+    accès aux moyennes par tag.
+    Il s'appuie principalement sur FormSemestre et sur ResultatsSemestreBUT.
+    """
+
+    def __init__(self, formsemestre_id: int):
+        """
+        Args:
+            formsemestre_id: Identifiant du ``FormSemestre`` sur lequel il se base
+        """
+        TableTag.__init__(self)
+
+        # Le semestre
+        self.formsemestre_id = formsemestre_id
+        self.formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+
+        # Le nom du semestre taggué
+        self.nom = self.get_repr()
+
+        # Les résultats du semestre
+        self.nt = load_formsemestre_results(self.formsemestre)
+
+        # Les étudiants
+        self.etuds = self.nt.etuds
+        self.etudiants = {etud.etudid: etud.etat_civil for etud in self.etuds}
+
+        # Les notes, les modules implémentés triés, les étudiants, les coeffs,
+        # récupérés notamment de py:mod:`res_but`
+        self.sem_cube = self.nt.sem_cube
+        self.modimpls_sorted = self.nt.formsemestre.modimpls_sorted
+        self.modimpl_coefs_df = self.nt.modimpl_coefs_df
+
+        # Les inscriptions au module et les dispenses d'UE
+        self.modimpl_inscr_df = self.nt.modimpl_inscr_df
+        self.ues = self.nt.ues
+        self.ues_inscr_parcours_df = self.nt.load_ues_inscr_parcours()
+        self.dispense_ues = self.nt.dispense_ues
+
+        # Les tags :
+        ## Saisis par l'utilisateur
+        tags_personnalises = get_synthese_tags_personnalises_semestre(
+            self.nt.formsemestre
+        )
+        noms_tags_perso = list(set(tags_personnalises.keys()))
+
+        ## Déduit des compétences
+        dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre)
+        noms_tags_comp = list(set(dict_ues_competences.values()))
+        noms_tags_auto = ["but"] + noms_tags_comp
+        self.tags = noms_tags_perso + noms_tags_auto
+        """Tags du semestre taggué"""
+
+        ## Vérifie l'unicité des tags
+        if len(set(self.tags)) != len(self.tags):
+            intersection = list(set(noms_tags_perso) & set(noms_tags_auto))
+            liste_intersection = "\n".join(
+                [f"<li><code>{tag}</code></li>" for tag in intersection]
+            )
+            s = "s" if len(intersection) > 0 else ""
+            message = f"""Erreur dans le module PE : Un des tags saisis dans votre
+            programme de formation fait parti des tags réservés. En particulier,
+            votre semestre <em>{self.formsemestre.titre_annee()}</em>
+            contient le{s} tag{s} réservé{s} suivant  :
+            <ul>
+            {liste_intersection}
+            </ul>
+            Modifiez votre programme de formation pour le{s} supprimer.
+            Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études.
+            """
+            raise ScoValueError(message)
+
+        # Calcul des moyennes & les classements de chaque étudiant à chaque tag
+        self.moyennes_tags = {}
+
+        for tag in tags_personnalises:
+            # pe_affichage.pe_print(f"  -> Traitement du tag {tag}")
+            moy_gen_tag = self.compute_moyenne_tag(tag, tags_personnalises)
+            self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
+
+        # Ajoute les moyennes générales de BUT pour le semestre considéré
+        moy_gen_but = self.nt.etud_moy_gen
+        self.moyennes_tags["but"] = MoyenneTag("but", moy_gen_but)
+
+        # Ajoute les moyennes par compétence
+        for ue_id, competence in dict_ues_competences.items():
+            if competence not in self.moyennes_tags:
+                moy_ue = self.nt.etud_moy_ue[ue_id]
+                self.moyennes_tags[competence] = MoyenneTag(competence, moy_ue)
+
+        self.tags_sorted = self.get_all_tags()
+        """Tags (personnalisés+compétences) par ordre alphabétique"""
+
+        # Synthétise l'ensemble des moyennes dans un dataframe
+
+        self.notes = self.df_notes()
+        """Dataframe synthétique des notes par tag"""
+
+        pe_affichage.pe_print(
+            f"   => Traitement des tags {', '.join(self.tags_sorted)}"
+        )
+
+    def get_repr(self):
+        """Nom affiché pour le semestre taggué"""
+        return app.pe.pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True)
+
+    def compute_moyenne_tag(self, tag: str, tags_infos: dict) -> pd.Series:
+        """Calcule la moyenne des étudiants pour le tag indiqué,
+        pour ce SemestreTag, en ayant connaissance des informations sur
+        les tags (dictionnaire donnant les coeff de repondération)
+
+        Sont pris en compte les modules implémentés associés au tag,
+        avec leur éventuel coefficient de **repondération**, en utilisant les notes
+        chargées pour ce SemestreTag.
+
+        Force ou non le calcul de la moyenne lorsque des notes sont manquantes.
+
+        Returns:
+            La série des moyennes
+        """
+
+        # Adaptation du mask de calcul des moyennes au tag visé
+        modimpls_mask = [
+            modimpl.module.ue.type != UE_SPORT
+            for modimpl in self.formsemestre.modimpls_sorted
+        ]
+
+        # Désactive tous les modules qui ne sont pas pris en compte pour ce tag
+        for i, modimpl in enumerate(self.formsemestre.modimpls_sorted):
+            if modimpl.moduleimpl_id not in tags_infos[tag]:
+                modimpls_mask[i] = False
+
+        # Applique la pondération des coefficients
+        modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
+        for modimpl_id in tags_infos[tag]:
+            ponderation = tags_infos[tag][modimpl_id]["ponderation"]
+            modimpl_coefs_ponderes_df[modimpl_id] *= ponderation
+
+        # Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)#
+        moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc(
+            self.sem_cube,
+            self.etuds,
+            self.formsemestre.modimpls_sorted,
+            self.modimpl_inscr_df,
+            modimpl_coefs_ponderes_df,
+            modimpls_mask,
+            self.dispense_ues,
+            block=self.formsemestre.block_moyennes,
+        )
+
+        # Les ects
+        ects = self.ues_inscr_parcours_df.fillna(0.0) * [
+            ue.ects for ue in self.ues if ue.type != UE_SPORT
+        ]
+
+        # Calcule la moyenne générale dans le semestre (pondérée par le ECTS)
+        moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects(
+            moyennes_ues_tag,
+            ects,
+            formation_id=self.formsemestre.formation_id,
+            skip_empty_ues=True,
+        )
+
+        return moy_gen_tag
+
+
+def get_moduleimpl(modimpl_id) -> dict:
+    """Renvoie l'objet modimpl dont l'id est modimpl_id"""
+    modimpl = db.session.get(ModuleImpl, modimpl_id)
+    if modimpl:
+        return modimpl
+    return None
+
+
+def get_moy_ue_from_nt(nt, etudid, modimpl_id) -> float:
+    """Renvoie la moyenne de l'UE d'un etudid dans laquelle se trouve
+    le module de modimpl_id
+    """
+    # ré-écrit
+    modimpl = get_moduleimpl(modimpl_id)  # le module
+    ue_status = nt.get_etud_ue_status(etudid, modimpl.module.ue.id)
+    if ue_status is None:
+        return None
+    return ue_status["moy"]
+
+
+def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre):
+    """Etant données les implémentations des modules du semestre (modimpls),
+    synthétise les tags renseignés dans le programme pédagogique &
+    associés aux modules du semestre,
+    en les associant aux modimpls qui les concernent (modimpl_id) et
+    aucoeff et pondération fournie avec le tag (par défaut 1 si non indiquée)).
+
+
+    Args:
+        formsemestre: Le formsemestre à la base de la recherche des tags
+
+    Return:
+        Un dictionnaire de tags
+    """
+    synthese_tags = {}
+
+    # Instance des modules du semestre
+    modimpls = formsemestre.modimpls_sorted
+
+    for modimpl in modimpls:
+        modimpl_id = modimpl.id
+
+        # Liste des tags pour le module concerné
+        tags = sco_tag_module.module_tag_list(modimpl.module.id)
+
+        # Traitement des tags recensés, chacun pouvant étant de la forme
+        # "mathématiques", "théorie", "pe:0", "maths:2"
+        for tag in tags:
+            # Extraction du nom du tag et du coeff de pondération
+            (tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag)
+
+            # Ajout d'une clé pour le tag
+            if tagname not in synthese_tags:
+                synthese_tags[tagname] = {}
+
+            # Ajout du module (modimpl) au tagname considéré
+            synthese_tags[tagname][modimpl_id] = {
+                "modimpl": modimpl,  # les données sur le module
+                # "coeff": modimpl.module.coefficient,  # le coeff du module dans le semestre
+                "ponderation": ponderation,  # la pondération demandée pour le tag sur le module
+                # "module_code": modimpl.module.code,  # le code qui doit se retrouver à l'identique dans des ue capitalisee
+                # "ue_id": modimpl.module.ue.id,  # les données sur l'ue
+                # "ue_code": modimpl.module.ue.ue_code,
+                # "ue_acronyme": modimpl.module.ue.acronyme,
+            }
+
+    return synthese_tags
+
+
+def get_noms_competences_from_ues(formsemestre: FormSemestre) -> dict[int, str]:
+    """Partant d'un formsemestre, extrait le nom des compétences associés
+    à (ou aux) parcours des étudiants du formsemestre.
+
+    Ignore les UEs non associées à un niveau de compétence.
+
+    Args:
+        formsemestre: Un FormSemestre
+
+    Returns:
+        Dictionnaire {ue_id: nom_competence} lisant tous les noms des compétences
+        en les raccrochant à leur ue
+    """
+    # Les résultats du semestre
+    nt = load_formsemestre_results(formsemestre)
+
+    noms_competences = {}
+    for ue in nt.ues:
+        if ue.niveau_competence and ue.type != UE_SPORT:
+            # ?? inutilisé  ordre = ue.niveau_competence.ordre
+            nom = ue.niveau_competence.competence.titre
+            noms_competences[ue.ue_id] = f"comp. {nom}"
+    return noms_competences
diff --git a/app/pe/pe_tabletags.py b/app/pe/pe_tabletags.py
new file mode 100644
index 0000000000000000000000000000000000000000..68be877275c49b8f317400a186cb2829fc7c0665
--- /dev/null
+++ b/app/pe/pe_tabletags.py
@@ -0,0 +1,263 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# Gestion scolarite IUT
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+##############################################################################
+#  Module "Avis de poursuite d'étude"
+#  conçu et développé par Cléo Baras (IUT de Grenoble)
+##############################################################################
+
+
+"""
+Created on Thu Sep  8 09:36:33 2016
+
+@author: barasc
+"""
+
+import datetime
+import numpy as np
+
+from app import ScoValueError
+from app.comp.moy_sem import comp_ranks_series
+from app.pe import pe_affichage
+from app.pe.pe_affichage import SANS_NOTE
+from app.scodoc import sco_utils as scu
+import pandas as pd
+
+
+TAGS_RESERVES = ["but"]
+
+
+class MoyenneTag:
+    def __init__(self, tag: str, notes: pd.Series):
+        """Classe centralisant la synthèse des moyennes/classements d'une série
+        d'étudiants à un tag donné, en stockant un dictionnaire :
+
+        ``
+        {
+            "notes": la Serie pandas des notes (float),
+            "classements": la Serie pandas des classements (float),
+            "min": la note minimum,
+            "max": la note maximum,
+            "moy": la moyenne,
+            "nb_inscrits": le nombre d'étudiants ayant une note,
+        }
+        ``
+
+        Args:
+            tag: Un tag
+            note: Une série de notes (moyenne) sous forme d'un pd.Series()
+        """
+        self.tag = tag
+        """Le tag associé à la moyenne"""
+        self.etudids = list(notes.index)  # calcul à venir
+        """Les id des étudiants"""
+        self.inscrits_ids = notes[notes.notnull()].index.to_list()
+        """Les id des étudiants dont la moyenne est non nulle"""
+        self.df: pd.DataFrame = self.comp_moy_et_stat(notes)
+        """Le dataframe retraçant les moyennes/classements/statistiques"""
+        self.synthese = self.to_dict()
+        """La synthèse (dictionnaire) des notes/classements/statistiques"""
+
+    def __eq__(self, other):
+        """Egalité de deux MoyenneTag lorsque leur tag sont identiques"""
+        return self.tag == other.tag
+
+    def comp_moy_et_stat(self, notes: pd.Series) -> dict:
+        """Calcule et structure les données nécessaires au PE pour une série
+        de notes (souvent une moyenne par tag) dans un dictionnaire spécifique.
+
+        Partant des notes, sont calculés les classements (en ne tenant compte
+        que des notes non nulles).
+
+        Args:
+            notes: Une série de notes (avec des éventuels NaN)
+
+        Returns:
+            Un dictionnaire stockant les notes, les classements, le min,
+            le max, la moyenne, le nb de notes (donc d'inscrits)
+        """
+        df = pd.DataFrame(
+            np.nan,
+            index=self.etudids,
+            columns=[
+                "note",
+                "classement",
+                "rang",
+                "min",
+                "max",
+                "moy",
+                "nb_etuds",
+                "nb_inscrits",
+            ],
+        )
+
+        # Supprime d'éventuelles chaines de caractères dans les notes
+        notes = pd.to_numeric(notes, errors="coerce")
+        df["note"] = notes
+
+        # Les nb d'étudiants & nb d'inscrits
+        df["nb_etuds"] = len(self.etudids)
+        df.loc[self.inscrits_ids, "nb_inscrits"] = len(self.inscrits_ids)
+
+        # Le classement des inscrits
+        notes_non_nulles = notes[self.inscrits_ids]
+        (class_str, class_int) = comp_ranks_series(notes_non_nulles)
+        df.loc[self.inscrits_ids, "classement"] = class_int
+
+        # Le rang (classement/nb_inscrit)
+        df["rang"] = df["rang"].astype(str)
+        df.loc[self.inscrits_ids, "rang"] = (
+            df.loc[self.inscrits_ids, "classement"].astype(int).astype(str)
+            + "/"
+            + df.loc[self.inscrits_ids, "nb_inscrits"].astype(int).astype(str)
+        )
+
+        # Les stat (des inscrits)
+        df.loc[self.inscrits_ids, "min"] = notes.min()
+        df.loc[self.inscrits_ids, "max"] = notes.max()
+        df.loc[self.inscrits_ids, "moy"] = notes.mean()
+
+        return df
+
+    def to_dict(self) -> dict:
+        """Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques"""
+        synthese = {
+            "notes": self.df["note"],
+            "classements": self.df["classement"],
+            "min": self.df["min"].mean(),
+            "max": self.df["max"].mean(),
+            "moy": self.df["moy"].mean(),
+            "nb_inscrits": self.df["nb_inscrits"].mean(),
+        }
+        return synthese
+
+    def get_notes(self):
+        """Série des notes, arrondies à 2 chiffres après la virgule"""
+        return self.df["note"].round(2)
+
+    def get_rangs_inscrits(self) -> pd.Series:
+        """Série des rangs classement/nbre_inscrit"""
+        return self.df["rang"]
+
+    def get_min(self) -> pd.Series:
+        """Série des min"""
+        return self.df["min"].round(2)
+
+    def get_max(self) -> pd.Series:
+        """Série des max"""
+        return self.df["max"].round(2)
+
+    def get_moy(self) -> pd.Series:
+        """Série des moy"""
+        return self.df["moy"].round(2)
+
+
+    def get_note_for_df(self, etudid: int):
+        """Note d'un étudiant donné par son etudid"""
+        return round(self.df["note"].loc[etudid], 2)
+
+    def get_min_for_df(self) -> float:
+        """Min renseigné pour affichage dans un df"""
+        return round(self.synthese["min"], 2)
+
+    def get_max_for_df(self) -> float:
+        """Max renseigné pour affichage dans un df"""
+        return round(self.synthese["max"], 2)
+
+    def get_moy_for_df(self) -> float:
+        """Moyenne renseignée pour affichage dans un df"""
+        return round(self.synthese["moy"], 2)
+
+    def get_class_for_df(self, etudid: int) -> str:
+        """Classement ramené au nombre d'inscrits,
+        pour un étudiant donné par son etudid"""
+        classement = self.df["rang"].loc[etudid]
+        if not pd.isna(classement):
+            return classement
+        else:
+            return pe_affichage.SANS_NOTE
+
+    def is_significatif(self) -> bool:
+        """Indique si la moyenne est significative (c'est-à-dire à des notes)"""
+        return self.synthese["nb_inscrits"] > 0
+
+
+class TableTag(object):
+    def __init__(self):
+        """Classe centralisant différentes méthodes communes aux
+        SemestreTag, TrajectoireTag, AggregatInterclassTag
+        """
+        pass
+
+    # -----------------------------------------------------------------------------------------------------------
+    def get_all_tags(self):
+        """Liste des tags de la table, triée par ordre alphabétique,
+        extraite des clés du dictionnaire ``moyennes_tags`` connues (tags en doublon
+        possible).
+
+        Returns:
+            Liste de tags triés par ordre alphabétique
+        """
+        return sorted(list(self.moyennes_tags.keys()))
+
+    def df_moyennes_et_classements(self) -> pd.DataFrame:
+        """Renvoie un dataframe listant toutes les moyennes,
+        et les classements des étudiants pour tous les tags.
+
+        Est utilisé pour afficher le détail d'un tableau taggué
+        (semestres, trajectoires ou aggrégat)
+
+        Returns:
+            Le dataframe des notes et des classements
+        """
+
+        etudiants = self.etudiants
+        df = pd.DataFrame.from_dict(etudiants, orient="index", columns=["nom"])
+
+        tags_tries = self.get_all_tags()
+        for tag in tags_tries:
+            moy_tag = self.moyennes_tags[tag]
+            df = df.join(moy_tag.synthese["notes"].rename(f"Moy {tag}"))
+            df = df.join(moy_tag.synthese["classements"].rename(f"Class {tag}"))
+
+        return df
+
+    def df_notes(self) -> pd.DataFrame | None:
+        """Renvoie un dataframe (etudid x tag) listant toutes les moyennes par tags
+
+        Returns:
+            Un dataframe etudids x tag (avec tag par ordre alphabétique)
+        """
+        tags_tries = self.get_all_tags()
+        if tags_tries:
+            dict_series = {}
+            for tag in tags_tries:
+                # Les moyennes associés au tag
+                moy_tag = self.moyennes_tags[tag]
+                dict_series[tag] = moy_tag.synthese["notes"]
+            df = pd.DataFrame(dict_series)
+            return df
diff --git a/app/templates/pe/pe_view_sem_recap.j2 b/app/templates/pe/pe_view_sem_recap.j2
new file mode 100644
index 0000000000000000000000000000000000000000..756b7f87054ae7b9cb6fc7ea5bc0f7d79376c8d3
--- /dev/null
+++ b/app/templates/pe/pe_view_sem_recap.j2
@@ -0,0 +1,75 @@
+{% extends "sco_page.j2" %}
+
+{% block styles %}
+    {{super()}}
+    <style>
+.div-warning {
+    color: red;
+    background-color: yellow;
+    font-size: 120%;
+    border: 2px solid red;
+    border-radius: 12px;
+    padding: 12px;
+    margin-top: 16px;
+    margin-bottom: 16px;
+    width: fit-content;
+}
+    </style>
+{% endblock styles %}
+
+{% block app_content %}
+
+<h2>Génération des avis de poursuites d'études (V2 BUT EXPERIMENTALE)</h2>
+
+<div class="div-warning">
+    Fonction expérimentale pour le BUT : travaux en cours, merci de tester
+    et de faire part de vos expériences sur le Discord.
+</div>
+
+<div class="help">
+    <p>
+    Cette fonction génère un ensemble de feuilles de calcul (xlsx)
+    permettant d'éditer des avis de poursuites d'études pour les étudiants
+        de BUT diplômés.
+    <br>
+    De nombreux aspects sont paramétrables:
+    <a href="https://scodoc.org/AvisPoursuiteEtudes"
+        target="_blank" rel="noopener noreferrer">
+        voir la documentation
+    </a> (en cours de révision).
+    </p>
+</div>
+
+    <h3>Avis de poursuites d'études de la promo {{ annee_diplome }}</h3>
+
+    <div class="help">
+    Seront (a minima) pris en compte les étudiants ayant été inscrits aux semestres suivants :
+
+    <ul>
+        {% for fid in cosemestres %}
+        <li>
+            {{ cosemestres[fid].titre_annee() }}
+        </li>
+        {%  endfor %}
+    </ul>
+    </div>
+
+    <div>
+    <progress id="pe_progress" style="visibility: hidden"></progress>
+    <br>
+    <button onclick="submitPEGeneration()">Générer les documents de la promo {{ annee_diplome }}</button>
+    </div>
+
+    <form method="post" id="pe_generation" style="visibility: hidden">
+        <input type="submit"
+               onclick="submitPEGeneration()" value=""/>
+        <input type="hidden" name="formsemestre_id" value="{{formsemestre.id}}">
+    </form>
+
+    <script>
+    function submitPEGeneration() {
+        // document.getElementById("pe_progress").style.visibility = 'visible';
+        document.getElementById("pe_generation").submit(); //attach an id to your form
+    }
+    </script>
+{% endblock app_content %}
\ No newline at end of file
diff --git a/migrations/versions/2e4875004e12_etudiant_annotations.py b/migrations/versions/2e4875004e12_etudiant_annotations.py
new file mode 100644
index 0000000000000000000000000000000000000000..2b06341b2109cd35f626193cc1ca73a02be99e79
--- /dev/null
+++ b/migrations/versions/2e4875004e12_etudiant_annotations.py
@@ -0,0 +1,56 @@
+"""etudiant_annotations : ajoute clé externe etudiant et moduleimpl
+
+Revision ID: 2e4875004e12
+Revises: 3fa988ff8970
+Create Date: 2024-02-11 12:10:36.743212
+
+"""
+
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision = "2e4875004e12"
+down_revision = "3fa988ff8970"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+
+    # Supprime les annotations orphelines
+    op.execute(
+        """DELETE FROM etud_annotations
+        WHERE etudid NOT IN (SELECT id FROM identite);
+        """
+    )
+    # Ajoute clé:
+    with op.batch_alter_table("etud_annotations", schema=None) as batch_op:
+        batch_op.create_foreign_key(None, "identite", ["etudid"], ["id"])
+
+    # Et modif liée au commit 072d013590abf715395bc987fb48de49f6750527
+    with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op:
+        batch_op.drop_constraint(
+            "notes_moduleimpl_responsable_id_fkey", type_="foreignkey"
+        )
+        batch_op.create_foreign_key(
+            None, "user", ["responsable_id"], ["id"], ondelete="SET NULL"
+        )
+
+    # cet index en trop trainait depuis longtemps...
+    with op.batch_alter_table("assiduites", schema=None) as batch_op:
+        batch_op.drop_index("ix_assiduites_user_id")
+
+
+def downgrade():
+    with op.batch_alter_table("notes_moduleimpl", schema=None) as batch_op:
+        batch_op.drop_constraint(None, type_="foreignkey")
+        batch_op.create_foreign_key(
+            "notes_moduleimpl_responsable_id_fkey", "user", ["responsable_id"], ["id"]
+        )
+
+    with op.batch_alter_table("etud_annotations", schema=None) as batch_op:
+        batch_op.drop_constraint(None, type_="foreignkey")
+
+    with op.batch_alter_table("assiduites", schema=None) as batch_op:
+        batch_op.create_index("ix_assiduites_user_id", ["user_id"], unique=False)
diff --git a/tests/api/exemple-api-list-modules.py b/tests/api/exemple-api-list-modules.py
new file mode 100644
index 0000000000000000000000000000000000000000..2cc146593f53072d365ad3dbcd7fe74264af6a93
--- /dev/null
+++ b/tests/api/exemple-api-list-modules.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+"""Exemple utilisation API ScoDoc 9 avec jeton obtenu par basic authentication
+
+    Extraction de la liste de tous les modules d'une année scolaire
+
+    Usage:
+        cd /opt/scodoc/tests/api
+        python -i exemple-api-list-modules.py
+
+
+Pour utiliser l'API, (sur une base quelconque):
+```
+cd /opt/scodoc/tests/api
+
+python -i exemple-api-list-modules.py
+>>> admin_h = get_auth_headers("admin", "xxx")
+>>> GET("/etudiant/etudid/14806", headers=admin_h)
+```
+
+Créer éventuellement un fichier `.env` dans /opt/scodoc/tests/api
+avec la config du client API:
+```
+    SCODOC_URL = "http://localhost:5000/"
+    API_USER = "admin"
+    API_PASSWORD = "test"
+```
+"""
+
+from pprint import pprint as pp
+import requests
+import sys
+import urllib3
+from setup_test_api import (
+    API_PASSWORD,  # lus de l'environnement ou du .env
+    API_URL,
+    API_USER,
+    APIError,
+    CHECK_CERTIFICATE,
+    get_auth_headers,
+    GET,
+    POST_JSON,
+    SCODOC_URL,
+)
+
+
+def logout_api_user():
+    r = requests.delete(API_URL + "/tokens", headers=HEADERS, verify=CHECK_CERTIFICATE)
+    assert r.status_code == 204
+
+
+if not CHECK_CERTIFICATE:
+    urllib3.disable_warnings()
+
+# Si vous n'utilisez pas .env:
+API_USER = "lecteur_api"
+API_PASSWORD = "azerty"
+
+HEADERS = get_auth_headers(API_USER, API_PASSWORD)
+print("connected to ScoDoc")
+
+# Liste des formsemestres de l'année scolaire
+ANNEE_SCOLAIRE = 2023  # int, année de début de l'année scolaire
+
+formsemestres = GET(
+    f"/formsemestres/query?annee_scolaire={ANNEE_SCOLAIRE}", headers=HEADERS
+)
+print(f"Nombre de semestres: {len(formsemestres)}")
+
+r = []  # liste de dict, résultat
+for formsemestre in formsemestres:
+    print(f"requesting {formsemestre['titre_num']}")
+    programme = GET(f"/formsemestre/{formsemestre['id']}/programme", headers=HEADERS)
+    for mod_type in ("ressources", "saes", "modules"):
+        mods = programme[mod_type]
+        for mod in mods:
+            r.append(
+                {
+                    "dept": formsemestre["departement"]["acronym"],
+                    "sem_id": formsemestre["id"],
+                    "sem_titre": formsemestre["titre"],
+                    "sem_modalite": formsemestre["modalite"],
+                    "sem_etape": formsemestre["etape_apo"],
+                    "mod_type": mod_type[:-1],
+                    "mod_code": mod["module"]["code"],
+                    "mod_titre": mod["module"]["titre"],
+                    "mod_abbrev": mod["module"]["abbrev"],
+                    "mod_code_apogee": mod["module"]["code_apogee"],
+                    "modimpl_code_apogee": mod["code_apogee"],
+                }
+            )
+
+# Dump to csv file
+SEP = "\t"
+with open("/tmp/modules.csv", "w", encoding="utf-8") as f:
+    f.write(SEP.join(r[0]) + "\n")
+    for l in r:
+        # on élimine les éventuels séparateurs des champs...
+        f.write(SEP.join([str(x).replace(SEP, " ") for x in l.values()]) + "\n")