Structure de dossiers par utilisateur : projects/<propriétaire>/<projet>/
Build and deploy / test-python (push) Failing after 3m14s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Remplace le rangement plat projects/<slug>/ par une vraie arborescence
par compte créateur, sans toucher aux routes existantes (aucune ne
déclare de "/" dans son <slug> : le chemin composé "propriétaire_projet"
reste un détail interne à db/, décodé par db/games/project_slug.py, seul
fichier à modifier si la convention change un jour).

- db/game_dir.py, create_game.py, delete_game.py, list_games.py,
  move_game.py : résolvent/construisent ce chemin composé. Bénéfice
  direct : deux comptes différents peuvent chacun avoir un projet nommé
  pareil sans collision (l'unicité ne se vérifie plus que par dossier
  propriétaire). move_game.py renomme désormais le dossier PROPRIÉTAIRE
  entier (changement d'email), prêt pour un futur multi-projet.
- scripts/migrate_flat_project_slugs.py : migration des projets déjà
  créés sous l'ancien rangement plat (simulation par défaut, --apply
  pour exécuter).
- routes/auth/profile.py, tests/conftest.py : suppression de compte/jeu
  de test via db.delete_game (nettoie aussi le dossier propriétaire
  devenu vide) plutôt qu'un shutil.rmtree direct du seul dossier projet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 06:22:03 +02:00
co-authored by Claude Sonnet 5
parent d5a59bdbd8
commit 0f23024882
10 changed files with 245 additions and 55 deletions
+9 -1
View File
@@ -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/<propriétaire>/<projet>/ — 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)
+29 -17
View File
@@ -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é
"<owner_folder>_<project_part>" résout vers un vrai chemin imbriqué
projects/<owner_folder>/<project_part>/. Omis (None) : repli sur
l'ancien comportement plat (slug à un seul segment, projects/<slug>/)
— 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
+13
View File
@@ -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)
+25 -8
View File
@@ -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/<propriétaire>/<projet>/game.db,
deux niveaux — plus le repli sur l'ancien rangement plat
projects/<slug>/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})
+34 -14
View File
@@ -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)
+32
View File
@@ -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/<propriétaire>/<projet>/), sans jamais toucher aux ~40 fichiers
de routes qui déclarent <slug> 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