From 6eb3921774abd3740e6096e73739e5efc2473f7c Mon Sep 17 00:00:00 2001
From: ilona <ilona@scodoc.org>
Date: Tue, 6 Aug 2024 22:30:30 +0200
Subject: [PATCH] API: modimpl and formsemestre inscription/desinscription

---
 app/api/__init__.py                         |   3 +-
 app/api/formsemestres.py                    | 105 +++++++++++++-------
 app/api/moduleimpl.py                       |  61 +++++++++++-
 app/models/__init__.py                      |   6 ++
 app/models/formsemestre.py                  |  12 +--
 app/models/moduleimpls.py                   |   2 +-
 app/models/notes.py                         |   8 +-
 app/scodoc/sco_formsemestre_inscriptions.py |  11 +-
 app/scodoc/sco_moduleimpl_inscriptions.py   |  35 +++----
 tests/api/setup_test_api.py                 |  19 +++-
 tests/api/test_api_exceptions.py            |  51 ++++++++++
 tests/api/test_api_formsemestre.py          |  41 ++++++++
 tools/create_api_map.py                     |   6 +-
 13 files changed, 282 insertions(+), 78 deletions(-)
 create mode 100644 tests/api/test_api_exceptions.py

diff --git a/app/api/__init__.py b/app/api/__init__.py
index 8a205424..a098d7e9 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -7,7 +7,7 @@ from flask_json import as_json
 from flask import Blueprint
 from flask import current_app, g, request
 from flask_login import current_user
-from app import db
+from app import db, log
 from app.decorators import permission_required
 from app.scodoc import sco_utils as scu
 from app.scodoc.sco_exceptions import AccessDenied, ScoException
@@ -47,6 +47,7 @@ def api_permission_required(permission):
 @api_bp.errorhandler(404)
 def api_error_handler(e):
     "erreurs API => json"
+    log(f"api_error_handler: {e}")
     return scu.json_error(404, message=str(e))
 
 
diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py
index 7dc8ccf1..7834c4cb 100644
--- a/app/api/formsemestres.py
+++ b/app/api/formsemestres.py
@@ -41,6 +41,10 @@ from app.models import (
 from app.models.formsemestre import GROUPS_AUTO_ASSIGNMENT_DATA_MAX
 from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json
 from app.scodoc import sco_edt_cal
+from app.scodoc.sco_formsemestre_inscriptions import (
+    do_formsemestre_inscription_with_modules,
+    do_formsemestre_desinscription,
+)
 from app.scodoc import sco_groups
 from app.scodoc.sco_permissions import Permission
 from app.scodoc.sco_utils import ModuleType
@@ -64,10 +68,7 @@ def formsemestre_get(formsemestre_id: int):
     -------
     /formsemestre/1
     """
-    query = FormSemestre.query.filter_by(id=formsemestre_id)
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     return formsemestre.to_dict_api()
 
 
@@ -400,12 +401,7 @@ def bulletins(formsemestre_id: int, version: str = "long"):
     -------
     /formsemestre/1/bulletins
     """
-    query = FormSemestre.query.filter_by(id=formsemestre_id)
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    formsemestre: FormSemestre = query.first()
-    if formsemestre is None:
-        return json_error(404, "formsemestre non trouve")
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     app.set_sco_dept(formsemestre.departement.acronym)
 
     data = []
@@ -432,10 +428,7 @@ def formsemestre_programme(formsemestre_id: int):
     -------
     /formsemestre/1/programme
     """
-    query = FormSemestre.query.filter_by(id=formsemestre_id)
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     ues = formsemestre.get_ues()
     m_list = {
         ModuleType.RESSOURCE: [],
@@ -508,10 +501,7 @@ def formsemestre_etudiants(
     /formsemestre/1/etudiants/query;
 
     """
-    query = FormSemestre.query.filter_by(id=formsemestre_id)
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     if with_query:
         etat = request.args.get("etat")
         if etat is not None:
@@ -543,6 +533,63 @@ def formsemestre_etudiants(
     return sorted(etuds, key=itemgetter("sort_key"))
 
 
+@bp.post("/formsemestre/<int:formsemestre_id>/etudid/<int:etudid>/inscrit")
+@api_web_bp.post("/formsemestre/<int:formsemestre_id>/etudid/<int:etudid>/inscrit")
+@login_required
+@scodoc
+@permission_required(Permission.EtudInscrit)
+@as_json
+def formsemestre_etud_inscrit(formsemestre_id: int, etudid: int):
+    """Inscrit l'étudiant à ce formsemestre et TOUS ses modules STANDARDS
+    (donc sauf les modules bonus sport).
+
+    DATA
+    ----
+    ```json
+    {
+        "dept_id" : int,  # le département
+        "etape" : string, # optionnel: l'étape Apogée d'inscription
+        "group_ids" : [int], # optionnel: liste des groupes où inscrire l'étudiant (doivent exister)
+    }
+    ```
+    """
+    data = request.get_json(force=True) if request.data else {}
+    dept_id = data.get("dept_id", g.scodoc_dept_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id)
+    app.set_sco_dept(formsemestre.departement.acronym)
+    etud = Identite.get_etud(etudid)
+
+    group_ids = data.get("group_ids", [])
+    etape = data.get("etape", None)
+    do_formsemestre_inscription_with_modules(
+        formsemestre.id, etud.id, dept_id=dept_id, etape=etape, group_ids=group_ids
+    )
+    app.log(f"formsemestre_etud_inscrit: {etud} inscrit à {formsemestre}")
+    return (
+        FormSemestreInscription.query.filter_by(
+            formsemestre_id=formsemestre.id, etudid=etud.id
+        )
+        .first()
+        .to_dict()
+    )
+
+
+@bp.post("/formsemestre/<int:formsemestre_id>/etudid/<int:etudid>/desinscrit")
+@api_web_bp.post("/formsemestre/<int:formsemestre_id>/etudid/<int:etudid>/desinscrit")
+@login_required
+@scodoc
+@permission_required(Permission.EtudInscrit)
+@as_json
+def formsemestre_etud_desinscrit(formsemestre_id: int, etudid: int):
+    """Désinscrit l'étudiant de ce formsemestre et TOUS ses modules"""
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    app.set_sco_dept(formsemestre.departement.acronym)
+    etud = Identite.get_etud(etudid)
+    do_formsemestre_desinscription(etud.id, formsemestre.id)
+    app.log(f"formsemestre_etud_desinscrit: {etud} désinscrit de {formsemestre}")
+    return {"status": "ok"}
+
+
 @bp.route("/formsemestre/<int:formsemestre_id>/etat_evals")
 @api_web_bp.route("/formsemestre/<int:formsemestre_id>/etat_evals")
 @login_required
@@ -649,10 +696,7 @@ def formsemestre_resultat(formsemestre_id: int):
         return json_error(API_CLIENT_ERROR, "invalid format specification")
     convert_values = format_spec != "raw"
 
-    query = FormSemestre.query.filter_by(id=formsemestre_id)
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     app.set_sco_dept(formsemestre.departement.acronym)
     res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
     # Ajoute le groupe de chaque partition,
@@ -690,10 +734,7 @@ def formsemestre_resultat(formsemestre_id: int):
 @as_json
 def groups_get_auto_assignment(formsemestre_id: int):
     """Rend les données stockées par `groups_save_auto_assignment`."""
-    query = FormSemestre.query.filter_by(id=formsemestre_id)
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     response = make_response(formsemestre.groups_auto_assignment_data or b"")
     response.headers["Content-Type"] = scu.JSON_MIMETYPE
     return response
@@ -713,11 +754,7 @@ def groups_save_auto_assignment(formsemestre_id: int):
     """Enregistre les données, associées à ce formsemestre.
     Usage réservé aux fonctions de gestion des groupes, ne pas utiliser ailleurs.
     """
-    query = FormSemestre.query.filter_by(id=formsemestre_id)
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
-
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     if not formsemestre.can_change_groups():
         return json_error(403, "non autorisé (can_change_groups)")
 
@@ -726,6 +763,7 @@ def groups_save_auto_assignment(formsemestre_id: int):
     formsemestre.groups_auto_assignment_data = request.data
     db.session.add(formsemestre)
     db.session.commit()
+    return {"status": "ok"}
 
 
 @bp.route("/formsemestre/<int:formsemestre_id>/edt")
@@ -746,10 +784,7 @@ def formsemestre_edt(formsemestre_id: int):
     group_ids : string (optionnel) filtre sur les groupes ScoDoc.
     show_modules_titles: show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
     """
-    query = FormSemestre.query.filter_by(id=formsemestre_id)
-    if g.scodoc_dept:
-        query = query.filter_by(dept_id=g.scodoc_dept_id)
-    formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     group_ids = request.args.getlist("group_ids", int)
     show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
     return sco_edt_cal.formsemestre_edt_dict(
diff --git a/app/api/moduleimpl.py b/app/api/moduleimpl.py
index b5dea29b..0ee6b226 100644
--- a/app/api/moduleimpl.py
+++ b/app/api/moduleimpl.py
@@ -16,12 +16,15 @@ from flask_json import as_json
 from flask_login import login_required
 
 import app
+from app import db
 from app.api import api_bp as bp, api_web_bp
 from app.api import api_permission_required as permission_required
 from app.decorators import scodoc
-from app.models import ModuleImpl
-from app.scodoc import sco_liste_notes
+from app.models import Identite, ModuleImpl, ModuleImplInscription
+from app.scodoc import sco_cache, sco_liste_notes
+from app.scodoc.sco_moduleimpl import do_moduleimpl_inscrit_etuds
 from app.scodoc.sco_permissions import Permission
+from app.scodoc.sco_utils import json_error
 
 
 @bp.route("/moduleimpl/<int:moduleimpl_id>")
@@ -63,6 +66,60 @@ def moduleimpl_inscriptions(moduleimpl_id: int):
     return [i.to_dict() for i in modimpl.inscriptions]
 
 
+@bp.post("/moduleimpl/<int:moduleimpl_id>/etudid/<int:etudid>/inscrit")
+@api_web_bp.post("/moduleimpl/<int:moduleimpl_id>/etudid/<int:etudid>/inscrit")
+@login_required
+@scodoc
+@permission_required(Permission.ScoView)
+@as_json
+def moduleimpl_etud_inscrit(moduleimpl_id: int, etudid: int):
+    """Inscrit l'étudiant à ce moduleimpl.
+
+    SAMPLES
+    -------
+    /moduleimpl/1/etudid/2/inscrit
+    """
+    modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
+    if not modimpl.can_change_inscriptions():
+        return json_error(403, "opération non autorisée")
+    etud = Identite.get_etud(etudid)
+    do_moduleimpl_inscrit_etuds(modimpl.id, modimpl.formsemestre_id, [etud.id])
+    app.log(f"moduleimpl_etud_inscrit: {etud} inscrit à {modimpl}")
+    return (
+        ModuleImplInscription.query.filter_by(moduleimpl_id=modimpl.id, etudid=etud.id)
+        .first()
+        .to_dict()
+    )
+
+
+@bp.post("/moduleimpl/<int:moduleimpl_id>/etudid/<int:etudid>/desinscrit")
+@api_web_bp.post("/moduleimpl/<int:moduleimpl_id>/etudid/<int:etudid>/desinscrit")
+@login_required
+@scodoc
+@permission_required(Permission.ScoView)
+@as_json
+def moduleimpl_etud_desinscrit(moduleimpl_id: int, etudid: int):
+    """Désinscrit l'étudiant de ce moduleimpl.
+
+    SAMPLES
+    -------
+    /moduleimpl/1/etudid/2/desinscrit
+    """
+    modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
+    if not modimpl.can_change_inscriptions():
+        return json_error(403, "opération non autorisée")
+    etud = Identite.get_etud(etudid)
+    inscription = ModuleImplInscription.query.filter_by(
+        etudid=etud.id, moduleimpl_id=modimpl.id
+    ).first()
+    if inscription:
+        db.session.delete(inscription)
+        db.session.commit()
+        sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id)
+        app.log(f"moduleimpl_etud_desinscrit: {etud} inscrit à {modimpl}")
+    return {"status": "ok"}
+
+
 @bp.route("/moduleimpl/<int:moduleimpl_id>/notes")
 @api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/notes")
 @login_required
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 43363570..0cf5498d 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -111,6 +111,12 @@ class ScoDocModel(db.Model):
         db.session.add(self)
         return modified
 
+    def to_dict(self) -> dict:
+        "dict"
+        d = dict(self.__dict__)
+        d.pop("_sa_instance_state", None)
+        return d
+
     def edit_from_form(self, form) -> bool:
         """Generic edit method for updating model instance.
         True if modification.
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 8ac11d39..6be8fc61 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -1318,7 +1318,7 @@ notes_formsemestre_responsables = db.Table(
 )
 
 
-class FormSemestreEtape(db.Model):
+class FormSemestreEtape(models.ScoDocModel):
     """Étape Apogée associée au semestre"""
 
     __tablename__ = "notes_formsemestre_etapes"
@@ -1349,7 +1349,7 @@ class FormSemestreEtape(db.Model):
         return ApoEtapeVDI(self.etape_apo)
 
 
-class FormationModalite(db.Model):
+class FormationModalite(models.ScoDocModel):
     """Modalités de formation, utilisées pour la présentation
     (grouper les semestres, générer des codes, etc.)
     """
@@ -1400,7 +1400,7 @@ class FormationModalite(db.Model):
             raise
 
 
-class FormSemestreUECoef(db.Model):
+class FormSemestreUECoef(models.ScoDocModel):
     """Coef des UE capitalisees arrivant dans ce semestre"""
 
     __tablename__ = "notes_formsemestre_uecoef"
@@ -1441,7 +1441,7 @@ class FormSemestreUEComputationExpr(db.Model):
     computation_expr = db.Column(db.Text())
 
 
-class FormSemestreCustomMenu(db.Model):
+class FormSemestreCustomMenu(models.ScoDocModel):
     """Menu custom associe au semestre"""
 
     __tablename__ = "notes_formsemestre_custommenu"
@@ -1457,7 +1457,7 @@ class FormSemestreCustomMenu(db.Model):
     idx = db.Column(db.Integer, default=0, server_default="0")  #  rang dans le menu
 
 
-class FormSemestreInscription(db.Model):
+class FormSemestreInscription(models.ScoDocModel):
     """Inscription à un semestre de formation"""
 
     __tablename__ = "notes_formsemestre_inscription"
@@ -1503,7 +1503,7 @@ class FormSemestreInscription(db.Model):
             } {('etape="'+self.etape+'"') if self.etape else ''}>"""
 
 
-class NotesSemSet(db.Model):
+class NotesSemSet(models.ScoDocModel):
     """semsets: ensemble de formsemestres pour exports Apogée"""
 
     __tablename__ = "notes_semset"
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index cb4c8a3b..281d28c3 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -325,7 +325,7 @@ notes_modules_enseignants = db.Table(
 # XXX il manque probablement une relation pour gérer cela
 
 
-class ModuleImplInscription(db.Model):
+class ModuleImplInscription(ScoDocModel):
     """Inscription à un module  (etudiants,moduleimpl)"""
 
     __tablename__ = "notes_moduleimpl_inscription"
diff --git a/app/models/notes.py b/app/models/notes.py
index 91401e8f..63e3b23f 100644
--- a/app/models/notes.py
+++ b/app/models/notes.py
@@ -56,7 +56,7 @@ class BulAppreciations(models.ScoDocModel):
         return safehtml.html_to_safe_html(self.comment or "")
 
 
-class NotesNotes(db.Model):
+class NotesNotes(models.ScoDocModel):
     """Une note"""
 
     __tablename__ = "notes_notes"
@@ -75,12 +75,6 @@ class NotesNotes(db.Model):
     date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
     uid = db.Column(db.Integer, db.ForeignKey("user.id"))
 
-    def to_dict(self) -> dict:
-        "dict"
-        d = dict(self.__dict__)
-        d.pop("_sa_instance_state", None)
-        return d
-
     def __repr__(self):
         "pour debug"
         from app.models.evaluations import Evaluation
diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py
index bbe9f4f3..3a7d095d 100644
--- a/app/scodoc/sco_formsemestre_inscriptions.py
+++ b/app/scodoc/sco_formsemestre_inscriptions.py
@@ -287,6 +287,8 @@ def do_formsemestre_inscription_with_modules(
     group_ids = group_ids or []
     if isinstance(group_ids, int):
         group_ids = [group_ids]
+    # Check that all groups exist before creating the inscription
+    groups = [GroupDescr.query.get_or_404(group_id) for group_id in group_ids]
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id)
     # inscription au semestre
     args = {"formsemestre_id": formsemestre_id, "etudid": etudid}
@@ -303,14 +305,13 @@ def do_formsemestre_inscription_with_modules(
     # 1- inscrit au groupe 'tous'
     group_id = sco_groups.get_default_group(formsemestre_id)
     sco_groups.set_group(etudid, group_id)
-    gdone = {group_id: 1}  # empeche doublons
+    gdone = {group_id}  # empeche doublons
 
     # 2- inscrit aux groupes
-    for group_id in group_ids:
-        if group_id and group_id not in gdone:
-            _ = GroupDescr.query.get_or_404(group_id)
+    for group in groups:
+        if group.id not in gdone:
             sco_groups.set_group(etudid, group_id)
-            gdone[group_id] = 1
+            gdone.add(group_id)
 
     # Inscription à tous les modules de ce semestre
     for modimpl in formsemestre.modimpls:
diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py
index bc78942f..cbd77ff9 100644
--- a/app/scodoc/sco_moduleimpl_inscriptions.py
+++ b/app/scodoc/sco_moduleimpl_inscriptions.py
@@ -132,14 +132,15 @@ def moduleimpl_inscriptions_edit(
 
     if (partitionIdx==-1) {
       for (var i =nb_inputs_to_skip; i < elems.length; i++) {
-         elems[i].checked=check;
+        elems[i].checked=check;
       }
     } else {
      for (var i =nb_inputs_to_skip; i < elems.length; i++) {
-       var cells = elems[i].parentNode.parentNode.getElementsByTagName("td")[partitionIdx].childNodes;
-       if (cells.length && cells[0].nodeValue == groupName) {
-          elems[i].checked=check;
-       }
+        let tds = elems[i].parentNode.parentNode.getElementsByTagName("td");
+        var cells = tds[partitionIdx].childNodes;
+        if (cells.length && cells[0].nodeValue == groupName) {
+            elems[i].checked=check;
+        }
      }
     }
     }
@@ -178,19 +179,19 @@ def moduleimpl_inscriptions_edit(
             else:
                 checked = ""
             H.append(
-                f"""<tr><td class="etud"><input type="checkbox" name="etudids:list" value="{etud['etudid']}" {checked}>"""
-            )
-            H.append(
-                f"""<a class="discretelink etudinfo" href="{
-                    url_for(
-                        "scolar.fiche_etud",
-                        scodoc_dept=g.scodoc_dept,
-                        etudid=etud["etudid"],
-                    )
-                    }" id="{etud['etudid']}">{etud['nomprenom']}</a>"""
+                f"""<tr><td class="etud">
+                        <input type="checkbox" name="etudids:list" value="{etud['etudid']}" {checked}>
+                            <a class="discretelink etudinfo" href="{
+                                url_for(
+                                    "scolar.fiche_etud",
+                                    scodoc_dept=g.scodoc_dept,
+                                    etudid=etud["etudid"],
+                                )
+                                }" id="{etud['etudid']}">{etud['nomprenom']}</a>
+                        </input>
+                        </td>
+                """
             )
