diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py
index 2e556908fc6722efacb9644d773d62a8f9a3c9f8..74d167eba06bde69c047dbab559eab222dd0e3cc 100644
--- a/app/but/bulletin_but.py
+++ b/app/but/bulletin_but.py
@@ -531,10 +531,6 @@ class BulletinBUT:
         ] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
         d["rang_txt"] = "Rang " + d["rang_nt"]
 
-        # --- Appréciations
-        d.update(
-            sco_bulletins.get_appreciations_list(self.res.formsemestre.id, etud.id)
-        )
         d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))
 
         return d
diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py
index ce215c14fb24d1bc309e82eb0c82a6bd22001292..5e89ab7062b82204099ec08242996ed18c4a24bf 100644
--- a/app/but/bulletin_but_pdf.py
+++ b/app/but/bulletin_but_pdf.py
@@ -12,8 +12,8 @@ La génération du bulletin PDF suit le chemin suivant:
     
     bul_dict = bulletin_but.BulletinBUT(formsemestre).bulletin_etud_complet(etud)
 
-- sco_bulletins_generator.make_formsemestre_bulletinetud(infos)
-- instance de BulletinGeneratorStandardBUT(infos)
+- sco_bulletins_generator.make_formsemestre_bulletin_etud()
+- instance de BulletinGeneratorStandardBUT
 - BulletinGeneratorStandardBUT.generate(format="pdf")
     sco_bulletins_generator.BulletinGenerator.generate()
     .generate_pdf()
@@ -42,7 +42,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
     multi_pages = True  # plusieurs pages par bulletins
     small_fontsize = "8"
 
