diff --git a/app/api/etudiants.py b/app/api/etudiants.py
index b1d07c5fc..10fb562b2 100755
--- a/app/api/etudiants.py
+++ b/app/api/etudiants.py
@@ -10,7 +10,7 @@
 from datetime import datetime
 from operator import attrgetter
 
-from flask import g, request
+from flask import g, request, Response
 from flask_json import as_json
 from flask_login import current_user
 from flask_login import login_required
@@ -18,7 +18,7 @@ from sqlalchemy import desc, func, or_
 from sqlalchemy.dialects.postgresql import VARCHAR
 
 import app
-from app import db
+from app import db, log
 from app.api import api_bp as bp, api_web_bp
 from app.api import tools
 from app.but import bulletin_but_court
@@ -26,6 +26,7 @@ from app.decorators import scodoc, permission_required
 from app.models import (
     Admission,
     Departement,
+    EtudAnnotation,
     FormSemestreInscription,
     FormSemestre,
     Identite,
@@ -54,6 +55,32 @@ import app.scodoc.sco_utils as scu
 #
 
 
+def _get_etud_by_code(
+    code_type: str, code: str, dept: Departement
+) -> tuple[bool, Response | Identite]:
+    """Get etud, using etudid, NIP or INE
+    Returns True, etud if ok, or False, error response.
+    """
+    if code_type == "nip":
+        query = Identite.query.filter_by(code_nip=code)
+    elif code_type == "etudid":
+        try:
+            etudid = int(code)
+        except ValueError:
+            return False, json_error(404, "invalid etudid type")
+        query = Identite.query.filter_by(id=etudid)
+    elif code_type == "ine":
+        query = Identite.query.filter_by(code_ine=code)
+    else:
+        return False, json_error(404, "invalid code_type")
+    if dept:
+        query = query.filter_by(dept_id=dept.id)
+    etud = query.first()
+    if etud is None:
+        return False, json_error(404, message="etudiant inexistant")
+    return True, etud
+
+
 @bp.route("/etudiants/courants", defaults={"long": False})
 @bp.route("/etudiants/courants/long", defaults={"long": True})
 @api_web_bp.route("/etudiants/courants", defaults={"long": False})
@@ -389,28 +416,15 @@ def bulletin(
         pdf = True
     if version not in scu.BULLETINS_VERSIONS_BUT:
         return json_error(404, "version invalide")
-    # return f"{code_type}={code}, version={version}, pdf={pdf}"
     formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
     dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
     if g.scodoc_dept and dept.acronym != g.scodoc_dept:
         return json_error(404, "formsemestre inexistant")
     app.set_sco_dept(dept.acronym)
 
-    if code_type == "nip":
-        query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
-    elif code_type == "etudid":
-        try:
-            etudid = int(code)
-        except ValueError:
-            return json_error(404, "invalid etudid type")
-        query = Identite.query.filter_by(id=etudid)
-    elif code_type == "ine":
-        query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
-    else:
-        return json_error(404, "invalid code_type")
-    etud = query.first()
-    if etud is None:
-        return json_error(404, message="etudiant inexistant")
+    ok, etud = _get_etud_by_code(code_type, code, dept)
+    if not ok:
+        return etud  # json error
 
     if version == "butcourt":
         if pdf:
@@ -562,26 +576,15 @@ def etudiant_create(force=False):
 @api_web_bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
 @scodoc
 @permission_required(Permission.EtudInscrit)
+@as_json
 def etudiant_edit(
     code_type: str = "etudid",
     code: str = None,
 ):
     """Edition des données étudiant (identité, admission, adresses)"""
-    if code_type == "nip":
-        query = Identite.query.filter_by(code_nip=code)
-    elif code_type == "etudid":
-        try:
-            etudid = int(code)
-        except ValueError:
-            return json_error(404, "invalid etudid type")
-        query = Identite.query.filter_by(id=etudid)
-    elif code_type == "ine":
-        query = Identite.query.filter_by(code_ine=code)
-    else:
-        return json_error(404, "invalid code_type")
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    etud: Identite = query.first()
+    ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
+    if not ok:
+        return etud  # json error
     #
     args = request.get_json(force=True)  # may raise 400 Bad Request
     etud.from_dict(args)
@@ -604,3 +607,67 @@ def etudiant_edit(
     restrict = not current_user.has_permission(Permission.ViewEtudData)
     r = etud.to_dict_api(restrict=restrict)
     return r
+
+
+@bp.route("/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"])
+@api_web_bp.route(
+    "/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"]
+)
+@scodoc
+@permission_required(Permission.EtudInscrit)  # il faut en plus ViewEtudData
+@as_json
+def etudiant_annotation(
+    code_type: str = "etudid",
+    code: str = None,
+):
+    """Ajout d'une annotation sur un étudiant"""
+    if not current_user.has_permission(Permission.ViewEtudData):
+        return json_error(403, "non autorisé (manque ViewEtudData)")
+    ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
+    if not ok:
+        return etud  # json error
+    #
+    args = request.get_json(force=True)  # may raise 400 Bad Request
+    comment = args.get("comment", None)
+    if not isinstance(comment, str):
+        return json_error(404, "invalid comment (expected string)")
+    if len(comment) > scu.MAX_TEXT_LEN:
+        return json_error(404, "invalid comment (too large)")
+    annotation = EtudAnnotation(comment=comment, author=current_user.user_name)
+    etud.annotations.append(annotation)
+    db.session.add(etud)
+    db.session.commit()
+    log(f"etudiant_annotation/{etud.id}/{annotation.id}")
+    return annotation.to_dict()
+
+
+@bp.route(
+    "/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
+    methods=["POST"],
+)
+@api_web_bp.route(
+    "/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
+    methods=["POST"],
+)
+@login_required
+@scodoc
+@as_json
+@permission_required(Permission.EtudInscrit)
+def etudiant_annotation_delete(
+    code_type: str = "etudid", code: str = None, annotation_id: int = None
+):
+    """
+    Suppression d'une annotation
+    """
+    ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
+    if not ok:
+        return etud  # json error
+    annotation = EtudAnnotation.query.filter_by(
+        etudid=etud.id, id=annotation_id
+    ).first()
+    if annotation is None:
+        return json_error(404, "annotation not found")
+    log(f"etudiant_annotation_delete/{etud.id}/{annotation.id}")
+    db.session.delete(annotation)
+    db.session.commit()
+    return "ok"
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 5e61d0289..bd70e03c1 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -101,7 +101,12 @@ class Identite(models.ScoDocModel):
     adresses = db.relationship(
         "Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
     )
-
+    annotations = db.relationship(
+        "EtudAnnotation",
+        backref="etudiant",
+        cascade="all, delete-orphan",
+        lazy="dynamic",
+    )
     billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
     #
     dispense_ues = db.relationship(
@@ -477,9 +482,9 @@ class Identite(models.ScoDocModel):
             "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 "",
+            "date_naissance": (
+                self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""
+            ),
             "dept_acronym": self.departement.acronym,
             "dept_id": self.dept_id,
             "dept_naissance": self.dept_naissance or "",
@@ -519,12 +524,16 @@ class Identite(models.ScoDocModel):
         e.pop("departement", None)
         e["sort_key"] = self.sort_key
         if with_annotations:
-            e["annotations"] = [
-                annot.to_dict(restrict=restrict)
-                for annot in EtudAnnotation.query.filter_by(etudid=self.id).order_by(
-                    desc(EtudAnnotation.date)
-                )
-            ]
+            e["annotations"] = (
+                [
+                    annot.to_dict()
+                    for annot in EtudAnnotation.query.filter_by(
+                        etudid=self.id
+                    ).order_by(desc(EtudAnnotation.date))
+                ]
+                if not restrict
+                else []
+            )
         if restrict:
             # Met à None les attributs protégés:
             for attr in self.protected_attrs:
@@ -1079,18 +1088,14 @@ class EtudAnnotation(db.Model):
 
     id = db.Column(db.Integer, primary_key=True)
     date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
-    etudid = db.Column(db.Integer)  # sans contrainte (compat ScoDoc 7))
+    etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
     author = db.Column(db.Text)  # le pseudo (user_name), was zope_authenticated_user
     comment = db.Column(db.Text)
 
-    protected_attrs = {"comment"}
-
-    def to_dict(self, restrict=False):
-        """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
+    def to_dict(self):
+        """Représentation dictionnaire."""
         e = dict(self.__dict__)
         e.pop("_sa_instance_state", None)
-        if restrict:
-            e = {k: v for (k, v) in e.items() if k not in self.protected_attrs}
         return e
 
 
diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py
index 8b1c82e99..58d380e47 100644
--- a/tests/api/test_api_etudiants.py
+++ b/tests/api/test_api_etudiants.py
@@ -204,8 +204,8 @@ def test_etudiants(api_headers):
 
     all_unique = True
     list_ids = [etud["id"] for etud in etud_nip]
-    for id in list_ids:
-        if list_ids.count(id) > 1:
+    for etudid in list_ids:
+        if list_ids.count(etudid) > 1:
             all_unique = False
     assert all_unique is True
 
@@ -226,8 +226,8 @@ def test_etudiants(api_headers):
 
     all_unique = True
     list_ids = [etud["id"] for etud in etud_ine]
-    for id in list_ids:
-        if list_ids.count(id) > 1:
+    for etudid in list_ids:
+        if list_ids.count(etudid) > 1:
             all_unique = False
     assert all_unique is True
 
@@ -267,6 +267,53 @@ def test_etudiants_by_name(api_headers):
     assert etuds[0]["nom"] == "RÉGNIER"
 
 
+def test_etudiant_annotations(api_headers):
+    """
+    Routes:
+     POST /etudiant/etudid/<int:etudid>/annotation
+     GET /etudiant/etudid/<int:etudid>[/long]
+    """
+    admin_header = get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN)
+    args = {
+        "prenom": "Annoté",
+        "nom": "Bach A",
+        "dept": DEPT_ACRONYM,
+        "civilite": "M",
+    }
+    etud = POST_JSON(
+        "/etudiant/create",
+        args,
+        headers=admin_header,
+    )
+    assert etud["nom"] == args["nom"].upper()
+    etudid = etud["id"]
+    # récupère annotation (liste vide)
+    etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers)
+    assert etud["nom"]
+    assert etud["annotations"] == []
+    # ajoute annotation
+    annotation = POST_JSON(
+        f"/etudiant/etudid/{etudid}/annotation",
+        {"comment": "annotation 1"},
+        headers=admin_header,
+    )
+    assert annotation
+    annotation_id = annotation["id"]
+    etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers)
+    assert len(etud["annotations"]) == 0  # pas le droit => cachée
+    etud = GET(f"/etudiant/etudid/{etudid}", headers=admin_header)
+    assert len(etud["annotations"]) == 1  # ok avec admin
+    assert etud["annotations"][0]["comment"] == "annotation 1"
+    assert etud["annotations"][0]["id"] == annotation_id
+    # Supprime annotation
+    POST_JSON(
+        f"/etudiant/etudid/{etudid}/annotation/{annotation_id}/delete",
+        headers=admin_header,
+    )
+    etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers)
+    assert len(etud["annotations"]) == 0
+
+
 def test_etudiant_photo(api_headers):
     """
     Routes : /etudiant/etudid/<int:etudid>/photo en GET et en POST
diff --git a/tests/unit/test_etudiants.py b/tests/unit/test_etudiants.py
index f2d83d2ec..d319360f6 100644
--- a/tests/unit/test_etudiants.py
+++ b/tests/unit/test_etudiants.py
@@ -15,7 +15,14 @@ from flask import current_app
 
 import app
 from app import db
-from app.models import Admission, Adresse, Departement, FormSemestre, Identite
+from app.models import (
+    Admission,
+    Adresse,
+    Departement,
+    EtudAnnotation,
+    FormSemestre,
+    Identite,
+)
 from app.scodoc import sco_etud
 from app.scodoc import sco_find_etud, sco_import_etuds
 from config import TestConfig
@@ -116,6 +123,32 @@ def test_etat_civil(test_client):
     assert e_d["ne"] == "(e)"
 
 
+def test_etud_annotations(test_client):
+    "Test ajout/suppression annotations"
+    dept = Departement.query.first()
+    args = {"nom": "nom_a", "prenom": "prénom_a", "civilite": "M", "dept_id": dept.id}
+    etud = Identite.create_etud(**args)
+    db.session.flush()
+    # Ajout annotations
+    etud.annotations.append(EtudAnnotation(comment="annotation 1"))
+    etud.annotations.append(EtudAnnotation(comment="annotation 2"))
+    db.session.add(etud)
+    db.session.commit()
+    assert etud.annotations.count() == 2
+    # Suppression de la première
+    a = etud.annotations.first()
+    assert a.comment == "annotation 1"
+    db.session.delete(a)
+    db.session.commit()
+    assert db.session.get(Identite, etud.id)
+    assert etud.annotations.count() == 1
+    # Suppression de l'étudiant (cascade)
+    a2_id = etud.annotations.first().id
+    db.session.delete(etud)
+    db.session.commit()
+    assert db.session.get(EtudAnnotation, a2_id) is None
+
+
 def test_etud_legacy(test_client):
     "Test certaines fonctions scodoc7 (sco_etud)"
     dept = Departement.query.first()