-            H.append("""</input></td>""")
-
             groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre.id)
             for partition in partitions:
                 if partition["partition_name"]:
diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py
index 3d7ca461..efc2b779 100644
--- a/tests/api/setup_test_api.py
+++ b/tests/api/setup_test_api.py
@@ -56,6 +56,9 @@ class APIError(Exception):
         self.message = message
         self.payload = payload or {}
 
+    def __str__(self):
+        return f"APIError: {self.message} payload={self.payload}"
+
 
 def get_auth_headers(user, password) -> dict:
     "Demande de jeton, dict à utiliser dans les en-têtes de requêtes http"
@@ -130,11 +133,17 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None, raw=False):
 
 
 def POST(
-    path: str, data: dict = {}, headers: dict = None, errmsg=None, dept=None, raw=False
+    path: str,
+    data: dict = None,
+    headers: dict = None,
+    errmsg=None,
+    dept=None,
+    raw=False,
 ):
     """Post
     Decode réponse en json, sauf si raw.
     """
+    data = data or {}
     if dept:
         url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path
     else:
@@ -147,7 +156,13 @@ def POST(
         timeout=SCO_TEST_API_TIMEOUT,
     )
     if r.status_code != 200:
-        raise APIError(errmsg or f"erreur status={r.status_code} !", r.json())
+        try:
+            payload = r.json()
+        except requests.exceptions.JSONDecodeError:
+            payload = r.text
+        raise APIError(
+            errmsg or f"erreur url={url} status={r.status_code} !", payload=payload
+        )
     return r if raw else r.json()  # decode la reponse JSON
 
 
