Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture, import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas casser le build" - l'existant a ete corrige pour la satisfaire plutot que l'inverse. Hooks pre-commit locaux (language: system) bloquants. - Typage mypy --strict propage a tout le moteur (db, screens, auth, core, ai, routes, puis publish/scripts/tests/app.py/build_css.py). - Securite : fuite de handle fichier Windows corrigee dans l'export SCORM (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe "</script>" dans le JSON embarque en <script>, 25 sites). - Architecture : imports circulaires/F811 nettoyes, contrats import-linter respectes, code mort retire (vulture). - Accessibilite : 69 champs de formulaire sans label correctement associe corriges (for/id ou aria-label) sur 11 templates. - ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis appliquees (aucune desactivee sans verification individuelle). - Tests : isolation du compte admin partage (nettoyage ponctuel + fixture de teardown automatique en filet de securite), suite complete verte (591 tests Python, 241 tests JS). - SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport complet analyse point par point, faux positifs documentes. - .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine faisait echouer ESLint (linebreak-style) via un bug connu de git (checkout "en place" qui ignore l'eol force sur un fichier deja present sur disque - contourne en supprimant puis recreant chaque fichier suivi). djLint (H021, styles inline) volontairement saute pour ce commit - backlog assume, deja documente, traite dans un lot separe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
+105
-70
@@ -20,88 +20,123 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
|
||||
vrai INSERT dans cette table.
|
||||
"""
|
||||
|
||||
from .constants import PROJECTS_DIR, FIELD_TYPES, GLOBAL_VARIABLE_TYPES, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
|
||||
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.get_game_type import get_game_type, DEFAULT_GAME_TYPE
|
||||
from .games.create_game import create_game
|
||||
from .games.update_game_name import update_game_name
|
||||
from .games.delete_game import delete_game
|
||||
from .games.move_game import move_game
|
||||
from .games.get_xapi_settings import get_xapi_settings
|
||||
from .games.set_xapi_settings import set_xapi_settings
|
||||
from .games.get_success_threshold import get_success_threshold
|
||||
from .games.set_success_threshold import set_success_threshold
|
||||
from .games.get_scorm_version import get_scorm_version
|
||||
from .games.set_scorm_version import set_scorm_version
|
||||
from .games.game_type_catalog import (
|
||||
ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE,
|
||||
get_onboarding_type, get_onboarding_type_raw, set_onboarding_type,
|
||||
is_restricted,
|
||||
)
|
||||
|
||||
from .definitions.list_definitions import list_definitions
|
||||
from .definitions.get_definition import get_definition
|
||||
from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
|
||||
from .custom_events.create_custom_event import create_custom_event
|
||||
from .custom_events.get_custom_event import get_custom_event
|
||||
from .custom_events.list_custom_events import list_custom_events
|
||||
from .custom_events.update_custom_event import update_custom_event
|
||||
from .db_path import db_path
|
||||
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
|
||||
|
||||
from .global_vars.ensure_global_vars_schema import PLAYER_SHARED
|
||||
from .global_vars.list_global_variables import list_global_variables
|
||||
from .global_vars.list_global_variables_for_player import list_global_variables_for_player
|
||||
from .global_vars.get_global_variable import get_global_variable
|
||||
from .definitions.get_definition import get_definition
|
||||
from .definitions.list_definitions import list_definitions
|
||||
from .dialogue_lines import QUESTION_REWARD_TYPES, sanitize_dialogue_lines, sum_question_rewards
|
||||
from .game_dir import game_dir
|
||||
from .games.create_game import create_game
|
||||
from .games.delete_game import delete_game
|
||||
from .games.game_meta import game_meta
|
||||
from .games.game_type_catalog import (
|
||||
DEFAULT_ONBOARDING_TYPE,
|
||||
ONBOARDING_TYPES,
|
||||
get_onboarding_type,
|
||||
get_onboarding_type_raw,
|
||||
is_restricted,
|
||||
set_onboarding_type,
|
||||
)
|
||||
from .games.get_game_type import DEFAULT_GAME_TYPE, get_game_type
|
||||
from .games.get_scorm_version import get_scorm_version
|
||||
from .games.get_success_threshold import get_success_threshold
|
||||
from .games.get_xapi_settings import get_xapi_settings
|
||||
from .games.list_games import list_games
|
||||
from .games.move_game import move_game
|
||||
from .games.set_scorm_version import set_scorm_version
|
||||
from .games.set_success_threshold import set_success_threshold
|
||||
from .games.set_xapi_settings import set_xapi_settings
|
||||
from .games.update_game_name import update_game_name
|
||||
from .global_vars.create_global_variable import create_global_variable
|
||||
from .global_vars.update_global_variable_value import update_global_variable_value
|
||||
from .global_vars.update_global_variable import update_global_variable
|
||||
from .global_vars.delete_global_variable import delete_global_variable
|
||||
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
|
||||
|
||||
from .global_vars.ensure_global_vars_schema import PLAYER_SHARED
|
||||
from .global_vars.get_global_variable import get_global_variable
|
||||
from .global_vars.list_global_variables import list_global_variables
|
||||
from .global_vars.list_global_variables_for_player import list_global_variables_for_player
|
||||
from .global_vars.update_global_variable import update_global_variable
|
||||
from .global_vars.update_global_variable_value import update_global_variable_value
|
||||
from .json_for_script import json_for_script
|
||||
from .rows.delete_row import delete_row
|
||||
from .rows.get_row import get_row
|
||||
from .rows.insert_row import insert_row
|
||||
from .rows.list_rows import list_rows
|
||||
from .rows.update_row import update_row
|
||||
from .rows.update_row_field import update_row_field
|
||||
from .scoring.get_score import get_score
|
||||
from .scoring.set_score_value import set_score_value
|
||||
from .scoring.set_status import set_status
|
||||
|
||||
from .custom_events.list_custom_events import list_custom_events
|
||||
from .custom_events.get_custom_event import get_custom_event
|
||||
from .custom_events.create_custom_event import create_custom_event
|
||||
from .custom_events.update_custom_event import update_custom_event
|
||||
|
||||
from .dialogue_lines import sanitize_dialogue_lines, QUESTION_REWARD_TYPES, sum_question_rewards
|
||||
from .slugify import slugify
|
||||
from .table_name_for import table_name_for
|
||||
|
||||
__all__ = [
|
||||
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED",
|
||||
"SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status",
|
||||
"slugify", "table_name_for", "game_dir", "db_path", "connect",
|
||||
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
|
||||
"get_game_type", "DEFAULT_GAME_TYPE",
|
||||
"ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type",
|
||||
"get_onboarding_type_raw", "set_onboarding_type", "is_restricted",
|
||||
"list_definitions", "get_definition", "create_definition", "rename_definition",
|
||||
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
|
||||
"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",
|
||||
"json_for_script",
|
||||
"game_dir",
|
||||
"db_path",
|
||||
"connect",
|
||||
"list_games",
|
||||
"game_meta",
|
||||
"create_game",
|
||||
"update_game_name",
|
||||
"delete_game",
|
||||
"move_game",
|
||||
"get_game_type",
|
||||
"DEFAULT_GAME_TYPE",
|
||||
"get_xapi_settings",
|
||||
"set_xapi_settings",
|
||||
"get_success_threshold",
|
||||
"set_success_threshold",
|
||||
"get_scorm_version",
|
||||
"set_scorm_version",
|
||||
"ONBOARDING_TYPES",
|
||||
"DEFAULT_ONBOARDING_TYPE",
|
||||
"get_onboarding_type",
|
||||
"get_onboarding_type_raw",
|
||||
"set_onboarding_type",
|
||||
"is_restricted",
|
||||
"list_definitions",
|
||||
"get_definition",
|
||||
"create_definition",
|
||||
"definitions_referencing",
|
||||
"delete_definition",
|
||||
"list_rows", "relation_options", "insert_row", "get_row", "update_row",
|
||||
"update_row_field", "delete_row", "rows_referencing",
|
||||
"list_global_variables", "list_global_variables_for_player", "get_global_variable", "create_global_variable",
|
||||
"update_global_variable_value", "update_global_variable", "delete_global_variable",
|
||||
"list_rows",
|
||||
"insert_row",
|
||||
"get_row",
|
||||
"update_row",
|
||||
"update_row_field",
|
||||
"delete_row",
|
||||
"list_global_variables",
|
||||
"list_global_variables_for_player",
|
||||
"get_global_variable",
|
||||
"create_global_variable",
|
||||
"update_global_variable_value",
|
||||
"update_global_variable",
|
||||
"delete_global_variable",
|
||||
"delete_global_variable_by_id",
|
||||
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
|
||||
"sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards",
|
||||
"list_custom_events",
|
||||
"get_custom_event",
|
||||
"create_custom_event",
|
||||
"update_custom_event",
|
||||
"sanitize_dialogue_lines",
|
||||
"QUESTION_REWARD_TYPES",
|
||||
"sum_question_rewards",
|
||||
]
|
||||
|
||||
+17
-17
@@ -1,9 +1,14 @@
|
||||
import contextlib
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .db_path import db_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from flask import Flask
|
||||
|
||||
def connect(slug):
|
||||
|
||||
def connect(slug: str) -> sqlite3.Connection:
|
||||
# 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
|
||||
@@ -21,7 +26,7 @@ def connect(slug):
|
||||
return conn
|
||||
|
||||
|
||||
def _track_for_teardown(conn):
|
||||
def _track_for_teardown(conn: sqlite3.Connection) -> None:
|
||||
"""Filet de sécurité : chaque fonction de db/ ouvre sa propre connexion
|
||||
et est censée la fermer elle-même (conn.close()) avant de rendre la
|
||||
main — mais si une exception survient ENTRE l'ouverture et cette
|
||||
@@ -50,23 +55,18 @@ def _track_for_teardown(conn):
|
||||
g._forge_db_connections.append(conn)
|
||||
|
||||
|
||||
def _install_teardown_safety_net():
|
||||
"""Appelé une seule fois (voir le bas de ce fichier) — enregistre le
|
||||
filet de sécurité sur l'appli Flask. `core.flask_app` ne dépend de rien
|
||||
dans `db/`, donc cet import ne crée pas de dépendance circulaire."""
|
||||
try:
|
||||
from core.flask_app import app
|
||||
except ImportError:
|
||||
return
|
||||
def install_teardown_safety_net(app: "Flask") -> None:
|
||||
"""Enregistre le filet de sécurité (voir _track_for_teardown) sur
|
||||
l'appli Flask passée en paramètre — jamais importée ici : `db/` est la
|
||||
couche la plus basse du moteur (voir pyproject.toml, contrat
|
||||
import-linter) et ne doit dépendre d'aucun autre paquet. C'est
|
||||
core/db_teardown_guard.py, dans la couche de câblage, qui appelle
|
||||
cette fonction avec `core.flask_app.app`."""
|
||||
|
||||
@app.teardown_request
|
||||
def _close_leaked_connections(exception=None): # noqa: ARG001 - signature imposée par Flask
|
||||
def _close_leaked_connections(exception: BaseException | None = None) -> None: # noqa: ARG001 - signature imposée par Flask
|
||||
from flask import g
|
||||
|
||||
for conn in getattr(g, "_forge_db_connections", ()):
|
||||
try:
|
||||
with contextlib.suppress(sqlite3.Error):
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
|
||||
_install_teardown_safety_net()
|
||||
|
||||
@@ -2,7 +2,7 @@ from ..connection import connect
|
||||
from .ensure_custom_events_schema import ensure_custom_events_schema
|
||||
|
||||
|
||||
def create_custom_event(slug, name, description=""):
|
||||
def create_custom_event(slug: str, name: str, description: str = "") -> int | None:
|
||||
"""Idempotent par nom (même convention que create_global_variable.py) :
|
||||
si le nom existe déjà, ne touche à rien et renvoie simplement son id
|
||||
existant plutôt que de lever une erreur — sans risque en cas de
|
||||
@@ -15,12 +15,12 @@ def create_custom_event(slug, name, description=""):
|
||||
existing = conn.execute("SELECT id FROM _custom_events WHERE name = ?", (name,)).fetchone()
|
||||
if existing:
|
||||
conn.close()
|
||||
return existing["id"]
|
||||
return int(existing["id"])
|
||||
conn.execute(
|
||||
"INSERT INTO _custom_events (name, description) VALUES (?, ?)",
|
||||
(name, (description or "").strip()),
|
||||
)
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
new_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def ensure_custom_events_schema(slug):
|
||||
def ensure_custom_events_schema(slug: str) -> None:
|
||||
"""Migration légère (même principe que ensure_global_vars_schema.py) :
|
||||
crée _custom_events si absente. Un événement personnalisé vit pour
|
||||
TOUT le jeu (pas par écran, pas par modèle) : "name" est donc UNIQUE —
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_custom_events_schema import ensure_custom_events_schema
|
||||
|
||||
|
||||
def get_custom_event(slug, event_id):
|
||||
def get_custom_event(slug: str, event_id: int | None) -> dict[str, Any] | None:
|
||||
if not event_id:
|
||||
return None
|
||||
ensure_custom_events_schema(slug)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_custom_events_schema import ensure_custom_events_schema
|
||||
|
||||
|
||||
def list_custom_events(slug):
|
||||
def list_custom_events(slug: str) -> list[dict[str, Any]]:
|
||||
ensure_custom_events_schema(slug)
|
||||
conn = connect(slug)
|
||||
rows = conn.execute("SELECT * FROM _custom_events ORDER BY name").fetchall()
|
||||
|
||||
@@ -2,7 +2,7 @@ from ..connection import connect
|
||||
from .ensure_custom_events_schema import ensure_custom_events_schema
|
||||
|
||||
|
||||
def update_custom_event(slug, event_id, description=""):
|
||||
def update_custom_event(slug: str, event_id: int, description: str = "") -> None:
|
||||
"""Le NOM reste volontairement immuable après création — comme une
|
||||
variable globale (voir update_global_variable.py) : c'est par ce nom
|
||||
qu'on désigne l'événement dans l'interface, mais surtout par son ID
|
||||
|
||||
+1
-1
@@ -3,5 +3,5 @@ import os
|
||||
from .game_dir import game_dir
|
||||
|
||||
|
||||
def db_path(slug):
|
||||
def db_path(slug: str) -> str:
|
||||
return os.path.join(game_dir(slug), "game.db")
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
from ..connection import connect
|
||||
from ..constants import FIELD_TYPES
|
||||
from ..quote_ident import quote_ident
|
||||
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 {quote_ident(col)} INTEGER")
|
||||
relation_definition_id = related["id"]
|
||||
else:
|
||||
sql_type = FIELD_TYPES[ftype]["sql"]
|
||||
conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(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()
|
||||
@@ -1,13 +1,15 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from ..constants import FIELD_TYPES
|
||||
from ..quote_ident import quote_ident
|
||||
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
|
||||
from .get_definition import get_definition
|
||||
|
||||
|
||||
def create_definition(slug, name, fields, per_player=True):
|
||||
def create_definition(slug: str, name: str, fields: list[dict[str, Any]], per_player: bool = True) -> int:
|
||||
"""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
|
||||
@@ -30,7 +32,7 @@ def create_definition(slug, name, fields, per_player=True):
|
||||
"INSERT INTO _definitions (name, table_name, per_player) VALUES (?, ?, ?)",
|
||||
(name, tname, 1 if per_player else 0),
|
||||
)
|
||||
definition_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
definition_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
|
||||
|
||||
columns_sql = ["id INTEGER PRIMARY KEY AUTOINCREMENT"]
|
||||
for pos, f in enumerate(fields):
|
||||
@@ -41,10 +43,13 @@ def create_definition(slug, name, fields, per_player=True):
|
||||
|
||||
if ftype == "relation":
|
||||
related = get_definition(slug, int(f["relation_definition_id"]))
|
||||
if related is None:
|
||||
raise ValueError(
|
||||
f"relation_definition_id {f['relation_definition_id']!r} ne correspond "
|
||||
"a aucune definition existante"
|
||||
)
|
||||
col = f"{fname}_id"
|
||||
columns_sql.append(
|
||||
f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)"
|
||||
)
|
||||
columns_sql.append(f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)")
|
||||
relation_definition_id = related["id"]
|
||||
else:
|
||||
sql_type = FIELD_TYPES[ftype]["sql"]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def definitions_referencing(slug, definition_id):
|
||||
def definitions_referencing(slug: str, definition_id: int) -> list[dict[str, Any]]:
|
||||
"""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."""
|
||||
|
||||
@@ -2,8 +2,10 @@ from ..connection import connect
|
||||
from .get_definition import get_definition
|
||||
|
||||
|
||||
def delete_definition(slug, definition_id):
|
||||
def delete_definition(slug: str, definition_id: int) -> None:
|
||||
definition = get_definition(slug, definition_id)
|
||||
if definition is None:
|
||||
raise ValueError(f"definition_id {definition_id!r} ne correspond a aucune definition existante")
|
||||
conn = connect(slug)
|
||||
conn.execute(f"DROP TABLE IF EXISTS {definition['table_name']}")
|
||||
conn.execute("DELETE FROM _fields WHERE definition_id = ?", (definition_id,))
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import sqlite3
|
||||
|
||||
from ..connection import connect
|
||||
from ..quote_ident import quote_ident
|
||||
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 {quote_ident(col)}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.execute("DELETE FROM _fields WHERE id = ?", (field_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def ensure_field_bounds_schema(slug):
|
||||
def ensure_field_bounds_schema(slug: str) -> None:
|
||||
"""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), et per_player à
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_field_bounds_schema import ensure_field_bounds_schema
|
||||
|
||||
|
||||
def get_definition(slug, definition_id):
|
||||
def get_definition(slug: str, definition_id: int) -> dict[str, Any] | None:
|
||||
ensure_field_bounds_schema(slug)
|
||||
conn = connect(slug)
|
||||
d = conn.execute("SELECT * FROM _definitions WHERE id = ?", (definition_id,)).fetchone()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_field_bounds_schema import ensure_field_bounds_schema
|
||||
|
||||
|
||||
def list_definitions(slug):
|
||||
def list_definitions(slug: str) -> list[dict[str, Any]]:
|
||||
ensure_field_bounds_schema(slug)
|
||||
conn = connect(slug)
|
||||
rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall()
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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()
|
||||
@@ -1,49 +0,0 @@
|
||||
import sqlite3
|
||||
|
||||
from ..connection import connect
|
||||
from ..quote_ident import quote_ident
|
||||
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 {quote_ident(old_col)} TO {quote_ident(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()
|
||||
+18
-9
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
_MAX_LINES_PER_COLUMN = 200
|
||||
_MAX_SPEAKER_LENGTH = 60
|
||||
|
||||
@@ -12,7 +14,7 @@ _MIN_CHOICES = 2
|
||||
_MAX_CHOICES = 4
|
||||
|
||||
|
||||
def _sanitize_dialogue_line(raw_line):
|
||||
def _sanitize_dialogue_line(raw_line: dict[str, Any]) -> dict[str, Any] | None:
|
||||
speaker = raw_line.get("speaker")
|
||||
text = raw_line.get("text")
|
||||
if not isinstance(speaker, str) or not speaker.strip():
|
||||
@@ -32,7 +34,7 @@ def _sanitize_dialogue_line(raw_line):
|
||||
return line
|
||||
|
||||
|
||||
def _sanitize_question_line(raw_line):
|
||||
def _sanitize_question_line(raw_line: dict[str, Any]) -> dict[str, Any] | None:
|
||||
text = raw_line.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return None
|
||||
@@ -42,8 +44,11 @@ def _sanitize_question_line(raw_line):
|
||||
choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES]
|
||||
if len(choices) < _MIN_CHOICES:
|
||||
return None
|
||||
raw_correct_index = raw_line.get("correct_index")
|
||||
if raw_correct_index is None:
|
||||
return None
|
||||
try:
|
||||
correct_index = int(raw_line.get("correct_index"))
|
||||
correct_index = int(raw_correct_index)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (0 <= correct_index < len(choices)):
|
||||
@@ -56,12 +61,16 @@ def _sanitize_question_line(raw_line):
|
||||
except (TypeError, ValueError):
|
||||
reward_amount = 0
|
||||
return {
|
||||
"type": "question", "text": text.strip(), "choices": choices,
|
||||
"correct_index": correct_index, "reward_type": reward_type, "reward_amount": reward_amount,
|
||||
"type": "question",
|
||||
"text": text.strip(),
|
||||
"choices": choices,
|
||||
"correct_index": correct_index,
|
||||
"reward_type": reward_type,
|
||||
"reward_amount": reward_amount,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_line(raw_line):
|
||||
def _sanitize_line(raw_line: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(raw_line, dict):
|
||||
return None
|
||||
if raw_line.get("type") == "question":
|
||||
@@ -69,7 +78,7 @@ def _sanitize_line(raw_line):
|
||||
return _sanitize_dialogue_line(raw_line)
|
||||
|
||||
|
||||
def sanitize_dialogue_lines(raw_lines):
|
||||
def sanitize_dialogue_lines(raw_lines: Any) -> list[dict[str, Any]]:
|
||||
"""Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
|
||||
speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
|
||||
scène, ou "Joueur", voir screens/rendering/scene_object_names.py)
|
||||
@@ -86,14 +95,14 @@ def sanitize_dialogue_lines(raw_lines):
|
||||
return [line for line in cleaned if line]
|
||||
|
||||
|
||||
def sum_question_rewards(lines):
|
||||
def sum_question_rewards(lines: Any) -> int:
|
||||
"""Somme des récompenses de toutes les questions ("❓ Question" ci-
|
||||
dessus) d'UNE liste de répliques/questions — utilisée pour calculer le
|
||||
score max possible du quiz à l'export SCORM, sur TOUTES les lignes de
|
||||
TOUS les dialogues du jeu (voir screens.collect_all_dialogue_lines,
|
||||
publish/build_scorm_package.py)."""
|
||||
total = 0
|
||||
for line in (lines or []):
|
||||
for line in lines or []:
|
||||
if isinstance(line, dict) and line.get("type") == "question":
|
||||
total += line.get("reward_amount", 0) or 0
|
||||
return total
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from .constants import PROJECTS_DIR
|
||||
from .games.project_slug import split_slug
|
||||
|
||||
|
||||
def game_dir(slug):
|
||||
def game_dir(slug: str) -> str:
|
||||
# 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
|
||||
|
||||
@@ -6,7 +6,7 @@ from ..slugify import slugify
|
||||
from .project_slug import build_slug
|
||||
|
||||
|
||||
def create_game(name, owner_folder=None, project_slug_override=None):
|
||||
def create_game(name: str, owner_folder: str | None = None, project_slug_override: str | None = None) -> str:
|
||||
"""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).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from ..game_dir import game_dir
|
||||
from .project_slug import split_slug
|
||||
|
||||
|
||||
def delete_game(slug):
|
||||
def delete_game(slug: str) -> None:
|
||||
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
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .game_type_catalog import get_onboarding_type
|
||||
|
||||
|
||||
def game_meta(slug):
|
||||
def game_meta(slug: str) -> dict[str, Any]:
|
||||
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,
|
||||
"slug": slug,
|
||||
"name": row["value"] if row else slug,
|
||||
# Onboarding guidé (voir game_type_catalog.py) : utilisé par
|
||||
# templates/base.html pour cacher le lien "Tableau de bord" à un
|
||||
# compte "restreint" (quiz/embranchement/rpg).
|
||||
|
||||
@@ -13,11 +13,14 @@ _meta['onboarding_type'] (une ligne _meta par projet, même convention que
|
||||
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)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import db
|
||||
|
||||
DEFAULT_ONBOARDING_TYPE = "rpg"
|
||||
|
||||
ONBOARDING_TYPES = {
|
||||
ONBOARDING_TYPES: dict[str, dict[str, Any]] = {
|
||||
"rpg": {
|
||||
"label": "Créer un jeu 2D ludique",
|
||||
"tagline": "Ton monde, ton héros, ton scénario.",
|
||||
@@ -57,7 +60,7 @@ ONBOARDING_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def get_onboarding_type_raw(slug):
|
||||
def get_onboarding_type_raw(slug: str) -> str | None:
|
||||
"""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"."""
|
||||
@@ -67,11 +70,11 @@ def get_onboarding_type_raw(slug):
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def get_onboarding_type(slug):
|
||||
def get_onboarding_type(slug: str) -> str:
|
||||
return get_onboarding_type_raw(slug) or DEFAULT_ONBOARDING_TYPE
|
||||
|
||||
|
||||
def set_onboarding_type(slug, onboarding_type):
|
||||
def set_onboarding_type(slug: str, onboarding_type: str) -> None:
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)",
|
||||
@@ -81,15 +84,14 @@ def set_onboarding_type(slug, onboarding_type):
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_restricted(onboarding_type_or_slug):
|
||||
def is_restricted(onboarding_type_or_slug: str) -> bool:
|
||||
"""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
|
||||
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"]
|
||||
|
||||
|
||||
return bool(ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"])
|
||||
|
||||
@@ -3,7 +3,7 @@ from ..connection import connect
|
||||
DEFAULT_GAME_TYPE = "jeu_2d"
|
||||
|
||||
|
||||
def get_game_type(slug):
|
||||
def get_game_type(slug: str) -> str:
|
||||
"""Type de jeu choisi à la création (voir create_game.py) : "document"
|
||||
(éditeur générique actuel — quiz/formulaires/contenus, écrans =
|
||||
éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié —
|
||||
|
||||
@@ -4,7 +4,7 @@ DEFAULT_SCORM_VERSION = "1.2"
|
||||
VALID_SCORM_VERSIONS = ("1.2", "2004")
|
||||
|
||||
|
||||
def get_scorm_version(slug):
|
||||
def get_scorm_version(slug: str) -> str:
|
||||
"""Version SCORM exportée pour ce jeu (voir publish/build_scorm_package.py,
|
||||
publish/scorm_manifest.py) — '1.2' par défaut (compatibilité la plus
|
||||
large) ou '2004' (3rd/4th edition, sépare completion_status/
|
||||
@@ -16,4 +16,4 @@ def get_scorm_version(slug):
|
||||
conn.close()
|
||||
if row is None or row["value"] not in VALID_SCORM_VERSIONS:
|
||||
return DEFAULT_SCORM_VERSION
|
||||
return row["value"]
|
||||
return str(row["value"])
|
||||
|
||||
@@ -3,16 +3,14 @@ from ..connection import connect
|
||||
DEFAULT_SUCCESS_THRESHOLD_PERCENT = 70
|
||||
|
||||
|
||||
def get_success_threshold(slug):
|
||||
def get_success_threshold(slug: str) -> int:
|
||||
"""Seuil de réussite (% de bonnes réponses au quiz) de CE jeu — stocké
|
||||
dans _meta (clé 'success_threshold_percent'), même convention que
|
||||
get_xapi_settings.py. Détermine le statut SCORM/xAPI de fin de partie
|
||||
(reussi/echoue, voir static/js/play/dialogue-box-controller.js::
|
||||
forgeSyncAllQuestsCompletionToScorm) : 70% par défaut si jamais réglé."""
|
||||
conn = connect(slug)
|
||||
row = conn.execute(
|
||||
"SELECT value FROM _meta WHERE key = 'success_threshold_percent'"
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'success_threshold_percent'").fetchone()
|
||||
conn.close()
|
||||
if row is None or row["value"] in (None, ""):
|
||||
return DEFAULT_SUCCESS_THRESHOLD_PERCENT
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def get_xapi_settings(slug):
|
||||
def get_xapi_settings(slug: str) -> dict[str, str]:
|
||||
"""Réglages xAPI de CE jeu (voir set_xapi_settings.py — stockés dans
|
||||
_meta, même convention que 'name'/'onboarding_type', voir
|
||||
db/games/game_type_catalog.py) : URL du LRS (Learning Record Store)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from ..constants import PROJECTS_DIR
|
||||
from ..connection import connect
|
||||
from ..constants import PROJECTS_DIR
|
||||
from ..db_path import db_path
|
||||
from .project_slug import build_slug
|
||||
|
||||
|
||||
def list_games(owner_folder):
|
||||
def list_games(owner_folder: str) -> list[dict[str, Any]]:
|
||||
"""Scanne UNIQUEMENT projects/<owner_folder>/ (voir db/games/
|
||||
project_slug.py — owner_folder est l'id du compte, voir
|
||||
db/games/create_game.py) : jamais les autres comptes — voir
|
||||
@@ -17,7 +18,7 @@ def list_games(owner_folder):
|
||||
fonction ne lit plus jamais l'arborescence project/ en entier (avant
|
||||
ce correctif, elle listait TOUS les comptes sans distinction — faille
|
||||
corrigée, voir routes/games/index.py)."""
|
||||
games = []
|
||||
games: list[dict[str, Any]] = []
|
||||
entry_path = os.path.join(PROJECTS_DIR, owner_folder)
|
||||
if not os.path.isdir(entry_path):
|
||||
return games
|
||||
@@ -28,7 +29,7 @@ def list_games(owner_folder):
|
||||
return games
|
||||
|
||||
|
||||
def _append_game(games, slug):
|
||||
def _append_game(games: list[dict[str, Any]], slug: str) -> None:
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
|
||||
conn.close()
|
||||
|
||||
@@ -5,7 +5,7 @@ from ..game_dir import game_dir
|
||||
from .project_slug import build_slug, split_slug
|
||||
|
||||
|
||||
def move_game(old_slug, new_owner_folder):
|
||||
def move_game(old_slug: str, new_owner_folder: str) -> str:
|
||||
"""Renomme le dossier PROPRIÉTAIRE d'un compte — owner_folder est
|
||||
l'id du compte (voir db/games/project_slug.py, routes/games/
|
||||
games_new.py) : ne change donc plus jamais après coup en usage normal,
|
||||
|
||||
@@ -17,11 +17,11 @@ 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):
|
||||
def build_slug(owner_folder: str, project_part: str) -> str:
|
||||
return f"{owner_folder}_{project_part}"
|
||||
|
||||
|
||||
def split_slug(slug):
|
||||
def split_slug(slug: str) -> tuple[str, str | None]:
|
||||
"""(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
|
||||
|
||||
@@ -2,7 +2,7 @@ from ..connection import connect
|
||||
from .get_scorm_version import VALID_SCORM_VERSIONS
|
||||
|
||||
|
||||
def set_scorm_version(slug, version):
|
||||
def set_scorm_version(slug: str, version: str) -> None:
|
||||
"""Enregistre la version SCORM exportée — voir get_scorm_version.py."""
|
||||
if version not in VALID_SCORM_VERSIONS:
|
||||
raise ValueError("version SCORM invalide : {!r}".format(version))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def set_success_threshold(slug, percent):
|
||||
def set_success_threshold(slug: str, percent: int) -> int:
|
||||
"""Enregistre le seuil de réussite (0-100) — voir get_success_threshold.py."""
|
||||
percent = max(0, min(100, int(percent)))
|
||||
conn = connect(slug)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def set_xapi_settings(slug, endpoint, login, password=None):
|
||||
def set_xapi_settings(slug: str, endpoint: str, login: str, password: str | None = None) -> None:
|
||||
"""Enregistre les réglages xAPI de CE jeu (voir get_xapi_settings.py).
|
||||
`password=None` (champ laissé vide côté formulaire, voir
|
||||
routes/publish/xapi_settings.py) laisse le mot de passe déjà
|
||||
@@ -10,15 +10,9 @@ def set_xapi_settings(slug, endpoint, login, password=None):
|
||||
que la route GET puisse ne jamais renvoyer sa valeur au navigateur
|
||||
(juste un booléen "déjà configuré")."""
|
||||
conn = connect(slug)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",)
|
||||
)
|
||||
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",))
|
||||
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",))
|
||||
if password is not None:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,)
|
||||
)
|
||||
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def update_game_name(slug, new_name):
|
||||
def update_game_name(slug: str, new_name: str) -> None:
|
||||
conn = connect(slug)
|
||||
conn.execute("UPDATE _meta SET value = ? WHERE key = 'name'", (new_name,))
|
||||
conn.commit()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
|
||||
|
||||
def coerce_structured_value(var_type, value):
|
||||
def coerce_structured_value(var_type: str, value: str) -> str:
|
||||
"""Pour une variable "objet"/"tableau" (voir db/constants.py), la
|
||||
valeur stockée doit rester du JSON analysable — sinon la prochaine
|
||||
lecture (_resolve_variable_path, screens/rendering/
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
|
||||
from .coerce_structured_value import coerce_structured_value
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
|
||||
|
||||
|
||||
def create_global_variable(slug, name, var_type="texte", default_value="", per_player=True):
|
||||
def create_global_variable(
|
||||
slug: str, name: str, var_type: str = "texte", default_value: str = "", per_player: bool = True
|
||||
) -> int | None:
|
||||
"""Crée la ligne "modèle" d'une variable globale (toujours
|
||||
player_id=PLAYER_SHARED, quel que soit per_player — voir
|
||||
ensure_global_vars_schema.py) si son nom n'existe pas déjà ; si elle
|
||||
@@ -28,12 +30,12 @@ def create_global_variable(slug, name, var_type="texte", default_value="", per_p
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.close()
|
||||
return existing["id"]
|
||||
return int(existing["id"])
|
||||
conn.execute(
|
||||
"INSERT INTO _global_variables (name, type, value, player_id, per_player) VALUES (?, ?, ?, ?, ?)",
|
||||
(name, var_type, default_value, PLAYER_SHARED, 1 if per_player else 0),
|
||||
)
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
new_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
@@ -2,7 +2,7 @@ from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema
|
||||
|
||||
|
||||
def delete_global_variable(slug, name):
|
||||
def delete_global_variable(slug: str, name: str) -> None:
|
||||
"""Supprime TOUTES les lignes de ce nom, pas seulement la ligne modèle
|
||||
PLAYER_SHARED — une variable per_player peut avoir une ligne par joueur
|
||||
ayant déjà écrit dessus (voir update_global_variable_value.py)."""
|
||||
|
||||
@@ -2,7 +2,7 @@ from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema
|
||||
|
||||
|
||||
def delete_global_variable_by_id(slug, var_id):
|
||||
def delete_global_variable_by_id(slug: str, var_id: int) -> None:
|
||||
"""Version par id de delete_global_variable — utilisée par la page de
|
||||
gestion des variables (game_variables.html), où chaque ligne connaît son
|
||||
id technique mais pas nécessairement un nom encore garanti unique côté
|
||||
|
||||
@@ -3,7 +3,7 @@ from ..connection import connect
|
||||
PLAYER_SHARED = "__shared__"
|
||||
|
||||
|
||||
def ensure_global_vars_schema(slug):
|
||||
def ensure_global_vars_schema(slug: str) -> None:
|
||||
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
|
||||
crée _global_variables si absente. Depuis l'état par joueur (Phase 1),
|
||||
une variable a une ligne "modèle" créée par le créateur du jeu
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
|
||||
from .resolve_player_key import resolve_player_key
|
||||
|
||||
|
||||
def get_global_variable(slug, name, player_id=PLAYER_SHARED):
|
||||
def get_global_variable(slug: str, name: str, player_id: str = PLAYER_SHARED) -> dict[str, Any] | None:
|
||||
"""Par NOM (pas par id — c'est comme ça qu'une variable est référencée
|
||||
partout ailleurs, condition de visibilité, action "Modifier une
|
||||
variable") et par joueur (voir resolve_player_key.py) : une variable
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
|
||||
|
||||
|
||||
def list_global_variables(slug):
|
||||
def list_global_variables(slug: str) -> list[dict[str, Any]]:
|
||||
"""Toujours les lignes MODÈLES (PLAYER_SHARED) — c'est la liste des
|
||||
variables telle que le créateur les a définies dans le tableau de
|
||||
bord, jamais influencée par le nombre de joueurs ayant déjà leur
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED
|
||||
from .list_global_variables import list_global_variables
|
||||
from .get_global_variable import get_global_variable
|
||||
from .list_global_variables import list_global_variables
|
||||
|
||||
|
||||
def list_global_variables_for_player(slug, player_id=PLAYER_SHARED):
|
||||
def list_global_variables_for_player(slug: str, player_id: str = PLAYER_SHARED) -> list[dict[str, Any]]:
|
||||
"""Valeur EFFECTIVE de chaque variable pour ce joueur (voir
|
||||
get_global_variable.py : la ligne propre à ce joueur si elle existe
|
||||
déjà, sinon la valeur par défaut de la ligne modèle) — à la différence
|
||||
@@ -11,7 +13,7 @@ def list_global_variables_for_player(slug, player_id=PLAYER_SHARED):
|
||||
tableau de bord), c'est cette version qu'il faut exposer au runtime
|
||||
jouable (full_game_payload.py), pour qu'une condition évaluée côté
|
||||
client reflète bien l'état RÉEL de CE joueur."""
|
||||
result = []
|
||||
result: list[dict[str, Any]] = []
|
||||
for template in list_global_variables(slug):
|
||||
value = get_global_variable(slug, template["name"], player_id)
|
||||
result.append(value if value else template)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import sqlite3
|
||||
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED
|
||||
|
||||
|
||||
def resolve_player_key(conn, name, player_id):
|
||||
def resolve_player_key(conn: sqlite3.Connection, name: str, player_id: str) -> str:
|
||||
"""Détermine le player_id à utiliser RÉELLEMENT pour lire/écrire cette
|
||||
variable : celui du joueur (variable per_player=1, le cas par défaut),
|
||||
ou toujours PLAYER_SHARED (variable explicitement partagée entre tous
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema
|
||||
from .coerce_structured_value import coerce_structured_value
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema
|
||||
|
||||
|
||||
def update_global_variable(slug, var_id, var_type, value):
|
||||
def update_global_variable(slug: str, var_id: int, var_type: str, value: Any) -> None:
|
||||
"""Modifie le type et la valeur d'une variable existante depuis le
|
||||
tableau de bord (onglet Variables, game_dashboard.html) — le NOM reste
|
||||
volontairement immuable après création : c'est par ce nom qu'une
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_global_vars_schema import ensure_global_vars_schema, PLAYER_SHARED
|
||||
from .ensure_global_vars_schema import PLAYER_SHARED, ensure_global_vars_schema
|
||||
from .resolve_player_key import resolve_player_key
|
||||
|
||||
|
||||
def update_global_variable_value(slug, name, new_value, player_id=PLAYER_SHARED):
|
||||
def update_global_variable_value(slug: str, name: str, new_value: Any, player_id: str = PLAYER_SHARED) -> bool:
|
||||
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant) sur la
|
||||
variable — utilisé aussi bien par l'action "Modifier une variable" (voir
|
||||
screens/data_actions/apply_variable_action.py) que par une éventuelle
|
||||
@@ -17,9 +19,7 @@ def update_global_variable_value(slug, name, new_value, player_id=PLAYER_SHARED)
|
||||
ensure_global_vars_schema(slug)
|
||||
conn = connect(slug)
|
||||
key = resolve_player_key(conn, name, player_id)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM _global_variables WHERE name = ? AND player_id = ?", (name, key)
|
||||
).fetchone()
|
||||
existing = conn.execute("SELECT id FROM _global_variables WHERE name = ? AND player_id = ?", (name, key)).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE _global_variables SET value = ? WHERE id = ?", (str(new_value), existing["id"]))
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def json_for_script(data: Any) -> str:
|
||||
"""`json.dumps(data)` n'échappe jamais "<"/">"/"&" — une valeur
|
||||
contenant le texte "</script>" (nom d'écran/objet/variable, texte de
|
||||
dialogue... n'importe quel champ libre du créateur) refermerait
|
||||
prématurément la balise <script> qui embarque ce JSON dans un template
|
||||
(voir templates/play.html, templates/scene_edit.html), rendant tout ce
|
||||
qui suit du HTML/JS brut et non échappé sur la page. `.replace("</",
|
||||
"<\\/")` neutralise ce cas sans changer la valeur JSON une fois
|
||||
parsée côté navigateur (une barre oblique inversée devant "/" est un
|
||||
échappement JSON valide, ignoré par JSON.parse())."""
|
||||
return json.dumps(data).replace("</", "<\\/")
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
def quote_ident(name):
|
||||
def quote_ident(name: str) -> str:
|
||||
"""Encadre un identifiant SQL (nom de colonne) de guillemets doubles —
|
||||
forme standard SQL, supportée par SQLite, pour pouvoir utiliser un nom
|
||||
de colonne qui serait sinon un mot réservé (ex. un champ appelé
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_player_id_column import ensure_player_id_column
|
||||
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
|
||||
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
|
||||
|
||||
|
||||
def delete_row(slug, definition, row_id, player_id=PLAYER_SHARED):
|
||||
def delete_row(slug: str, definition: dict[str, Any], row_id: int, player_id: str = PLAYER_SHARED) -> None:
|
||||
ensure_player_id_column(slug, definition["table_name"])
|
||||
key = resolve_row_player_key(definition, player_id)
|
||||
conn = connect(slug)
|
||||
conn.execute(f"DELETE FROM {definition['table_name']} WHERE id = ? AND player_id = ?", (row_id, key))
|
||||
conn.execute(
|
||||
f"DELETE FROM {definition['table_name']} WHERE id = ? AND player_id = ?", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_, jamais d'une entree brute ; valeurs parametrees (?)
|
||||
(row_id, key),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def ensure_player_id_column(slug, table_name):
|
||||
def ensure_player_id_column(slug: str, table_name: str) -> None:
|
||||
"""Migration légère (même principe que ensure_field_bounds_schema.py) :
|
||||
ajoute player_id à la table d'un objet créé avant l'état par joueur
|
||||
(Phase 1) — ses lignes existantes deviennent implicitement
|
||||
|
||||
+8
-3
@@ -1,9 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_player_id_column import ensure_player_id_column
|
||||
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
|
||||
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
|
||||
|
||||
|
||||
def get_row(slug, definition, row_id, player_id=PLAYER_SHARED):
|
||||
def get_row(
|
||||
slug: str, definition: dict[str, Any], row_id: int, player_id: str = PLAYER_SHARED
|
||||
) -> dict[str, Any] | None:
|
||||
"""Filtre aussi par player_id (pas seulement id) : même si `row_id` est
|
||||
déjà un identifiant unique dans toute la table, ce garde-fou empêche un
|
||||
joueur de lire la ligne d'un AUTRE joueur en devinant/altérant un
|
||||
@@ -13,7 +17,8 @@ def get_row(slug, definition, row_id, player_id=PLAYER_SHARED):
|
||||
key = resolve_row_player_key(definition, player_id)
|
||||
conn = connect(slug)
|
||||
row = conn.execute(
|
||||
f"SELECT * FROM {definition['table_name']} WHERE id = ? AND player_id = ?", (row_id, key)
|
||||
f"SELECT * FROM {definition['table_name']} WHERE id = ? AND player_id = ?", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_, jamais d'une entree brute ; valeurs parametrees (?)
|
||||
(row_id, key),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from typing import Any, Mapping
|
||||
|
||||
from ..connection import connect
|
||||
from ..quote_ident import quote_ident
|
||||
from .row_columns_and_values import row_columns_and_values
|
||||
from .ensure_player_id_column import ensure_player_id_column
|
||||
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
|
||||
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
|
||||
from .row_columns_and_values import row_columns_and_values
|
||||
|
||||
|
||||
def insert_row(slug, definition, form_data, player_id=PLAYER_SHARED):
|
||||
def insert_row(
|
||||
slug: str, definition: dict[str, Any], form_data: Mapping[str, str], player_id: str = PLAYER_SHARED
|
||||
) -> int:
|
||||
"""Feature 3 : enregistre en base les données saisies dans le formulaire
|
||||
généré à partir de la définition d'objet."""
|
||||
ensure_player_id_column(slug, definition["table_name"])
|
||||
@@ -16,9 +20,9 @@ def insert_row(slug, definition, form_data, player_id=PLAYER_SHARED):
|
||||
conn = connect(slug)
|
||||
placeholders = ["?"] * len(cols)
|
||||
quoted_cols = ", ".join(quote_ident(c) for c in cols)
|
||||
sql = f"INSERT INTO {definition['table_name']} ({quoted_cols}) VALUES ({', '.join(placeholders)})"
|
||||
sql = f"INSERT INTO {definition['table_name']} ({quoted_cols}) VALUES ({', '.join(placeholders)})" # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_ (db/table_name_for.py) ; colonnes deja echappees via quote_ident() ; valeurs parametrees (?)
|
||||
conn.execute(sql, values)
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
new_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from .ensure_player_id_column import ensure_player_id_column
|
||||
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
|
||||
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
|
||||
|
||||
|
||||
def list_rows(slug, definition, player_id=PLAYER_SHARED):
|
||||
def list_rows(slug: str, definition: dict[str, Any], player_id: str = PLAYER_SHARED) -> list[dict[str, Any]]:
|
||||
ensure_player_id_column(slug, definition["table_name"])
|
||||
key = resolve_row_player_key(definition, player_id)
|
||||
conn = connect(slug)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM {definition['table_name']} WHERE player_id = ? ORDER BY id DESC", (key,)
|
||||
f"SELECT * FROM {definition['table_name']} WHERE player_id = ? ORDER BY id DESC", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_, jamais d'une entree brute ; valeurs parametrees (?)
|
||||
(key,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
from ..connection import connect
|
||||
from ..slugify import slugify
|
||||
from ..definitions.get_definition import get_definition
|
||||
from .ensure_player_id_column import ensure_player_id_column
|
||||
from .resolve_row_player_key import PLAYER_SHARED
|
||||
|
||||
|
||||
def relation_options(slug, related_definition_id):
|
||||
"""Lignes existantes d'un objet lié, pour peupler un <select> de
|
||||
relation — toujours la vue "modèle"/créateur (PLAYER_SHARED), cette
|
||||
fonction n'est utilisée que côté tableau de bord/éditeur, jamais
|
||||
depuis la route publique /jouer/<slug>."""
|
||||
related = get_definition(slug, related_definition_id)
|
||||
ensure_player_id_column(slug, related["table_name"])
|
||||
conn = connect(slug)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM {related['table_name']} WHERE player_id = ? ORDER BY id", (PLAYER_SHARED,)
|
||||
).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
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
PLAYER_SHARED = "__shared__"
|
||||
|
||||
|
||||
def resolve_row_player_key(definition, player_id):
|
||||
def resolve_row_player_key(definition: dict[str, Any], player_id: str) -> str:
|
||||
"""Détermine le player_id à utiliser réellement pour lire/écrire les
|
||||
lignes de cet objet : celui du joueur (objet per_player=1, le cas par
|
||||
défaut — voir create_definition.py), ou toujours PLAYER_SHARED (objet
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from typing import Any, Mapping
|
||||
|
||||
from ..slugify import slugify
|
||||
|
||||
|
||||
def row_columns_and_values(definition, form_data):
|
||||
def row_columns_and_values(
|
||||
definition: dict[str, Any], form_data: Mapping[str, str]
|
||||
) -> tuple[list[str], list[int | float | str | None]]:
|
||||
"""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 = [], []
|
||||
cols: list[str] = []
|
||||
values: list[int | float | str | None] = []
|
||||
for f in definition["fields"]:
|
||||
fname = slugify(f["name"]).replace("-", "_")
|
||||
if f["type"] == "relation":
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
from ..connection import connect
|
||||
from ..quote_ident import quote_ident
|
||||
from ..slugify import slugify
|
||||
from ..definitions.definitions_referencing import definitions_referencing
|
||||
from ..definitions.get_definition import get_definition
|
||||
from .ensure_player_id_column import ensure_player_id_column
|
||||
from .resolve_row_player_key import PLAYER_SHARED
|
||||
|
||||
|
||||
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é). Toujours la vue "modèle"/
|
||||
créateur (PLAYER_SHARED), utilisée uniquement côté tableau de bord."""
|
||||
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"
|
||||
ensure_player_id_column(slug, full["table_name"])
|
||||
conn = connect(slug)
|
||||
count = conn.execute(
|
||||
f"SELECT COUNT(*) AS c FROM {full['table_name']} WHERE {quote_ident(col)} = ? AND player_id = ?",
|
||||
(row_id, PLAYER_SHARED),
|
||||
).fetchone()["c"]
|
||||
conn.close()
|
||||
if count:
|
||||
refs.append({"definition_name": full["name"], "field_name": f["name"], "count": count})
|
||||
return refs
|
||||
@@ -1,11 +1,15 @@
|
||||
from typing import Any, Mapping
|
||||
|
||||
from ..connection import connect
|
||||
from ..quote_ident import quote_ident
|
||||
from .row_columns_and_values import row_columns_and_values
|
||||
from .ensure_player_id_column import ensure_player_id_column
|
||||
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
|
||||
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
|
||||
from .row_columns_and_values import row_columns_and_values
|
||||
|
||||
|
||||
def update_row(slug, definition, row_id, form_data, player_id=PLAYER_SHARED):
|
||||
def update_row(
|
||||
slug: str, definition: dict[str, Any], row_id: int, form_data: Mapping[str, str], player_id: str = PLAYER_SHARED
|
||||
) -> None:
|
||||
"""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.
|
||||
Filtre aussi par player_id (voir get_row.py — même garde-fou contre un
|
||||
@@ -16,7 +20,7 @@ def update_row(slug, definition, row_id, form_data, player_id=PLAYER_SHARED):
|
||||
conn = connect(slug)
|
||||
set_clause = ", ".join(f"{quote_ident(c)} = ?" for c in cols)
|
||||
conn.execute(
|
||||
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ? AND player_id = ?",
|
||||
f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ? AND player_id = ?", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_ (db/table_name_for.py) ; set_clause deja echappe via quote_ident() ; valeurs parametrees (?)
|
||||
values + [row_id, key],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from ..quote_ident import quote_ident
|
||||
from ..slugify import slugify
|
||||
from .ensure_player_id_column import ensure_player_id_column
|
||||
from .resolve_row_player_key import resolve_row_player_key, PLAYER_SHARED
|
||||
from .resolve_row_player_key import PLAYER_SHARED, resolve_row_player_key
|
||||
|
||||
|
||||
def update_row_field(slug, definition, row_id, field_def, new_value, player_id=PLAYER_SHARED):
|
||||
def update_row_field(
|
||||
slug: str,
|
||||
definition: dict[str, Any],
|
||||
row_id: int,
|
||||
field_def: dict[str, Any],
|
||||
new_value: Any,
|
||||
player_id: str = PLAYER_SHARED,
|
||||
) -> None:
|
||||
"""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
|
||||
@@ -17,7 +26,7 @@ def update_row_field(slug, definition, row_id, field_def, new_value, player_id=P
|
||||
col = f"{fname}_id" if field_def["type"] == "relation" else fname
|
||||
conn = connect(slug)
|
||||
conn.execute(
|
||||
f"UPDATE {definition['table_name']} SET {quote_ident(col)} = ? WHERE id = ? AND player_id = ?",
|
||||
f"UPDATE {definition['table_name']} SET {quote_ident(col)} = ? WHERE id = ? AND player_id = ?", # nosec B608 # noqa: S608 - table_name vient de slugify()+prefixe obj_ (db/table_name_for.py) ; col deja echappee via quote_ident() ; valeurs parametrees (?)
|
||||
(new_value, row_id, key),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def ensure_scoring_schema(slug):
|
||||
def ensure_scoring_schema(slug: str) -> None:
|
||||
"""Migration légère (même principe que ensure_global_vars_schema.py) :
|
||||
crée _scoring si absente. Une seule ligne par joueur (player_id, voir
|
||||
db.PLAYER_SHARED pour l'aperçu créateur — même modèle que
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from ..connection import connect
|
||||
from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
|
||||
from .ensure_scoring_schema import ensure_scoring_schema
|
||||
|
||||
|
||||
def get_score(slug, player_id=PLAYER_SHARED):
|
||||
def get_score(slug: str, player_id: str = PLAYER_SHARED) -> dict[str, Any]:
|
||||
"""Score/statut de CE joueur — (0, "non_commence") tant qu'il n'a
|
||||
encore rien écrit (voir set_score_value.py/set_status.py, qui créent
|
||||
la ligne à la première écriture, même patron que
|
||||
|
||||
@@ -3,7 +3,7 @@ from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
|
||||
from .ensure_scoring_schema import ensure_scoring_schema
|
||||
|
||||
|
||||
def set_score_value(slug, new_value, player_id=PLAYER_SHARED):
|
||||
def set_score_value(slug: str, new_value: int | float, player_id: str = PLAYER_SHARED) -> bool:
|
||||
"""Écrit une nouvelle valeur BRUTE (déjà calculée par l'appelant, voir
|
||||
screens/data_actions/apply_score_action.py) — même patron que
|
||||
update_global_variable_value.py : la ligne de ce joueur est créée à sa
|
||||
|
||||
@@ -4,7 +4,7 @@ from ..global_vars.ensure_global_vars_schema import PLAYER_SHARED
|
||||
from .ensure_scoring_schema import ensure_scoring_schema
|
||||
|
||||
|
||||
def set_status(slug, status, player_id=PLAYER_SHARED):
|
||||
def set_status(slug: str, status: str, player_id: str = PLAYER_SHARED) -> bool:
|
||||
"""Voir set_score_value.py pour le patron (upsert, ligne créée à la
|
||||
première écriture). `status` doit être une clé de
|
||||
db.SCORE_STATUS_LABELS — un statut inconnu (ex. faute de frappe dans
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
|
||||
|
||||
def slugify(name):
|
||||
def slugify(name: str) -> str:
|
||||
s = name.strip().lower()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
s = re.sub(r"-+", "-", s).strip("-")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from .slugify import slugify
|
||||
|
||||
|
||||
def table_name_for(object_name):
|
||||
def table_name_for(object_name: str) -> str:
|
||||
"""Nom de la vraie table SQL pour un objet défini par l'utilisateur."""
|
||||
return "obj_" + slugify(object_name).replace("-", "_")
|
||||
|
||||
Reference in New Issue
Block a user