first commit
Build and deploy / build-and-push (push) Successful in 17s
Build and deploy / deploy (push) Successful in 10s

This commit is contained in:
william
2026-08-21 16:23:49 +02:00
committed by william
commit 3f4ebc4527
248 changed files with 10180 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
"""
db — toute la logique base de données du moteur Forge, organisée en un
sous-module par responsabilité (un fichier = une fonction, un dossier = un
domaine) plutôt qu'un seul gros fichier. Ce fichier réexporte l'intégralité
de l'API publique historique : le reste du moteur continue simplement
d'écrire `import db` puis `db.ma_fonction(...)`, sans rien savoir de cette
organisation interne.
Chaque JEU créé a SA PROPRE base SQLite (fichier .db dans son dossier de
projet). Cette base contient :
- des tables "méta" (_definitions, _fields) qui décrivent les objets que
l'utilisateur a définis via l'interface (l'équivalent d'un schéma de
base de données, mais construit à la souris plutôt qu'en SQL) ;
- une VRAIE table SQL par objet défini (obj_<nom>), avec des colonnes du
bon type SQLite selon le type choisi pour chaque champ, y compris des
colonnes de clé étrangère pour les champs de type "relation".
Rien n'est une simulation : créer une "définition d'objet" avec des champs
exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
vrai INSERT dans cette table.
"""
from .constants import PROJECTS_DIR, FIELD_TYPES
from .slugify import slugify
from .table_name_for import table_name_for
from .game_dir import game_dir
from .db_path import db_path
from .connection import connect
from .games.list_games import list_games
from .games.game_meta import game_meta
from .games.create_game import create_game
from .games.update_game_name import update_game_name
from .games.delete_game import delete_game
from .definitions.list_definitions import list_definitions
from .definitions.get_definition import get_definition
from .definitions.create_definition import create_definition
from .definitions.rename_definition import rename_definition
from .definitions.definitions_referencing import definitions_referencing
from .definitions.add_field_to_definition import add_field_to_definition
from .definitions.update_field import update_field
from .definitions.delete_field import delete_field
from .definitions.delete_definition import delete_definition
from .rows.list_rows import list_rows
from .rows.relation_options import relation_options
from .rows.insert_row import insert_row
from .rows.get_row import get_row
from .rows.update_row import update_row
from .rows.update_row_field import update_row_field
from .rows.delete_row import delete_row
from .rows.rows_referencing import rows_referencing
__all__ = [
"PROJECTS_DIR", "FIELD_TYPES",
"slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game",
"list_definitions", "get_definition", "create_definition", "rename_definition",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"delete_definition",
"list_rows", "relation_options", "insert_row", "get_row", "update_row",
"update_row_field", "delete_row", "rows_referencing",
]
+20
View File
@@ -0,0 +1,20 @@
import sqlite3
from .db_path import db_path
def connect(slug):
# timeout=10 : si une autre connexion tient un verrou d'écriture au même
# instant (deux requêtes qui arrivent presque en même temps, ex. deux
# onglets, ou le navigateur qui recharge plusieurs ressources), sqlite3
# réessaie pendant 10 secondes au lieu de lever immédiatement "database
# is locked". Le mode WAL (Write-Ahead Logging) va plus loin : il permet
# à des lectures de se faire PENDANT qu'une écriture est en cours
# ailleurs, ce qui est la cause la plus fréquente de ce blocage avec le
# serveur de développement Flask.
conn = sqlite3.connect(db_path(slug), timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 8000")
return conn
+16
View File
@@ -0,0 +1,16 @@
"""Constantes partagées de la couche données."""
import os
PROJECTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects")
# Types de champ exposés dans l'interface -> type de colonne SQLite réel.
FIELD_TYPES = {
"texte": {"label": "Texte court", "sql": "TEXT"},
"texte_long": {"label": "Texte long", "sql": "TEXT"},
"nombre_entier": {"label": "Nombre entier", "sql": "INTEGER"},
"nombre_decimal": {"label": "Nombre décimal", "sql": "REAL"},
"booleen": {"label": "Oui / Non", "sql": "INTEGER"},
"date": {"label": "Date", "sql": "TEXT"},
"relation": {"label": "Relation vers un autre objet", "sql": "INTEGER"},
}
+7
View File
@@ -0,0 +1,7 @@
import os
from .game_dir import game_dir
def db_path(slug):
return os.path.join(game_dir(slug), "game.db")
+1
View File
@@ -0,0 +1 @@
"""Définitions d'objet (le "schéma" créé à la souris) et leurs champs."""
+43
View File
@@ -0,0 +1,43 @@
from ..connection import connect
from ..constants import FIELD_TYPES
from ..slugify import slugify
from .get_definition import get_definition
def add_field_to_definition(slug, definition_id, field):
"""CRUD — Update d'une définition : ajoute un champ à un objet déjà
créé. Exécute un vrai ALTER TABLE ... ADD COLUMN sur la table SQL
existante (les lignes déjà enregistrées reçoivent NULL pour ce
nouveau champ, comportement standard d'un ALTER TABLE)."""
definition = get_definition(slug, definition_id)
conn = connect(slug)
fname = slugify(field["name"]).replace("-", "_")
ftype = field["type"]
required = 1 if field.get("required") else 0
relation_definition_id = None
position = (max((f["position"] for f in definition["fields"]), default=-1)) + 1
if ftype == "relation":
related = get_definition(slug, int(field["relation_definition_id"]))
col = f"{fname}_id"
# ALTER TABLE ADD COLUMN de SQLite n'accepte pas de contrainte
# REFERENCES portant sur une colonne ajoutée après coup avec la
# même simplicité qu'à la création : on ajoute la colonne simple —
# c'est la table _fields qui reste la source de vérité utilisée par
# le moteur pour savoir que cette colonne est une relation.
conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {col} INTEGER")
relation_definition_id = related["id"]
else:
sql_type = FIELD_TYPES[ftype]["sql"]
conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {fname} {sql_type}")
min_value = field.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None
max_value = field.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None
conn.execute(
"""INSERT INTO _fields
(definition_id, name, type, relation_definition_id, required, position, min_value, max_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(definition_id, field["name"], ftype, relation_definition_id, required, position, min_value, max_value),
)
conn.commit()
conn.close()
+57
View File
@@ -0,0 +1,57 @@
from ..connection import connect
from ..constants import FIELD_TYPES
from ..slugify import slugify
from ..table_name_for import table_name_for
from .get_definition import get_definition
from .ensure_field_bounds_schema import ensure_field_bounds_schema
def create_definition(slug, name, fields):
"""Feature 2 : crée une définition d'objet (comme une table de BDD) et
exécute le vrai CREATE TABLE correspondant, avec les bons types de
colonne, y compris les colonnes de clé étrangère pour les relations
vers d'autres définitions déjà créées dans CE jeu (une définition peut
donc "en contenir" une autre, au sens relationnel)."""
ensure_field_bounds_schema(slug)
conn = connect(slug)
tname = table_name_for(name)
conn.execute(
"INSERT INTO _definitions (name, table_name) VALUES (?, ?)", (name, tname)
)
definition_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
columns_sql = ["id INTEGER PRIMARY KEY AUTOINCREMENT"]
for pos, f in enumerate(fields):
fname = slugify(f["name"]).replace("-", "_")
ftype = f["type"]
required = 1 if f.get("required") else 0
relation_definition_id = None
if ftype == "relation":
related = get_definition(slug, int(f["relation_definition_id"]))
col = f"{fname}_id"
columns_sql.append(
f"{col} INTEGER REFERENCES {related['table_name']}(id)"
)
relation_definition_id = related["id"]
else:
sql_type = FIELD_TYPES[ftype]["sql"]
not_null = " NOT NULL" if required else ""
columns_sql.append(f"{fname} {sql_type}{not_null}")
min_value = f.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None
max_value = f.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None
conn.execute(
"""INSERT INTO _fields
(definition_id, name, type, relation_definition_id, required, position, min_value, max_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(definition_id, f["name"], ftype, relation_definition_id, required, pos, min_value, max_value),
)
columns_sql.append("created_at TEXT DEFAULT CURRENT_TIMESTAMP")
create_sql = f"CREATE TABLE {tname} ({', '.join(columns_sql)})"
conn.execute(create_sql)
conn.commit()
conn.close()
return definition_id
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
def definitions_referencing(slug, definition_id):
"""Autres définitions de CE jeu qui ont un champ de type relation
pointant vers cette définition — utilisé pour bloquer une suppression
qui casserait ces relations."""
conn = connect(slug)
rows = conn.execute(
"""SELECT DISTINCT d.id, d.name FROM _fields f
JOIN _definitions d ON d.id = f.definition_id
WHERE f.relation_definition_id = ?""",
(definition_id,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
+12
View File
@@ -0,0 +1,12 @@
from ..connection import connect
from .get_definition import get_definition
def delete_definition(slug, definition_id):
definition = get_definition(slug, definition_id)
conn = connect(slug)
conn.execute(f"DROP TABLE IF EXISTS {definition['table_name']}")
conn.execute("DELETE FROM _fields WHERE definition_id = ?", (definition_id,))
conn.execute("DELETE FROM _definitions WHERE id = ?", (definition_id,))
conn.commit()
conn.close()
+28
View File
@@ -0,0 +1,28 @@
import sqlite3
from ..connection import connect
from ..slugify import slugify
from .get_definition import get_definition
def delete_field(slug, definition_id, field_id):
"""CRUD — Update d'une définition : retire un champ. Exécute un vrai
ALTER TABLE ... DROP COLUMN (SQLite ≥ 3.35). Sur une version de SQLite
trop ancienne pour DROP COLUMN, le champ est retiré de la définition
(le moteur ne le proposera plus dans les formulaires) mais la colonne
SQL peut subsister sans casser quoi que ce soit d'autre."""
definition = get_definition(slug, definition_id)
field = next((f for f in definition["fields"] if f["id"] == field_id), None)
if not field:
return
col = slugify(field["name"]).replace("-", "_")
if field["type"] == "relation":
col += "_id"
conn = connect(slug)
try:
conn.execute(f"ALTER TABLE {definition['table_name']} DROP COLUMN {col}")
except sqlite3.OperationalError:
pass
conn.execute("DELETE FROM _fields WHERE id = ?", (field_id,))
conn.commit()
conn.close()
@@ -0,0 +1,16 @@
from ..connection import connect
def ensure_field_bounds_schema(slug):
"""Migration légère (voir screens/screens_repo/ensure_schema.py pour le
même principe) : ajoute les colonnes min_value/max_value à _fields pour
les jeux créés avant le bornage automatique (2.2) — aucune migration
manuelle nécessaire, la prochaine lecture d'une définition suffit."""
conn = connect(slug)
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_fields)").fetchall()}
if "min_value" not in cols:
conn.execute("ALTER TABLE _fields ADD COLUMN min_value REAL")
if "max_value" not in cols:
conn.execute("ALTER TABLE _fields ADD COLUMN max_value REAL")
conn.commit()
conn.close()
+18
View File
@@ -0,0 +1,18 @@
from ..connection import connect
from .ensure_field_bounds_schema import ensure_field_bounds_schema
def get_definition(slug, definition_id):
ensure_field_bounds_schema(slug)
conn = connect(slug)
d = conn.execute("SELECT * FROM _definitions WHERE id = ?", (definition_id,)).fetchone()
if not d:
conn.close()
return None
fields = conn.execute(
"SELECT * FROM _fields WHERE definition_id = ? ORDER BY position, id", (definition_id,)
).fetchall()
conn.close()
d = dict(d)
d["fields"] = [dict(f) for f in fields]
return d
+8
View File
@@ -0,0 +1,8 @@
from ..connection import connect
def list_definitions(slug):
conn = connect(slug)
rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall()
conn.close()
return [dict(r) for r in rows]
+8
View File
@@ -0,0 +1,8 @@
from ..connection import connect
def rename_definition(slug, definition_id, new_name):
conn = connect(slug)
conn.execute("UPDATE _definitions SET name = ? WHERE id = ?", (new_name, definition_id))
conn.commit()
conn.close()
+48
View File
@@ -0,0 +1,48 @@
import sqlite3
from ..connection import connect
from ..slugify import slugify
from .get_definition import get_definition
def update_field(slug, definition_id, field_id, new_name, required, relation_definition_id=None, min_value=None, max_value=None):
"""CRUD — Update d'une définition : modifie un champ déjà créé (nom,
obligatoire, objet lié si c'est une relation, et bornes mini/maxi si
c'est un champ numérique — voir 2.2, bornage automatique). Si le nom
change, la vraie colonne SQL est renommée (ALTER TABLE ... RENAME
COLUMN, SQLite ≥ 3.25) pour que la colonne réelle continue de
correspondre exactement au nom du champ tel qu'affiché — pas de dérive
entre la définition et la table. Le TYPE d'un champ existant ne se
change pas ici (une vraie conversion de type SQLite demanderait de
reconstruire la table et de convertir les données déjà enregistrées,
hors scope de cette version)."""
definition = get_definition(slug, definition_id)
field = next((f for f in definition["fields"] if f["id"] == field_id), None)
if not field:
return
old_col = slugify(field["name"]).replace("-", "_")
new_col = slugify(new_name).replace("-", "_")
if field["type"] == "relation":
old_col += "_id"
new_col += "_id"
conn = connect(slug)
if old_col != new_col:
try:
conn.execute(
f"ALTER TABLE {definition['table_name']} RENAME COLUMN {old_col} TO {new_col}"
)
except sqlite3.OperationalError:
pass # SQLite trop ancien pour RENAME COLUMN : la colonne SQL garde son ancien nom
rel_id = int(relation_definition_id) if (field["type"] == "relation" and relation_definition_id) else field["relation_definition_id"]
is_numeric = field["type"] in ("nombre_entier", "nombre_decimal")
min_v = (min_value if min_value not in (None, "") else None) if is_numeric else None
max_v = (max_value if max_value not in (None, "") else None) if is_numeric else None
conn.execute(
"UPDATE _fields SET name = ?, required = ?, relation_definition_id = ?, min_value = ?, max_value = ? WHERE id = ?",
(new_name, 1 if required else 0, rel_id, min_v, max_v, field_id),
)
conn.commit()
conn.close()
+7
View File
@@ -0,0 +1,7 @@
import os
from .constants import PROJECTS_DIR
def game_dir(slug):
return os.path.join(PROJECTS_DIR, slug)
+1
View File
@@ -0,0 +1 @@
"""Cycle de vie d'un jeu (création, méta, suppression)."""
+77
View File
@@ -0,0 +1,77 @@
import os
from ..connection import connect
from ..game_dir import game_dir
from ..slugify import slugify
def create_game(name):
"""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 = slugify(name)
base = slug
n = 2
while os.path.isdir(game_dir(base)):
base = f"{slug}-{n}"
n += 1
slug = base
os.makedirs(game_dir(slug), exist_ok=True)
index_html = f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name}</title>
<link rel="stylesheet" href="index.css">
</head>
<body>
<h1>{name}</h1>
<p>Squelette généré par le moteur Forge — à remplir au fur et à mesure
des fonctionnalités du jeu.</p>
<script src="index.js"></script>
</body>
</html>
"""
index_css = """body {
font-family: system-ui, sans-serif;
margin: 40px;
color: #222;
}
"""
index_js = """// index.js — point d'entrée du jeu, généré par le moteur Forge.
console.log("Jeu chargé.");
"""
with open(os.path.join(game_dir(slug), "index.html"), "w", encoding="utf-8") as f:
f.write(index_html)
with open(os.path.join(game_dir(slug), "index.css"), "w", encoding="utf-8") as f:
f.write(index_css)
with open(os.path.join(game_dir(slug), "index.js"), "w", encoding="utf-8") as f:
f.write(index_js)
conn = connect(slug)
conn.executescript(
"""
CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE _definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
table_name TEXT NOT NULL UNIQUE,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE _fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES _definitions(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
relation_definition_id INTEGER REFERENCES _definitions(id),
required INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0
);
"""
)
conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,))
conn.commit()
conn.close()
return slug
+7
View File
@@ -0,0 +1,7 @@
import shutil
from ..game_dir import game_dir
def delete_game(slug):
shutil.rmtree(game_dir(slug))
+8
View File
@@ -0,0 +1,8 @@
from ..connection import connect
def game_meta(slug):
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close()
return {"slug": slug, "name": row["value"] if row else slug}
+20
View File
@@ -0,0 +1,20 @@
import os
from ..constants import PROJECTS_DIR
from ..connection import connect
from ..db_path import db_path
def list_games():
"""Scanne le dossier projects/ : chaque sous-dossier avec un game.db est un jeu."""
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})
return games
+8
View File
@@ -0,0 +1,8 @@
from ..connection import connect
def update_game_name(slug, new_name):
conn = connect(slug)
conn.execute("UPDATE _meta SET value = ? WHERE key = 'name'", (new_name,))
conn.commit()
conn.close()
+1
View File
@@ -0,0 +1 @@
"""Lignes de données (CRUD) pour les objets définis dans un jeu."""
+8
View File
@@ -0,0 +1,8 @@
from ..connection import connect
def delete_row(slug, definition, row_id):
conn = connect(slug)
conn.execute(f"DELETE FROM {definition['table_name']} WHERE id = ?", (row_id,))
conn.commit()
conn.close()
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
def get_row(slug, definition, row_id):
conn = connect(slug)
row = conn.execute(
f"SELECT * FROM {definition['table_name']} WHERE id = ?", (row_id,)
).fetchone()
conn.close()
return dict(row) if row else None
+14
View File
@@ -0,0 +1,14 @@
from ..connection import connect
from .row_columns_and_values import row_columns_and_values
def insert_row(slug, definition, form_data):
"""Feature 3 : enregistre en base les données saisies dans le formulaire
généré à partir de la définition d'objet."""
cols, values = row_columns_and_values(definition, form_data)
conn = connect(slug)
placeholders = ["?"] * len(cols)
sql = f"INSERT INTO {definition['table_name']} ({', '.join(cols)}) VALUES ({', '.join(placeholders)})"
conn.execute(sql, values)
conn.commit()
conn.close()
+8
View File
@@ -0,0 +1,8 @@
from ..connection import connect
def list_rows(slug, definition):
conn = connect(slug)
rows = conn.execute(f"SELECT * FROM {definition['table_name']} ORDER BY id DESC").fetchall()
conn.close()
return [dict(r) for r in rows]
+19
View File
@@ -0,0 +1,19 @@
from ..connection import connect
from ..slugify import slugify
from ..definitions.get_definition import get_definition
def relation_options(slug, related_definition_id):
"""Lignes existantes d'un objet lié, pour peupler un <select> de relation."""
related = get_definition(slug, related_definition_id)
conn = connect(slug)
rows = conn.execute(f"SELECT * FROM {related['table_name']} ORDER BY id").fetchall()
conn.close()
display_field = related["fields"][0]["name"] if related["fields"] else "id"
display_col = slugify(display_field).replace("-", "_")
options = []
for r in rows:
r = dict(r)
label = r.get(display_col, f"#{r['id']}")
options.append({"id": r["id"], "label": f"{label} (#{r['id']})"})
return options
+26
View File
@@ -0,0 +1,26 @@
from ..slugify import slugify
def row_columns_and_values(definition, form_data):
"""Traduit les données d'un formulaire généré en (colonnes, valeurs)
prêtes pour un INSERT ou un UPDATE — factorisé pour que les deux
partagent exactement la même logique de conversion de type."""
cols, values = [], []
for f in definition["fields"]:
fname = slugify(f["name"]).replace("-", "_")
if f["type"] == "relation":
col = f"{fname}_id"
raw = form_data.get(col, "")
values.append(int(raw) if raw else None)
elif f["type"] == "booleen":
values.append(1 if form_data.get(fname) == "on" else 0)
col = fname
elif f["type"] in ("nombre_entier", "nombre_decimal"):
raw = form_data.get(fname, "")
values.append((int(raw) if f["type"] == "nombre_entier" else float(raw)) if raw != "" else None)
col = fname
else:
values.append(form_data.get(fname, "") or None)
col = fname
cols.append(col)
return cols, values
+26
View File
@@ -0,0 +1,26 @@
from ..connection import connect
from ..slugify import slugify
from ..definitions.definitions_referencing import definitions_referencing
from ..definitions.get_definition import get_definition
def rows_referencing(slug, definition_id, row_id):
"""Pour l'écran de suppression d'une ligne : liste, pour chaque objet
qui a une relation vers l'objet de cette ligne, combien de ses lignes
pointent vers CETTE ligne précise — pour avertir avant une suppression
qui laisserait des relations orphelines (aucune suppression en cascade
n'est faite automatiquement, par sécurité)."""
refs = []
for d in definitions_referencing(slug, definition_id):
full = get_definition(slug, d["id"])
for f in full["fields"]:
if f["type"] == "relation" and f["relation_definition_id"] == definition_id:
col = slugify(f["name"]).replace("-", "_") + "_id"
conn = connect(slug)
count = conn.execute(
f"SELECT COUNT(*) AS c FROM {full['table_name']} WHERE {col} = ?", (row_id,)
).fetchone()["c"]
conn.close()
if count:
refs.append({"definition_name": full["name"], "field_name": f["name"], "count": count})
return refs
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
from .row_columns_and_values import row_columns_and_values
def update_row(slug, definition, row_id, form_data):
"""CRUD — Update : ré-enregistre une ligne existante avec les nouvelles
valeurs saisies dans le même formulaire généré que pour la création."""
cols, values = row_columns_and_values(definition, form_data)
conn = connect(slug)
set_clause = ", ".join(f"{c} = ?" for c in cols)
conn.execute(
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ?",
values + [row_id],
)
conn.commit()
conn.close()
+15
View File
@@ -0,0 +1,15 @@
from ..connection import connect
from ..slugify import slugify
def update_row_field(slug, definition, row_id, field_def, new_value):
"""Met à jour UNE SEULE colonne d'une ligne existante — utilisé par les
actions "Modifier une donnée" du mode jouable, qui ne doivent jamais
écraser les autres champs de la ligne (contrairement à update_row, qui
attend un formulaire complet)."""
fname = slugify(field_def["name"]).replace("-", "_")
col = f"{fname}_id" if field_def["type"] == "relation" else fname
conn = connect(slug)
conn.execute(f"UPDATE {definition['table_name']} SET {col} = ? WHERE id = ?", (new_value, row_id))
conn.commit()
conn.close()
+8
View File
@@ -0,0 +1,8 @@
import re
def slugify(name):
s = name.strip().lower()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-+", "-", s).strip("-")
return s or "jeu"
+6
View File
@@ -0,0 +1,6 @@
from .slugify import slugify
def table_name_for(object_name):
"""Nom de la vraie table SQL pour un objet défini par l'utilisateur."""
return "obj_" + slugify(object_name).replace("-", "_")