diff --git a/app/api/__init__.py b/app/api/__init__.py
index a098d7e9378df31cc1b3b7dfa68c87028ef169c0..6c2e493ffb3d94980b06397b22b0821db83d049d 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -113,6 +113,7 @@ from app.api import (
     assiduites,
     billets_absences,
     departements,
+    etud_suivi,
     etudiants,
     evaluations,
     formations,
diff --git a/app/api/etud_suivi.py b/app/api/etud_suivi.py
new file mode 100644
index 0000000000000000000000000000000000000000..76703bad56166ddaf655dba5f15a060449ded404
--- /dev/null
+++ b/app/api/etud_suivi.py
@@ -0,0 +1,213 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""
+  API : itemsuivi devenir/débouché des étudiants
+
+  CATEGORY
+  --------
+  Étudiants
+"""
+import datetime
+
+from flask import g, request, Response
+from flask_json import as_json
+from flask_login import login_required
+
+from app.api import api_bp as bp, api_web_bp
+from app.api import api_permission_required as permission_required
+from app import db, log
+from app.decorators import scodoc
+from app.models import Identite, ItemSuivi, ItemSuiviTag, Scolog
+from app.scodoc import sco_permissions_check
+from app.scodoc.sco_permissions import Permission
+from app.scodoc.sco_exceptions import AccessDenied
+import app.scodoc.sco_utils as scu
+
+
+@bp.post("/etudiant/<int:etudid>/itemsuivi/create")
+@api_web_bp.post("/etudiant/<int:etudid>/itemsuivi/create")
+@login_required
+@scodoc
+@permission_required(Permission.EtudChangeAdr)
+@as_json
+def itemsuivi_create(etudid):
+    """Création d'un item de suivi associé à l'étudiant.
+
+    The form MAY contain:
+    - item_date: date of the item
+    - situation: text
+    """
+    if not sco_permissions_check.can_edit_suivi():
+        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
+    etud = Identite.get_etud(etudid)
+    ok, item_date = _get_date_from_form()
+    item_date = item_date if ok else datetime.datetime.now()
+    situation = request.form.get("situation") or ""
+    item = ItemSuivi(etudid=etudid, item_date=item_date, situation=situation)
+    db.session.add(item)
+    db.session.commit()
+    Scolog.logdb(method="itemsuivi_create", etudid=etud.id, commit=True)
+    log(f"itemsuivi_create {item} for {etud}")
+    db.session.refresh(item)
+    return item.to_dict(merge_tags=True)
+
+
+@bp.post("/etudiant/itemsuivi/<int:itemid>/delete")
+@api_web_bp.post("/etudiant/itemsuivi/<int:itemid>/delete")
+@login_required
+@scodoc
+@permission_required(Permission.EtudChangeAdr)
+@as_json
+def itemsuivi_suppress(itemid):
+    """Suppression d'un item"""
+    if not sco_permissions_check.can_edit_suivi():
+        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
+    item: ItemSuivi = ItemSuivi.get_instance(itemid, accept_none=True)
+    if item is None:
+        return {"status": "ok", "message": "item not found"}
+    etudid = item.etudid
+    db.session.delete(item)
+    db.session.commit()
+    Scolog.logdb(method="itemsuivi_suppress", etudid=etudid, commit=True)
+    log(f"itemsuivi_suppress: itemid={itemid}")
+    return {"status": "ok", "message": "item deleted"}
+
+
+@bp.get("/etudiant/<int:etudid>/itemsuivis")
+@api_web_bp.get("/etudiant/<int:etudid>/itemsuivis")
+@login_required
+@scodoc
+@permission_required(Permission.ScoView)
+@as_json
+def itemsuivi_list_etud(etudid: int):
+    """Liste des items pour cet étudiant, avec tags"""
+    etud = Identite.get_etud(etudid)
+    items = [it.to_dict(merge_tags=True) for it in etud.itemsuivis]
+    return items
+
+
+@bp.post("/etudiant/itemsuivi/<int:itemid>/tag")
+@api_web_bp.post("/etudiant/itemsuivi/<int:itemid>/tag")
+@login_required
+@scodoc
+@permission_required(Permission.EtudChangeAdr)
+@as_json
+def itemsuivi_tag_set(itemid: int):
+    """
+    taglist a string with tag names separated by commas e.g.: `un,deux`
+    """
+    if not sco_permissions_check.can_edit_suivi():
+        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
+    item: ItemSuivi = ItemSuivi.get_instance(itemid)
+    taglist_str = request.form.get("taglist")[: scu.MAX_TEXT_LEN]
+    if taglist_str is None:
+        return scu.json_error(400, "missing taglist in form")
+    taglist = [t.strip() for t in taglist_str.split(",")]
+    item.set_tags(taglist)
+    db.session.add(item)
+    db.session.commit()
+    log(f"itemsuivi_tag_set: itemsuivi_id={item.id} taglist={taglist}")
+    return item.to_dict(merge_tags=True)
+
+
+@bp.post("/etudiant/itemsuivi/<int:itemid>/set_situation")
+@api_web_bp.post("/etudiant/itemsuivi/<int:itemid>/set_situation")
+@login_required
+@scodoc
+@permission_required(Permission.EtudChangeAdr)
+def itemsuivi_set_situation(itemid: int):
+    """Modifie le champ situation de l'item de suivi de l'étudiant.
+
+    Form data: "value" : "situation ..."
+
+    Appel utilisé par champ éditable sur fiche étudiant.
+    """
+    if not sco_permissions_check.can_edit_suivi():
+        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
+    item: ItemSuivi = ItemSuivi.get_instance(itemid)
+    if "value" not in request.form:
+        return scu.json_error(400, "missing value in form")
+    situation = request.form["value"]
+    item.situation = situation.strip("-_ \t\r\n")[: scu.MAX_TEXT_LEN]
+    db.session.add(item)
+    db.session.commit()
+    log(f"itemsuivi_set_situation: itemid={itemid} situation={item.situation[:32]}")
+    return item.situation or scu.IT_SITUATION_MISSING_STR
+
+
+@bp.post("/etudiant/itemsuivi/<int:itemid>/set_date")
+@api_web_bp.post("/etudiant/itemsuivi/<int:itemid>/set_date")
+@login_required
+@scodoc
+@permission_required(Permission.EtudChangeAdr)
+@as_json
+def itemsuivi_set_date(itemid: int):
+    """set item date
+
+    Specify date as an ISO date string
+    or date_dmy, string dd/mm/yyyy
+    """
+    if not sco_permissions_check.can_edit_suivi():
+        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
+    ok, date_or_resp = _get_date_from_form()
+    if not ok:
+        return date_or_resp
+    item: ItemSuivi = ItemSuivi.get_instance(itemid)
+    item.item_date = date_or_resp
+    db.session.add(item)
+    db.session.commit()
+    log(f"itemsuivi_set_date: itemid={itemid} date={item.item_date}")
+    return item.to_dict(merge_tags=True)
+
+
+def _get_date_from_form() -> tuple[bool, datetime.datetime | Response]:
+    """get date from form
+    Specify date as an ISO date string
+    or date_dmy, string dd/mm/yyyy
+    """
+    date_iso = request.form.get("date")  # ISO format
+    if date_iso:
+        try:
+            return True, datetime.datetime.fromisoformat(date_iso)
+        except ValueError:
+            return False, scu.json_error(400, "invalide date format (iso)")
+    date_dmy = request.form.get("date_dmy")
+    if not date_dmy:
+        return False, scu.json_error(400, "missing date or date_dmy in form")
+    try:
+        return True, datetime.datetime.strptime(date_dmy, scu.DATE_FMT)
+    except ValueError:
+        return False, scu.json_error(400, "invalide date format (jj/mm/aaaa)")
+
+
+@bp.get("/etudiant/itemsuivi/tags/search")
+@api_web_bp.get("/etudiant/itemsuivi/tags/search")
+@login_required
+@scodoc
+@permission_required(Permission.ScoView)
+@as_json
+def itemsuivi_tag_search():
+    """List all used tag names (for auto-completion)
+
+    Query: term
+    """
+    if getattr(g, "scodoc_dept_id", None) is None:
+        return []  # accès départemental seulement
+    term = request.args.get("term", "").strip()[: scu.MAX_TEXT_LEN]
+    # restrict charset to avoid injections
+    if not scu.ALPHANUM_EXP.match(term):
+        data = []
+    else:
+        data = [
+            x.title
+            for x in ItemSuiviTag.query.filter(
+                ItemSuiviTag.title.like(f"{term}%"),
+                ItemSuiviTag.dept_id == g.scodoc_dept_id,
+            ).all()
+        ]
+
+    return data
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 82e8416ff194494316b7b25a13c149dcb51628ee..26ed712f02708e292b2b98dbe2e82f70fb092e62 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -128,9 +128,13 @@ class ScoDocModel(db.Model):
     def get_instance(cls, oid: int, accept_none=False):
         """Instance du modèle ou ou 404 (ou None si accept_none),
         cherche uniquement dans le département courant.
-        Ne fonctionne que si le modèle a un attribut dept_id.
+
+        Ne fonctionne que si le modèle a un attribut dept_id
+        ou que l'attribut de classe _sco_dept_relations indique les jointures
+        à effectuer pour trouver le département.
+
         Si accept_none, return None si l'id est invalide ou ne correspond
-        pas à une validation.
+        pas à une instance. Sinon lève 404 en cas d'erreur.
         """
         if not isinstance(oid, int):
             try:
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 18387a5656c5e523f820f2c1d2b7c1626dfc05d3..af2207df6125b7136dd2b0f5cc45431a4278b92a 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -115,7 +115,12 @@ class Identite(models.ScoDocModel):
         cascade="all, delete",
         passive_deletes=True,
     )
