diff --git a/app/__init__.py b/app/__init__.py
index 2ecd0d33a..eb9d1980a 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -250,6 +250,7 @@ def create_app(config_class=DevConfig):
     from app.views import users_bp
     from app.views import absences_bp
     from app.api import bp as api_bp
+    from app.api import api_web_bp as api_web_bp
 
     # https://scodoc.fr/ScoDoc
     app.register_blueprint(scodoc_bp)
@@ -264,6 +265,8 @@ def create_app(config_class=DevConfig):
         absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences"
     )
     app.register_blueprint(api_bp, url_prefix="/ScoDoc/api")
+    app.register_blueprint(api_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/apiweb")
+
     scodoc_log_formatter = LogRequestFormatter(
         "[%(asctime)s] %(sco_user)s@%(remote_addr)s requested %(url)s\n"
         "%(levelname)s: %(message)s"
@@ -351,7 +354,7 @@ def create_app(config_class=DevConfig):
     return app
 
 
-def set_sco_dept(scodoc_dept: str):
+def set_sco_dept(scodoc_dept: str, open_cnx=True):
     """Set global g object to given dept and open db connection if needed"""
     # Check that dept exists
     try:
@@ -362,7 +365,7 @@ def set_sco_dept(scodoc_dept: str):
         raise ScoValueError(f"Invalid dept: {scodoc_dept}")
     g.scodoc_dept = scodoc_dept  # l'acronyme
     g.scodoc_dept_id = dept.id  # l'id
-    if not hasattr(g, "db_conn"):
+    if open_cnx and not hasattr(g, "db_conn"):
         ndb.open_db_connection()
     if not hasattr(g, "stored_get_formsemestre"):
         g.stored_get_formsemestre = {}
diff --git a/app/api/__init__.py b/app/api/__init__.py
index 9be4d3b69..45cad23ba 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -5,6 +5,7 @@ from flask import Blueprint
 from flask import request
 
 bp = Blueprint("api", __name__)
+api_web_bp = Blueprint("apiweb", __name__)
 
 
 def requested_format(default_format="json", allowed_formats=None):
diff --git a/app/api/auth.py b/app/api/auth.py
index 8662312aa..22492620c 100644
--- a/app/api/auth.py
+++ b/app/api/auth.py
@@ -33,7 +33,9 @@ from flask_login import current_user
 
 from app import log
 from app.auth.models import User
+from app.api import bp, api_web_bp
 from app.api.errors import error_response
+from app.decorators import scodoc, permission_required
 
 basic_auth = HTTPBasicAuth()
 token_auth = HTTPTokenAuth()
@@ -42,7 +44,7 @@ token_auth = HTTPTokenAuth()
 @basic_auth.verify_password
 def verify_password(username, password):
     "Verify password for this user"
-    user = User.query.filter_by(user_name=username).first()
+    user: User = User.query.filter_by(user_name=username).first()
     if user and user.check_password(password):
         g.current_user = user
         # note: est aussi basic_auth.current_user()
@@ -85,7 +87,6 @@ def token_permission_required(permission):
     def decorator(f):
         @wraps(f)
         def decorated_function(*args, **kwargs):
-            # token_auth.login_required()
             current_user = basic_auth.current_user()
             if not current_user or not current_user.has_permission(permission, None):
                 if current_user:
@@ -95,6 +96,8 @@ def token_permission_required(permission):
                 log(message)
                 # raise werkzeug.exceptions.Forbidden(description=message)
                 return error_response(403, message=None)
+            if not hasattr(g, "scodoc_dept"):
+                g.scodoc_dept = None
             return f(*args, **kwargs)
 
         # return decorated_function(token_auth.login_required())
@@ -125,3 +128,21 @@ def permission_required_api(permission_web, permission_api):
         return decorated_function
 
     return decorator
+
+
+def web_publish(route, function, permission, methods=("GET",)):
+    """Declare a route for a python function protected by permission
+    using web http cookie
+    """
+    return api_web_bp.route(route, methods=methods)(
+        scodoc(permission_required(permission)(function))
+    )
+
+
+def api_publish(route, function, permission, methods=("GET",)):
+    """Declare a route for a python function protected by permission
+    using API token
+    """
+    return bp.route(route, methods=methods)(
+        token_auth.login_required(token_permission_required(permission)(function))
+    )
diff --git a/app/api/etudiants.py b/app/api/etudiants.py
index fc5de0f71..01b86b5e2 100644
--- a/app/api/etudiants.py
+++ b/app/api/etudiants.py
@@ -15,8 +15,9 @@ from sqlalchemy import or_
 import app
 from app.api import bp
 from app.api.errors import error_response
-from app.api.auth import permission_required_api
+from app.api.auth import permission_required_api, api_publish, web_publish
 from app.api import tools
+
 from app.models import Departement, FormSemestreInscription, FormSemestre, Identite
 from app.scodoc import sco_bulletins
 from app.scodoc import sco_groups
@@ -24,6 +25,17 @@ from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
 from app.scodoc.sco_permissions import Permission
 
 
+def api_function(arg: int):
+    """Une fonction quelconque de l'API"""
+    # u = current_user
+    # dept = g.scodoc_dept  # peut être None si accès API
+    return jsonify({"current_user": current_user.to_dict(), "dept": g.scodoc_dept})
+
+
+api_publish("/api_function/<int:arg>", api_function, Permission.APIView)
+web_publish("/api_function/<int:arg>", api_function, Permission.ScoView)
+
+
 @bp.route("/etudiants/courants", defaults={"long": False})
 @bp.route("/etudiants/courants/long", defaults={"long": True})
 @permission_required_api(Permission.ScoView, Permission.APIView)
@@ -164,6 +176,9 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
     Dans 99% des cas, la liste contient un seul étudiant, mais si l'étudiant a
     été inscrit dans plusieurs départements, on a plusieurs objets (1 par dept.).
     """
+    allowed_depts = current_user.get_depts_with_permission(
+        Permission.APIView | Permission.ScoView
+    )
     if etudid is not None:
         query = Identite.query.filter_by(id=etudid)
     elif nip is not None:
@@ -175,7 +190,11 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
             404,
             message="parametre manquant",
         )
-
+    if not None in allowed_depts:
+        # restreint aux départements autorisés:
+        etuds = etuds.join(Departement).filter(
+            or_(Departement.acronym == acronym for acronym in allowed_depts)
+        )
     return jsonify([etud.to_dict_bul(include_urls=False) for etud in query])
 
 
diff --git a/app/api/partitions.py b/app/api/partitions.py
index 20b92ce30..c55ef9113 100644
--- a/app/api/partitions.py
+++ b/app/api/partitions.py
@@ -182,6 +182,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
     for g in groups:
         g.etuds.remove(etud)
     db.session.commit()
+    app.set_sco_dept(partition.formsemestre.departement.acronym)
     sco_cache.invalidate_formsemestre(partition.formsemestre_id)
     return jsonify({"partition_id": partition_id, "etudid": etudid})
 
@@ -319,6 +320,7 @@ def formsemestre_order_partitions(formsemestre_id: int):
         p.numero = numero
         db.session.add(p)
     db.session.commit()
+    app.set_sco_dept(formsemestre.departement.acronym)
     sco_cache.invalidate_formsemestre(formsemestre_id)
     return jsonify(formsemestre.to_dict())
 
@@ -343,6 +345,7 @@ def partition_order_groups(partition_id: int):
         group.numero = numero
         db.session.add(group)
     db.session.commit()
+    app.set_sco_dept(partition.formsemestre.departement.acronym)
     sco_cache.invalidate_formsemestre(partition.formsemestre_id)
     return jsonify(partition.to_dict(with_groups=True))
 
diff --git a/app/api/tools.py b/app/api/tools.py
index d2a4d1d6c..9da42fabc 100644
--- a/app/api/tools.py
+++ b/app/api/tools.py
@@ -6,11 +6,13 @@
 """ScoDoc 9 API : outils
 """
 
-from sqlalchemy import desc
+from flask_login import current_user
+from sqlalchemy import desc, or_
 
 from app import models
 from app.api.errors import error_response
-from app.models import Identite, Admission
+from app.models import Departement, Identite, Admission
+from app.scodoc.sco_permissions import Permission
 
 
 def get_etud(etudid=None, nip=None, ine=None) -> models.Identite:
@@ -24,8 +26,15 @@ def get_etud(etudid=None, nip=None, ine=None) -> models.Identite:
 
     Return None si étudiant inexistant.
     """
+    allowed_depts = current_user.get_depts_with_permission(
+        Permission.APIView | Permission.ScoView
+    )
+
     if etudid is not None:
-        return Identite.query.get(etudid)
+        etud: Identite = Identite.query.get(etudid)
+        if (None in allowed_depts) or etud.departement.acronym in allowed_depts:
+            return etud
+        return None  # accès interdit => pas d'étudiant
 
     if nip is not None:
         query = Identite.query.filter_by(code_nip=nip)
@@ -36,4 +45,9 @@ def get_etud(etudid=None, nip=None, ine=None) -> models.Identite:
             404,
             message="parametre manquant",
         )
+    if None not in allowed_depts:
+        # restreint aux départements autorisés:
+        etuds = etuds.join(Departement).filter(
+            or_(Departement.acronym == acronym for acronym in allowed_depts)
+        )
     return query.join(Admission).order_by(desc(Admission.annee)).first()
diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py
index 28e46cfc5..8cdeed439 100644
--- a/app/scodoc/sco_cache.py
+++ b/app/scodoc/sco_cache.py
@@ -53,16 +53,18 @@ import traceback
 
 from flask import g
 
+import app
 from app import log
 from app.scodoc import notesdb as ndb
 from app.scodoc import sco_utils as scu
+from app.scodoc.sco_exceptions import ScoException
 
 CACHE = None  # set in app.__init__.py
 
 
 class ScoDocCache:
     """Cache for ScoDoc objects.
-    keys are prefixed by the current departement.
+    keys are prefixed by the current departement: g.scodoc_dept MUST be set.
     """
 
     timeout = None  # ttl, infinite by default
@@ -240,6 +242,15 @@ def invalidate_formsemestre(  # was inval_cache(formsemestre_id=None, pdfonly=Fa
     if getattr(g, "defer_cache_invalidation", 0) > 0:
         g.sem_to_invalidate.add(formsemestre_id)
         return
+    if getattr(g, "scodoc_dept") is None:
+        # appel via API ou tests sans dept:
+        formsemestre = None
+        if formsemestre_id:
+            formsemestre = FormSemestre.query.get(formsemestre_id)
+        if formsemestre is None:
+            raise ScoException("invalidate_formsemestre: departement must be set")
+        app.set_sco_dept(formsemestre.departement.acronym, open_cnx=False)
+
     if formsemestre_id is None:
         # clear all caches
         log(
diff --git a/app/views/notes.py b/app/views/notes.py
index 111019d82..b1514ef5a 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -153,34 +153,6 @@ def sco_publish(route, function, permission, methods=("GET",)):
     )
 
 
-# ---------------------  Quelques essais élémentaires:
-# @bp.route("/essai")
-# @scodoc
-# @permission_required(Permission.ScoView)
-# @scodoc7func
-# def essai():
-#     return essai_()
-
-
-# def essai_():
-#     return "<html><body><h2>essai !</h2><p>%s</p></body></html>" % ()
-
-
-# def essai2():
-#     err_page = f"""<h3>Destruction du module impossible car il est utilisé dans des semestres existants !</h3>
-#     <p class="help">Il faut d'abord supprimer le semestre. Mais il est peut être préférable de
-#     laisser ce programme intact et d'en créer une nouvelle version pour la modifier.
-#     </p>
-#     <a href="url_for('notes.ue_table', scodoc-dept=g.scodoc_dept, formation_id='XXX')">reprendre</a>
-#     """
-#     raise ScoGenError(err_page)
-#     # raise ScoGenError("une erreur banale")
-#     return essai_("sans request")
-
-
-# sco_publish("/essai2", essai2, Permission.ScoImplement)
-
-
 # --------------------------------------------------------------------
 #
 #    Notes/ methods
diff --git a/tests/api/exemple-api-basic.py b/tests/api/exemple-api-basic.py
index b03b9ff64..c889909a3 100644
--- a/tests/api/exemple-api-basic.py
+++ b/tests/api/exemple-api-basic.py
@@ -169,6 +169,7 @@ pp(partitions)
 POST_JSON(f"/group/5559/delete")
 POST_JSON(f"/group/5327/edit", data={"group_name": "TDXXX"})
 
+# --------- XXX à passer en dans les tests unitaires
 # 1- Crée une partition, puis la change de nom
 js = POST_JSON(
     f"/formsemestre/{formsemestre_id}/partition/create",
@@ -192,8 +193,10 @@ POST_JSON(f"/group/{group_id}/set_etudiant/{etudid}")
 # 4- retire du groupe
 POST_JSON(f"/group/{group_id}/remove_etudiant/{etudid}")
 
-# Suppression
+# 5- Suppression
 POST_JSON(f"/partition/{partition_id}/delete")
+# ------
+
 
 #
 POST_JSON(