-    def bul_table(self, format="html"):
+    def bul_table(self, fmt="html"):
         """Génère la table centrale du bulletin de notes
         Renvoie:
         - en HTML: une chaine
@@ -71,7 +71,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
                 html_class_ignore_default=True,
                 html_with_td_classes=True,
             )
-            table_objects = table.gen(format=format)
+            table_objects = table.gen(format=fmt)
             objects += table_objects
             # objects += [KeepInFrame(0, 0, table_objects, mode="shrink")]
             if i != 2:
diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py
index 45668ac59ff4ef028218a137bace0339aa21c60e..75dade0a1820387d78678d87167bcc37f115c72b 100644
--- a/app/but/bulletin_but_xml_compat.py
+++ b/app/but/bulletin_but_xml_compat.py
@@ -40,7 +40,7 @@ from xml.etree.ElementTree import Element
 
 from app import log
 from app.but import bulletin_but
-from app.models import FormSemestre, Identite
+from app.models import BulAppreciations, FormSemestre, Identite
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app.scodoc import codes_cursus
@@ -315,16 +315,13 @@ def bulletin_but_xml_compat(
         else:
             doc.append(Element("decision", code="", etat="DEM"))
     # --- Appreciations
-    cnx = ndb.GetDBConnexion()
-    apprecs = sco_etud.appreciations_list(
-        cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
-    )
-    for appr in apprecs:
+    appreciations = BulAppreciations.get_appreciations_list(formsemestre.id, etudid)
+    for appreciation in appreciations:
         x_appr = Element(
             "appreciation",
-            date=ndb.DateDMYtoISO(appr["date"]),
+            date=appreciation.date.isoformat() if appreciation.date else "",
         )
-        x_appr.text = quote_xml_attr(appr["comment"])
+        x_appr.text = quote_xml_attr(appreciation.comment_safe())
         doc.append(x_appr)
 
     if is_appending:
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 5c4ea31e79c0ea8e0d799b0c19b3fd54f73852be..e00f8465071c8aeec25b42200484da081cf71b49 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -296,26 +296,27 @@ class Identite(db.Model):
         from app.scodoc import sco_photos
 
         d = {
+            "boursier": self.boursier or "",
+            "civilite_etat_civil": self.civilite_etat_civil,
             "civilite": self.civilite,
             "code_ine": self.code_ine or "",
             "code_nip": self.code_nip or "",
             "date_naissance": self.date_naissance.strftime("%d/%m/%Y")
             if self.date_naissance
             else "",
-            "dept_id": self.dept_id,
             "dept_acronym": self.departement.acronym,
+            "dept_id": self.dept_id,
+            "dept_naissance": self.dept_naissance or "",
             "email": self.get_first_email() or "",
             "emailperso": self.get_first_email("emailperso"),
+            "etat_civil": self.etat_civil,
             "etudid": self.id,
-            "nom": self.nom_disp(),
-            "prenom": self.prenom or "",
-            "nomprenom": self.nomprenom or "",
             "lieu_naissance": self.lieu_naissance or "",
-            "dept_naissance": self.dept_naissance or "",
             "nationalite": self.nationalite or "",
-            "boursier": self.boursier or "",
-            "civilite_etat_civil": self.civilite_etat_civil,
+            "nom": self.nom_disp(),
+            "nomprenom": self.nomprenom or "",
             "prenom_etat_civil": self.prenom_etat_civil,
+            "prenom": self.prenom or "",
         }
         if include_urls and has_request_context():
             # test request context so we can use this func in tests under the flask shell
diff --git a/app/models/notes.py b/app/models/notes.py
index 2024a43683c8235947f1df5b3843962d6edb9cd5..61eb1773383229fb659571a028ee812bc3b867f0 100644
--- a/app/models/notes.py
+++ b/app/models/notes.py
@@ -5,6 +5,7 @@
 
 import sqlalchemy as sa
 from app import db
+from app.scodoc import safehtml
 import app.scodoc.sco_utils as scu
 
 
@@ -26,6 +27,31 @@ class BulAppreciations(db.Model):
     author = db.Column(db.Text)  # le pseudo (user_name), sans contrainte
     comment = db.Column(db.Text)  # texte libre
 
+    @classmethod
+    def get_appreciations_list(
+        cls, formsemestre_id: int, etudid: int
+    ) -> list["BulAppreciations"]:
+        "Liste des appréciations pour cet étudiant dans ce semestre"
+        return (
+            BulAppreciations.query.filter_by(
+                etudid=etudid, formsemestre_id=formsemestre_id
+            )
+            .order_by(BulAppreciations.date)
+            .all()
+        )
+
+    @classmethod
+    def summarize(cls, appreciations: list["BulAppreciations"]) -> list[str]:
+        "Liste de chaines résumant une liste d'appréciations, pour bulletins"
+        return [
+            f"{x.date.strftime('%d/%m/%Y') if x.date else ''}: {x.comment or ''}"
+            for x in appreciations
+        ]
+
+    def comment_safe(self) -> str:
+        "Le comment, safe pour inclusion dans HTML (None devient '')"
+        return safehtml.html_to_safe_html(self.comment or "")
+
 
 class NotesNotes(db.Model):
     """Une note"""
diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py
index ce5f65090bb9ee0a20b0fcbcad2dfcefbc1730f4..ba27c9b96c2ed5a097a3b992cde382fdbfdf4d3e 100644
--- a/app/scodoc/sco_bulletins.py
+++ b/app/scodoc/sco_bulletins.py
@@ -224,9 +224,6 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
     elif I["etud_etat"] == codes_cursus.DEF:
         I["demission"] = "(Défaillant)"
 
-    # --- Appreciations
-    I.update(get_appreciations_list(formsemestre_id, etudid))
-
     # --- Notes
     ues = nt.get_ues_stat_dict()
     modimpls = nt.get_modimpls_dict()
@@ -417,21 +414,6 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
     return C
 
 
-def get_appreciations_list(formsemestre_id: int, etudid: int) -> dict:
-    """Appréciations pour cet étudiant dans ce semestre"""
-    cnx = ndb.GetDBConnexion()
-    apprecs = sco_etud.appreciations_list(
-        cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
-    )
-    d = {
-        "appreciations_list": apprecs,
-        "appreciations_txt": [x["date"] + ": " + x["comment"] for x in apprecs],
-    }
-    # deprecated / keep it for backward compat in templates:
-    d["appreciations"] = d["appreciations_txt"]
-    return d
-
-
 def _get_etud_etat_html(etat: str) -> str:
     """chaine html représentant l'état (backward compat sco7)"""
     if etat == scu.INSCRIT:
