diff --git a/app/api/etudiants.py b/app/api/etudiants.py
index 10fb562b265d829811aada5b002228f7518680b8..9b21db43458dc5b27529456e7acde621ef02af54 100755
--- a/app/api/etudiants.py
+++ b/app/api/etudiants.py
@@ -414,9 +414,16 @@ def bulletin(
     if version == "pdf":
         version = "long"
         pdf = True
-    if version not in scu.BULLETINS_VERSIONS_BUT:
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    if version not in (
+        scu.BULLETINS_VERSIONS_BUT
+        if formsemestre.formation.is_apc()
+        else scu.BULLETINS_VERSIONS
+    ):
         return json_error(404, "version invalide")
-    formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
+    if formsemestre.bul_hide_xml and pdf:
+        return json_error(403, "bulletin non disponible")
+        # note: la version json est réduite si bul_hide_xml
     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")
diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py
index 4c9c40241ccdd6d9cbbc5ea7521a97dbd5e0768c..1c3ddac2d086bae1e5f1d257456cb9e4f1ef9f46 100644
--- a/app/api/formsemestres.py
+++ b/app/api/formsemestres.py
@@ -12,7 +12,7 @@ from operator import attrgetter, itemgetter
 from flask import g, make_response, request
 from flask_json import as_json
 from flask_login import current_user, login_required
-
+import sqlalchemy as sa
 import app
 from app import db
 from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
@@ -171,6 +171,44 @@ def formsemestres_query():
     ]
 
 