diff --git a/tests/api/test_api_exceptions.py b/tests/api/test_api_exceptions.py
new file mode 100644
index 00000000..49f96da6
--- /dev/null
+++ b/tests/api/test_api_exceptions.py
@@ -0,0 +1,51 @@
+"""Test API exceptions
+"""
+
+import json
+import requests
+
+import pytest
+from tests.api.setup_test_api import (
+    API_URL,
+    CHECK_CERTIFICATE,
+    api_headers,
+)
+from app.scodoc import sco_utils as scu
+
+
+def test_exceptions(api_headers):
+    """
+    Vérifie que les exceptions de l'API sont toutes en JSON.
+    """
+    # Une requete sur une url inexistante ne passe pas par les blueprints API
+    # et est donc en HTML
+    r = requests.get(
+        f"{API_URL}/mmm/non/existant/mmm",
+        headers=api_headers,
+        verify=CHECK_CERTIFICATE,
+        timeout=scu.SCO_TEST_API_TIMEOUT,
+    )
+    assert r.status_code == 404
+    assert r.headers["Content-Type"] == "text/html; charset=utf-8"
+
+    # Une requete d'un objet non existant est en JSON
+    r = requests.get(
+        f"{API_URL}/formsemestre/999999",
+        headers=api_headers,
+        verify=CHECK_CERTIFICATE,
+        timeout=scu.SCO_TEST_API_TIMEOUT,
+    )
+    assert r.status_code == 404
+    assert r.headers["Content-Type"] == "application/json"
+    assert r.json()
+
+    # Une requête API sans autorisation est en JSON
+    r = requests.post(
+        f"{API_URL}/formsemestre/1/etudid/1/inscrit",
+        headers=api_headers,
+        verify=CHECK_CERTIFICATE,
+        timeout=scu.SCO_TEST_API_TIMEOUT,
+    )
+    assert r.status_code == 401
+    assert r.headers["Content-Type"] == "application/json"
+    assert r.json()
diff --git a/tests/api/test_api_formsemestre.py b/tests/api/test_api_formsemestre.py
index 8b1dc015..23d55ccc 100644
--- a/tests/api/test_api_formsemestre.py
+++ b/tests/api/test_api_formsemestre.py
@@ -29,6 +29,7 @@ from tests.api.setup_test_api import (
     CHECK_CERTIFICATE,
     GET,
     api_headers,
+    api_admin_headers,
 )
 
 from tests.api.tools_test_api import (
@@ -585,6 +586,46 @@ def test_formsemestre_etudiants(api_headers):
     assert r_error_defaillants.status_code == 404
 
 
+def test_formsemestre_inscriptions(api_admin_headers):
+    """
+    Route: /formsemestre/<int:formsemestre_id>/etudid/<int:etudid>/inscrit
+    """
+    dept_id = 1
+    formsemestre_id = 1
+    etudid = 20  # pas déjà inscrit au semestre 1
+    # -- Inscription
+    r = requests.post(
+        f"{API_URL}/formsemestre/{formsemestre_id}/etudid/{etudid}/inscrit",
+        data=json.dumps({"dept_id": dept_id}),
+        headers=api_admin_headers,
+        verify=CHECK_CERTIFICATE,
+        timeout=scu.SCO_TEST_API_TIMEOUT,
+    )
+    assert r.status_code == 200
+    inscription = r.json()
+    assert inscription["formsemestre_id"] == formsemestre_id
+    assert inscription["etudid"] == etudid
+    assert inscription["etat"] == "I"
+    # -- Désincription
+    r = requests.post(
+        f"{API_URL}/formsemestre/{formsemestre_id}/etudid/{etudid}/desinscrit",
+        headers=api_admin_headers,
+        verify=CHECK_CERTIFICATE,
+        timeout=scu.SCO_TEST_API_TIMEOUT,
+    )
+    assert r.status_code == 200
+
+    ### ERROR ###
+    etudid_inexistant = 165165165165165165165
+    r_error = requests.post(
+        f"{API_URL}/formsemestre/{formsemestre_id}/etudid/{etudid_inexistant}/inscrit",
+        headers=api_admin_headers,
+        verify=CHECK_CERTIFICATE,
+        timeout=scu.SCO_TEST_API_TIMEOUT,
+    )
+    assert r_error.status_code == 404
+
+
 def test_formsemestre_programme(api_headers):
     """
     Route:  /formsemestre/1/programme
diff --git a/tools/create_api_map.py b/tools/create_api_map.py
index 92456420..2e68b79f 100644
--- a/tools/create_api_map.py
+++ b/tools/create_api_map.py
@@ -968,6 +968,8 @@ def gen_api_doc(app, endpoint_start="api."):
     with open(fname, "w", encoding="utf-8") as f:
         f.write(mdpage)
     print(
-        "La documentation API a été générée avec succès. "
-        f"Vous pouvez la consulter à l'adresse suivante : {fname}"
+        f"""La documentation API a été générée avec succès.
+Vous pouvez la consulter à l'adresse suivante : {fname}.
+Vous pouvez maintenant générer les samples avec `tools/test_api.sh --make-samples`.
+"""
     )
-- 
GitLab