@@ -1035,16 +1017,18 @@ def do_formsemestre_bulletinetud(
         bul_dict = formsemestre_bulletinetud_dict(formsemestre.id, etud.id)
 
     if format == "html":
-        htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud(
-            bul_dict, version=version, format="html"
+        htm, _ = sco_bulletins_generator.make_formsemestre_bulletin_etud(
+            bul_dict, etud=etud, formsemestre=formsemestre, version=version, fmt="html"
         )
         return htm, bul_dict["filigranne"]
 
     elif format == "pdf" or format == "pdfpart":
-        bul, filename = sco_bulletins_generator.make_formsemestre_bulletinetud(
+        bul, filename = sco_bulletins_generator.make_formsemestre_bulletin_etud(
             bul_dict,
+            etud=etud,
+            formsemestre=formsemestre,
             version=version,
-            format="pdf",
+            fmt="pdf",
             stand_alone=(format != "pdfpart"),
             with_img_signatures_pdf=with_img_signatures_pdf,
         )
@@ -1062,8 +1046,8 @@ def do_formsemestre_bulletinetud(
         if not can_send_bulletin_by_mail(formsemestre.id):
             raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
 
-        pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletinetud(
-            bul_dict, version=version, format="pdf"
+        pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletin_etud(
+            bul_dict, etud=etud, formsemestre=formsemestre, version=version, fmt="pdf"
         )
 
         if prefer_mail_perso:
diff --git a/app/scodoc/sco_bulletins_example.py b/app/scodoc/sco_bulletins_example.py
index 83ad85c3daaaadf57bb71d7682260520941cf1fd..663fc8d0de7aa2e4e1f1f1a2b6995b2bb6e0aa16 100644
--- a/app/scodoc/sco_bulletins_example.py
+++ b/app/scodoc/sco_bulletins_example.py
@@ -47,14 +47,14 @@ class BulletinGeneratorExample(sco_bulletins_standard.BulletinGeneratorStandard)
     # En général, on veut définir un format de table spécial, sans changer le reste (titre, pied de page).
     # Si on veut changer le reste, surcharger les méthodes:
     #  .bul_title_pdf(self)  : partie haute du bulletin
-    #  .bul_part_below(self, format='') : infos sous la table
+    #  .bul_part_below(self, fmt='') : infos sous la table
     #  .bul_signatures_pdf(self) : signatures
 
-    def bul_table(self, format=""):
+    def bul_table(self, fmt=""):
         """Défini la partie centrale de notre bulletin PDF.
         Doit renvoyer une liste d'objets PLATYPUS
         """
-        assert format == "pdf"  # garde fou
+        assert fmt == "pdf"  # garde fou
         return [
             Paragraph(
                 sco_pdf.SU(
diff --git a/app/scodoc/sco_bulletins_generator.py b/app/scodoc/sco_bulletins_generator.py
index b122d7a46b255b326c655115996a072e753b7db9..499833e1981aa58aae17803c810ac7a0b15f213e 100644
--- a/app/scodoc/sco_bulletins_generator.py
+++ b/app/scodoc/sco_bulletins_generator.py
@@ -31,8 +31,8 @@ class BulletinGenerator:
  description
  supported_formats = [ 'pdf', 'html' ]
  .bul_title_pdf()
- .bul_table(format)
- .bul_part_below(format)
+ .bul_table(fmt)
+ .bul_part_below(fmt)
  .bul_signatures_pdf()
 
  .__init__ et .generate(format) methodes appelees par le client (sco_bulletin)
@@ -62,6 +62,7 @@ from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
 from flask import request
 from flask_login import current_user
 
+from app.models import FormSemestre, Identite
 from app.scodoc import sco_utils as scu
 from app.scodoc.sco_exceptions import NoteProcessError
 from app import log
@@ -85,9 +86,11 @@ class BulletinGenerator:
         self,
         bul_dict,
         authuser=None,
-        version="long",
+        etud: Identite = None,
         filigranne=None,
+        formsemestre: FormSemestre = None,
         server_name=None,
+        version="long",
         with_img_signatures_pdf: bool = True,
     ):
         from app.scodoc import sco_preferences
@@ -98,9 +101,11 @@ class BulletinGenerator:
         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.etud: Identite = etud
         self.filigranne = filigranne
+        self.formsemestre: FormSemestre = formsemestre
         self.server_name = server_name
+        self.version = version
         self.with_img_signatures_pdf = with_img_signatures_pdf
         # Store preferences for convenience:
         formsemestre_id = self.bul_dict["formsemestre_id"]
@@ -151,9 +156,9 @@ class BulletinGenerator:
         """Return bulletin as an HTML string"""
         H = ['<div class="notes_bulletin">']
         # table des notes:
-        H.append(self.bul_table(format="html"))  # pylint: disable=no-member
+        H.append(self.bul_table(fmt="html"))  # pylint: disable=no-member
         # infos sous la table:
-        H.append(self.bul_part_below(format="html"))  # pylint: disable=no-member
+        H.append(self.bul_part_below(fmt="html"))  # pylint: disable=no-member
         H.append("</div>")
         return "\n".join(H)
 
@@ -169,7 +174,7 @@ class BulletinGenerator:
         nomprenom = self.bul_dict["etud"]["nomprenom"]
         etat_civil = self.bul_dict["etud"]["etat_civil"]
         marque_debut_bulletin = sco_pdf.DebutBulletin(
-            self.bul_dict["etat_civil"],
+            etat_civil,
             filigranne=self.bul_dict["filigranne"],
             footer_content=f"""ScoDoc - Bulletin de {nomprenom} - {time.strftime("%d/%m/%Y %H:%M")}""",
         )
@@ -179,9 +184,9 @@ class BulletinGenerator:
         index_obj_debut = len(story)
 
         # table des notes
-        story += self.bul_table(format="pdf")  # pylint: disable=no-member
+        story += self.bul_table(fmt="pdf")  # pylint: disable=no-member
         # infos sous la table
-        story += self.bul_part_below(format="pdf")  # pylint: disable=no-member
+        story += self.bul_part_below(fmt="pdf")  # pylint: disable=no-member
         # signatures
         story += self.bul_signatures_pdf()  # pylint: disable=no-member
         if self.scale_table_in_page:
@@ -249,10 +254,12 @@ class BulletinGenerator:
 
 
 # ---------------------------------------------------------------------------
-def make_formsemestre_bulletinetud(
+def make_formsemestre_bulletin_etud(
     bul_dict,
+    etud: Identite = None,
+    formsemestre: FormSemestre = None,
     version=None,  # short, long, selectedevals
-    format="pdf",  # html, pdf
+    fmt="pdf",  # html, pdf
     stand_alone=True,
     with_img_signatures_pdf: bool = True,
 ):
@@ -277,7 +284,7 @@ def make_formsemestre_bulletinetud(
         # si pas trouvé (modifs locales bizarres ,), ré-essaye avec la valeur par défaut
         bulletin_default_class_name(),
     ):
-        if bul_dict.get("type") == "BUT" and format.startswith("pdf"):
+        if bul_dict.get("type") == "BUT" and fmt.startswith("pdf"):
             gen_class = bulletin_get_class(bul_class_name + "BUT")
         if gen_class is None:
             gen_class = bulletin_get_class(bul_class_name)
@@ -290,28 +297,32 @@ def make_formsemestre_bulletinetud(
         bul_generator = gen_class(
             bul_dict,
             authuser=current_user,
-            version=version,
+            etud=etud,
             filigranne=bul_dict["filigranne"],
+            formsemestre=formsemestre,
             server_name=request.url_root,
+            version=version,
             with_img_signatures_pdf=with_img_signatures_pdf,
         )
-        if format not in bul_generator.supported_formats:
+        if fmt not in bul_generator.supported_formats:
             # use standard generator
             log(
                 "Bulletin format %s not supported by %s, using %s"
-                % (format, bul_class_name, bulletin_default_class_name())
+                % (fmt, bul_class_name, bulletin_default_class_name())
             )
             bul_class_name = bulletin_default_class_name()
             gen_class = bulletin_get_class(bul_class_name)
             bul_generator = gen_class(
                 bul_dict,
                 authuser=current_user,
-                version=version,
+                etud=etud,
                 filigranne=bul_dict["filigranne"],
+                formsemestre=formsemestre,
                 server_name=request.url_root,
+                version=version,
                 with_img_signatures_pdf=with_img_signatures_pdf,
             )
-        data = bul_generator.generate(format=format, stand_alone=stand_alone)
+        data = bul_generator.generate(format=fmt, stand_alone=stand_alone)
     finally:
         PDFLOCK.release()
 
diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py
index fc4d4b427f54f707bd662dacd3312ee5c2285fb9..a4ee2ed3ec36a68e769015d2d7969e542f4cf2d1 100644
--- a/app/scodoc/sco_bulletins_json.py
+++ b/app/scodoc/sco_bulletins_json.py
@@ -37,7 +37,7 @@ from app import db, ScoDocJSONEncoder
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.models import but_validations
-from app.models import Evaluation, Matiere, UniteEns
+from app.models import BulAppreciations, Evaluation, Matiere, UniteEns
 from app.models.etudiants import Identite
 from app.models.formsemestre import FormSemestre
 
@@ -303,18 +303,14 @@ def formsemestre_bulletinetud_published_dict(
     d.update(dict_decision_jury(etud, formsemestre, with_decisions=xml_with_decisions))
 
     # --- Appréciations
-    cnx = ndb.GetDBConnexion()
-    apprecs = sco_etud.appreciations_list(
-        cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
-    )
-    d["appreciation"] = []
-    for app in apprecs:
-        d["appreciation"].append(
-            dict(
-                comment=quote_xml_attr(app["comment"]),
-                date=ndb.DateDMYtoISO(app["date"]),
-            )
-        )
+    appreciations = BulAppreciations.get_appreciations_list(formsemestre.id, etudid)
+    d["appreciation"] = [
+        {
+            "comment": quote_xml_attr(appreciation["comment"]),
+            "date": appreciation.date.isoformat() if appreciation.date else "",
+        }
+        for appreciation in appreciations
+    ]
 
     #
     return d
diff --git a/app/scodoc/sco_bulletins_legacy.py b/app/scodoc/sco_bulletins_legacy.py
index 280c6f7b49fc8fc8b43fe1a32c840a75669dfb40..419a243776ba02ae4ea0ece5e2cc8a10172d043c 100644
--- a/app/scodoc/sco_bulletins_legacy.py
+++ b/app/scodoc/sco_bulletins_legacy.py
@@ -34,10 +34,14 @@
  CE FORMAT N'EVOLUERA PLUS ET EST CONSIDERE COMME OBSOLETE.
  
 """
+
+from flask import g, url_for
+
 from reportlab.lib.colors import Color, blue
 from reportlab.lib.units import cm, mm
 from reportlab.platypus import Paragraph, Spacer, Table
 
+from app.models import BulAppreciations
 from app.scodoc import sco_bulletins_generator
 from app.scodoc import sco_bulletins_pdf
 from app.scodoc import sco_formsemestre
@@ -65,14 +69,14 @@ class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator):
         )  # impose un espace vertical entre le titre et la table qui suit
         return objects
 
-    def bul_table(self, format="html"):
+    def bul_table(self, fmt="html"):
         """Table bulletin"""
-        if format == "pdf":
+        if fmt == "pdf":
             return self.bul_table_pdf()
-        elif format == "html":
+        elif fmt == "html":
             return self.bul_table_html()
         else:
-            raise ValueError("invalid bulletin format (%s)" % format)
+            raise ValueError(f"invalid bulletin format ({fmt})")
 
     def bul_table_pdf(self):
         """Génère la table centrale du bulletin de notes
@@ -239,16 +243,16 @@ class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator):
         # ---------------
         return "\n".join(H)
 
-    def bul_part_below(self, format="html"):
+    def bul_part_below(self, fmt="html"):
         """Génère les informations placées sous la table de notes
         (absences, appréciations, décisions de jury...)
         """
-        if format == "pdf":
+        if fmt == "pdf":
             return self.bul_part_below_pdf()
-        elif format == "html":
+        elif fmt == "html":
             return self.bul_part_below_html()
         else:
-            raise ValueError("invalid bulletin format (%s)" % format)
+            raise ValueError("invalid bulletin format (%s)" % fmt)
 
     def bul_part_below_pdf(self):
         """
@@ -277,11 +281,17 @@ class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator):
                 )
 
         # ----- APPRECIATIONS
-        if self.infos.get("appreciations_list", False):
+        appreciations = BulAppreciations.get_appreciations_list(
+            self.formsemestre.id, self.etud.id
+        )
+        if appreciations:
             objects.append(Spacer(1, 3 * mm))
             objects.append(
                 Paragraph(
-                    SU("Appréciation : " + "\n".join(self.infos["appreciations_txt"])),
+                    SU(
+                        "Appréciation : "
+                        + "\n".join(BulAppreciations.summarize(appreciations))
+                    ),
                     self.CellStyle,
                 )
             )
@@ -325,24 +335,40 @@ class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator):
             authuser.has_permission(Permission.ScoEtudInscrit)
         )
         H.append('<div class="bull_appreciations">')
-        if I["appreciations_list"]:
+        appreciations = BulAppreciations.get_appreciations_list(
+            self.formsemestre.id, self.etud.id
+        )
+        if appreciations:
             H.append("<p><b>Appréciations</b></p>")
-        for app in I["appreciations_list"]:
+        for appreciation in appreciations:
             if can_edit_app:
-                mlink = (
-                    '<a class="stdlink" href="appreciation_add_form?id=%s">modifier</a> <a class="stdlink" href="appreciation_add_form?id=%s&suppress=1">supprimer</a>'
-                    % (app["id"], app["id"])
-                )
+                mlink = f"""<a class="stdlink" href="{
+                        url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dpt, appreciation_id=appreciation.id)
+                    }">modifier</a>
+                    <a class="stdlink" href="{
+                        url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dpt, appreciation_id=appreciation.id, suppress=1)
+                    }">supprimer</a>"""
             else:
                 mlink = ""
             H.append(
-                '<p><span class="bull_appreciations_date">%s</span>%s<span class="bull_appreciations_link">%s</span></p>'
-                % (app["date"], app["comment"], mlink)
+                f"""<p>
+                <span class="bull_appreciations_date">{
+                    appreciation.date.strftime("%d/%m/%y") if appreciation.date else ""
+                }</span>
+                {appreciation.comment_safe()}
+                <span class="bull_appreciations_link">{mlink}</span>
+                </p>"""
             )
         if can_edit_app:
             H.append(
-                '<p><a class="stdlink" href="appreciation_add_form?etudid=%(etudid)s&formsemestre_id=%(formsemestre_id)s">Ajouter une appréciation</a></p>'
-                % self.infos
+                f"""<p>
+                <a class="stdlink" href="{
+                    url_for('notes.appreciation_add_form',
+                            scodoc_dept=g.scodoc_dept,
+                            etudid=self.etud.id,
+                            formsemestre_id=self.formsemestre.id)
+                }">Ajouter une appréciation</a>
+                </p>"""
             )
         H.append("</div>")
         # ---------------
diff --git a/app/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py
index 0039dba5ef5d83e2455bf2db12979a8e03a25696..dd3e8d7d679c94ff79884227926d509b38bb7d2f 100644
--- a/app/scodoc/sco_bulletins_standard.py
+++ b/app/scodoc/sco_bulletins_standard.py
@@ -51,6 +51,7 @@ from reportlab.lib.colors import Color, blue
 from reportlab.lib.units import cm, mm
 from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table
 
+from app.models import BulAppreciations
 import app.scodoc.sco_utils as scu
 from app.scodoc import (
     gen_tables,
@@ -92,7 +93,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
         )  # impose un espace vertical entre le titre et la table qui suit
         return objects
 
-    def bul_table(self, format="html"):
+    def bul_table(self, fmt="html"):
         """Génère la table centrale du bulletin de notes
         Renvoie:
         - en HTML: une chaine
@@ -112,9 +113,9 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
             html_with_td_classes=True,
         )
 
-        return T.gen(format=format)
+        return T.gen(format=fmt)
 
-    def bul_part_below(self, format="html"):
+    def bul_part_below(self, fmt="html"):
         """Génère les informations placées sous la table de notes
         (absences, appréciations, décisions de jury...)
         Renvoie:
@@ -156,45 +157,53 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
         # ---- APPRECIATIONS
         # le dir. des etud peut ajouter des appreciations,
         # mais aussi le chef (perm. ScoEtudInscrit)
-        can_edit_app = (self.authuser.id in self.infos["responsables"]) or (
+        can_edit_app = (self.formsemestre.est_responsable(self.authuser)) or (
             self.authuser.has_permission(Permission.ScoEtudInscrit)
         )
         H.append('<div class="bull_appreciations">')
-        for app in self.infos["appreciations_list"]:
+        appreciations = BulAppreciations.get_appreciations_list(
+            self.formsemestre.id, self.etud.id
+        )
+        for appreciation in appreciations:
             if can_edit_app:
                 mlink = f"""<a class="stdlink" href="{
-                        url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dept, id=app['id'])
+                        url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dept, appreciation_id=appreciation.id)
                     }">modifier</a>
                     <a class="stdlink" href="{
-                        url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dept, id=app['id'], suppress=1)
+                        url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dept, appreciation_id=appreciation.id, suppress=1)
                     }">supprimer</a>"""
             else:
                 mlink = ""
             H.append(
-                f"""<p><span class="bull_appreciations_date">{app["date"]}</span>{
-                    app["comment"]}<span class="bull_appreciations_link">{mlink}</span>
-                    </p>"""
+                f"""<p>
+                <span class="bull_appreciations_date">{
+                    appreciation.date.strftime("%d/%m/%Y")
+                    if appreciation.date else ""}</span>
+                {appreciation.comment_safe()}
+                <span class="bull_appreciations_link">{mlink}</span>
+                </p>
+                """
             )
         if can_edit_app:
             H.append(
                 f"""<p><a class="stdlink" href="{
                     url_for('notes.appreciation_add_form', scodoc_dept=g.scodoc_dept, 
-                            etudid=self.infos['etudid'],
-                            formsemestre_id=self.infos['formsemestre_id']
+                            etudid=self.etud.etudid,
+                            formsemestre_id=self.formsemestre.id
                     )
                     }">Ajouter une appréciation</a></p>"""
                 % self.infos
             )
         H.append("</div>")
         # Appréciations sur PDF:
-        if self.infos.get("appreciations_list", False):
+        if appreciations:
             story.append(Spacer(1, 3 * mm))
             try:
                 story.append(
                     Paragraph(
                         SU(
                             "Appréciation : "
-                            + "\n".join(self.infos["appreciations_txt"])
+                            + "\n".join(BulAppreciations.summarize(appreciations))
                         ),
                         self.CellStyle,
                     )
@@ -221,14 +230,14 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
             H.append('<div class="bul_decision">' + field + "</div>")
 
         # -----
-        if format == "pdf":
+        if fmt == "pdf":
             if self.scale_table_in_page:
                 # le scaling (pour tenir sur une page) semble incompatible avec
                 # le KeepTogether()
                 return story
             else:
                 return [KeepTogether(story)]
-        elif format == "html":
+        elif fmt == "html":
             return "\n".join(H)
 
     def bul_signatures_pdf(self):
diff --git a/app/scodoc/sco_bulletins_xml.py b/app/scodoc/sco_bulletins_xml.py
index 4d96009b935e782e263ed097896c27b4da62aa14..3eafc5cabfc7678b854b45ec2d9c6f114097feec 100644
--- a/app/scodoc/sco_bulletins_xml.py
+++ b/app/scodoc/sco_bulletins_xml.py
@@ -50,8 +50,7 @@ import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
 from app import log
 from app.but.bulletin_but_xml_compat import bulletin_but_xml_compat
-from app.models.evaluations import Evaluation
-from app.models.formsemestre import FormSemestre
+from app.models import BulAppreciations, Evaluation, FormSemestre
 from app.scodoc import sco_assiduites
 from app.scodoc import codes_cursus
 from app.scodoc import sco_edit_ue
@@ -415,16 +414,13 @@ def make_xml_formsemestre_bulletinetud(
         else:
             doc.append(Element("decision", code="", etat="DEM"))
     # --- Appreciations
-    cnx = ndb.GetDBConnexion()
-    apprecs = sco_etud.appreciations_list(
-        cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id}
-    )
-    for appr in apprecs:
+    appreciations = BulAppreciations.get_appreciations_list(formsemestre.id, etudid)
+    for appreciation in appreciations:
         x_appr = Element(
             "appreciation",
-            date=ndb.DateDMYtoISO(appr["date"]),
+            date=appreciation.date.isoformat() if appreciation.date else "",
         )
-        x_appr.text = quote_xml_attr(appr["comment"])
+        x_appr.text = quote_xml_attr(appreciation.comment_safe())
         doc.append(x_appr)
 
     if is_appending:
diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py
index 2fb1f52da7741cdf0d4707344ed4cea060a60039..9365ef7c5abe6de0df44ec66854e195fe24e0256 100644
--- a/app/scodoc/sco_etud.py
+++ b/app/scodoc/sco_etud.py
@@ -741,31 +741,6 @@ def add_annotations_to_etud_list(etuds):
         etud["annotations_str"] = ", ".join(l)
 
 
-# -------- APPRECIATIONS (sur bulletins) -------------------
-# Les appreciations sont dans la table postgres notes_appreciations
-_appreciationsEditor = ndb.EditableTable(
-    "notes_appreciations",
-    "id",
-    (
-        "id",
-        "date",
-        "etudid",
-        "formsemestre_id",
-        "author",
-        "comment",
-        "author",
-    ),
-    sortkey="date desc",
-    convert_null_outputs_to_empty=True,
-    output_formators={"comment": safehtml.html_to_safe_html, "date": ndb.DateISOtoDMY},
-)
-
-appreciations_create = _appreciationsEditor.create
-appreciations_delete = _appreciationsEditor.delete
-appreciations_list = _appreciationsEditor.list
-appreciations_edit = _appreciationsEditor.edit
-
-
 # -------- Noms des Lycées à partir du code
 def read_etablissements():
     filename = os.path.join(scu.SCO_TOOLS_DIR, scu.CONFIG.ETABL_FILENAME)
diff --git a/app/templates/bul_foot.j2 b/app/templates/bul_foot.j2
index e2085cb75250b4c54ec9e98eb712b40578e65695..e06718f13348962d4bc5df2127c6ba3c5a7e822b 100644
--- a/app/templates/bul_foot.j2
+++ b/app/templates/bul_foot.j2
@@ -23,9 +23,9 @@
                 app.comment}}<span 
                 class="bull_appreciations_link">{% if can_edit_appreciations %}<a 
                 class="stdlink" href="{{url_for('notes.appreciation_add_form', 
-                scodoc_dept=g.scodoc_dept, id=app.id)}}">modifier</a>
+                scodoc_dept=g.scodoc_dept, appreciation_id=app.id)}}">modifier</a>
                 <a class="stdlink" href="{{url_for('notes.appreciation_add_form', 