+@bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
+@api_web_bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
+@scodoc
+@permission_required(Permission.EditFormSemestre)
+@as_json
+def formsemestre_edit(formsemestre_id: int):
+    """Modifie les champs d'un formsemestre."""
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    args = request.get_json(force=True)  # may raise 400 Bad Request
+    editable_keys = {
+        "semestre_id",
+        "titre",
+        "date_debut",
+        "date_fin",
+        "edt_id",
+        "etat",
+        "modalite",
+        "gestion_compensation",
+        "bul_hide_xml",
+        "block_moyennes",
+        "block_moyenne_generale",
+        "mode_calcul_moyennes",
+        "gestion_semestrielle",
+        "bul_bgcolor",
+        "resp_can_edit",
+        "resp_can_change_ens",
+        "ens_can_edit_eval",
+        "elt_sem_apo",
+        "elt_annee_apo",
+    }
+    formsemestre.from_dict({k: v for (k, v) in args.items() if k in editable_keys})
+    try:
+        db.session.commit()
+    except sa.exc.StatementError as exc:
+        return json_error(404, f"invalid argument(s): {exc.args[0]}")
+    return formsemestre.to_dict_api()
+
+
 @bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
 @bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
 @api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@@ -468,13 +506,13 @@ def etat_evals(formsemestre_id: int):
                 date_mediane = notes_sorted[len(notes_sorted) // 2].date
 
             eval_dict["saisie_notes"] = {
-                "datetime_debut": date_debut.isoformat()
-                if date_debut is not None
-                else None,
+                "datetime_debut": (
+                    date_debut.isoformat() if date_debut is not None else None
+                ),
                 "datetime_fin": date_fin.isoformat() if date_fin is not None else None,
-                "datetime_mediane": date_mediane.isoformat()
-                if date_mediane is not None
-                else None,
+                "datetime_mediane": (
+                    date_mediane.isoformat() if date_mediane is not None else None
+                ),
             }
 
             list_eval.append(eval_dict)
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index ba08fed489c6a1ea49cb9f1a99f5b555b5382144..00bac5c203d1f133b7006fbad11ab13f978b8782 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -25,6 +25,7 @@ from sqlalchemy import func
 import app.scodoc.sco_utils as scu
 from app import db, log
 from app.auth.models import User
+from app import models
 from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
 from app.models.but_refcomp import (
     ApcParcours,
@@ -54,7 +55,7 @@ from app.scodoc.sco_vdi import ApoEtapeVDI
 GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024  # bytes
 
 
-class FormSemestre(db.Model):
+class FormSemestre(models.ScoDocModel):
     """Mise en oeuvre d'un semestre de formation"""
 
     __tablename__ = "notes_formsemestre"
@@ -84,7 +85,7 @@ class FormSemestre(db.Model):
     bul_hide_xml = db.Column(
         db.Boolean(), nullable=False, default=False, server_default="false"
     )
-    "ne publie pas le bulletin XML ou JSON"
+    "ne publie pas le bulletin sur l'API"
     block_moyennes = db.Column(
         db.Boolean(), nullable=False, default=False, server_default="false"
     )
@@ -191,7 +192,8 @@ class FormSemestre(db.Model):
     def get_formsemestre(
         cls, formsemestre_id: int | str, dept_id: int = None
     ) -> "FormSemestre":
-        """FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant"""
+        """FormSemestre ou 404, cherche uniquement dans le département spécifié
+        ou le courant (g.scodoc_dept)"""
         if not isinstance(formsemestre_id, int):
             try:
                 formsemestre_id = int(formsemestre_id)
@@ -251,6 +253,7 @@ class FormSemestre(db.Model):
         d.pop("_sa_instance_state", None)
         d.pop("groups_auto_assignment_data", None)
         d["annee_scolaire"] = self.annee_scolaire()
+        d["bul_hide_xml"] = self.bul_hide_xml
         if self.date_debut:
             d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
             d["date_debut_iso"] = self.date_debut.isoformat()
diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py
index a7848b39e172557ac08a5fba5e673348eee2ed4c..42e2a910e500ccd43ba1a2842e70207f16f79d8f 100644
--- a/app/scodoc/sco_bulletins_json.py
+++ b/app/scodoc/sco_bulletins_json.py
@@ -114,10 +114,8 @@ def formsemestre_bulletinetud_published_dict(
     if etudid not in nt.identdict:
         abort(404, "etudiant non inscrit dans ce semestre")
     d = {"type": "classic", "version": "0"}
-    if (not sem["bul_hide_xml"]) or force_publishing:
-        published = True
-    else:
-        published = False
+
+    published = (not formsemestre.bul_hide_xml) or force_publishing
     if xml_nodate:
         docdate = ""
     else:
diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py
index f564b9d1569df7ae227f063f507096763a5748bf..65ac47b5099c49b1b9d2d92ca12c33207e1bd591 100644
--- a/tests/api/setup_test_api.py
+++ b/tests/api/setup_test_api.py
@@ -79,10 +79,11 @@ if pytest:
         return get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN)
 
 
-def GET(path: str, headers: dict = None, errmsg=None, dept=None):
-    """Get and returns as JSON
+def GET(path: str, headers: dict = None, errmsg=None, dept=None, raw=False):
+    """Get and optionaly returns as JSON
     Special case for non json result (image or pdf):
         return Content-Disposition string (inline or attachment)
+    If raw, return a requests.Response
     """
     if dept:
         url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path
@@ -101,10 +102,11 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None):
         raise APIError(
             errmsg or f"""erreur status={reply.status_code} !""", reply.json()
         )
-
+    if raw:
+        return reply
     if reply.headers.get("Content-Type", None) == "application/json":
         return reply.json()  # decode la reponse JSON
-    elif reply.headers.get("Content-Type", None) in [
+    if reply.headers.get("Content-Type", None) in [
         "image/jpg",
         "image/png",
         "application/pdf",
diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py
index 58d380e47ab8b9c07ee4f0b4fcc5a563e69939c5..4ee134b4017e35cd887f787efd0871e8022f9be1 100644
--- a/tests/api/test_api_etudiants.py
+++ b/tests/api/test_api_etudiants.py
@@ -823,16 +823,13 @@ def test_etudiant_bulletin_semestre(api_headers):
     assert r.content[:4] == b"%PDF"
 
     ######## Bulletin BUT format intermédiaire en pdf #########
-    r = requests.get(
-        API_URL
-        + "/etudiant/ine/"
-        + str(INE)
-        + "/formsemestre/1/bulletin/selectedevals/pdf",
+    r = GET(
+        f"/etudiant/ine/{INE}/formsemestre/1/bulletin/selectedevals/pdf",
         headers=api_headers,
-        verify=CHECK_CERTIFICATE,
-        timeout=scu.SCO_TEST_API_TIMEOUT,
+        raw=True,  # get response, do not convert to json
     )
     assert r.status_code == 200
+    assert r.headers.get("Content-Type", None) == "application/pdf"
     assert r.content[:4] == b"%PDF"
 
     ################### LONG + PDF #####################
@@ -869,37 +866,17 @@ def test_etudiant_bulletin_semestre(api_headers):
     ################### SHORT #####################
 
     ######### Test etudid #########
-    r = requests.get(
-        API_URL + "/etudiant/etudid/" + str(ETUDID) + "/formsemestre/1/bulletin/short",
-        headers=api_headers,
-        verify=CHECK_CERTIFICATE,
-        timeout=scu.SCO_TEST_API_TIMEOUT,
+    bul = GET(
+        f"/etudiant/etudid/{ETUDID}/formsemestre/1/bulletin/short", headers=api_headers
     )
-    assert r.status_code == 200
-    bul = r.json()
     assert len(bul) == 14  # HARDCODED
 
     ######### Test code nip #########
-
-    r = requests.get(
-        API_URL + "/etudiant/nip/" + str(NIP) + "/formsemestre/1/bulletin/short",
-        headers=api_headers,
-        verify=CHECK_CERTIFICATE,
-        timeout=scu.SCO_TEST_API_TIMEOUT,
-    )
-    assert r.status_code == 200
-    bul = r.json()
+    bul = GET(f"/etudiant/nip/{NIP}/formsemestre/1/bulletin/short", headers=api_headers)
     assert len(bul) == 14  # HARDCODED
 
     ######### Test code ine #########
-    r = requests.get(
-        API_URL + "/etudiant/ine/" + str(INE) + "/formsemestre/1/bulletin/short",
-        headers=api_headers,
-        verify=CHECK_CERTIFICATE,
-        timeout=scu.SCO_TEST_API_TIMEOUT,
-    )
-    assert r.status_code == 200
-    bul = r.json()
+    bul = GET(f"/etudiant/ine/{INE}/formsemestre/1/bulletin/short", headers=api_headers)
     assert len(bul) == 14  # HARDCODED
 
     ################### SHORT + PDF #####################
@@ -941,6 +918,20 @@ def test_etudiant_bulletin_semestre(api_headers):
     )
     assert r.status_code == 404
 
+    ### -------- Modifie publication bulletins
+    admin_header = get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN)
+    formsemestre = POST_JSON(
+        f"/formsemestre/{1}/edit", {"bul_hide_xml": True}, headers=admin_header
+    )
+    assert formsemestre["bul_hide_xml"] is True
+    # La forme utilisée par la passerelle:
+    bul = GET(f"/etudiant/nip/{NIP}/formsemestre/1/bulletin", headers=api_headers)
+    assert len(bul) == 9  # version raccourcie, longueur HARDCODED
+    # TODO forme utilisée par la passerelle pour les PDF
+    # /ScoDoc/api/etudiant/nip/12345/formsemestre/123/bulletin/long/pdf/nosi
+    # TODO voir forme utilisée par ScoDoc en interne:
+    # formsemestre_bulletinetud?formsemestre_id=1263&etudid=16387
+
 
 def test_etudiant_groups(api_headers):
     """