From f7a50e5afef22c7fc9a2c30476c145ebd126f8c6 Mon Sep 17 00:00:00 2001 From: william Date: Sat, 5 Sep 2026 10:00:57 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20un=20suivi=20xAPI=20optionnel=20(bolt-?= =?UTF-8?q?on)=20au=20paquet=20SCORM=20export=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greffe l'envoi de statements xAPI vers un LRS configurable par le créateur du jeu, en parallèle du reporting SCORM existant : réglages stockés en base (_meta), formulaire dans la modale d'export, injection dans le paquet exporté. Relié aussi bien à l'action de flow "Modifier un score/statut" qu'au parcours quête/quiz (qui alimentait déjà le SCORM classique via un chemin séparé). L'export ne se lance plus automatiquement à l'ouverture de la modale, pour laisser le temps d'enregistrer les réglages xAPI avant de générer le paquet. --- db/__init__.py | 2 + db/games/get_xapi_settings.py | 23 ++++ db/games/set_xapi_settings.py | 24 +++++ publish/build_scorm_package.py | 26 +++++ routes/publish/__init__.py | 1 + routes/publish/xapi_settings.py | 33 ++++++ static/js/play/dialogue-box-controller.js | 6 ++ static/js/play/offline/apply-actions.js | 5 + static/js/play/offline/xapi-client.js | 122 ++++++++++++++++++++++ templates/base.html | 86 ++++++++++++++- templates/play.html | 7 ++ tests/test_export_scorm.py | 24 +++++ tests/test_xapi_settings.py | 66 ++++++++++++ 13 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 db/games/get_xapi_settings.py create mode 100644 db/games/set_xapi_settings.py create mode 100644 routes/publish/xapi_settings.py create mode 100644 static/js/play/offline/xapi-client.js create mode 100644 tests/test_xapi_settings.py diff --git a/db/__init__.py b/db/__init__.py index 25f3a850..83579465 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -34,6 +34,8 @@ from .games.create_game import create_game from .games.update_game_name import update_game_name from .games.delete_game import delete_game from .games.move_game import move_game +from .games.get_xapi_settings import get_xapi_settings +from .games.set_xapi_settings import set_xapi_settings from .games.game_type_catalog import ( ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE, get_onboarding_type, get_onboarding_type_raw, set_onboarding_type, diff --git a/db/games/get_xapi_settings.py b/db/games/get_xapi_settings.py new file mode 100644 index 00000000..7e7cb1bf --- /dev/null +++ b/db/games/get_xapi_settings.py @@ -0,0 +1,23 @@ +from ..connection import connect + + +def get_xapi_settings(slug): + """Réglages xAPI de CE jeu (voir set_xapi_settings.py — stockés dans + _meta, même convention que 'name'/'onboarding_type', voir + db/games/game_type_catalog.py) : URL du LRS (Learning Record Store) + + identifiants, saisis une seule fois par le créateur du jeu (voir + routes/publish/xapi_settings.py) plutôt que négociés à chaque + lancement (jamais le protocole cmi5 complet, voir publish/ + build_scorm_package.py::build_scorm_zip). Chaînes vides si non + configuré — `endpoint` vide signifie "xAPI désactivé pour ce jeu".""" + conn = connect(slug) + rows = conn.execute( + "SELECT key, value FROM _meta WHERE key IN ('xapi_lrs_endpoint', 'xapi_lrs_login', 'xapi_lrs_password')" + ).fetchall() + conn.close() + values = {row["key"]: row["value"] for row in rows} + return { + "endpoint": values.get("xapi_lrs_endpoint") or "", + "login": values.get("xapi_lrs_login") or "", + "password": values.get("xapi_lrs_password") or "", + } diff --git a/db/games/set_xapi_settings.py b/db/games/set_xapi_settings.py new file mode 100644 index 00000000..0c63315b --- /dev/null +++ b/db/games/set_xapi_settings.py @@ -0,0 +1,24 @@ +from ..connection import connect + + +def set_xapi_settings(slug, endpoint, login, password=None): + """Enregistre les réglages xAPI de CE jeu (voir get_xapi_settings.py). + `password=None` (champ laissé vide côté formulaire, voir + routes/publish/xapi_settings.py) laisse le mot de passe déjà + enregistré INCHANGÉ — jamais écrasé par une chaîne vide, pour ne pas + obliger à le retaper à chaque modification de l'URL/du login, et pour + que la route GET puisse ne jamais renvoyer sa valeur au navigateur + (juste un booléen "déjà configuré").""" + conn = connect(slug) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",) + ) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",) + ) + if password is not None: + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,) + ) + conn.commit() + conn.close() diff --git a/publish/build_scorm_package.py b/publish/build_scorm_package.py index ed41f43c..20c7437b 100644 --- a/publish/build_scorm_package.py +++ b/publish/build_scorm_package.py @@ -238,6 +238,26 @@ def build_scorm_zip(slug): # seule personne à la fois sur son poste, comme /game//play — # jamais d'état "par joueur" à distinguer hors ligne. payload = screens.full_game_payload(slug) + + # xAPI (voir db/games/get_xapi_settings.py, routes/publish/ + # xapi_settings.py) : bolt-on volontairement simple, JAMAIS le + # protocole cmi5 complet (pas de négociation de jeton au lancement) — + # les identifiants du LRS sont saisis une fois par le créateur du jeu + # et embarqués tels quels dans le paquet exporté, lu par + # static/js/play/offline/xapi-client.js. `endpoint` vide (réglage + # jamais renseigné) désactive entièrement xAPI pour ce jeu : le + # SCORM classique (scorm-api.js) continue de fonctionner à l'identique + # dans tous les cas. + xapi_settings = db.get_xapi_settings(slug) + xapi_config = None + if xapi_settings["endpoint"]: + xapi_config = { + "endpoint": xapi_settings["endpoint"], + "login": xapi_settings["login"], + "password": xapi_settings["password"], + "activity_id": f"urn:forge-engine:game:{slug}", + "activity_name": game["name"], + } # gameData.icon_data_uris (voir forgeRenderIcone, # static/js/play/offline/render-special-widgets.js) : ajouté au # payload AVANT le rendu pour qu'il fasse partie du même payload_json @@ -253,6 +273,12 @@ def build_scorm_zip(slug): "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), offline_mode=True, asset_url=lambda filename: "static/" + filename, scorm_api_wrapper_url="static/js/play/offline/scorm-api.js", + # " "<\\/" : endpoint/login sont saisis librement par le + # créateur du jeu (voir routes/publish/xapi_settings.py) — sans + # cet échappement, une valeur contenant "" refermerait + # prématurément la balise {% if scorm_api_wrapper_url %}{% endif %} + {% endif %} diff --git a/tests/test_export_scorm.py b/tests/test_export_scorm.py index 7f46d9e2..c8ca32a4 100644 --- a/tests/test_export_scorm.py +++ b/tests/test_export_scorm.py @@ -8,6 +8,7 @@ plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite automatisée).""" import zipfile +import db from tests.test_auth import anon_client # noqa: F401 @@ -59,6 +60,29 @@ def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game): assert "adlcp:scormtype=\"sco\"" in manifest +def test_export_scorm_index_html_has_no_xapi_config_when_unconfigured(client, game): + resp = client.post(f"/game/{game}/export-scorm") + import io + with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: + html = zf.read("index.html").decode("utf-8") + assert "window.FORGE_XAPI_CONFIG = null;" in html + # Toujours chargé, même sans réglage (no-op tant que la config est null). + assert 'src="static/js/play/offline/xapi-client.js"' in html + + +def test_export_scorm_index_html_embeds_the_xapi_config_when_set(client, game): + db.set_xapi_settings(game, "https://lrs.example.com/xapi/", "monlogin", "monmotdepasse") + resp = client.post(f"/game/{game}/export-scorm") + import io + with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: + html = zf.read("index.html").decode("utf-8") + assert "window.FORGE_XAPI_CONFIG = {" in html + assert "https://lrs.example.com/xapi/" in html + assert "monlogin" in html + assert "monmotdepasse" in html + assert 'src="static/js/play/offline/xapi-client.js"' in html + + def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client): """Même garde d'accès que /game//publish (core/auth_guard.py) — aucune vérification supplémentaire écrite pour cette route.""" diff --git a/tests/test_xapi_settings.py b/tests/test_xapi_settings.py new file mode 100644 index 00000000..4294e01e --- /dev/null +++ b/tests/test_xapi_settings.py @@ -0,0 +1,66 @@ +"""Réglages xAPI (bolt-on, voir db/games/get_xapi_settings.py, +routes/publish/xapi_settings.py) — le mot de passe ne doit JAMAIS +ressortir en clair (seulement un booléen `password_set`), et un POST +sans mot de passe ne doit pas écraser celui déjà enregistré.""" +from tests.test_auth import anon_client # noqa: F401 + + +def test_xapi_settings_get_defaults_to_empty(client, game): + resp = client.get(f"/game/{game}/xapi-settings") + assert resp.status_code == 200 + body = resp.get_json() + assert body == {"endpoint": "", "login": "", "password_set": False} + + +def test_xapi_settings_post_then_get_round_trip(client, game): + resp = client.post(f"/game/{game}/xapi-settings", json={ + "endpoint": "https://lrs.example.com/xapi/", + "login": "monlogin", + "password": "monmotdepasse", + }) + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True} + + resp = client.get(f"/game/{game}/xapi-settings") + body = resp.get_json() + assert body["endpoint"] == "https://lrs.example.com/xapi/" + assert body["login"] == "monlogin" + assert body["password_set"] is True + assert "password" not in body + + +def test_xapi_settings_post_with_blank_password_keeps_the_existing_one(client, game): + client.post(f"/game/{game}/xapi-settings", json={ + "endpoint": "https://lrs.example.com/xapi/", + "login": "monlogin", + "password": "monmotdepasse", + }) + + resp = client.post(f"/game/{game}/xapi-settings", json={ + "endpoint": "https://lrs.example.com/xapi/v2/", + "login": "monlogin", + "password": "", + }) + assert resp.status_code == 200 + + import db + settings = db.get_xapi_settings(game) + assert settings["endpoint"] == "https://lrs.example.com/xapi/v2/" + assert settings["password"] == "monmotdepasse" + + +def test_xapi_settings_route_is_isolated_like_other_game_routes(anon_client): + """Même garde d'accès que /game//publish (core/auth_guard.py) — + aucune vérification supplémentaire écrite pour cette route.""" + from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project + + _register(anon_client, "xapisettingsisolation@example.com") + _confirm_2fa(anon_client) + _complete_onboarding(anon_client) + try: + resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/xapi-settings") + assert resp.status_code == 403 + resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/xapi-settings", json={}) + assert resp.status_code == 403 + finally: + _cleanup_project("xapisettingsisolation@example.com")