diff --git a/core/auth_guard.py b/core/auth_guard.py index 5f60bbd7..3a455682 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -31,9 +31,20 @@ _PUBLIC_ENDPOINTS = { "forgot_password", "reset_password", "game_play_public", "runtime_payload_public", "flow_node_run_data_public", "flow_node_run_variable_public", + "flow_node_run_score_public", "flow_node_run_status_public", "flow_node_run_add_row_public", } +# Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion +# requise, mais indépendants d'avoir un projet ou non (voir onboarding +# guidé ci-dessous) : un compte pas encore passé par /onboarding doit +# quand même pouvoir changer son mot de passe, son email, ou supprimer +# son compte. +_REACHABLE_WITHOUT_PROJECT = { + "onboarding_new", "profile", "profile_update_name", "profile_update_email", + "profile_regenerate_recovery_codes", "profile_change_password", "profile_delete", +} + @app.before_request def _require_login_and_enforce_project_isolation(): @@ -61,9 +72,22 @@ def _require_login_and_enforce_project_isolation(): if user["role"] != "admin": if endpoint in ("index", "games_new") and user.get("project_slug"): return redirect(url_for("game_dashboard", slug=user["project_slug"])) + # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : un + # compte "user" fraîchement inscrit n'a plus de projet créé + # d'office (voir routes/auth/register_2fa.py) — tant qu'il n'en a + # pas, seules ces routes restent atteignables (toutes exigent déjà + # une connexion, absentes de _PUBLIC_ENDPOINTS). + if not user.get("project_slug") and endpoint not in _REACHABLE_WITHOUT_PROJECT: + return redirect(url_for("onboarding_new")) slug = request.view_args.get("slug") if request.view_args else None if slug is not None and slug != user.get("project_slug"): abort(403) + # Type d'onboarding "restreint" (quiz/embranchement/rpg, voir + # db/games/game_type_catalog.py) : game_dashboard reste atteignable + # comme pour "custom", mais routes/games/game_dashboard.py y rend + # une version SIMPLIFIÉE (juste les écrans, pas d'onglets Objets/ + # Éléments de jeu/Variables) — rien à bloquer ici, la restriction + # se fait au niveau du rendu, pas de l'accès à la route. if endpoint == "game_delete": # Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en # recréer un une fois supprimé (games_new le renvoie toujours diff --git a/db/__init__.py b/db/__init__.py index a6a19fc2..e409d25e 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -20,7 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un vrai INSERT dans cette table. """ -from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES +from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS from .slugify import slugify from .table_name_for import table_name_for from .game_dir import game_dir @@ -36,6 +36,11 @@ 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.game_type_catalog import ( + ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE, + get_onboarding_type, get_onboarding_type_raw, set_onboarding_type, + is_restricted, +) from .definitions.list_definitions import list_definitions from .definitions.get_definition import get_definition @@ -66,6 +71,10 @@ from .global_vars.update_global_variable import update_global_variable from .global_vars.delete_global_variable import delete_global_variable from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id +from .scoring.get_score import get_score +from .scoring.set_score_value import set_score_value +from .scoring.set_status import set_status + from .custom_events.list_custom_events import list_custom_events from .custom_events.get_custom_event import get_custom_event from .custom_events.create_custom_event import create_custom_event @@ -73,9 +82,12 @@ from .custom_events.update_custom_event import update_custom_event __all__ = [ "PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", + "SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status", "slugify", "table_name_for", "game_dir", "db_path", "connect", "list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game", "is_public_played", "set_public_played", "get_game_type", "DEFAULT_GAME_TYPE", + "ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type", + "get_onboarding_type_raw", "set_onboarding_type", "is_restricted", "list_definitions", "get_definition", "create_definition", "rename_definition", "definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "delete_definition", diff --git a/db/games/game_meta.py b/db/games/game_meta.py index 38043ae6..40eee84b 100644 --- a/db/games/game_meta.py +++ b/db/games/game_meta.py @@ -1,6 +1,6 @@ from ..connection import connect from .is_public_played import is_public_played -from .get_game_type import get_game_type +from .game_type_catalog import get_onboarding_type def game_meta(slug): @@ -14,8 +14,11 @@ def game_meta(slug): # ligne" en a besoin pour afficher son état actuel), sans avoir à # appeler is_public_played(slug) séparément dans chaque route. "is_public_played": is_public_played(slug), - # Type de jeu (fondations multi-éditeurs) : "document" (éditeur - # générique) ou "jeu_2d" (éditeur de scène) — voir - # get_game_type.py et routes/screens/screen_edit.py. - "game_type": get_game_type(slug), + # Onboarding guidé (voir game_type_catalog.py) : utilisé par + # templates/base.html pour cacher le lien "Tableau de bord" à un + # compte "restreint" (quiz/embranchement/rpg). + "onboarding_type": get_onboarding_type(slug), + # Pas de "game_type" ici : depuis la fusion des moteurs (voir + # screens/screens_repo/ensure_schema.py), "document"/"jeu_2d" est + # une propriété PAR ÉCRAN (screen["kind"]), plus du projet. } diff --git a/db/games/game_type_catalog.py b/db/games/game_type_catalog.py new file mode 100644 index 00000000..438a9dd7 --- /dev/null +++ b/db/games/game_type_catalog.py @@ -0,0 +1,98 @@ +"""Onboarding guidé (voir routes/onboarding/onboarding_new.py) : les 4 +choix proposés à CHAQUE création de projet — PAS un 3e type de moteur +(voir screens/screens_repo/ensure_schema.py, fusion des moteurs : +"document"/"jeu_2d" est une propriété PAR ÉCRAN), une classification +produit posée par-dessus. Chaque choix décide du "kind" du tout premier +écran créé (screen_kind ci-dessous, aussi imposé à tout écran ajouté +ensuite — voir routes/screens/screens_new.py) et si le tableau de bord +complet (game_dashboard, objets/éléments de jeu/variables) reste +accessible ensuite, ou seulement sa version simplifiée (juste les écrans +en cartes — voir routes/games/game_dashboard.py, restricted). + +_meta['onboarding_type'] (une ligne _meta par projet, même convention que +is_public_played/l'ancien game_type) retient CE choix — jamais lu par le +rendu jouable, seulement par le routage (routes/games/game_dashboard.py, +routes/screens/screens_new.py).""" +import db + +DEFAULT_ONBOARDING_TYPE = "custom" + +ONBOARDING_TYPES = { + "quiz": { + "label": "Quiz", + "tagline": "Questions, réponses, score.", + "icon": "🎯", + "description": "Gérer des questions/réponses, avec score.", + "screen_kind": "document", + "restricted": True, + }, + "embranchement": { + "label": "Jeu à embranchement / escape game", + "tagline": "Chaque choix a une conséquence.", + "icon": "🗝️", + "description": ( + "Former à travers le jeu sur des concepts métier avec des " + "conséquences observables ; réaliser des escape games sous " + "forme d'énigmes." + ), + "screen_kind": "document", + "restricted": True, + }, + "rpg": { + "label": "Jeu RPG", + "tagline": "Ton monde, ton héros, ton scénario.", + "icon": "🗺️", + "description": "Réaliser des serious games scénarisés sous forme de RPG 2D sur navigateur.", + "screen_kind": "jeu_2d", + "restricted": True, + }, + "custom": { + "label": "Créer mon jeu de A à Z", + "tagline": "Toute la puissance du moteur, sans limite.", + "icon": "⚡", + "description": ( + "Pour les utilisateurs avec des notions de dev/jeu vidéo : " + "accès à toute la puissance du moteur." + ), + "screen_kind": "document", + "restricted": False, + }, +} + + +def get_onboarding_type_raw(slug): + """None si _meta['onboarding_type'] est absent — distingue un projet + JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par + l'admin via "+ Nouveau jeu") d'un projet explicitement "custom".""" + conn = db.connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'onboarding_type'").fetchone() + conn.close() + return row["value"] if row else None + + +def get_onboarding_type(slug): + return get_onboarding_type_raw(slug) or DEFAULT_ONBOARDING_TYPE + + +def set_onboarding_type(slug, onboarding_type): + conn = db.connect(slug) + conn.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)", + (onboarding_type,), + ) + conn.commit() + conn.close() + + +def is_restricted(onboarding_type_or_slug): + """Accepte directement une clé de ONBOARDING_TYPES, ou un slug de + projet (résout alors son onboarding_type d'abord) — pratique aussi + bien pour core/auth_guard.py (a le slug) que pour un test unitaire (a + déjà la clé).""" + onboarding_type = ( + onboarding_type_or_slug if onboarding_type_or_slug in ONBOARDING_TYPES + else get_onboarding_type(onboarding_type_or_slug) + ) + return ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"] + + diff --git a/routes/__init__.py b/routes/__init__.py index 54de4fd4..004bb4e4 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play, scenes +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks, public_play, scenes, onboarding diff --git a/routes/auth/login_2fa.py b/routes/auth/login_2fa.py index c5006a9b..f5373f6a 100644 --- a/routes/auth/login_2fa.py +++ b/routes/auth/login_2fa.py @@ -52,5 +52,10 @@ def login_2fa(): return redirect(next_url) if user["role"] == "admin": return redirect(url_for("index")) + # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : + # un compte "user" peut ne pas encore avoir de projet du tout + # (inscription interrompue avant l'onboarding). + if not user.get("project_slug"): + return redirect(url_for("onboarding_new")) return redirect(url_for("game_dashboard", slug=user["project_slug"])) return render_template("auth/login_2fa.html", error=error) diff --git a/routes/auth/register_2fa.py b/routes/auth/register_2fa.py index 9c2d32ef..ddb05db4 100644 --- a/routes/auth/register_2fa.py +++ b/routes/auth/register_2fa.py @@ -1,7 +1,6 @@ from flask import redirect, render_template, request, session, url_for import auth -import db from core.flask_app import app @@ -28,16 +27,6 @@ def register_2fa(): else: auth.reset_failed_attempts(user_id) auth.confirm_totp(user_id) - # Un compte "user" (pas "admin") reçoit ICI son unique projet, - # nommé d'après son adresse email (voir create_user.py — le - # rôle est déjà tranché à la création : premier compte jamais - # créé = admin, tous les suivants = user) — un admin, lui, - # reste libre de créer ses jeux comme avant ("+ Nouveau jeu"), - # aucun projet ne lui est imposé d'office. - if user["role"] != "admin": - slug = db.create_game(f"Jeu de {user['prenom']}", slug_override=user["email"]) - auth.set_project_slug(user_id, slug) - user = auth.get_user_by_id(user_id) session.pop("pending_register_user_id", None) session["user_id"] = user_id # Générés ici, une seule fois, à l'instant même où la 2FA @@ -50,7 +39,14 @@ def register_2fa(): session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id) if user["role"] == "admin": return redirect(url_for("index")) - return redirect(url_for("game_dashboard", slug=user["project_slug"])) + # Un compte "user" (pas "admin") n'a plus de projet créé + # d'office ici — voir routes/onboarding/onboarding_new.py : il + # choisit d'abord un type de jeu et nomme son projet, PUIS + # auth.set_project_slug() y est appelé (core/auth_guard.py + # renvoie ici tout compte sans project_slug). Un admin, lui, + # reste libre de créer ses jeux comme avant ("+ Nouveau jeu"), + # aucun projet ne lui est imposé d'office. + return redirect(url_for("onboarding_new")) qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"]) return render_template( diff --git a/routes/games/game_dashboard.py b/routes/games/game_dashboard.py index bbb71a0e..a1c10fed 100644 --- a/routes/games/game_dashboard.py +++ b/routes/games/game_dashboard.py @@ -13,8 +13,42 @@ def game_dashboard(slug): accès direct à chaque élément — la barre de navigation du jeu (voir base.html) permet déjà de sauter directement vers chaque éditeur, cette page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes - liens.""" + liens. + + Onboarding guidé (voir db/games/game_type_catalog.py) : un compte + "restreint" (quiz/embranchement/rpg) voit ici une version SIMPLIFIÉE — + juste ses écrans, en cartes avec un aperçu de leur contenu, plus un + bouton "+ Ajouter un écran" qui ne demande PAS de type (imposé par le + projet : "jeu_2d" pour un RPG, "document" pour les autres — voir + routes/screens/screens_new.py). Objets/éléments de jeu réutilisables/ + variables restent réservés à "Créer mon jeu de A à Z", la seule + formule pensée pour la puissance complète du moteur.""" game = db.game_meta(slug) + if db.is_restricted(slug): + return _render_simple_dashboard(slug, game) + return _render_full_dashboard(slug, game) + + +def _render_simple_dashboard(slug, game): + screen_list = screens.list_screens(slug) + previews = {} + for s in screen_list: + if s["kind"] == "jeu_2d": + objects = screens.list_scene_objects(slug, s["id"]) + for o in objects: + o["rendered_html"] = screens.render_scene_object(o) + previews[s["id"]] = {"kind": "jeu_2d", "objects": objects} + else: + previews[s["id"]] = { + "kind": "document", + "elements": screens.list_elements(slug, s["id"]), + } + return render_template( + "game_dashboard_simple.html", game=game, screen_list=screen_list, previews=previews, + ) + + +def _render_full_dashboard(slug, game): definitions = db.list_definitions(slug) # Détail complet (avec ses champs) de chaque objet, pour le panneau # d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon diff --git a/routes/onboarding/__init__.py b/routes/onboarding/__init__.py new file mode 100644 index 00000000..3a6b157f --- /dev/null +++ b/routes/onboarding/__init__.py @@ -0,0 +1 @@ +from . import onboarding_new diff --git a/routes/onboarding/onboarding_new.py b/routes/onboarding/onboarding_new.py new file mode 100644 index 00000000..d0f04212 --- /dev/null +++ b/routes/onboarding/onboarding_new.py @@ -0,0 +1,66 @@ +from flask import g, redirect, render_template, request, url_for + +import auth +import db +import screens + +from core.flask_app import app + + +@app.route("/onboarding", methods=["GET", "POST"]) +def onboarding_new(): + """Point d'entrée UNIQUE pour créer un jeu — comportement SYSTÉMATIQUE + à chaque création, pas une formalité réservée à la toute première + inscription : chaque projet (celui d'un compte "user" comme n'importe + lequel des projets d'un admin) choisit son propre type ici. Prépare + aussi le futur multi-projet payant (plusieurs jeux de types + différents pour un même compte "user"). + + Un compte "user" fraîchement inscrit n'a plus de projet créé d'office + (voir routes/auth/register_2fa.py) : core/auth_guard.py le redirige + ici tant qu'il n'a pas encore de project_slug — et n'en aura jamais + qu'UN SEUL (voir core/auth_guard.py). Un admin, lui, n'a PAS de + project_slug dédié (illimité, voir create_user.py) : repasse ici à + volonté pour créer un projet de PLUS, jamais redirigé ailleurs. + + Choisit un type (voir db.ONBOARDING_TYPES) puis nomme son jeu — crée + le projet ET son premier écran (du "kind" correspondant), puis envoie + vers son tableau de bord (routes/games/game_dashboard.py y rend une + version SIMPLIFIÉE pour un type restreint — quiz/embranchement/rpg — + ou la version complète pour "custom", même route pour tous).""" + user = g.current_user + if user["role"] != "admin" and user.get("project_slug"): + # Retour en arrière après coup (l'onboarding a déjà été fait) — + # ne recrée rien, renvoie directement vers son tableau de bord. Ne + # s'applique PAS à un admin (illimité, jamais "déjà fait" une fois + # pour toutes). + return redirect(url_for("game_dashboard", slug=user["project_slug"])) + + error = None + if request.method == "POST": + onboarding_type = request.form.get("onboarding_type", "") + name = request.form.get("name", "").strip() + if onboarding_type not in db.ONBOARDING_TYPES: + error = "Choisis un type de jeu." + elif not name: + error = "Donne un nom à ton jeu." + else: + return redirect(_create_project_for_user(user, onboarding_type, name)) + + return render_template( + "onboarding/onboarding_new.html", + onboarding_types=db.ONBOARDING_TYPES, error=error, + ) + + +def _create_project_for_user(user, onboarding_type, name): + slug = db.create_game(name, owner_folder=db.slugify(user["email"])) + db.set_onboarding_type(slug, onboarding_type) + if user["role"] != "admin": + # project_slug = LE seul projet d'un compte "user" (voir + # core/auth_guard.py) — un admin n'utilise pas cette colonne, ses + # projets se retrouvent via db.list_games() comme avant. + auth.set_project_slug(user["id"], slug) + screen_kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"] + screens.create_screen(slug, name, kind=screen_kind) + return url_for("game_dashboard", slug=slug) diff --git a/routes/screens/screens_new.py b/routes/screens/screens_new.py index 7566471f..cab0bdc1 100644 --- a/routes/screens/screens_new.py +++ b/routes/screens/screens_new.py @@ -1,5 +1,6 @@ from flask import request, redirect, url_for +import db import screens from core.flask_app import app @@ -8,7 +9,19 @@ from core.flask_app import app @app.route("/game//screens/new", methods=["POST"]) def screens_new(slug): name = request.form.get("name", "").strip() + if db.is_restricted(slug): + # Dashboard simplifié (voir routes/games/game_dashboard.py) : pas + # de sélecteur de type dans son formulaire "+ Ajouter un écran" — + # imposé par le projet (jeu_2d pour un RPG, document pour les + # autres), même valeur pour TOUS ses écrans (voir + # db/games/game_type_catalog.py). + onboarding_type = db.get_onboarding_type(slug) + kind = db.ONBOARDING_TYPES[onboarding_type]["screen_kind"] + else: + kind = request.form.get("kind", "document") + if kind not in ("document", "jeu_2d"): + kind = "document" if name: - screen_id = screens.create_screen(slug, name) + screen_id = screens.create_screen(slug, name, kind=kind) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) return redirect(url_for("game_dashboard", slug=slug, tab="screens")) diff --git a/scripts/backfill_onboarding_type.py b/scripts/backfill_onboarding_type.py new file mode 100644 index 00000000..00b29fe6 --- /dev/null +++ b/scripts/backfill_onboarding_type.py @@ -0,0 +1,41 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : pose la clé +_meta['onboarding_type'] sur tout projet existant qui ne l'a pas encore +(créé avant l'existence de l'onboarding guidé — voir routes/onboarding/ +onboarding_new.py et db/games/game_type_catalog.py). Pas strictement +nécessaire : get_onboarding_type() retombe déjà sur DEFAULT_ONBOARDING_TYPE +("custom") pour tout projet sans cette clé — ce script rend juste ce choix +EXPLICITE dans les données plutôt qu'implicite, sans rien changer au +comportement observable. + +Marque "custom" tout projet trouvé sans onboarding_type (comportement +neutre : tableau de bord complet garanti visible, comme aujourd'hui) — +aucune distinction "jeu_2d"/RPG à faire ici, cette notion n'existe plus au +niveau projet depuis la fusion des moteurs (écran par écran, voir +screens/screens_repo/ensure_schema.py) : un projet historiquement "jeu_2d" +reste "custom" comme n'importe quel autre, ses écrans gardent +individuellement leur kind="jeu_2d". + + python scripts/backfill_onboarding_type.py +""" +import os +import sys + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, _BASE_DIR) + +import db # noqa: E402 + + +def backfill(): + games = db.list_games() + updated = 0 + for g in games: + if db.get_onboarding_type_raw(g["slug"]) is None: + db.set_onboarding_type(g["slug"], "custom") + updated += 1 + print(f"{g['slug']!r} -> 'custom'") + print(f"{updated}/{len(games)} projet(s) mis à jour.") + + +if __name__ == "__main__": + backfill() diff --git a/static/js/game_dashboard_simple.js b/static/js/game_dashboard_simple.js new file mode 100644 index 00000000..a053b6a8 --- /dev/null +++ b/static/js/game_dashboard_simple.js @@ -0,0 +1,15 @@ +// Dashboard simplifié (templates/game_dashboard_simple.html, voir +// routes/games/game_dashboard.py) — affiche/masque le petit formulaire +// "+ Ajouter un écran", même comportement que toggleDashCreate() du +// tableau de bord complet (static/js/screen_edit/tabs-and-blocks.js), +// repris ici pour ne pas dépendre de tout ce fichier (pensé pour l'éditeur +// complet, pas cette page). +function toggleDashCreate(tab) { + var bar = document.getElementById("dashCreate-" + tab); + if (!bar) return; + bar.style.display = bar.style.display === "none" ? "flex" : "none"; + if (bar.style.display === "flex") { + var firstInput = bar.querySelector("input, select"); + if (firstInput) firstInput.focus(); + } +} diff --git a/static/js/onboarding/onboarding_new.js b/static/js/onboarding/onboarding_new.js new file mode 100644 index 00000000..38b31703 --- /dev/null +++ b/static/js/onboarding/onboarding_new.js @@ -0,0 +1,65 @@ +// Onboarding guidé (templates/onboarding/onboarding_new.html) — interactions +// pures DOM, aucune logique métier ici (voir routes/onboarding/ +// onboarding_new.py pour la validation réelle, côté serveur). Deux +// "pages" (choix du type / nom du jeu) posées côte à côte dans +// #onboardingTrack, révélées par un défilement horizontal animé en CSS +// (voir .onboardingTrack--step2 dans static/style.css) plutôt qu'un vrai +// changement de page — pas de rechargement, pas d'aller-retour serveur +// tant que le formulaire n'est pas soumis pour de vrai. + +function forgeOnboardingGoToStep(step) { + var track = document.getElementById("onboardingTrack"); + if (track) track.classList.toggle("onboardingTrack--step2", step === 2); + if (step === 2) { + var nameField = document.getElementById("onboardingNameInput"); + // Après la fin de l'animation de défilement (voir la durée de + // transition posée sur .onboardingTrack) — focus immédiat plus tôt + // ferait sauter le scroll du champ à moitié visible. + if (nameField) setTimeout(function () { nameField.focus(); }, 500); + } +} + +function forgeOnboardingChooseType(card) { + document.querySelectorAll(".onboardingCard").forEach(function (c) { + c.classList.toggle("is-selected", c === card); + }); + var hiddenType = document.getElementById("onboardingTypeHidden"); + if (hiddenType) hiddenType.value = card.dataset.type || ""; + forgeOnboardingUpdateSubmitState(); + forgeOnboardingGoToStep(2); +} + +function forgeOnboardingUpdateSubmitState() { + var nameField = document.getElementById("onboardingNameInput"); + var hiddenType = document.getElementById("onboardingTypeHidden"); + var submitBtn = document.getElementById("onboardingSubmitBtn"); + if (!nameField || !hiddenType || !submitBtn) return; + submitBtn.disabled = !(hiddenType.value && nameField.value.trim()); +} + +function forgeOnboardingInit() { + // Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé + // par un +{% endblock %} diff --git a/tests/test_auth.py b/tests/test_auth.py index b25fadb9..93909773 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -34,19 +34,41 @@ def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!" def _confirm_2fa(client): """Récupère la clé TOTP affichée sur la page de confirmation et soumet le code du moment — comme un humain qui vient de scanner le - QR code avec son application.""" + QR code avec son application. Un compte "user" n'a plus de projet + créé d'office ici (voir routes/onboarding/onboarding_new.py) — la + redirection renvoie désormais vers /onboarding, pas /game/ ; + voir _complete_onboarding ci-dessous pour les tests qui ont besoin + d'un vrai projet ensuite.""" html = client.get("/register/2fa").get_data(as_text=True) secret = re.search(r"([A-Z0-9]+)", html).group(1) code = pyotp.TOTP(secret).now() return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret +def _complete_onboarding(client, name="Mon jeu", onboarding_type="custom"): + """Termine l'onboarding guidé (voir routes/onboarding/onboarding_new.py) + — la plupart des tests de ce fichier n'ont pas besoin de tester + l'onboarding lui-même (voir test_onboarding.py), juste d'obtenir un + project_slug réel pour la suite ; "custom" est le choix le plus proche + du comportement historique (tableau de bord complet).""" + return client.post( + "/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False + ) + + def _cleanup_project(email): - game_dir = db.game_dir(db.slugify(email)) import os - import shutil - if os.path.isdir(game_dir): - shutil.rmtree(game_dir) + user = auth.get_user_by_email(email) + if user and user.get("project_slug"): + if os.path.isdir(db.game_dir(user["project_slug"])): + # db.delete_game (voir db/games/project_slug.py — structure de + # dossiers par utilisateur) nettoie aussi le dossier + # propriétaire s'il devient vide, contrairement à un simple + # shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires + # vides s'accumulent sous projects/ d'un run de tests à l'autre + # (déjà observé : ça a fini par provoquer une collision de nom + # dans db.move_game, un dossier vide comptant comme "occupé"). + db.delete_game(user["project_slug"]) def test_anonymous_request_redirects_to_login(anon_client): @@ -100,13 +122,23 @@ def test_full_registration_flow_requires_totp_before_account_is_usable(anon_clie user = auth.get_user_by_email("flow@example.com") assert user["totp_confirmed"] == 1 assert user["role"] == "user" # jamais le premier compte de la session de tests - assert user["project_slug"] == db.slugify("flow@example.com") + # Onboarding guidé (voir routes/onboarding/onboarding_new.py) : plus + # de projet créé d'office ici — connecté mais sans project_slug tant + # qu'il n'a pas choisi un type de jeu. + assert user["project_slug"] is None try: - assert resp.headers["Location"] == "/game/" + user["project_slug"] - # Connecté pour de vrai maintenant. + assert resp.headers["Location"] == "/onboarding" + # Connecté pour de vrai maintenant, mais renvoyé vers l'onboarding + # tant qu'aucun projet n'existe (voir core/auth_guard.py). resp2 = anon_client.get("/", follow_redirects=False) assert resp2.status_code == 302 - assert resp2.headers["Location"] == "/game/" + user["project_slug"] + assert resp2.headers["Location"] == "/onboarding" + + onboard_resp = _complete_onboarding(anon_client) + assert onboard_resp.status_code == 302 + user = auth.get_user_by_email("flow@example.com") + assert user["project_slug"] is not None + assert onboard_resp.headers["Location"] == "/game/" + user["project_slug"] finally: _cleanup_project("flow@example.com") @@ -123,6 +155,7 @@ def test_wrong_totp_code_is_rejected(anon_client): def test_login_requires_correct_password_then_totp(anon_client): _register(anon_client, "login@example.com") resp, secret = _confirm_2fa(anon_client) + _complete_onboarding(anon_client) slug = auth.get_user_by_email("login@example.com")["project_slug"] try: anon_client.post("/logout") @@ -146,6 +179,7 @@ def test_login_requires_correct_password_then_totp(anon_client): def test_non_admin_user_is_isolated_to_their_own_project(anon_client): _register(anon_client, "isolated@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) user = auth.get_user_by_email("isolated@example.com") try: # Un autre projet (celui de l'admin partagé par conftest.py, ou @@ -173,6 +207,7 @@ def test_non_admin_user_cannot_delete_their_only_project(anon_client): retour — bloqué plutôt que risqué.""" _register(anon_client, "nodelete@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) user = auth.get_user_by_email("nodelete@example.com") try: resp = anon_client.post(f"/game/{user['project_slug']}/delete") @@ -281,6 +316,7 @@ def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client): def test_login_with_a_recovery_code_instead_of_totp(anon_client): _register(anon_client, "recoverylogin@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) user = auth.get_user_by_email("recoverylogin@example.com") slug = user["project_slug"] codes = auth.generate_recovery_codes(user["id"]) diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 00000000..2daeb0c4 --- /dev/null +++ b/tests/test_onboarding.py @@ -0,0 +1,171 @@ +"""Onboarding guidé (routes/onboarding/onboarding_new.py) : un compte +"user" fraîchement inscrit choisit un type de jeu (Quiz/Embranchement/ +RPG/Créer mon jeu de A à Z) avant d'avoir accès à quoi que ce soit — voir +le plan "Fusion des deux moteurs + structure de dossiers + onboarding +guidé". Réutilise les fixtures de test_auth.py (inscription/2FA).""" +import os + +import auth +import db +import screens + +from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401 + + +def test_account_without_a_project_is_redirected_to_onboarding(anon_client): + _register(anon_client, "needsonboarding@example.com") + _confirm_2fa(anon_client) + try: + resp = anon_client.get("/", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"] == "/onboarding" + + resp = anon_client.get("/onboarding") + assert resp.status_code == 200 + assert "Quiz" in resp.get_data(as_text=True) + finally: + _cleanup_project("needsonboarding@example.com") + + +def test_quiz_lands_on_simplified_dashboard_with_one_document_screen(anon_client): + """Le dashboard simplifié (routes/games/game_dashboard.py) — un compte + restreint (quiz/embranchement/rpg) atterrit maintenant sur SON + tableau de bord (même route que "custom"), juste rendu en version + simplifiée : que des écrans en cartes, pas d'onglets Objets/Éléments + de jeu/Variables (réservés à "Créer mon jeu de A à Z").""" + _register(anon_client, "quizuser@example.com") + _confirm_2fa(anon_client) + try: + resp = _complete_onboarding(anon_client, name="Mon Quiz", onboarding_type="quiz") + assert resp.status_code == 302 + user = auth.get_user_by_email("quizuser@example.com") + slug = user["project_slug"] + assert resp.headers["Location"] == f"/game/{slug}" + assert db.get_onboarding_type(slug) == "quiz" + + screen_list = screens.list_screens(slug) + assert len(screen_list) == 1 + assert screen_list[0]["kind"] == "document" + + html = anon_client.get(f"/game/{slug}").get_data(as_text=True) + assert "screenCardGrid" in html + assert "+ Ajouter un écran" in html + assert 'name="kind"' not in html # pas de choix de type à l'ajout + assert "openNewObjectPanel" not in html # onglet Objets du dashboard complet absent + + # L'éditeur de l'écran, lui, reste atteignable comme avant. + edit_html = anon_client.get(f"/game/{slug}/screens/{screen_list[0]['id']}/edit").get_data(as_text=True) + assert "scene-editor.js" not in edit_html + finally: + _cleanup_project("quizuser@example.com") + + +def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_client): + _register(anon_client, "rpguser@example.com") + _confirm_2fa(anon_client) + try: + resp = _complete_onboarding(anon_client, name="Mon RPG", onboarding_type="rpg") + slug = auth.get_user_by_email("rpguser@example.com")["project_slug"] + assert resp.headers["Location"] == f"/game/{slug}" + screen_list = screens.list_screens(slug) + assert screen_list[0]["kind"] == "jeu_2d" + + html = anon_client.get(f"/game/{slug}").get_data(as_text=True) + assert "screenPreviewScene" in html + assert 'name="kind"' not in html + + # "+ Ajouter un écran" ne propose pas de choix de type — imposé + # jeu_2d par le projet, même en tentant de forcer "document" via + # un POST direct (défense en profondeur, pas seulement l'UI). + anon_client.post(f"/game/{slug}/screens/new", data={"name": "Scène 2", "kind": "document"}, follow_redirects=False) + new_screen_list = screens.list_screens(slug) + assert len(new_screen_list) == 2 + assert new_screen_list[1]["kind"] == "jeu_2d" + + edit_html = anon_client.get(f"/game/{slug}/screens/{screen_list[0]['id']}/edit").get_data(as_text=True) + assert "scene-editor.js" in edit_html + finally: + _cleanup_project("rpguser@example.com") + + +def test_custom_keeps_todays_full_dashboard_behavior(anon_client): + _register(anon_client, "customuser@example.com") + _confirm_2fa(anon_client) + try: + resp = _complete_onboarding(anon_client, name="Mon jeu complet", onboarding_type="custom") + assert resp.status_code == 302 + + slug = auth.get_user_by_email("customuser@example.com")["project_slug"] + assert resp.headers["Location"] == f"/game/{slug}" + + html = anon_client.get(f"/game/{slug}").get_data(as_text=True) + assert "Tableau de bord" in html or "gameNavLink" in html + finally: + _cleanup_project("customuser@example.com") + + +def test_onboarding_rejects_unknown_type(anon_client): + _register(anon_client, "badtype@example.com") + _confirm_2fa(anon_client) + try: + resp = anon_client.post("/onboarding", data={"onboarding_type": "n_importe_quoi", "name": "Test"}) + assert resp.status_code == 200 + assert auth.get_user_by_email("badtype@example.com")["project_slug"] is None + finally: + _cleanup_project("badtype@example.com") + + +def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client): + _register(anon_client, "revisit@example.com") + _confirm_2fa(anon_client) + try: + first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="custom") + first_slug = auth.get_user_by_email("revisit@example.com")["project_slug"] + + resp = anon_client.get("/onboarding", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["Location"] == first.headers["Location"] + + again = anon_client.post("/onboarding", data={"onboarding_type": "quiz", "name": "Second jeu"}, follow_redirects=False) + assert auth.get_user_by_email("revisit@example.com")["project_slug"] == first_slug + assert again.headers["Location"] == first.headers["Location"] + finally: + _cleanup_project("revisit@example.com") + + +def test_admin_is_never_redirected_to_onboarding(client, game): + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 200 + + +def test_admin_can_create_several_games_of_different_types_via_onboarding(client): + """Comportement systématique à CHAQUE création de jeu, pas une + formalité réservée à la toute première inscription (voir le plan) — + un admin, illimité (pas de project_slug dédié), doit pouvoir repasser + par /onboarding autant de fois qu'il crée un nouveau jeu, sans jamais + être bloqué/redirigé vers un projet précédent.""" + resp1 = _complete_onboarding(client, name="Jeu Quiz Admin", onboarding_type="quiz") + assert resp1.status_code == 302 + slug1 = resp1.headers["Location"].split("/game/", 1)[1].split("/", 1)[0] + slug2 = None + try: + # project_slug reste NON posé sur le compte admin (voir + # auth/create_user.py — colonne réservée à un compte "user" à + # projet unique) : repasser sur /onboarding ne redirige PAS vers + # le premier projet créé, la page de choix s'affiche à nouveau. + resp = client.get("/onboarding") + assert resp.status_code == 200 + assert "Quiz" in resp.get_data(as_text=True) + + resp2 = _complete_onboarding(client, name="Jeu RPG Admin", onboarding_type="rpg") + assert resp2.status_code == 302 + slug2 = resp2.headers["Location"].split("/game/", 1)[1].split("/", 1)[0] + assert slug2 != slug1 + + slugs = {g["slug"] for g in db.list_games()} + assert slug1 in slugs + assert slug2 in slugs + finally: + for slug in (slug1, slug2): + if slug and os.path.isdir(db.game_dir(slug)): + db.delete_game(slug) diff --git a/tests/test_profile.py b/tests/test_profile.py index 3f6699a5..a6e936df 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -5,8 +5,9 @@ import pyotp import auth import db +from db.games.project_slug import build_slug, split_slug -from tests.test_auth import _register, _confirm_2fa, _cleanup_project, anon_client # noqa: F401 +from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401 def test_profile_page_shows_current_user_info(anon_client): @@ -122,6 +123,7 @@ def test_delete_account_requires_the_confirmation_phrase(anon_client): def test_delete_account_removes_user_and_their_project(anon_client): _register(anon_client, "deleteforreal@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) user = auth.get_user_by_email("deleteforreal@example.com") slug = user["project_slug"] import os @@ -218,6 +220,7 @@ def test_update_email_renames_the_user_project_folder(anon_client): _register(anon_client, "oldmail@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) old_user = auth.get_user_by_email("oldmail@example.com") old_slug = old_user["project_slug"] assert os.path.isdir(db.game_dir(old_slug)) @@ -229,7 +232,10 @@ def test_update_email_renames_the_user_project_folder(anon_client): new_user = auth.get_user_by_email("newmail@example.com") assert new_user is not None new_slug = new_user["project_slug"] - assert new_slug == db.slugify("newmail@example.com") + # Structure de dossiers par utilisateur (voir db/games/ + # project_slug.py) : seul le dossier PROPRIÉTAIRE change de nom, + # le dossier projet (project_part) reste le même. + assert new_slug == build_slug(db.slugify("newmail@example.com"), split_slug(old_slug)[1]) assert not os.path.isdir(db.game_dir(old_slug)) assert os.path.isdir(db.game_dir(new_slug)) diff --git a/tests/test_publish.py b/tests/test_publish.py index a7a01801..d010c7d1 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -80,10 +80,11 @@ def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_clie code supplémentaire — un compte non-admin ne doit pas pouvoir publier le projet d'un autre (même vérification que /game/ lui-même, voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project).""" - from tests.test_auth import _register, _confirm_2fa, _cleanup_project + from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project _register(anon_client, "publishisolation@example.com") _confirm_2fa(anon_client) + _complete_onboarding(anon_client) try: resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish") assert resp.status_code == 403