-
+    itemsuivis = db.relationship(
+        "ItemSuivi",
+        backref="etudiant",
+        cascade="all, delete-orphan",
+        lazy="dynamic",
+    )
     # Relations avec les assiduites et les justificatifs
     assiduites = db.relationship(
         "Assiduite", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
@@ -1118,8 +1123,39 @@ class ItemSuivi(models.ScoDocModel):
 
     _sco_dept_relations = ("Identite",)  # accès au dept_id
 
+    tags = db.relationship(
+        "ItemSuiviTag",
+        secondary="itemsuivi_tags_assoc",
+        lazy=True,
+        backref=db.backref("items", lazy=True),
+    )
+
+    def to_dict(self, merge_tags=False):
+        """Représentation dictionnaire.
+        Si merge_tags, regroupe les tags sur une seule chaine, valeurs séparées par des virgules
+        """
+        d = super().to_dict()
+        # Convertit les tags en liste de strings:
+        if merge_tags:
+            d["tags"] = ", ".join([tag.title for tag in self.tags])
+        else:
+            d["tags"] = [tag.title for tag in self.tags]
+        # Ajoute date locale
+        d["item_date_dmy"] = self.item_date.strftime(scu.DATE_FMT)
+        return d
+
+    def set_tags(self, tags: list[str]):
+        """Définit les tags de l'itemsuivi"""
+        self.tags = []
+        for tag in tags:
+            tag_obj = ItemSuiviTag.query.filter_by(title=tag).first()
+            if tag_obj is None:
+                tag_obj = ItemSuiviTag(title=tag)
+            self.tags.append(tag_obj)
+
 
-class ItemSuiviTag(db.Model):
+class ItemSuiviTag(models.ScoDocModel):
+    "Tag sur un itemsuivi"
     __tablename__ = "itemsuivi_tags"
     id = db.Column(db.Integer, primary_key=True)
     dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
@@ -1127,7 +1163,7 @@ class ItemSuiviTag(db.Model):
     title = db.Column(db.Text(), nullable=False, unique=True)
 
 
-# Association tag <-> module
+# Association tag <-> itemsuivi
 itemsuivi_tags_assoc = db.Table(
     "itemsuivi_tags_assoc",
     db.Column(
diff --git a/app/scodoc/sco_debouche.py b/app/scodoc/sco_debouche.py
index a5c97ab9395f1e6bc78947b2523fe87adb3b0590..52e17e16725478db1c049c5974719d49df5861f8 100644
--- a/app/scodoc/sco_debouche.py
+++ b/app/scodoc/sco_debouche.py
@@ -28,22 +28,15 @@
 """
 Rapport (table) avec dernier semestre fréquenté et débouché de chaque étudiant
 """
-import http
 from flask import g, render_template, request, url_for
 
-from app import log
 from app.comp import res_sem
 from app.comp.res_compat import NotesTableCompat
-from app.models import FormSemestre, Scolog
+from app.models import Identite
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
-from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
 from app.scodoc.gen_tables import GenTable
-from app.scodoc import safehtml
-from app.scodoc import sco_permissions_check
 from app.scodoc import sco_preferences
-from app.scodoc import sco_tag_module
-from app.scodoc import sco_etud
 import sco_version
 
 
@@ -75,6 +68,7 @@ def report_debouche_date(start_year=None, fmt="html"):
     tab.base_url = f"{request.base_url}?start_year={start_year}"
     return tab.make_page(
         title="""<h2 class="formsemestre">Débouchés étudiants </h2>""",
+        page_title="Débouchés étudiants",
         fmt=fmt,
         with_html_headers=True,
         template="sco_page_dept.j2",
@@ -115,70 +109,62 @@ def table_debouche_etudids(etudids, keep_numeric=True):
     """Rapport pour ces étudiants"""
     rows = []
     # Recherche les débouchés:
-    itemsuivi_etuds = {etudid: itemsuivi_list_etud(etudid) for etudid in etudids}
+    # itemsuivi_etuds = {etudid: itemsuivi_list_etud(etudid) for etudid in etudids}
     all_tags = set()
-    for debouche in itemsuivi_etuds.values():
-        if debouche:
-            for it in debouche:
-                all_tags.update(tag.strip() for tag in it["tags"].split(","))
-    all_tags = tuple(sorted(all_tags))
-
     for etudid in etudids:
-        etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
+        etud = Identite.get_etud(etudid)
+        # collecte les tags
+        all_tags.update(tag.title for item in etud.itemsuivis for tag in item.tags)
         # retrouve le "dernier" semestre (au sens de la date de fin)
-        sems = etud["sems"]
-        es = [(s["date_fin_iso"], i) for i, s in enumerate(sems)]
-        imax = max(es)[1]
-        last_sem = sems[imax]
-        formsemestre = FormSemestre.query.get_or_404(last_sem["formsemestre_id"])
+        formsemestres = etud.get_formsemestres()
+        dates_fin = [s.date_fin for s in formsemestres]
+        imax = dates_fin.index(max(dates_fin))
+        formsemestre = formsemestres[imax]
         nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
         row = {
             "etudid": etudid,
-            "civilite": etud["civilite"],
-            "nom": etud["nom"],
-            "prenom": etud["prenom"],
+            "civilite": etud.civilite,
+            "nom": etud.nom,
+            "prenom": etud.prenom,
             "_nom_target": url_for(
                 "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
             ),
             "_prenom_target": url_for(
                 "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
             ),
-            "_nom_td_attrs": 'id="%s" class="etudinfo"' % (etud["etudid"]),
+            "_nom_td_attrs": f'id="{etudid}" class="etudinfo"',
             # 'debouche' : etud['debouche'],
             "moy": scu.fmt_note(nt.get_etud_moy_gen(etudid), keep_numeric=keep_numeric),
             "rang": nt.get_etud_rang(etudid),
             "effectif": len(nt.T),
-            "semestre_id": last_sem["semestre_id"],
-            "semestre": last_sem["titre"],
-            "date_debut": last_sem["date_debut"],
-            "date_fin": last_sem["date_fin"],
-            "periode": "%s - %s" % (last_sem["mois_debut"], last_sem["mois_fin"]),
-            "sem_ident": "%s %s"
-            % (last_sem["date_debut_iso"], last_sem["titre"]),  # utile pour tris
+            "semestre_id": formsemestre.semestre_id,
+            "semestre": formsemestre.titre,
+            "date_debut": formsemestre.date_debut,
+            "date_fin": formsemestre.date_fin,
+            "periode": f"{formsemestre.mois_debut()} - {formsemestre.mois_fin()}",
+            "sem_ident": f"{formsemestre.date_debut.isoformat()} {formsemestre.titre}",  # utile pour tris
         }
         # recherche des débouchés
-        debouche = itemsuivi_etuds[etudid]  # liste de plusieurs items
-        if debouche:
+        itemsuivis = etud.itemsuivis  # liste de plusieurs items
+        if itemsuivis:
             if keep_numeric:  # pour excel:
                 row["debouche"] = "\n".join(
-                    f"""{it["item_date"]}: {it["situation"]}""" for it in debouche
+                    f"""{it.item_date.strftime(scu.DATE_FMT)}: {it.situation or ""}"""
+                    for it in itemsuivis
                 )
             else:
                 row["debouche"] = "<br>".join(
                     [
-                        str(it["item_date"])
-                        + " : "
-                        + it["situation"]
-                        + " <i>"
-                        + it["tags"]
-                        + "</i>"
-                        for it in debouche
+                        f"""{it.item_date.strftime(scu.DATE_FMT)} : {it.situation or ""}
+                        <i>{', '.join( tag.title for tag in it.tags)}</i>
+                        """
+                        for it in itemsuivis
                     ]
                 )
-            for it in debouche:
-                for tag in it["tags"].split(","):
-                    tag = tag.strip()
-                    row[f"tag_{tag}"] = tag
+            for it in itemsuivis:
+                for tag in it.tags:
+                    tag_name = tag.title.strip()
+                    row[f"tag_{tag_name}"] = tag_name
         else:
             row["debouche"] = "non renseigné"
         rows.append(row)
@@ -236,177 +222,3 @@ def report_debouche_ask_date(msg: str) -> str:
     </form>
     """,
     )
-
-
-# ----------------------------------------------------------------------------
-#
-# Nouveau suivi des etudiants (nov 2017)
-#
-# ----------------------------------------------------------------------------
-
-
-_itemsuiviEditor = ndb.EditableTable(
-    "itemsuivi",
-    "itemsuivi_id",
-    ("itemsuivi_id", "etudid", "item_date", "situation"),
-    sortkey="item_date desc",
-    convert_null_outputs_to_empty=True,
-    output_formators={
-        "situation": safehtml.html_to_safe_html,
-        "item_date": ndb.DateISOtoDMY,
-    },
-    input_formators={"item_date": ndb.DateDMYtoISO},
-)
-
-_itemsuivi_create = _itemsuiviEditor.create
-_itemsuivi_delete = _itemsuiviEditor.delete
-_itemsuivi_list = _itemsuiviEditor.list
-_itemsuivi_edit = _itemsuiviEditor.edit
-
-
-class ItemSuiviTag(sco_tag_module.ScoTag):
-    """Les tags sur les items"""
-
-    tag_table = "itemsuivi_tags"  # table (tag_id, title)
-    assoc_table = "itemsuivi_tags_assoc"  # table (tag_id, object_id)
-    obj_colname = "itemsuivi_id"  # column name for object_id in assoc_table
-
-
-def itemsuivi_get(cnx, itemsuivi_id, ignore_errors=False):
-    """get an item"""
-    items = _itemsuivi_list(cnx, {"itemsuivi_id": itemsuivi_id})
-    if items:
-        return items[0]
-    elif not ignore_errors:
-        raise ScoValueError(f"Débouché: item inexistant ({itemsuivi_id})")
-    return None
-
-
-def itemsuivi_suppress(itemsuivi_id):
-    """Suppression d'un item"""
-    if not sco_permissions_check.can_edit_suivi():
-        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
-    cnx = ndb.GetDBConnexion()
-    item = itemsuivi_get(cnx, itemsuivi_id, ignore_errors=True)
-    if item:
-        _itemsuivi_delete(cnx, itemsuivi_id)
-        Scolog.logdb(method="itemsuivi_suppress", etudid=item["etudid"], commit=True)
-        log(f"suppressed itemsuivi {itemsuivi_id}")
-    return ("", 204)
-
-
-def itemsuivi_create(etudid, item_date=None, situation="", fmt=None):
-    """Creation d'un item"""
-    if not sco_permissions_check.can_edit_suivi():
-        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
-    cnx = ndb.GetDBConnexion()
-    itemsuivi_id = _itemsuivi_create(
-        cnx, args={"etudid": etudid, "item_date": item_date, "situation": situation}
-    )
-    Scolog.logdb(method="itemsuivi_create", etudid=etudid, commit=True)
-    log("created itemsuivi %s for %s" % (itemsuivi_id, etudid))
-    item = itemsuivi_get(cnx, itemsuivi_id)
-    if fmt == "json":
-        return scu.sendJSON(item)
-    return item
-
-
-def itemsuivi_set_date(itemsuivi_id, item_date):
-    """set item date
-    item_date is a string dd/mm/yyyy
-    """
-    if not sco_permissions_check.can_edit_suivi():
-        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
-    # log('itemsuivi_set_date %s : %s' % (itemsuivi_id, item_date))
-    cnx = ndb.GetDBConnexion()
-    item = itemsuivi_get(cnx, itemsuivi_id)
-    item["item_date"] = item_date
-    _itemsuivi_edit(cnx, item)
-    return ("", 204)
-
-
-def itemsuivi_set_situation(obj, value):
-    """set situation"""
-    if not sco_permissions_check.can_edit_suivi():
-        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
-    itemsuivi_id = obj
-    situation = value.strip("-_ \t")
-    # log('itemsuivi_set_situation %s : %s' % (itemsuivi_id, situation))
-    cnx = ndb.GetDBConnexion()
-    item = itemsuivi_get(cnx, itemsuivi_id)
-    item["situation"] = situation
-    _itemsuivi_edit(cnx, item)
-    return situation or scu.IT_SITUATION_MISSING_STR
-
-
-def itemsuivi_list_etud(etudid, fmt=None):
-    """Liste des items pour cet étudiant, avec tags"""
-    cnx = ndb.GetDBConnexion()
-    items = _itemsuivi_list(cnx, {"etudid": etudid})
-    for it in items:
-        it["tags"] = ", ".join(itemsuivi_tag_list(it["itemsuivi_id"]))
-    if fmt == "json":
-        return scu.sendJSON(items)
-    return items
-
-
-def itemsuivi_tag_list(itemsuivi_id):
-    """les noms de tags associés à cet item"""
-    r = ndb.SimpleDictFetch(
-        """SELECT t.title
-          FROM itemsuivi_tags_assoc a, itemsuivi_tags t
-          WHERE a.tag_id = t.id
-          AND a.itemsuivi_id = %(itemsuivi_id)s
-          """,
-        {"itemsuivi_id": itemsuivi_id},
-    )
-    return [x["title"] for x in r]
-
-
-def itemsuivi_tag_search(term):
-    """List all used tag names (for auto-completion)"""
-    # restrict charset to avoid injections
-    if not scu.ALPHANUM_EXP.match(term):
-        data = []
-    else:
-        r = ndb.SimpleDictFetch(
-            "SELECT title FROM itemsuivi_tags WHERE title LIKE %(term)s AND dept_id=%(dept_id)s",
-            {
-                "term": term + "%",
-                "dept_id": g.scodoc_dept_id,
-            },
-        )
-        data = [x["title"] for x in r]
-
-    return scu.sendJSON(data)
-
-
-def itemsuivi_tag_set(itemsuivi_id="", taglist=None):
-    """taglist may either be:
-    a string with tag names separated by commas ("un;deux")
-    or a list of strings (["un", "deux"])
-    """
-    if not sco_permissions_check.can_edit_suivi():
-        raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
-    if not taglist:
-        taglist = []
-    elif isinstance(taglist, str):
-        taglist = taglist.split(",")
-    taglist = [t.strip() for t in taglist]
-    # log('itemsuivi_tag_set: itemsuivi_id=%s taglist=%s' % (itemsuivi_id, taglist))
-    # Sanity check:
-    cnx = ndb.GetDBConnexion()
-    _ = itemsuivi_get(cnx, itemsuivi_id)
-
-    newtags = set(taglist)
-    oldtags = set(itemsuivi_tag_list(itemsuivi_id))
-    to_del = oldtags - newtags
-    to_add = newtags - oldtags
-
-    # should be atomic, but it's not.
-    for tagname in to_add:
-        t = ItemSuiviTag(tagname, object_id=itemsuivi_id)
-    for tagname in to_del:
-        t = ItemSuiviTag(tagname)
-        t.remove_tag_from_object(itemsuivi_id)
-    return "", http.HTTPStatus.NO_CONTENT
diff --git a/app/scodoc/sco_tag_module.py b/app/scodoc/sco_tag_module.py
index e5f6a5d887ef7c258040a3f20a18d8c95235cf8d..bc5fd03cfb0fb558b95070e88c8da12924aeac0f 100644
--- a/app/scodoc/sco_tag_module.py
+++ b/app/scodoc/sco_tag_module.py
@@ -262,6 +262,7 @@ def module_tag_set(module_id="", taglist=None):
             return scu.json_error(404, "invalid tag")
 
     # TODO code à moderniser (+ revoir classe ScoTag, utiliser modèle)
+    # TODO Voir ItemSuiviTag et api etud_suivi
 
     # Sanity check:
     mod_dict = sco_edit_module.module_list(args={"module_id": module_id})
diff --git a/app/static/js/etud_debouche.js b/app/static/js/etud_debouche.js
index e5a8f65107ef17be266f110003e19f83b6e821e6..a3d06d8b4692763b08fed724f951bf47979f033a 100644
--- a/app/static/js/etud_debouche.js
+++ b/app/static/js/etud_debouche.js
@@ -16,9 +16,8 @@ function display_itemsuivis(active) {
       .off("click")
       .click(function (e) {
         e.preventDefault();
-        $.post(SCO_URL + "itemsuivi_create", {
+        $.post(`${SCO_URL}../api/etudiant/${etudid}/itemsuivi/create`, {
           etudid: etudid,
-          fmt: "json",
         }).done(item_insert_new);
 
         return false;
@@ -26,13 +25,13 @@ function display_itemsuivis(active) {
   }
   // add existing items
   $.get(
-    SCO_URL + "itemsuivi_list_etud",
-    { etudid: etudid, fmt: "json" },
+    `${SCO_URL}../api/etudiant/${etudid}/itemsuivis`,
+    { },
     function (L) {
       for (var i in L) {
         item_insert(
-          L[i]["itemsuivi_id"],
-          L[i]["item_date"],
+          L[i]["id"],
+          L[i]["item_date_dmy"],
           L[i]["situation"],
           L[i]["tags"],
           readonly
@@ -49,7 +48,7 @@ function display_itemsuivis(active) {
 }
 
 function item_insert_new(it) {
-  item_insert(it.itemsuivi_id, it.item_date, it.situation, "", false);
+  item_insert(it.id, it.item_date, it.situation, "", false);
 }
 
 function item_insert(itemsuivi_id, item_date, situation, tags, readonly) {
@@ -78,35 +77,36 @@ function item_nodes(itemsuivi_id, item_date, situation, tags, readonly) {
 
   var h = sel_mois;
   // situation
-  h +=
-    '<div class="itemsituation editable" data-type="textarea" data-url="itemsuivi_set_situation" data-placeholder="<em>décrire situation...</em>" data-object="' +
-    itemsuivi_id +
-    '">' +
-    situation +
-    "</div>";
+  h += `<div class="itemsituation editable"
+    data-type="textarea"
+    data-url="${SCO_URL}../api/etudiant/itemsuivi/${itemsuivi_id}/set_situation"
+    data-placeholder="<em>décrire situation...</em>"
+    data-object="'${itemsuivi_id}'">${situation}</div>`;
   // tags:
   h +=
-    '<div class="itemsuivi_tag_edit"><textarea class="itemsuivi_tag_editor">' +
-    tags +
-    "</textarea></div>";
+    `<div class="itemsuivi_tag_edit">
+      <textarea class="itemsuivi_tag_editor"
+        data-itemsuivi_id="${itemsuivi_id}"
+        >${tags}</textarea>
+    </div>`;
 
   var nodes = $($.parseHTML('<li class="itemsuivi">' + h + "</li>"));
   var dp = nodes.find(".itemsuividatepicker");
   dp.blur(function (e) {
     var date = this.value;
-    // console.log('selected text: ' + date);
-    $.post(SCO_URL + "itemsuivi_set_date", {
-      item_date: date,
-      itemsuivi_id: itemsuivi_id,
-    });
+    if (date) {
+      $.post(`${SCO_URL}../api/etudiant/itemsuivi/${itemsuivi_id}/set_date`, {
+        date_dmy: date,
+      });
+    }
   });
   dp.datepicker({
     onSelect: function (date, instance) {
-      // console.log('selected: ' + date + 'for itemsuivi_id ' + itemsuivi_id);
-      $.post(SCO_URL + "itemsuivi_set_date", {
-        item_date: date,
-        itemsuivi_id: itemsuivi_id,
-      });
+      if (date) {
+        $.post(`${SCO_URL}../api/etudiant/itemsuivi/${itemsuivi_id}/set_date`, {
+          date_dmy: date,
+        });
+      }
     },
     showOn: "button",
     buttonImage: "/ScoDoc/static/icons/calendar_img.png",
@@ -129,7 +129,8 @@ function item_nodes(itemsuivi_id, item_date, situation, tags, readonly) {
       initialTags: "",
       placeholder: "Tags...",
       onChange: function (field, editor, tags) {
-        $.post("itemsuivi_tag_set", {
+        let itemsuivi_id = field.data("itemsuivi_id");
+        $.post(`${SCO_URL}../api/etudiant/itemsuivi/${itemsuivi_id}/tag`, {
           itemsuivi_id: itemsuivi_id,
           taglist: tags.join(),
         });
@@ -160,9 +161,20 @@ function Date2DMY(date) {
   return day + "/" + month + "/" + year;
 }
 
-function itemsuivi_suppress(itemsuivi_id) {
-  $.post(SCO_URL + "itemsuivi_suppress", { itemsuivi_id: itemsuivi_id });
-  // Clear items and rebuild:
-  $("ul.listdebouches li.itemsuivi").remove();
-  display_itemsuivis(0);
+async function itemsuivi_suppress(itemsuivi_id) {
+  const deleteUrl = `${SCO_URL}../api/etudiant/itemsuivi/${itemsuivi_id}/delete`;
+
+  try {
+    const response = await fetch(deleteUrl, {
+      method: 'POST'
+    });
+
+    if (response.ok) {
+      // Clear items and rebuild:
+      $("ul.listdebouches li.itemsuivi").remove();
+      display_itemsuivis(0);
+    }
+  } catch (error) {
+    console.error('Error deleting itemsuivi:', error);
+  }
 }
diff --git a/app/views/scolar.py b/app/views/scolar.py
index 68bab7b4c98c3053130146811daf0b22c92e767e..59cdba4727347a9e26bbfc2ab732d47d5ef3a56a 100644
--- a/app/views/scolar.py
+++ b/app/views/scolar.py
@@ -719,46 +719,6 @@ sco_publish(
 )
 
 
-# Debouche / devenir etudiant
-sco_publish(
-    "/itemsuivi_suppress",
-    sco_debouche.itemsuivi_suppress,
-    Permission.EtudChangeAdr,
-    methods=["GET", "POST"],
-)
-sco_publish(
-    "/itemsuivi_create",
-    sco_debouche.itemsuivi_create,
-    Permission.EtudChangeAdr,
-    methods=["GET", "POST"],
-)
-sco_publish(
-    "/itemsuivi_set_date",
-    sco_debouche.itemsuivi_set_date,
-    Permission.EtudChangeAdr,
-    methods=["GET", "POST"],
-)
-sco_publish(
-    "/itemsuivi_set_situation",
-    sco_debouche.itemsuivi_set_situation,
-    Permission.EtudChangeAdr,
-    methods=["GET", "POST"],
-)
-sco_publish(
-    "/itemsuivi_list_etud", sco_debouche.itemsuivi_list_etud, Permission.ScoView
-)
-sco_publish("/itemsuivi_tag_list", sco_debouche.itemsuivi_tag_list, Permission.ScoView)
-sco_publish(
-    "/itemsuivi_tag_search", sco_debouche.itemsuivi_tag_search, Permission.ScoView
-)
-sco_publish(
-    "/itemsuivi_tag_set",
-    sco_debouche.itemsuivi_tag_set,
-    Permission.EtudChangeAdr,
-    methods=["GET", "POST"],
-)
-
-
 @bp.route("/doAddAnnotation", methods=["GET", "POST"])
 @scodoc
 @permission_required(Permission.EtudAddAnnotations)