diff --git a/db/game_dir.py b/db/game_dir.py index bfc8d731..921a1bc3 100644 --- a/db/game_dir.py +++ b/db/game_dir.py @@ -1,7 +1,15 @@ import os from .constants import PROJECTS_DIR +from .games.project_slug import split_slug def game_dir(slug): - return os.path.join(PROJECTS_DIR, slug) + # Structure de dossiers par utilisateur (voir db/games/project_slug.py) + # : un slug composé "propriétaire_projet" résout vers un vrai chemin + # imbriqué projects/// — repli sur l'ancien + # comportement plat pour un slug pas encore migré (project_part=None). + owner_folder, project_part = split_slug(slug) + if project_part is None: + return os.path.join(PROJECTS_DIR, slug) + return os.path.join(PROJECTS_DIR, owner_folder, project_part) diff --git a/db/games/create_game.py b/db/games/create_game.py index 0500c7fe..9dd29eeb 100644 --- a/db/games/create_game.py +++ b/db/games/create_game.py @@ -3,30 +3,43 @@ import os from ..connection import connect from ..game_dir import game_dir from ..slugify import slugify -from .get_game_type import DEFAULT_GAME_TYPE +from .project_slug import build_slug -def create_game(name, slug_override=None, game_type=DEFAULT_GAME_TYPE): +def create_game(name, owner_folder=None, project_slug_override=None): """Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js reliés entre eux, et sa base de données dédiée (nom du jeu en méta). - slug_override : impose le nom du dossier au lieu de le dériver de - `name` — utilisé par l'inscription (auth/) pour que le dossier d'un - compte porte son adresse email (slugifiée), indépendamment du nom - donné à son jeu. Toujours rendu unique de la même façon (suffixe - -2/-3...) si, par un hasard extrême, le dossier existe déjà. + owner_folder : dossier PROPRIÉTAIRE (voir db/games/project_slug.py — + structure de dossiers par utilisateur), typiquement + slugify(email_du_compte) — le slug final composé + "_" résout vers un vrai chemin imbriqué + projects///. Omis (None) : repli sur + l'ancien comportement plat (slug à un seul segment, projects//) + — utile pour les tests de la couche données qui n'ont pas de compte + utilisateur à représenter. - game_type : "document" (défaut, éditeur générique actuel) ou "jeu_2d" - (éditeur de scène — voir get_game_type.py) — choisi une fois pour - toutes à la création, gate quel éditeur/quel rendu jouable ce jeu - utilise (voir routes/screens/screen_edit.py).""" - slug = slugify(slug_override) if slug_override else slugify(name) - base = slug + project_slug_override : impose le nom du dossier PROJET au lieu de le + dériver de `name` — utilisé par l'inscription (auth/, voir + routes/onboarding/) pour que le tout premier projet d'un compte porte + un nom de dossier prévisible. Le suffixe d'unicité (-2/-3...) ne + compare que parmi les projets déjà présents dans CE `owner_folder` + (deux comptes différents peuvent donc avoir chacun un projet nommé + pareil, sans collision). + + Un projet n'a plus de "type" propre (voir screens/screens_repo/ + ensure_schema.py — fusion des moteurs, "document"/"jeu_2d" est + désormais une propriété PAR ÉCRAN) : un projet fraîchement créé n'a + par défaut aucun écran, c'est à l'appelant d'en créer un premier avec + le "kind" voulu (voir screens.create_screen).""" + project_part = slugify(project_slug_override) if project_slug_override else slugify(name) + base = project_part n = 2 - while os.path.isdir(game_dir(base)): - base = f"{slug}-{n}" + while os.path.isdir(game_dir(build_slug(owner_folder, base) if owner_folder else base)): + base = f"{project_part}-{n}" n += 1 - slug = base + project_part = base + slug = build_slug(owner_folder, project_part) if owner_folder else project_part os.makedirs(game_dir(slug), exist_ok=True) @@ -84,7 +97,6 @@ console.log("Jeu chargé."); """ ) conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,)) - conn.execute("INSERT INTO _meta (key, value) VALUES ('game_type', ?)", (game_type,)) conn.commit() conn.close() return slug diff --git a/db/games/delete_game.py b/db/games/delete_game.py index 48be17e9..afa8bf4c 100644 --- a/db/games/delete_game.py +++ b/db/games/delete_game.py @@ -1,7 +1,20 @@ +import os import shutil +from ..constants import PROJECTS_DIR from ..game_dir import game_dir +from .project_slug import split_slug def delete_game(slug): shutil.rmtree(game_dir(slug)) + # Structure de dossiers par utilisateur (voir project_slug.py) : + # nettoie aussi le dossier propriétaire s'il ne contient plus aucun + # projet — purement cosmétique (game_dir()/list_games() fonctionnent + # très bien avec un dossier propriétaire vide), évite juste + # d'accumuler des dossiers fantômes sous projects/. + owner_folder, project_part = split_slug(slug) + if project_part is not None: + owner_path = os.path.join(PROJECTS_DIR, owner_folder) + if os.path.isdir(owner_path) and not os.listdir(owner_path): + os.rmdir(owner_path) diff --git a/db/games/list_games.py b/db/games/list_games.py index 656bf76d..19b34ec6 100644 --- a/db/games/list_games.py +++ b/db/games/list_games.py @@ -3,18 +3,35 @@ import os from ..constants import PROJECTS_DIR from ..connection import connect from ..db_path import db_path +from .project_slug import build_slug def list_games(): - """Scanne le dossier projects/ : chaque sous-dossier avec un game.db est un jeu.""" + """Scanne projects/ : structure par utilisateur (voir + db/games/project_slug.py) — projects///game.db, + deux niveaux — plus le repli sur l'ancien rangement plat + projects//game.db pour tout slug pas encore migré (voir + scripts/migrate_flat_project_slugs.py).""" games = [] if not os.path.isdir(PROJECTS_DIR): return games - for slug in sorted(os.listdir(PROJECTS_DIR)): - path = db_path(slug) - if os.path.isfile(path): - conn = connect(slug) - row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() - conn.close() - games.append({"slug": slug, "name": row["value"] if row else slug}) + for entry in sorted(os.listdir(PROJECTS_DIR)): + entry_path = os.path.join(PROJECTS_DIR, entry) + if not os.path.isdir(entry_path): + continue + if os.path.isfile(os.path.join(entry_path, "game.db")): + # Repli : un ancien dossier plat porte directement game.db. + _append_game(games, entry) + continue + for project_part in sorted(os.listdir(entry_path)): + slug = build_slug(entry, project_part) + if os.path.isfile(db_path(slug)): + _append_game(games, slug) return games + + +def _append_game(games, slug): + conn = connect(slug) + row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() + conn.close() + games.append({"slug": slug, "name": row["value"] if row else slug}) diff --git a/db/games/move_game.py b/db/games/move_game.py index 387ba126..a8ecd869 100644 --- a/db/games/move_game.py +++ b/db/games/move_game.py @@ -1,23 +1,43 @@ import os +from ..constants import PROJECTS_DIR from ..game_dir import game_dir +from .project_slug import build_slug, split_slug -def move_game(old_slug, new_slug): - """Renomme le dossier d'un jeu vers un nouveau slug — utilisé quand un +def move_game(old_slug, new_owner_folder): + """Renomme le dossier PROPRIÉTAIRE d'un compte — utilisé quand un utilisateur change son adresse email (voir routes/auth/profile.py, - project_slug = slugify(email) pour un compte "user") : le dossier - physique du jeu doit suivre. Rend le nouveau slug unique de la même - façon que create_game() si, par un hasard extrême, il correspond déjà - à un dossier existant. Renvoie le slug FINAL (potentiellement suffixé), - à enregistrer comme nouveau project_slug.""" - if old_slug == new_slug: + owner_folder = slugify(email) pour un compte "user", voir + db/games/project_slug.py) : le dossier physique doit suivre. Renomme + le dossier propriétaire ENTIER en un coup (déplace tous les projets de + ce compte ensemble — prêt pour un futur multi-projet, même si un seul + aujourd'hui), pas juste un projet. Rend le nouveau dossier propriétaire + unique de la même façon que create_game() si, par un hasard extrême, + il correspond déjà à un dossier existant. Renvoie le nouveau slug + composé FINAL (même projet, propriétaire renommé), à enregistrer comme + nouveau project_slug.""" + old_owner_folder, project_part = split_slug(old_slug) + if project_part is None: + # Slug pas encore migré vers la structure par utilisateur (voir + # scripts/migrate_flat_project_slugs.py) — repli sur l'ancien + # comportement plat (renomme le dossier du projet lui-même). + if old_slug == new_owner_folder: + return old_slug + base = new_owner_folder + n = 2 + while os.path.isdir(game_dir(base)): + base = f"{new_owner_folder}-{n}" + n += 1 + os.rename(game_dir(old_slug), game_dir(base)) + return base + + if old_owner_folder == new_owner_folder: return old_slug - base = new_slug + base = new_owner_folder n = 2 - while os.path.isdir(game_dir(base)): - base = f"{new_slug}-{n}" + while os.path.isdir(os.path.join(PROJECTS_DIR, base)): + base = f"{new_owner_folder}-{n}" n += 1 - new_slug = base - os.rename(game_dir(old_slug), game_dir(new_slug)) - return new_slug + os.rename(os.path.join(PROJECTS_DIR, old_owner_folder), os.path.join(PROJECTS_DIR, base)) + return build_slug(base, project_part) diff --git a/db/games/project_slug.py b/db/games/project_slug.py new file mode 100644 index 00000000..be9a8043 --- /dev/null +++ b/db/games/project_slug.py @@ -0,0 +1,32 @@ +"""Convention du slug composé "propriétaire_projet" (voir le plan +"Structure de dossiers propriétaire/projet") : un slug encode en réalité +deux segments — le dossier PROPRIÉTAIRE (slugifié depuis l'email du +compte qui a créé le projet) et le dossier PROJET à l'intérieur — pour +que projects/ soit une vraie arborescence par utilisateur +(projects///), sans jamais toucher aux ~40 fichiers +de routes qui déclarent dans leur URL : le slug reste, pour elles, +un seul segment plat et opaque. + +"_" comme séparateur est sûr et non ambigu : slugify() (voir +db/slugify.py) ne produit jamais de "_", seulement [a-z0-9-]+ — un slug +composé peut donc toujours être découpé sans collision sur la PREMIÈRE +occurrence de "_" (le dossier propriétaire, lui, n'en contient jamais). + +Seule une poignée de fonctions dans db/games/ (game_dir, create_game, +move_game, list_games) savent que le slug encode ce chemin composé — si +la convention change un jour, ce fichier est le seul à modifier.""" + + +def build_slug(owner_folder, project_part): + return f"{owner_folder}_{project_part}" + + +def split_slug(slug): + """(owner_folder, project_part) si `slug` est bien composé, sinon + (slug, None) — un slug "plat" (créé avant cette convention, pas encore + migré par scripts/migrate_flat_project_slugs.py) reste lisible tel + quel par le code appelant (repli sur l'ancien comportement).""" + owner_folder, sep, project_part = slug.partition("_") + if not sep: + return slug, None + return owner_folder, project_part diff --git a/routes/auth/profile.py b/routes/auth/profile.py index 73726aa2..3bced677 100644 --- a/routes/auth/profile.py +++ b/routes/auth/profile.py @@ -1,5 +1,4 @@ import os -import shutil from flask import g, redirect, render_template, request, session, url_for @@ -106,9 +105,12 @@ def profile_delete(): # plusieurs jeux qui ne lui sont pas dédiés de la même façon : ses # projets restent intacts. if user["role"] != "admin" and user.get("project_slug"): - game_dir = db.game_dir(user["project_slug"]) - if os.path.isdir(game_dir): - 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 du dossier projet seul. + db.delete_game(user["project_slug"]) auth.delete_user(user["id"]) session.clear() diff --git a/routes/games/games_new.py b/routes/games/games_new.py index a45fa912..72d354b6 100644 --- a/routes/games/games_new.py +++ b/routes/games/games_new.py @@ -1,4 +1,4 @@ -from flask import request, redirect, url_for +from flask import g, request, redirect, url_for import db @@ -10,8 +10,12 @@ def games_new(): name = request.form.get("name", "").strip() if not name: return redirect(url_for("index")) - game_type = request.form.get("game_type", db.DEFAULT_GAME_TYPE) - if game_type not in ("document", "jeu_2d"): - game_type = db.DEFAULT_GAME_TYPE - slug = db.create_game(name, game_type=game_type) + # Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un + # jeu n'a plus de type propre — aucun écran créé d'office ici, comme + # avant (l'admin en crée via "+ Nouvel écran" dans le dashboard, en + # choisissant "document" ou "jeu_2d" pour chacun). + # owner_folder (voir db/games/project_slug.py — structure de dossiers + # par utilisateur) : posé par core/auth_guard.py pour tout compte + # connecté, y compris l'admin qui utilise cette route. + slug = db.create_game(name, owner_folder=db.slugify(g.current_user["email"])) return redirect(url_for("game_dashboard", slug=slug)) diff --git a/scripts/migrate_flat_project_slugs.py b/scripts/migrate_flat_project_slugs.py new file mode 100644 index 00000000..36496bd1 --- /dev/null +++ b/scripts/migrate_flat_project_slugs.py @@ -0,0 +1,84 @@ +"""Script à usage unique (PAS exécuté au runtime du moteur) : migre les +projets encore rangés à plat (projects//game.db, l'ancien schéma) +vers la structure par utilisateur (projects///, +voir db/games/project_slug.py et le plan "Structure de dossiers +propriétaire/projet") — db/game_dir.py sait déjà lire les deux formes, +cette migration n'est donc pas bloquante pour faire tourner le moteur, +mais range enfin les dossiers comme voulu et met à jour project_slug (voir +auth/set_project_slug.py) pour les comptes concernés. + +owner_folder = l'ancien slug plat lui-même : c'était déjà, historiquement, +slugify(email) pour le SEUL projet d'un compte "user" (voir l'ancienne +version de routes/auth/register_2fa.py, avant l'onboarding guidé) — donc +le bon dossier propriétaire, tel quel. project_part = slugify(nom du jeu +stocké en _meta), pour donner un vrai nom de dossier PROJET distinct. + +Mode simulation par défaut (n'écrit rien, affiche ce qui serait fait) — +`--apply` requis pour exécuter réellement : + python scripts/migrate_flat_project_slugs.py # aperçu + python scripts/migrate_flat_project_slugs.py --apply # exécute +""" +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 +import auth # noqa: E402 +from db.constants import PROJECTS_DIR # noqa: E402 +from db.games.project_slug import build_slug # noqa: E402 +from auth.connection import connect as connect_auth_db # noqa: E402 + + +def _flat_project_slugs(): + """Slugs plats trouvés directement sous projects/ (un dossier avec + game.db à la racine, pas dans un sous-dossier propriétaire).""" + if not os.path.isdir(PROJECTS_DIR): + return [] + found = [] + for entry in sorted(os.listdir(PROJECTS_DIR)): + entry_path = os.path.join(PROJECTS_DIR, entry) + if os.path.isfile(os.path.join(entry_path, "game.db")): + found.append(entry) + return found + + +def migrate(apply=False): + slugs = _flat_project_slugs() + if not slugs: + print("Aucun projet à plat trouvé — rien à migrer.") + return + conn = connect_auth_db() + for old_slug in slugs: + game_meta = db.game_meta(old_slug) + project_part = db.slugify(game_meta["name"]) or "jeu" + new_slug = build_slug(old_slug, project_part) + owning_users = [ + dict(r) for r in + conn.execute("SELECT id, email FROM _users WHERE project_slug = ?", (old_slug,)).fetchall() + ] + print(f"{old_slug!r} -> {new_slug!r}" + f" (comptes concernés : {[u['email'] for u in owning_users] or 'aucun'})") + if not apply: + continue + old_dir = os.path.join(PROJECTS_DIR, old_slug) + new_dir = os.path.join(PROJECTS_DIR, old_slug, project_part) + # Renommer EN PLACE (dossier propriétaire = ancien dossier projet, + # déplacé dans son propre sous-dossier) demanderait un dossier + # intermédiaire — plus sûr de passer par un nom temporaire. + tmp_dir = old_dir + ".migrating" + os.rename(old_dir, tmp_dir) + os.makedirs(os.path.dirname(new_dir), exist_ok=True) + os.rename(tmp_dir, new_dir) + for user in owning_users: + auth.set_project_slug(user["id"], new_slug) + conn.close() + if apply: + print(f"{len(slugs)} projet(s) migré(s).") + else: + print("Aperçu seulement — relance avec --apply pour exécuter.") + + +if __name__ == "__main__": + migrate(apply="--apply" in sys.argv) diff --git a/tests/conftest.py b/tests/conftest.py index e1a83063..66e79b1f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -109,9 +109,8 @@ def user_game(client, user_client): with user_client.session_transaction() as sess: auth.set_project_slug(sess["user_id"], slug) yield slug - game_dir = db.game_dir(slug) - if os.path.isdir(game_dir): - shutil.rmtree(game_dir) + if os.path.isdir(db.game_dir(slug)): + db.delete_game(slug) @pytest.fixture @@ -122,9 +121,8 @@ def game(client): assert resp.status_code == 302 slug = resp.headers["Location"].rstrip("/").split("/")[-1] yield slug - game_dir = db.game_dir(slug) - if os.path.isdir(game_dir): - shutil.rmtree(game_dir) + if os.path.isdir(db.game_dir(slug)): + db.delete_game(slug) _STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "js")