-                scodoc_dept=g.scodoc_dept, id=app.id, suppress=1)}}">supprimer</a>{% endif %}
+                scodoc_dept=g.scodoc_dept, appreciation_id=app.id, suppress=1)}}">supprimer</a>{% endif %}
                 </span>
                 </p>        
             {% endfor %}
diff --git a/app/views/notes.py b/app/views/notes.py
index 3b3ee2a6ee15cdaf46000d589d1a794b2b7e4f4e..11c25dbe583dda0744fbe4f9af2a1fd0be00312d 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -58,6 +58,7 @@ from app.but.forms import jury_but_forms
 from app.comp import jury, res_sem
 from app.comp.res_compat import NotesTableCompat
 from app.models import (
+    BulAppreciations,
     Evaluation,
     Formation,
     ScolarAutorisationInscription,
@@ -316,14 +317,14 @@ def formsemestre_bulletinetud(
     if formsemestre.formation.is_apc() and format == "html":
         return render_template(
             "but/bulletin.j2",
-            appreciations=models.BulAppreciations.query.filter_by(
-                etudid=etudid, formsemestre_id=formsemestre.id
-            ).order_by(models.BulAppreciations.date),
+            appreciations=BulAppreciations.get_appreciations_list(
+                formsemestre.id, etud.id
+            ),
             bul_url=url_for(
                 "notes.formsemestre_bulletinetud",
                 scodoc_dept=g.scodoc_dept,
                 formsemestre_id=formsemestre_id,
-                etudid=etudid,
+                etudid=etud.id,
                 format="json",
                 force_publishing=1,  # pour ScoDoc lui même
                 version=version,
@@ -2039,64 +2040,72 @@ sco_publish(
 def appreciation_add_form(
     etudid=None,
     formsemestre_id=None,
-    id=None,  # si id, edit
+    appreciation_id=None,  # si id, edit
     suppress=False,  # si true, supress id
 ):
     "form ajout ou edition d'une appreciation"
-    cnx = ndb.GetDBConnexion()
-    if id:  # edit mode
-        apps = sco_etud.appreciations_list(cnx, args={"id": id})
-        if not apps:
+    if appreciation_id:  # edit mode
+        appreciation = db.session.get(BulAppreciations, appreciation_id)
+        if appreciation is None:
             raise ScoValueError("id d'appreciation invalide !")
-        app = apps[0]
-        formsemestre_id = app["formsemestre_id"]
-        etudid = app["etudid"]
+        formsemestre_id = appreciation.formsemestre_id
+        etudid = appreciation.etudid
+    etud: Identite = Identite.query.filter_by(
+        id=etudid, dept_id=g.scodoc_dept_id
+    ).first_or_404()
     vals = scu.get_request_args()
     if "edit" in vals:
         edit = int(vals["edit"])
-    elif id:
+    elif appreciation_id:
         edit = 1
     else:
         edit = 0
-    sem = sco_formsemestre.get_formsemestre(formsemestre_id)
+    formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
     # check custom access permission
-    can_edit_app = (current_user.id in sem["responsables"]) or (
+    can_edit_app = formsemestre.est_responsable(current_user) or (
         current_user.has_permission(Permission.ScoEtudInscrit)
     )
     if not can_edit_app:
         raise AccessDenied("vous n'avez pas le droit d'ajouter une appreciation")
     #
-    bull_url = "formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" % (
-        formsemestre_id,
-        etudid,
+    bul_url = url_for(
+        "notes.formsemestre_bulletinetud",
+        scodoc_dept=g.scodoc_dept,
+        formsemestre_id=formsemestre_id,
+        etudid=etudid,
     )
+
     if suppress:
-        sco_etud.appreciations_delete(cnx, id)
-        logdb(cnx, method="appreciation_suppress", etudid=etudid, msg="")
-        return flask.redirect(bull_url)
+        db.session.delete(appreciation)
+        Scolog.logdb(
+            method="appreciation_suppress",
+            etudid=etudid,
+        )
+        db.session.commit()
+        flash("appréciation supprimée")
+        return flask.redirect(bul_url)
     #
-    etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
-    if id:
-        a = "Edition"
+    if appreciation_id:
+        action = "Édition"
     else:
-        a = "Ajout"
+        action = "Ajout"
     H = [
-        html_sco_header.sco_header()
-        + "<h2>%s d'une appréciation sur %s</h2>" % (a, etud["nomprenom"])
+        html_sco_header.sco_header(),
+        f"""<h2>{action} d'une appréciation sur {etud.nomprenom}</h2>""",
     ]
     F = html_sco_header.sco_footer()
     descr = [
         ("edit", {"input_type": "hidden", "default": edit}),
         ("etudid", {"input_type": "hidden"}),
         ("formsemestre_id", {"input_type": "hidden"}),
-        ("id", {"input_type": "hidden"}),
+        ("appreciation_id", {"input_type": "hidden"}),
         ("comment", {"title": "", "input_type": "textarea", "rows": 4, "cols": 60}),
     ]
-    if id:
+    if appreciation_id:
         initvalues = {
             "etudid": etudid,
             "formsemestre_id": formsemestre_id,
-            "comment": app["comment"],
+            "comment": appreciation.comment,
         }
     else:
         initvalues = {}
@@ -2111,31 +2120,33 @@ def appreciation_add_form(
     if tf[0] == 0:
         return "\n".join(H) + "\n" + tf[1] + F
     elif tf[0] == -1:
-        return flask.redirect(bull_url)
+        return flask.redirect(bul_url)
     else:
-        args = {
-            "etudid": etudid,
-            "formsemestre_id": formsemestre_id,
-            "author": current_user.user_name,
-            "comment": tf[2]["comment"],
-        }
         if edit:
-            args["id"] = id
-            sco_etud.appreciations_edit(cnx, args)
+            appreciation.author = (current_user.user_name,)
+            appreciation.comment = tf[2]["comment"].strip()
+            flash("appréciation modifiée")
         else:  # nouvelle
-            sco_etud.appreciations_create(cnx, args)
+            appreciation = BulAppreciations(
+                etudid=etudid,
+                formsemestre_id=formsemestre_id,
+                author=current_user.user_name,
+                comment=tf[2]["comment"].strip(),
+            )
+            flash("appréciation ajoutée")
+        db.session.add(appreciation)
         # log
-        logdb(
-            cnx,
+        Scolog.logdb(
             method="appreciation_add",
             etudid=etudid,
-            msg=tf[2]["comment"],
+            msg=appreciation.comment_safe(),
         )
+        db.session.commit()
         # ennuyeux mais necessaire (pour le PDF seulement)
         sco_cache.invalidate_formsemestre(
             pdfonly=True, formsemestre_id=formsemestre_id
         )  # > appreciation_add
-        return flask.redirect(bull_url)
+        return flask.redirect(bul_url)
 
 
 # --- FORMULAIRE POUR VALIDATION DES UE ET SEMESTRES