diff --git a/app/api/etudiants.py b/app/api/etudiants.py
index c6d8cf2b6fd269d5f016b5247abfe6f27a179d2c..af0aa32f10759093af153da96316ecaf556237fd 100644
--- a/app/api/etudiants.py
+++ b/app/api/etudiants.py
@@ -286,7 +286,7 @@ def bulletin(
     if pdf:
         pdf_response, _ = do_formsemestre_bulletinetud(
             formsemestre,
-            etud.id,
+            etud,
             version=version,
             format="pdf",
             with_img_signatures_pdf=with_img_signatures_pdf,
diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py
index ca0c4ead3037241159a0988391b134b8871a561a..481393c5ef404c42d5f48ef789dd3b11367dbc2a 100644
--- a/app/but/bulletin_but.py
+++ b/app/but/bulletin_but.py
@@ -187,6 +187,8 @@ class BulletinBUT:
                     )
                     if ue_capitalisee.formsemestre_id
                     else None,
+                    "ressources": {},  # sans détail en BUT
+                    "saes": {},
                 }
                 if self.prefs["bul_show_ects"]:
                     d[ue.acronyme]["ECTS"] = {
@@ -473,6 +475,7 @@ class BulletinBUT:
 
     def bulletin_etud_complet(self, etud: Identite, version="long") -> dict:
         """Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf
+        (pas utilisé pour json/html)
         Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict
         """
         d = self.bulletin_etud(
diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py
index 0916e6cbd1eb98507ac78f38bd69ec9c1acda9b3..cd78a9e8bf2922f5e52ad0d81c011480f140bb5e 100644
--- a/app/but/bulletin_but_pdf.py
+++ b/app/but/bulletin_but_pdf.py
@@ -5,6 +5,20 @@
 ##############################################################################
 
 """Génération bulletin BUT au format PDF standard
+
+La génération du bulletin PDF suit le chemin suivant:
+
+- vue formsemestre_bulletinetud -> sco_bulletins.formsemestre_bulletinetud
+    
+    bul_dict = bulletin_but.BulletinBUT(formsemestre).bulletin_etud_complet(etud)
+
+- sco_bulletins_generator.make_formsemestre_bulletinetud(infos)
+- instance de BulletinGeneratorStandardBUT(infos)
+- BulletinGeneratorStandardBUT.generate(format="pdf")
+    sco_bulletins_generator.BulletinGenerator.generate()
+    .generate_pdf()
+    .bul_table() (ci-dessous)
+
 """
 from reportlab.lib.colors import blue
 from reportlab.lib.units import cm, mm
@@ -65,7 +79,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
 
         return objects
 
-    def but_table_synthese_ues(self, title_bg=(182, 235, 255)):
+    def but_table_synthese_ues(
+        self, title_bg=(182, 235, 255), title_ue_cap_bg=(150, 207, 147)
+    ):
         """La table de synthèse; pour chaque UE, liste des ressources et SAÉs avec leurs notes
         et leurs coefs.
         Renvoie: colkeys, P, pdf_style, colWidths
@@ -74,6 +90,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
         - pdf_style : commandes table Platypus
         - largeurs de colonnes pour PDF
         """
+        # nb: self.infos a ici été donné par BulletinBUT.bulletin_etud_complet()
         col_widths = {
             "titre": None,
             "min": 1.5 * cm,
@@ -95,6 +112,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
         col_keys += ["coef", "moyenne"]
         # Couleur fond:
         title_bg = tuple(x / 255.0 for x in title_bg)
+        title_ue_cap_bg = tuple(x / 255.0 for x in title_ue_cap_bg)
         # elems pour générer table avec gen_table (liste de dicts)
         rows = [
             # Ligne de titres
@@ -141,9 +159,13 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
                 blue,
             ),
         ]
-
+        ues_capitalisees = self.infos.get("ues_capitalisees", {})
         for ue_acronym, ue in self.infos["ues"].items():
-            self.ue_rows(rows, ue_acronym, ue, title_bg)
+            self._ue_rows(rows, ue_acronym, ue, title_bg)
+            if ue_acronym in ues_capitalisees:
+                self._ue_rows(
+                    rows, ue_acronym, ues_capitalisees[ue_acronym], title_ue_cap_bg
+                )
 
         # Global pdf style commands:
         pdf_style = [
@@ -152,20 +174,18 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
         ]
         return col_keys, rows, pdf_style, col_widths
 
-    def ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple):
+    def _ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple):
         "Décrit une UE dans la table synthèse: titre, sous-titre et liste modules"
         if (ue["type"] == UE_SPORT) and len(ue.get("modules", [])) == 0:
             # ne mentionne l'UE que s'il y a des modules
             return
         # 1er ligne titre UE
