Onboarding guidé systématique + tableau de bord simplifié
Comportement SYSTÉMATIQUE à chaque création de jeu (admin compris), pas une formalité réservée à l'inscription : /onboarding (routes/onboarding/) devient le point d'entrée unique — 4 cartes retournables (survol = explication au dos), défilement horizontal animé vers le nom du jeu. Un admin y repasse à volonté (pas de project_slug dédié, jamais bloqué/ redirigé vers un projet précédent) ; un compte "user" n'en a plus qu'un créé d'office (routes/auth/register_2fa.py), guidé ici à la place. - db/games/game_type_catalog.py : catalogue des 4 types (Quiz/ Embranchement-escape game/RPG/Créer mon jeu de A à Z), _meta['onboarding_type'] décide du "kind" du premier écran créé et si le tableau de bord complet reste accessible. - routes/games/game_dashboard.py, templates/game_dashboard_simple.html : un type restreint (quiz/embranchement/rpg) voit désormais SON tableau de bord (même route que "custom"), rendu en version simplifiée — juste ses écrans en cartes avec un aperçu RÉEL du contenu (scène mise à l'échelle par container query CSS, adaptée à la largeur réelle de la carte). "+ Ajouter un écran" n'y propose pas de choix de type : imposé par le projet (routes/screens/screens_new.py), verrouillé aussi côté serveur. - core/auth_guard.py : plus de blocage de game_dashboard par type — la restriction se fait au rendu, pas à l'accès à la route. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0f23024882
commit
76a50fa87a
@@ -31,9 +31,20 @@ _PUBLIC_ENDPOINTS = {
|
|||||||
"forgot_password", "reset_password",
|
"forgot_password", "reset_password",
|
||||||
"game_play_public", "runtime_payload_public",
|
"game_play_public", "runtime_payload_public",
|
||||||
"flow_node_run_data_public", "flow_node_run_variable_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",
|
"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
|
@app.before_request
|
||||||
def _require_login_and_enforce_project_isolation():
|
def _require_login_and_enforce_project_isolation():
|
||||||
@@ -61,9 +72,22 @@ def _require_login_and_enforce_project_isolation():
|
|||||||
if user["role"] != "admin":
|
if user["role"] != "admin":
|
||||||
if endpoint in ("index", "games_new") and user.get("project_slug"):
|
if endpoint in ("index", "games_new") and user.get("project_slug"):
|
||||||
return redirect(url_for("game_dashboard", slug=user["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
|
slug = request.view_args.get("slug") if request.view_args else None
|
||||||
if slug is not None and slug != user.get("project_slug"):
|
if slug is not None and slug != user.get("project_slug"):
|
||||||
abort(403)
|
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":
|
if endpoint == "game_delete":
|
||||||
# Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en
|
# 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
|
# recréer un une fois supprimé (games_new le renvoie toujours
|
||||||
|
|||||||
+13
-1
@@ -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.
|
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 .slugify import slugify
|
||||||
from .table_name_for import table_name_for
|
from .table_name_for import table_name_for
|
||||||
from .game_dir import game_dir
|
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.update_game_name import update_game_name
|
||||||
from .games.delete_game import delete_game
|
from .games.delete_game import delete_game
|
||||||
from .games.move_game import move_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.list_definitions import list_definitions
|
||||||
from .definitions.get_definition import get_definition
|
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 import delete_global_variable
|
||||||
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
|
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.list_custom_events import list_custom_events
|
||||||
from .custom_events.get_custom_event import get_custom_event
|
from .custom_events.get_custom_event import get_custom_event
|
||||||
from .custom_events.create_custom_event import create_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__ = [
|
__all__ = [
|
||||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
"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",
|
"slugify", "table_name_for", "game_dir", "db_path", "connect",
|
||||||
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
|
"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",
|
"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",
|
"list_definitions", "get_definition", "create_definition", "rename_definition",
|
||||||
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
|
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
|
||||||
"delete_definition",
|
"delete_definition",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from ..connection import connect
|
from ..connection import connect
|
||||||
from .is_public_played import is_public_played
|
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):
|
def game_meta(slug):
|
||||||
@@ -14,8 +14,11 @@ def game_meta(slug):
|
|||||||
# ligne" en a besoin pour afficher son état actuel), sans avoir à
|
# ligne" en a besoin pour afficher son état actuel), sans avoir à
|
||||||
# appeler is_public_played(slug) séparément dans chaque route.
|
# appeler is_public_played(slug) séparément dans chaque route.
|
||||||
"is_public_played": is_public_played(slug),
|
"is_public_played": is_public_played(slug),
|
||||||
# Type de jeu (fondations multi-éditeurs) : "document" (éditeur
|
# Onboarding guidé (voir game_type_catalog.py) : utilisé par
|
||||||
# générique) ou "jeu_2d" (éditeur de scène) — voir
|
# templates/base.html pour cacher le lien "Tableau de bord" à un
|
||||||
# get_game_type.py et routes/screens/screen_edit.py.
|
# compte "restreint" (quiz/embranchement/rpg).
|
||||||
"game_type": get_game_type(slug),
|
"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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -52,5 +52,10 @@ def login_2fa():
|
|||||||
return redirect(next_url)
|
return redirect(next_url)
|
||||||
if user["role"] == "admin":
|
if user["role"] == "admin":
|
||||||
return redirect(url_for("index"))
|
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 redirect(url_for("game_dashboard", slug=user["project_slug"]))
|
||||||
return render_template("auth/login_2fa.html", error=error)
|
return render_template("auth/login_2fa.html", error=error)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from flask import redirect, render_template, request, session, url_for
|
from flask import redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
import db
|
|
||||||
|
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
|
||||||
@@ -28,16 +27,6 @@ def register_2fa():
|
|||||||
else:
|
else:
|
||||||
auth.reset_failed_attempts(user_id)
|
auth.reset_failed_attempts(user_id)
|
||||||
auth.confirm_totp(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.pop("pending_register_user_id", None)
|
||||||
session["user_id"] = user_id
|
session["user_id"] = user_id
|
||||||
# Générés ici, une seule fois, à l'instant même où la 2FA
|
# 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)
|
session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id)
|
||||||
if user["role"] == "admin":
|
if user["role"] == "admin":
|
||||||
return redirect(url_for("index"))
|
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"])
|
qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"])
|
||||||
return render_template(
|
return render_template(
|
||||||
|
|||||||
@@ -13,8 +13,42 @@ def game_dashboard(slug):
|
|||||||
accès direct à chaque élément — la barre de navigation du jeu (voir
|
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
|
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
|
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)
|
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)
|
definitions = db.list_definitions(slug)
|
||||||
# Détail complet (avec ses champs) de chaque objet, pour le panneau
|
# 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
|
# d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import onboarding_new
|
||||||
@@ -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)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from flask import request, redirect, url_for
|
from flask import request, redirect, url_for
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
|
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
@@ -8,7 +9,19 @@ from core.flask_app import app
|
|||||||
@app.route("/game/<slug>/screens/new", methods=["POST"])
|
@app.route("/game/<slug>/screens/new", methods=["POST"])
|
||||||
def screens_new(slug):
|
def screens_new(slug):
|
||||||
name = request.form.get("name", "").strip()
|
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:
|
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("screen_edit", slug=slug, screen_id=screen_id))
|
||||||
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
|
return redirect(url_for("game_dashboard", slug=slug, tab="screens"))
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <script src> posé à la fin du contenu de la page — les
|
||||||
|
// éléments dont on a besoin existent déjà à ce stade. Nécessaire aussi
|
||||||
|
// pour la navigation pjax (voir static/pjax.js — runScriptsIn
|
||||||
|
// ré-exécute ce script à chaque page swappée, mais DOMContentLoaded,
|
||||||
|
// lui, ne se redéclenche jamais après le tout premier chargement réel).
|
||||||
|
|
||||||
|
// Entrée en cascade des cartes — délai posé en JS plutôt qu'en CSS pur
|
||||||
|
// (nth-child) pour rester correct quel que soit le nombre de types
|
||||||
|
// affichés (voir db.ONBOARDING_TYPES, appelé à générer la liste).
|
||||||
|
document.querySelectorAll(".onboardingCard").forEach(function (card, i) {
|
||||||
|
card.style.animationDelay = (i * 70) + "ms";
|
||||||
|
card.addEventListener("click", function () { forgeOnboardingChooseType(card); });
|
||||||
|
});
|
||||||
|
|
||||||
|
var backBtn = document.getElementById("onboardingBackBtn");
|
||||||
|
if (backBtn) backBtn.addEventListener("click", function () { forgeOnboardingGoToStep(1); });
|
||||||
|
|
||||||
|
var nameField = document.getElementById("onboardingNameInput");
|
||||||
|
if (nameField) nameField.addEventListener("input", forgeOnboardingUpdateSubmitState);
|
||||||
|
|
||||||
|
forgeOnboardingUpdateSubmitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
forgeOnboardingInit();
|
||||||
@@ -226,6 +226,87 @@ input[type=checkbox]{ width:auto; margin-top:6px; }
|
|||||||
.content{ max-width:760px; margin:0 auto; padding:28px 20px; }
|
.content{ max-width:760px; margin:0 auto; padding:28px 20px; }
|
||||||
|
|
||||||
.card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; }
|
.card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; }
|
||||||
|
/* Onboarding guidé (voir routes/onboarding/onboarding_new.py) — comportement
|
||||||
|
SYSTÉMATIQUE à chaque création de jeu (admin compris) : c'est la première
|
||||||
|
impression du moteur, pensée pour "vendre du rêve" plutôt qu'un simple
|
||||||
|
formulaire administratif. Deux "pages" côte à côte (choix du type / nom
|
||||||
|
du jeu) dans #onboardingTrack, révélées par un défilement horizontal
|
||||||
|
animé — voir static/js/onboarding/onboarding_new.js pour la mécanique
|
||||||
|
(jamais de JS mélangé au HTML, voir CLAUDE.md). */
|
||||||
|
.onboardingPage{ max-width:1200px; }
|
||||||
|
.onboardingError{ text-align:center; color:var(--accent); font-weight:600; margin-bottom:12px; }
|
||||||
|
.onboardingTitle{ display:block; text-align:center; font-size:2em; font-weight:700; margin:0 0 24px; }
|
||||||
|
|
||||||
|
.onboardingViewport{ overflow:hidden; }
|
||||||
|
.onboardingTrack{
|
||||||
|
display:flex; width:200%;
|
||||||
|
transition:transform .65s cubic-bezier(.65,0,.35,1);
|
||||||
|
}
|
||||||
|
.onboardingTrack.onboardingTrack--step2{ transform:translateX(-50%); }
|
||||||
|
.onboardingStep{ flex:0 0 50%; width:50%; box-sizing:border-box; padding:12px 24px; }
|
||||||
|
|
||||||
|
/* ---------- Étape 1 : cartes de type, grandes, retournables ---------- */
|
||||||
|
.onboardingGrid{ display:flex; gap:18px; height:66vh; min-height:420px; max-height:640px; }
|
||||||
|
.onboardingCard{
|
||||||
|
flex:1; position:relative; cursor:pointer; padding:0; margin:0; border:none; background:none;
|
||||||
|
border-radius:20px; perspective:1400px;
|
||||||
|
opacity:0; animation:forgeOnboardCardIn .5s ease both;
|
||||||
|
transition:flex-grow .3s ease;
|
||||||
|
}
|
||||||
|
.onboardingCard:hover{ flex-grow:1.25; }
|
||||||
|
.onboardingCardInner{
|
||||||
|
position:relative; display:block; width:100%; height:100%;
|
||||||
|
transform-style:preserve-3d; transition:transform .6s cubic-bezier(.4,.2,.2,1);
|
||||||
|
border-radius:20px; box-shadow:0 10px 30px rgba(0,0,0,.28);
|
||||||
|
}
|
||||||
|
.onboardingCard:hover .onboardingCardInner, .onboardingCard.is-selected .onboardingCardInner{ transform:rotateY(180deg); }
|
||||||
|
.onboardingCardFace{
|
||||||
|
position:absolute; inset:0; backface-visibility:hidden; border-radius:20px;
|
||||||
|
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px;
|
||||||
|
padding:20px; text-align:center;
|
||||||
|
}
|
||||||
|
.onboardingCardFront{ color:#fff; }
|
||||||
|
.onboardingCardIcon{ font-size:3.4em; line-height:1; filter:drop-shadow(0 4px 10px rgba(0,0,0,.35)); }
|
||||||
|
.onboardingCardLabel{ font-weight:800; font-size:1.3em; letter-spacing:.01em; }
|
||||||
|
.onboardingCardBack{
|
||||||
|
transform:rotateY(180deg); background:var(--panel); border:2px solid var(--accent);
|
||||||
|
}
|
||||||
|
.onboardingCardDesc{ color:var(--text); font-size:1em; line-height:1.5; }
|
||||||
|
|
||||||
|
/* Une couleur de dégradé par type — voir data-type dans le template,
|
||||||
|
généré depuis db.ONBOARDING_TYPES (mêmes clés que ci-dessous). */
|
||||||
|
.onboardingCard[data-type="quiz"] .onboardingCardFront{ background:linear-gradient(150deg, #2ea3ff, #1560d8); }
|
||||||
|
.onboardingCard[data-type="embranchement"] .onboardingCardFront{ background:linear-gradient(150deg, #b06bff, #6a2fd6); }
|
||||||
|
.onboardingCard[data-type="rpg"] .onboardingCardFront{ background:linear-gradient(150deg, #33d182, #159a5c); }
|
||||||
|
.onboardingCard[data-type="custom"] .onboardingCardFront{ background:var(--gradient); color:#1a1108; }
|
||||||
|
.onboardingCard[data-type="custom"] .onboardingCardIcon{ filter:drop-shadow(0 4px 10px rgba(0,0,0,.2)); }
|
||||||
|
|
||||||
|
/* ---------- Étape 2 : nom du jeu, grand champ centré ---------- */
|
||||||
|
.onboardingStep--name{ display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:66vh; }
|
||||||
|
.onboardingBackBtn{
|
||||||
|
align-self:flex-start; background:none; border:none; color:var(--forge-text-muted, #8b95a7);
|
||||||
|
font-size:.95em; cursor:pointer; padding:6px 0; margin-bottom:auto;
|
||||||
|
}
|
||||||
|
.onboardingBackBtn:hover{ color:var(--accent); }
|
||||||
|
.onboardingNameBox{ width:100%; max-width:520px; text-align:center; }
|
||||||
|
.onboardingNameInput{
|
||||||
|
width:100%; text-align:center; font-size:1.5em; font-weight:600; padding:18px 16px;
|
||||||
|
border-radius:14px; border:2px solid var(--border); background:var(--panel); color:var(--text);
|
||||||
|
transition:border-color .18s ease, box-shadow .18s ease;
|
||||||
|
}
|
||||||
|
.onboardingNameInput:focus{ outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(255,95,46,.2); }
|
||||||
|
.onboardingSubmitBtn{
|
||||||
|
margin-top:20px; background:var(--gradient); border:none; font-weight:700; letter-spacing:.01em;
|
||||||
|
font-size:1.05em; padding:12px 28px; transition:opacity .18s ease, transform .12s ease;
|
||||||
|
}
|
||||||
|
.onboardingSubmitBtn:disabled{ opacity:.4; cursor:not-allowed; }
|
||||||
|
.onboardingSubmitBtn:not(:disabled):hover{ transform:translateY(-2px); }
|
||||||
|
|
||||||
|
@keyframes forgeOnboardCardIn{ from{ opacity:0; transform:translateY(14px); } to{ opacity:1; transform:translateY(0); } }
|
||||||
|
@media (prefers-reduced-motion: reduce){
|
||||||
|
.onboardingCard{ animation:none; opacity:1; }
|
||||||
|
.onboardingTrack, .onboardingCardInner{ transition:none; }
|
||||||
|
}
|
||||||
.inline-form .fieldRow{ display:flex; gap:8px; align-items:flex-end; }
|
.inline-form .fieldRow{ display:flex; gap:8px; align-items:flex-end; }
|
||||||
.inline-form .fieldRow input{ flex:1; }
|
.inline-form .fieldRow input{ flex:1; }
|
||||||
.fieldRow{ display:flex; gap:10px; }
|
.fieldRow{ display:flex; gap:10px; }
|
||||||
@@ -773,6 +854,43 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
|
|||||||
.dashCreateBar input, .dashCreateBar select{ width:auto; flex:1 1 160px; }
|
.dashCreateBar input, .dashCreateBar select{ width:auto; flex:1 1 160px; }
|
||||||
.dashDangerZone{ margin-top:28px; }
|
.dashDangerZone{ margin-top:28px; }
|
||||||
|
|
||||||
|
/* Dashboard simplifié (templates/game_dashboard_simple.html, voir
|
||||||
|
routes/games/game_dashboard.py) — un compte "restreint" (quiz/
|
||||||
|
embranchement/rpg) ne voit que ses écrans, en cartes avec un aperçu
|
||||||
|
RÉEL de leur contenu (élements % réutilisés tels quels pour un écran
|
||||||
|
document — voir elstyle/eltransform — ou objets de scène mis à
|
||||||
|
l'échelle pour un écran jeu_2d, voir --scenePreviewScale posé par
|
||||||
|
carte). */
|
||||||
|
.screenCardGrid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(260px, 1fr)); gap:20px; margin-top:14px; }
|
||||||
|
.screenCard{
|
||||||
|
border:1px solid var(--border); border-radius:14px; overflow:hidden; background:var(--panel);
|
||||||
|
transition:transform .18s ease, box-shadow .18s ease;
|
||||||
|
}
|
||||||
|
.screenCard:hover{ transform:translateY(-3px); box-shadow:0 10px 24px rgba(0,0,0,.25); }
|
||||||
|
.screenCardPreviewLink{ display:block; }
|
||||||
|
.screenPreviewBox{
|
||||||
|
position:relative; width:100%; overflow:hidden; container-type:inline-size;
|
||||||
|
background:#0b0d12; border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
/* Scène "jeu_2d" (positionnement en PIXELS fixes, voir render_scene_object.py)
|
||||||
|
mise à l'échelle EXACTEMENT à la largeur réelle de .screenPreviewBox —
|
||||||
|
"cqw" (1% de la largeur du conteneur, posé ci-dessus) rend ce calcul
|
||||||
|
toujours juste, quelle que soit la largeur réellement occupée par la
|
||||||
|
carte (grille responsive) — jamais une largeur fixe supposée. */
|
||||||
|
.screenPreviewScene{
|
||||||
|
position:absolute; top:0; left:0; transform-origin:top left;
|
||||||
|
transform:scale(calc(100cqw / var(--scene-w)));
|
||||||
|
}
|
||||||
|
.screenPreviewEmpty{
|
||||||
|
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||||
|
color:var(--forge-text-muted, #8b95a7); font-size:.85em;
|
||||||
|
}
|
||||||
|
.screenCardFooter{ display:flex; align-items:center; justify-content:space-between; gap:8px; padding:10px 12px; }
|
||||||
|
.screenCardName{ font-weight:600; color:var(--text); text-decoration:none; }
|
||||||
|
.screenCardName:hover{ color:var(--accent); }
|
||||||
|
.screenCardActions{ display:flex; gap:4px; }
|
||||||
|
.screenCardActions form{ display:inline; }
|
||||||
|
|
||||||
/* ---------- Filtre de colonnes (onglet Données, game_dashboard.html) ---------- */
|
/* ---------- Filtre de colonnes (onglet Données, game_dashboard.html) ---------- */
|
||||||
/* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec
|
/* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec
|
||||||
beaucoup de champs rend le tableau illisible, ce menu permet d'en
|
beaucoup de champs rend le tableau illisible, ce menu permet d'en
|
||||||
|
|||||||
+5
-1
@@ -48,7 +48,11 @@
|
|||||||
délibérément le DERNIER lien — une action à part (ouvre l'aperçu
|
délibérément le DERNIER lien — une action à part (ouvre l'aperçu
|
||||||
jouable dans un nouvel onglet), pas une page de gestion de plus. -->
|
jouable dans un nouvel onglet), pas une page de gestion de plus. -->
|
||||||
<nav class="gameNavBar" aria-label="Navigation du jeu">
|
<nav class="gameNavBar" aria-label="Navigation du jeu">
|
||||||
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
|
<!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) : même
|
||||||
|
route pour tous les comptes — routes/games/game_dashboard.py y rend
|
||||||
|
une version simplifiée (juste les écrans) pour un type "restreint"
|
||||||
|
(quiz/embranchement/rpg), la version complète pour "custom". -->
|
||||||
|
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="{{ 'Écrans du jeu' if game.onboarding_type in ('quiz', 'embranchement', 'rpg') else 'Écrans, objets, templates, variables' }}">📊 Tableau de bord</a>
|
||||||
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a>
|
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu (une seule partie partagée, pour la conception)">▶️ Jouer</a>
|
||||||
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
|
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
|
||||||
<!-- "Publier en ligne" (état par joueur, Phase 1) : distinct de
|
<!-- "Publier en ligne" (état par joueur, Phase 1) : distinct de
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ game.name }} — Écrans{% endblock %}
|
||||||
|
{% block content_class %}content-wide{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{#- Dashboard simplifié (voir routes/games/game_dashboard.py) — un compte
|
||||||
|
"restreint" (quiz/embranchement/rpg, voir db/games/game_type_catalog.py)
|
||||||
|
ne voit QUE ses écrans, en cartes avec un aperçu réel de leur contenu
|
||||||
|
(pas d'onglets Objets/Éléments de jeu/Variables, réservés à "Créer mon
|
||||||
|
jeu de A à Z"). Ajouter un écran n'y demande pas de type : imposé par
|
||||||
|
le projet, voir routes/screens/screens_new.py. -#}
|
||||||
|
|
||||||
|
{#- Aperçu adapté à la taille RÉELLE de la carte (pas une largeur fixe
|
||||||
|
supposée) : container-type:inline-size + l'unité "cqw" (1% de la
|
||||||
|
largeur du conteneur le plus proche qui la déclare) permettent à
|
||||||
|
.screenPreviewScene de se mettre à l'échelle EXACTEMENT à la largeur
|
||||||
|
réelle de .screenPreviewBox, quelle que soit la largeur de la grille
|
||||||
|
ou la taille de la fenêtre — sans JS ni recalcul serveur. Le ratio de
|
||||||
|
la boîte elle-même vient de la vraie proportion de la scène
|
||||||
|
(scene_width/scene_height), pas de preview_aspect (propre aux écrans
|
||||||
|
"document", sans rapport avec une scène "jeu_2d"). -#}
|
||||||
|
{% macro screen_preview(s, preview) %}
|
||||||
|
{% if preview.kind == 'jeu_2d' %}
|
||||||
|
<div class="screenPreviewBox" style="aspect-ratio:{{ s.scene_width }} / {{ s.scene_height }};">
|
||||||
|
<div class="screenPreviewScene" style="width:{{ s.scene_width }}px; height:{{ s.scene_height }}px; --scene-w:{{ s.scene_width }}px;">
|
||||||
|
{% for obj in preview.objects %}{{ obj.rendered_html|safe }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if not preview.objects %}<div class="screenPreviewEmpty">Écran vide</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="screenPreviewBox" style="aspect-ratio:{{ (s.preview_aspect or '9/16')|replace('/', ' / ') }};">
|
||||||
|
{% for el in preview.elements if not el.parent_id %}
|
||||||
|
<div style="{{ el|elstyle }}"><div style="{{ el|eltransform }}">{{ el.rendered_html|safe }}</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not preview.elements %}<div class="screenPreviewEmpty">Écran vide</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div class="dashPanelHeader">
|
||||||
|
<h2 class="title is-4 mb-0">{{ game.name }}</h2>
|
||||||
|
<button type="button" class="button is-small primary" onclick="toggleDashCreate('screens')">+ Ajouter un écran</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('screens_new', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-screens" style="display:none;">
|
||||||
|
<input type="text" name="name" placeholder="Nom du nouvel écran (ex. Question 2, Scène 2...)" required autocomplete="off">
|
||||||
|
<button type="submit" class="primary">Créer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if screen_list %}
|
||||||
|
<div class="screenCardGrid">
|
||||||
|
{% for s in screen_list %}
|
||||||
|
<div class="screenCard">
|
||||||
|
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}" class="screenCardPreviewLink">
|
||||||
|
{{ screen_preview(s, previews[s.id]) }}
|
||||||
|
</a>
|
||||||
|
<div class="screenCardFooter">
|
||||||
|
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=s.id) }}" class="screenCardName">
|
||||||
|
{{ s.name }}{% if loop.first %} <span class="tag is-info is-light">★ Départ</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="screenCardActions">
|
||||||
|
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="up"><button type="submit" class="button is-small" title="Monter" {{ 'disabled' if loop.first else '' }}>↑</button></form>
|
||||||
|
<form method="post" action="{{ url_for('screen_move', slug=game.slug, screen_id=s.id) }}"><input type="hidden" name="direction" value="down"><button type="submit" class="button is-small" title="Descendre" {{ 'disabled' if loop.last else '' }}>↓</button></form>
|
||||||
|
<form method="post" action="{{ url_for('screen_delete', slug=game.slug, screen_id=s.id) }}"
|
||||||
|
onsubmit="return confirm('Supprimer l\'écran « {{ s.name }} » et tout son contenu ?');">
|
||||||
|
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">Aucun écran pour l'instant — clique sur "+ Ajouter un écran" pour commencer.</p>
|
||||||
|
{% endif %}
|
||||||
|
<script src="{{ url_for('static', filename='js/game_dashboard_simple.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
+10
-17
@@ -11,24 +11,17 @@
|
|||||||
<div class="twoCol">
|
<div class="twoCol">
|
||||||
<div class="colLeft">
|
<div class="colLeft">
|
||||||
<h2>Créer un jeu</h2>
|
<h2>Créer un jeu</h2>
|
||||||
<form method="post" action="{{ url_for('games_new') }}" class="card">
|
<!-- Onboarding guidé (voir routes/onboarding/onboarding_new.py) :
|
||||||
<div class="formField">
|
comportement SYSTÉMATIQUE à chaque création de jeu, pas une
|
||||||
<label for="name">Nom du nouveau jeu</label>
|
formalité réservée à l'inscription — un admin y repasse à
|
||||||
<input type="text" id="name" name="name" placeholder="ex. CyberQuest RGPD" required>
|
volonté (illimité, pas de project_slug dédié qui bloquerait le
|
||||||
|
retour). Choisir un type ici, ce n'est pas juste pour un compte
|
||||||
|
"user" : ça prépare aussi le jour où un admin (ou un compte
|
||||||
|
payant multi-projet) gère plusieurs jeux de types différents. -->
|
||||||
|
<div class="card">
|
||||||
|
<p class="hint" style="margin-top:0;">Choisis un type de jeu, nomme-le : Forge Engine crée le projet et t'envoie directement sur l'éditeur adapté.</p>
|
||||||
|
<div class="actionsRow"><a href="{{ url_for('onboarding_new') }}" class="button primary">+ Créer un jeu</a></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Fondations multi-éditeurs : le type choisi ici gate quel éditeur
|
|
||||||
(générique ou scène 2D) et quel rendu jouable ce jeu utilisera,
|
|
||||||
voir db/games/get_game_type.py — non modifiable ensuite. -->
|
|
||||||
<div class="formField">
|
|
||||||
<label for="game_type">Type de jeu</label>
|
|
||||||
<select id="game_type" name="game_type">
|
|
||||||
<option value="document">Document — quiz, formulaire, contenu (éditeur actuel)</option>
|
|
||||||
<option value="jeu_2d">Jeu 2D — RPG/serious game (scène, personnages, collision)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="actionsRow"><button type="submit" class="primary">+ Créer le jeu</button></div>
|
|
||||||
<p class="hint" style="margin-top:6px;">Crée un dossier avec index.html / index.css / index.js reliés entre eux, et une base de données dédiée à ce jeu.</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="colRight">
|
<div class="colRight">
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Forge Engine — Créer mon jeu{% endblock %}
|
||||||
|
{% block body_class %}objectEditBody{% endblock %}
|
||||||
|
{% block content_class %}content-wide onboardingPage{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% if error %}<p class="hint onboardingError">⚠️ {{ error }}</p>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('onboarding_new') }}" id="onboardingForm">
|
||||||
|
<input type="hidden" name="onboarding_type" id="onboardingTypeHidden">
|
||||||
|
|
||||||
|
<div class="onboardingViewport">
|
||||||
|
<div class="onboardingTrack" id="onboardingTrack">
|
||||||
|
|
||||||
|
<section class="onboardingStep onboardingStep--pick">
|
||||||
|
<h2 class="onboardingTitle">Quel jeu veux-tu créer ?</h2>
|
||||||
|
<div class="onboardingGrid">
|
||||||
|
{% for key, info in onboarding_types.items() %}
|
||||||
|
<button type="button" class="onboardingCard" data-type="{{ key }}" data-label="{{ info.label }}">
|
||||||
|
<span class="onboardingCardInner">
|
||||||
|
<span class="onboardingCardFace onboardingCardFront">
|
||||||
|
<span class="onboardingCardIcon">{{ info.icon }}</span>
|
||||||
|
<span class="onboardingCardLabel">{{ info.label }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="onboardingCardFace onboardingCardBack">
|
||||||
|
<span class="onboardingCardDesc">{{ info.description }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="onboardingStep onboardingStep--name">
|
||||||
|
<button type="button" class="onboardingBackBtn" id="onboardingBackBtn">← Changer de type</button>
|
||||||
|
<div class="onboardingNameBox">
|
||||||
|
<label class="onboardingTitle" for="onboardingNameInput">Quel est le nom de votre jeu ?</label>
|
||||||
|
<input type="text" name="name" id="onboardingNameInput" class="onboardingNameInput" placeholder="ex. CyberQuest RGPD" autocomplete="off">
|
||||||
|
<button type="submit" id="onboardingSubmitBtn" class="primary onboardingSubmitBtn" disabled>Créer mon jeu 🚀</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<script src="{{ url_for('static', filename='js/onboarding/onboarding_new.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
+45
-9
@@ -34,19 +34,41 @@ def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!"
|
|||||||
def _confirm_2fa(client):
|
def _confirm_2fa(client):
|
||||||
"""Récupère la clé TOTP affichée sur la page de confirmation et
|
"""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
|
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/<slug> ;
|
||||||
|
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)
|
html = client.get("/register/2fa").get_data(as_text=True)
|
||||||
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
|
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
|
||||||
code = pyotp.TOTP(secret).now()
|
code = pyotp.TOTP(secret).now()
|
||||||
return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret
|
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):
|
def _cleanup_project(email):
|
||||||
game_dir = db.game_dir(db.slugify(email))
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
user = auth.get_user_by_email(email)
|
||||||
if os.path.isdir(game_dir):
|
if user and user.get("project_slug"):
|
||||||
shutil.rmtree(game_dir)
|
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):
|
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")
|
user = auth.get_user_by_email("flow@example.com")
|
||||||
assert user["totp_confirmed"] == 1
|
assert user["totp_confirmed"] == 1
|
||||||
assert user["role"] == "user" # jamais le premier compte de la session de tests
|
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:
|
try:
|
||||||
assert resp.headers["Location"] == "/game/" + user["project_slug"]
|
assert resp.headers["Location"] == "/onboarding"
|
||||||
# Connecté pour de vrai maintenant.
|
# 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)
|
resp2 = anon_client.get("/", follow_redirects=False)
|
||||||
assert resp2.status_code == 302
|
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:
|
finally:
|
||||||
_cleanup_project("flow@example.com")
|
_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):
|
def test_login_requires_correct_password_then_totp(anon_client):
|
||||||
_register(anon_client, "login@example.com")
|
_register(anon_client, "login@example.com")
|
||||||
resp, secret = _confirm_2fa(anon_client)
|
resp, secret = _confirm_2fa(anon_client)
|
||||||
|
_complete_onboarding(anon_client)
|
||||||
slug = auth.get_user_by_email("login@example.com")["project_slug"]
|
slug = auth.get_user_by_email("login@example.com")["project_slug"]
|
||||||
try:
|
try:
|
||||||
anon_client.post("/logout")
|
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):
|
def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
|
||||||
_register(anon_client, "isolated@example.com")
|
_register(anon_client, "isolated@example.com")
|
||||||
_confirm_2fa(anon_client)
|
_confirm_2fa(anon_client)
|
||||||
|
_complete_onboarding(anon_client)
|
||||||
user = auth.get_user_by_email("isolated@example.com")
|
user = auth.get_user_by_email("isolated@example.com")
|
||||||
try:
|
try:
|
||||||
# Un autre projet (celui de l'admin partagé par conftest.py, ou
|
# 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é."""
|
retour — bloqué plutôt que risqué."""
|
||||||
_register(anon_client, "nodelete@example.com")
|
_register(anon_client, "nodelete@example.com")
|
||||||
_confirm_2fa(anon_client)
|
_confirm_2fa(anon_client)
|
||||||
|
_complete_onboarding(anon_client)
|
||||||
user = auth.get_user_by_email("nodelete@example.com")
|
user = auth.get_user_by_email("nodelete@example.com")
|
||||||
try:
|
try:
|
||||||
resp = anon_client.post(f"/game/{user['project_slug']}/delete")
|
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):
|
def test_login_with_a_recovery_code_instead_of_totp(anon_client):
|
||||||
_register(anon_client, "recoverylogin@example.com")
|
_register(anon_client, "recoverylogin@example.com")
|
||||||
_confirm_2fa(anon_client)
|
_confirm_2fa(anon_client)
|
||||||
|
_complete_onboarding(anon_client)
|
||||||
user = auth.get_user_by_email("recoverylogin@example.com")
|
user = auth.get_user_by_email("recoverylogin@example.com")
|
||||||
slug = user["project_slug"]
|
slug = user["project_slug"]
|
||||||
codes = auth.generate_recovery_codes(user["id"])
|
codes = auth.generate_recovery_codes(user["id"])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -5,8 +5,9 @@ import pyotp
|
|||||||
|
|
||||||
import auth
|
import auth
|
||||||
import db
|
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):
|
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):
|
def test_delete_account_removes_user_and_their_project(anon_client):
|
||||||
_register(anon_client, "deleteforreal@example.com")
|
_register(anon_client, "deleteforreal@example.com")
|
||||||
_confirm_2fa(anon_client)
|
_confirm_2fa(anon_client)
|
||||||
|
_complete_onboarding(anon_client)
|
||||||
user = auth.get_user_by_email("deleteforreal@example.com")
|
user = auth.get_user_by_email("deleteforreal@example.com")
|
||||||
slug = user["project_slug"]
|
slug = user["project_slug"]
|
||||||
import os
|
import os
|
||||||
@@ -218,6 +220,7 @@ def test_update_email_renames_the_user_project_folder(anon_client):
|
|||||||
|
|
||||||
_register(anon_client, "oldmail@example.com")
|
_register(anon_client, "oldmail@example.com")
|
||||||
_confirm_2fa(anon_client)
|
_confirm_2fa(anon_client)
|
||||||
|
_complete_onboarding(anon_client)
|
||||||
old_user = auth.get_user_by_email("oldmail@example.com")
|
old_user = auth.get_user_by_email("oldmail@example.com")
|
||||||
old_slug = old_user["project_slug"]
|
old_slug = old_user["project_slug"]
|
||||||
assert os.path.isdir(db.game_dir(old_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")
|
new_user = auth.get_user_by_email("newmail@example.com")
|
||||||
assert new_user is not None
|
assert new_user is not None
|
||||||
new_slug = new_user["project_slug"]
|
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 not os.path.isdir(db.game_dir(old_slug))
|
||||||
assert os.path.isdir(db.game_dir(new_slug))
|
assert os.path.isdir(db.game_dir(new_slug))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
code supplémentaire — un compte non-admin ne doit pas pouvoir publier
|
||||||
le projet d'un autre (même vérification que /game/<slug> lui-même,
|
le projet d'un autre (même vérification que /game/<slug> lui-même,
|
||||||
voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
|
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")
|
_register(anon_client, "publishisolation@example.com")
|
||||||
_confirm_2fa(anon_client)
|
_confirm_2fa(anon_client)
|
||||||
|
_complete_onboarding(anon_client)
|
||||||
try:
|
try:
|
||||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
|
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|||||||
Reference in New Issue
Block a user