-        moy_ue = ue.get("moyenne")
+        moy_ue = ue.get("moyenne", "-")
+        if isinstance(moy_ue, dict):
+            moy_ue = moy_ue.get("value", "-") if moy_ue is not None else "-"
         t = {
             "titre": f"{ue_acronym} - {ue['titre']}",
-            "moyenne": Paragraph(
-                f"""<para align=right><b>{moy_ue.get("value", "-")
-                if moy_ue is not None else "-"
-                }</b></para>"""
-            ),
+            "moyenne": Paragraph(f"""<para align=right><b>{moy_ue}</b></para>"""),
             "_css_row_class": "note_bold",
             "_pdf_row_markup": ["b"],
             "_pdf_style": [
@@ -196,25 +216,40 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
         # case Bonus/Malus/Rang "bmr"
         fields_bmr = []
         try:
-            value = float(ue["bonus"])
+            value = float(ue.get("bonus", 0.0))
             if value != 0:
                 fields_bmr.append(f"Bonus: {ue['bonus']}")
         except ValueError:
             pass
         try:
-            value = float(ue["malus"])
+            value = float(ue.get("malus", 0.0))
             if value != 0:
                 fields_bmr.append(f"Malus: {ue['malus']}")
         except ValueError:
             pass
-        if self.preferences["bul_show_ue_rangs"]:
-            fields_bmr.append(
-                f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
+
+        moy_ue = ue.get("moyenne", "-")
+        if isinstance(moy_ue, dict):  # UE non capitalisées
+            if self.preferences["bul_show_ue_rangs"]:
+                fields_bmr.append(
+                    f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
+                )
+            ue_min, ue_max, ue_moy = (
+                ue["moyenne"]["min"],
+                ue["moyenne"]["max"],
+                ue["moyenne"]["moy"],
             )
+        else:  # UE capitalisée
+            ue_min, ue_max, ue_moy = "", "", moy_ue
+            date_capitalisation = ue.get("date_capitalisation")
+            if date_capitalisation:
+                fields_bmr.append(
+                    f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
+                )
         t = {
             "titre": " - ".join(fields_bmr),
             "coef": ects_txt,
-            "_coef_pdf": Paragraph(f"""<para align=left>{ects_txt}</para>"""),
+            "_coef_pdf": Paragraph(f"""<para align=right>{ects_txt}</para>"""),
             "_coef_colspan": 2,
             "_pdf_style": [
                 ("BACKGROUND", (0, 0), (-1, 0), title_bg),
@@ -222,9 +257,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
                 # ligne au dessus du bonus/malus, gris clair
                 ("LINEABOVE", (0, 0), (-1, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)),
             ],
-            "min": ue["moyenne"]["min"],
-            "max": ue["moyenne"]["max"],
-            "moy": ue["moyenne"]["moy"],
+            "min": ue_min,
+            "max": ue_max,
+            "moy": ue_moy,
         }
         rows.append(t)
 
diff --git a/app/comp/res_but.py b/app/comp/res_but.py
index 9e8719ef900c189132f1dc576fc9870fed1d89e5..c65fabc750d27e66803b61dff31cddc24fc29025 100644
--- a/app/comp/res_but.py
+++ b/app/comp/res_but.py
@@ -234,7 +234,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
             )
         # matrice de NaN: inscrits par défaut à AUCUNE UE:
         ues_inscr_parcours_df = pd.DataFrame(
-            np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float  # XXX
+            np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
         )
         # Construit pour chaque parcours du référentiel l'ensemble de ses UE
         # (considère aussi le cas des semestres sans parcours: None)
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 9cfc56a43043a64af69d387890f3bf85c266e077..b7547a852b96cd7fc8bcf028db36df0bee1b345d 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -6,6 +6,8 @@
 
 import datetime
 from functools import cached_property
+from operator import attrgetter
+
 from flask import abort, has_request_context, url_for
 from flask import g, request
 import sqlalchemy
@@ -155,9 +157,19 @@ class Identite(db.Model):
         )
 
     def get_first_email(self, field="email") -> str:
-        "Le mail associé à la première adrese de l'étudiant, ou None"
+        "Le mail associé à la première adresse de l'étudiant, ou None"
         return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
 
+    def get_formsemestres(self) -> list:
+        """Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
+        triée par date_debut
+        """
+        return sorted(
+            [ins.formsemestre for ins in self.formsemestre_inscriptions],
+            key=attrgetter("date_debut"),
+            reverse=True,
+        )
+
     def to_dict_short(self) -> dict:
         """Les champs essentiels"""
         return {
diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py
index 1c1694e04a72c8cc2f83d65ed72c892e7791e070..1db285eac98b063b5951231857dfc05044da5adb 100644
--- a/app/scodoc/sco_bulletins.py
+++ b/app/scodoc/sco_bulletins.py
@@ -82,11 +82,11 @@ def get_formsemestre_bulletin_etud_json(
 ) -> str:
     """Le JSON du bulletin d'un étudiant, quel que soit le type de formation."""
     if formsemestre.formation.is_apc():
-        bul = bulletin_but.BulletinBUT(formsemestre)
-        if not etud.id in bul.res.identdict:
+        bulletins_sem = bulletin_but.BulletinBUT(formsemestre)
+        if not etud.id in bulletins_sem.res.identdict:
             return json_error(404, "get_formsemestre_bulletin_etud_json: invalid etud")
         return jsonify(
-            bul.bulletin_etud(
+            bulletins_sem.bulletin_etud(
                 etud,
                 formsemestre,
                 force_publishing=force_publishing,
@@ -746,7 +746,10 @@ def etud_descr_situation_semestre(
     infos["refcomp_specialite_long"] = ""
     if formsemestre.formation.is_apc():
         res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
-        parcour: ApcParcours = ApcParcours.query.get(res.etuds_parcour_id[etudid])
+        parcour_id = res.etuds_parcour_id[etudid]
+        parcour: ApcParcours = (
+            ApcParcours.query.get(parcour_id) if parcour_id is not None else None
+        )
         if parcour:
             infos["parcours_titre"] = parcour.libelle or ""
             infos["parcours_code"] = parcour.code or ""
@@ -930,13 +933,14 @@ def formsemestre_bulletinetud(
 
     bulletin = do_formsemestre_bulletinetud(
         formsemestre,
-        etud.id,
+        etud,
         format=format,
         version=version,
         xml_with_decisions=xml_with_decisions,
         force_publishing=force_publishing,
         prefer_mail_perso=prefer_mail_perso,
     )[0]
+
     if format not in {"html", "pdfmail"}:
         filename = scu.bul_filename(formsemestre, etud, format)
         mime, suffix = scu.get_mime_suffix(format)
@@ -973,7 +977,7 @@ def can_send_bulletin_by_mail(formsemestre_id):
 
 def do_formsemestre_bulletinetud(
     formsemestre: FormSemestre,
-    etudid: int,
+    etud: Identite,
     version="long",  # short, long, selectedevals
     format=None,
     xml_with_decisions: bool = False,
@@ -1001,7 +1005,7 @@ def do_formsemestre_bulletinetud(
     if format == "xml":
         bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud(
             formsemestre.id,
-            etudid,
+            etud.id,
             xml_with_decisions=xml_with_decisions,
             force_publishing=force_publishing,
             version=version,
@@ -1012,7 +1016,7 @@ def do_formsemestre_bulletinetud(
     elif format == "json":  # utilisé pour classic et "oldjson"
         bul = sco_bulletins_json.make_json_formsemestre_bulletinetud(
             formsemestre.id,
-            etudid,
+            etud.id,
             xml_with_decisions=xml_with_decisions,
             force_publishing=force_publishing,
             version=version,
@@ -1022,22 +1026,20 @@ def do_formsemestre_bulletinetud(
         version = version[:-4]  # enlève le "_mat"
 
     if formsemestre.formation.is_apc():
-        etudiant = Identite.query.get(etudid)
-        r = bulletin_but.BulletinBUT(formsemestre)
-        infos = r.bulletin_etud_complet(etudiant, version=version)
+        bulletins_sem = bulletin_but.BulletinBUT(formsemestre)
+        bul_dict = bulletins_sem.bulletin_etud_complet(etud, version=version)
     else:
-        infos = formsemestre_bulletinetud_dict(formsemestre.id, etudid)
-    etud = infos["etud"]
+        bul_dict = formsemestre_bulletinetud_dict(formsemestre.id, etud.id)
 
     if format == "html":
         htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud(
-            infos, version=version, format="html"
+            bul_dict, version=version, format="html"
         )
-        return htm, infos["filigranne"]
+        return htm, bul_dict["filigranne"]
 
     elif format == "pdf" or format == "pdfpart":
         bul, filename = sco_bulletins_generator.make_formsemestre_bulletinetud(
-            infos,
+            bul_dict,
             version=version,
             format="pdf",
             stand_alone=(format != "pdfpart"),
@@ -1046,10 +1048,10 @@ def do_formsemestre_bulletinetud(
         if format == "pdf":
             return (
                 scu.sendPDFFile(bul, filename),
-                infos["filigranne"],
+                bul_dict["filigranne"],
             )  # unused ret. value
         else:
-            return bul, infos["filigranne"]
+            return bul, bul_dict["filigranne"]
 
     elif format == "pdfmail":
         # format pdfmail: envoie le pdf par mail a l'etud, et affiche le html
@@ -1058,24 +1060,28 @@ def do_formsemestre_bulletinetud(
             raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
 
         pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletinetud(
-            infos, version=version, format="pdf"
+            bul_dict, version=version, format="pdf"
         )
 
         if prefer_mail_perso:
-            recipient_addr = etud.get("emailperso", "") or etud.get("email", "")
+            recipient_addr = (
+                etud.get_first_email("emailperso") or etud.get_first_email()
+            )
         else:
-            recipient_addr = etud.get("email", "") or etud.get("emailperso", "")
+            recipient_addr = etud.get_first_email() or etud.get_first_email(
+                "emailperso"
+            )
 
         if not recipient_addr:
-            flash(f"{etud['nomprenom']} n'a pas d'adresse e-mail !")
-            return False, infos["filigranne"]
+            flash(f"{etud.nomprenom} n'a pas d'adresse e-mail !")
+            return False, bul_dict["filigranne"]
         else:
-            mail_bulletin(formsemestre.id, infos, pdfdata, filename, recipient_addr)
+            mail_bulletin(formsemestre.id, bul_dict, pdfdata, filename, recipient_addr)
             flash(f"mail envoyé à {recipient_addr}")
 
-            return True, infos["filigranne"]
+            return True, bul_dict["filigranne"]
 
-    raise ValueError("do_formsemestre_bulletinetud: invalid format (%s)" % format)
+    raise ValueError(f"do_formsemestre_bulletinetud: invalid format ({format})")
 
 
 def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
diff --git a/app/scodoc/sco_bulletins_generator.py b/app/scodoc/sco_bulletins_generator.py
index 36e69cd68f896398d9d1e32c72411204e15ae932..ec5d258a7a23a1ec63b03684f25f17a7a9eb4204 100644
--- a/app/scodoc/sco_bulletins_generator.py
+++ b/app/scodoc/sco_bulletins_generator.py
@@ -83,7 +83,7 @@ class BulletinGenerator:
 
     def __init__(
         self,
-        infos,
+        bul_dict,
         authuser=None,
         version="long",
         filigranne=None,
@@ -92,16 +92,18 @@ class BulletinGenerator:
     ):
         from app.scodoc import sco_preferences
 
-        if not version in scu.BULLETINS_VERSIONS:
+        if version not in scu.BULLETINS_VERSIONS:
             raise ValueError("invalid version code !")
-        self.infos = infos
-        self.authuser = authuser  # nécessaire pour version HTML qui contient liens dépendant de l'utilisateur
+        self.bul_dict = bul_dict
+        self.infos = bul_dict  # legacy code compat
+        # authuser nécessaire pour version HTML qui contient liens dépendants de l'utilisateur
+        self.authuser = authuser
         self.version = version
         self.filigranne = filigranne
         self.server_name = server_name
         self.with_img_signatures_pdf = with_img_signatures_pdf
         # Store preferences for convenience:
-        formsemestre_id = self.infos["formsemestre_id"]
+        formsemestre_id = self.bul_dict["formsemestre_id"]
         self.preferences = sco_preferences.SemPreferences(formsemestre_id)
         self.diagnostic = None  # error message if any problem
         # Common PDF styles:
@@ -127,13 +129,13 @@ class BulletinGenerator:
 
     def get_filename(self):
         """Build a filename to be proposed to the web client"""
-        sem = sco_formsemestre.get_formsemestre(self.infos["formsemestre_id"])
-        return scu.bul_filename_old(sem, self.infos["etud"], "pdf")
+        sem = sco_formsemestre.get_formsemestre(self.bul_dict["formsemestre_id"])
+        return scu.bul_filename_old(sem, self.bul_dict["etud"], "pdf")
 
     def generate(self, format="", stand_alone=True):
         """Return bulletin in specified format"""
         if not format in self.supported_formats:
-            raise ValueError("unsupported bulletin format (%s)" % format)
+            raise ValueError(f"unsupported bulletin format ({format})")
         try:
             PDFLOCK.acquire()  # this lock is necessary since reportlab is not re-entrant
             if format == "html":
@@ -141,7 +143,7 @@ class BulletinGenerator:
             elif format == "pdf":
                 return self.generate_pdf(stand_alone=stand_alone)
             else:
-                raise ValueError("invalid bulletin format (%s)" % format)
+                raise ValueError(f"invalid bulletin format ({format})")
         finally:
             PDFLOCK.release()
 
@@ -163,11 +165,12 @@ class BulletinGenerator:
         """
         from app.scodoc import sco_preferences
 
-        formsemestre_id = self.infos["formsemestre_id"]
+        formsemestre_id = self.bul_dict["formsemestre_id"]
+        nomprenom = self.bul_dict["etud"]["nomprenom"]
         marque_debut_bulletin = sco_pdf.DebutBulletin(
-            self.infos["etud"]["nomprenom"],
-            filigranne=self.infos["filigranne"],
-            footer_content=f"""ScoDoc - Bulletin de {self.infos["etud"]["nomprenom"]} - {time.strftime("%d/%m/%Y %H:%M")}""",
+            nomprenom,
+            filigranne=self.bul_dict["filigranne"],
+            footer_content=f"""ScoDoc - Bulletin de {nomprenom} - {time.strftime("%d/%m/%Y %H:%M")}""",
         )
         story = []
         # partie haute du bulletin
@@ -208,8 +211,7 @@ class BulletinGenerator:
                     document,
                     author="%s %s (E. Viennet) [%s]"
                     % (sco_version.SCONAME, sco_version.SCOVERSION, self.description),
-                    title="Bulletin %s de %s"
-                    % (sem["titremois"], self.infos["etud"]["nomprenom"]),
+                    title=f"""Bulletin {sem["titremois"]} de {nomprenom}""",
                     subject="Bulletin de note",
                     margins=self.margins,
                     server_name=self.server_name,
@@ -247,7 +249,7 @@ class BulletinGenerator:
 
 # ---------------------------------------------------------------------------
 def make_formsemestre_bulletinetud(
-    infos,
+    bul_dict,
     version=None,  # short, long, selectedevals
     format="pdf",  # html, pdf
     stand_alone=True,
@@ -262,10 +264,10 @@ def make_formsemestre_bulletinetud(
     from app.scodoc import sco_preferences
 
     version = version or "long"
-    if not version in scu.BULLETINS_VERSIONS:
+    if version not in scu.BULLETINS_VERSIONS:
         raise ValueError("invalid version code !")
 
-    formsemestre_id = infos["formsemestre_id"]
+    formsemestre_id = bul_dict["formsemestre_id"]
     bul_class_name = sco_preferences.get_preference("bul_class_name", formsemestre_id)
 
     gen_class = None
@@ -274,7 +276,7 @@ def make_formsemestre_bulletinetud(
         # si pas trouvé (modifs locales bizarres ,), ré-essaye avec la valeur par défaut
         bulletin_default_class_name(),
     ):
-        if infos.get("type") == "BUT" and format.startswith("pdf"):
+        if bul_dict.get("type") == "BUT" and format.startswith("pdf"):
             gen_class = bulletin_get_class(bul_class_name + "BUT")
         if gen_class is None:
             gen_class = bulletin_get_class(bul_class_name)
@@ -285,10 +287,10 @@ def make_formsemestre_bulletinetud(
     try:
         PDFLOCK.acquire()
         bul_generator = gen_class(
-            infos,
+            bul_dict,
             authuser=current_user,
             version=version,
-            filigranne=infos["filigranne"],
+            filigranne=bul_dict["filigranne"],
             server_name=request.url_root,
             with_img_signatures_pdf=with_img_signatures_pdf,
         )
@@ -301,24 +303,22 @@ def make_formsemestre_bulletinetud(
             bul_class_name = bulletin_default_class_name()
             gen_class = bulletin_get_class(bul_class_name)
             bul_generator = gen_class(
-                infos,
+                bul_dict,
                 authuser=current_user,
                 version=version,
-                filigranne=infos["filigranne"],
+                filigranne=bul_dict["filigranne"],
                 server_name=request.url_root,
                 with_img_signatures_pdf=with_img_signatures_pdf,
             )
-
         data = bul_generator.generate(format=format, stand_alone=stand_alone)
     finally:
         PDFLOCK.release()
 
     if bul_generator.diagnostic:
-        log("bul_error: %s" % bul_generator.diagnostic)
+        log(f"bul_error: {bul_generator.diagnostic}")
         raise NoteProcessError(bul_generator.diagnostic)
 
     filename = bul_generator.get_filename()
-
     return data, filename
 
 
diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py
index a26397915ded351ebaeca4aa0eebed96304bac24..f99b2f998215126305bb9829de25b44d610d913c 100644
--- a/app/scodoc/sco_bulletins_pdf.py
+++ b/app/scodoc/sco_bulletins_pdf.py
@@ -45,7 +45,7 @@ Pour définir un nouveau type de bulletin:
     (s'inspirer de sco_bulletins_pdf_default);
  - en fin du fichier sco_bulletins_pdf.py, ajouter la ligne
     import sco_bulletins_pdf_xxxx
- - votre type sera alors (après redémarrage de ScoDoc) proposé dans le formulaire de paramètrage ScoDoc.
+ - votre type sera alors (après redémarrage de ScoDoc) proposé dans le formulaire de paramètrage.
 
 Chaque semestre peut si nécessaire utiliser un type de bulletin différent.
 
@@ -60,13 +60,12 @@ import traceback
 from flask import g, request
 
 from app import log, ScoValueError
-from app.models import FormSemestre
+from app.models import FormSemestre, Identite
 
 from app.scodoc import sco_cache
 from app.scodoc import codes_cursus
 from app.scodoc import sco_pdf
 from app.scodoc import sco_preferences
-from app.scodoc import sco_etud
 from app.scodoc.sco_logos import find_logo
 import app.scodoc.sco_utils as scu
 
@@ -97,8 +96,8 @@ def assemble_bulletins_pdf(
     document.addPageTemplates(
         sco_pdf.ScoDocPageTemplate(
             document,
-            author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION),
-            title="Bulletin %s" % bul_title,
+            author=f"{sco_version.SCONAME} {sco_version.SCOVERSION} (E. Viennet)",
+            title=f"Bulletin {bul_title}",
             subject="Bulletin de note",
             server_name=server_name,
             margins=margins,
@@ -125,11 +124,13 @@ def replacement_function(match):
 
 
 class WrapDict(object):
-    """Wrap a dict so that getitem returns '' when values are None"""
+    """Wrap a dict so that getitem returns '' when values are None
+    and non existent keys returns an error message as value.
+    """
 
-    def __init__(self, adict, NoneValue=""):
+    def __init__(self, adict, none_value=""):
         self.dict = adict
-        self.NoneValue = NoneValue
+        self.none_value = none_value
 
     def __getitem__(self, key):
         try:
@@ -137,12 +138,11 @@ class WrapDict(object):
         except KeyError:
             return f"XXX {key} invalide XXX"
         if value is None:
-            return self.NoneValue
-        else:
-            return value
+            return self.none_value
+        return value
 
 
-def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"):
+def process_field(field, cdict, style, suppress_empty_pars=False, fmt="pdf"):
     """Process a field given in preferences, returns
     - if format = 'pdf': a list of Platypus objects
     - if format = 'html' : a string
@@ -183,7 +183,7 @@ def process_field(field, cdict, style, suppress_empty_pars=False, format="pdf"):
         )
     # remove unhandled or dangerous tags:
     text = re.sub(r"<\s*img", "", text)
-    if format == "html":
+    if fmt == "html":
         # convert <para>
         text = re.sub(r"<\s*para(\s*)(.*?)>", r"<p>", text)
         return text
@@ -219,7 +219,7 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"):
     for etud in formsemestre.get_inscrits(include_demdef=True, order=True):
         frag, _ = sco_bulletins.do_formsemestre_bulletinetud(
             formsemestre,
-            etud.id,
+            etud,
             format="pdfpart",
             version=version,
         )
@@ -256,22 +256,21 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"):
     "Bulletins pdf de tous les semestres de l'étudiant, et filename"
     from app.scodoc import sco_bulletins
 
-    etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
+    etud: Identite = Identite.query.get_or_404(etudid)
     fragments = []
     bookmarks = {}
     filigrannes = {}
     i = 1
-    for sem in etud["sems"]:
-        formsemestre = FormSemestre.query.get(sem["formsemestre_id"])
+    for formsemestre in etud.get_formsemestres():
         frag, filigranne = sco_bulletins.do_formsemestre_bulletinetud(
             formsemestre,
-            etudid,
+            etud,
             format="pdfpart",
             version=version,
         )
         fragments += frag
         filigrannes[i] = filigranne
-        bookmarks[i] = sem["session_id"]  # eg RT-DUT-FI-S1-2015
+        bookmarks[i] = formsemestre.session_id()  # eg RT-DUT-FI-S1-2015
         i = i + 1
     infos = {"DeptName": sco_preferences.get_preference("DeptName")}
     if request:
@@ -283,7 +282,7 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"):
         pdfdoc = assemble_bulletins_pdf(
             None,
             fragments,
-            etud["nomprenom"],
+            etud.nomprenom,
             infos,
             bookmarks,
             filigranne=filigrannes,
@@ -292,7 +291,7 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"):
     finally:
         sco_pdf.PDFLOCK.release()
     #
-    filename = "bul-%s" % (etud["nomprenom"])
+    filename = f"bul-{etud.nomprenom}"
     filename = (
         scu.unescape_html(filename).replace(" ", "_").replace("&", "").replace(".", "")
         + ".pdf"
diff --git a/app/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py
index da854cbe14fced126f288f6046cefd640a7bf4c4..c7c51135ce793b6a68ad9f09068090054bb2e974 100644
--- a/app/scodoc/sco_bulletins_standard.py
+++ b/app/scodoc/sco_bulletins_standard.py
@@ -186,13 +186,13 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
                 self.preferences["bul_pdf_caption"],
                 self.infos,
                 self.FieldStyle,
-                format="pdf",
+                fmt="pdf",
             )
             field = sco_bulletins_pdf.process_field(
                 self.preferences["bul_pdf_caption"],
                 self.infos,
                 self.FieldStyle,
-                format="html",
+                fmt="html",
             )
             H.append('<div class="bul_decision">' + field + "</div>")
 
diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py
index 4b2f5e887c52bcca8350773d43d0c8e4d29d80bd..5a2313fae1036968dec66443fb151f2e2fac0e7f 100644
--- a/app/scodoc/sco_etud.py
+++ b/app/scodoc/sco_etud.py
@@ -939,7 +939,7 @@ def fill_etuds_info(etuds: list[dict], add_admission=True):
 def etud_inscriptions_infos(etudid: int, ne="") -> dict:
     """Dict avec les informations sur les semestres passés et courant.
     {
-        "sems" : ,
+        "sems" : , # trie les semestres par date de debut, le plus recent d'abord
         "ins" : ,
         "cursem" : ,
         "inscription" : ,
diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py
index c711846921e3583f2d55c885c58a133f31f2fc24..022b470c443b95d92ac328adb320ad1cf6aa89b4 100644
--- a/app/scodoc/sco_recapcomplet.py
+++ b/app/scodoc/sco_recapcomplet.py
@@ -397,8 +397,8 @@ def gen_formsemestre_recapcomplet_json(
         etudid = t[-1]
         if is_apc:
             etud = Identite.query.get(etudid)
-            r = bulletin_but.BulletinBUT(formsemestre)
-            bul = r.bulletin_etud(etud, formsemestre)
+            bulletins_sem = bulletin_but.BulletinBUT(formsemestre)
+            bul = bulletins_sem.bulletin_etud(etud, formsemestre)
         else:
             bul = sco_bulletins_json.formsemestre_bulletinetud_published_dict(
                 formsemestre_id,
diff --git a/app/views/notes.py b/app/views/notes.py
index ae2a69d377f623623ae850c5fbe5acf082685a34..62071bcf2e3f1d4423870d40422577f05f045328 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -335,7 +335,8 @@ def formsemestre_bulletinetud(
 
     if format == "oldjson":
         format = "json"
-    r = sco_bulletins.formsemestre_bulletinetud(
+
+    response = sco_bulletins.formsemestre_bulletinetud(
         etud,
         formsemestre_id=formsemestre_id,
         format=format,
@@ -344,7 +345,8 @@ def formsemestre_bulletinetud(
         force_publishing=force_publishing,
         prefer_mail_perso=prefer_mail_perso,
     )
-    if format == "pdfmail":
+
+    if format == "pdfmail":  # ne renvoie rien dans ce cas (mails envoyés)
         return redirect(
             url_for(
                 "notes.formsemestre_bulletinetud",
@@ -353,7 +355,7 @@ def formsemestre_bulletinetud(
                 formsemestre_id=formsemestre_id,
             )
         )
-    return r
+    return response
 
 
 sco_publish(
@@ -2074,7 +2076,7 @@ def formsemestre_bulletins_mailetuds(
     for inscription in inscriptions:
         sent, _ = sco_bulletins.do_formsemestre_bulletinetud(
             formsemestre,
-            inscription.etudid,
+            inscription.etud,
             version=version,
             prefer_mail_perso=prefer_mail_perso,
             format="pdfmail",