Compare commits

...
2 Commits
Author SHA1 Message Date
williamandClaude Sonnet 5 2ff127f68e Phase 4 (CI) : lint-python/lint-js bloquants + sonarqube non-bloquant
Build and deploy / test-python (push) Successful in 13m44s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / lint-python (push) Successful in 4m32s
Build and deploy / lint-js (push) Successful in 3m5s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m3s
Les hooks pre-commit locaux (deja tous verts, voir le commit Phase 3) ne
protegent que la machine du committeur, jamais un push direct ou une PR
mergee depuis ailleurs sans passer par ces hooks. Ajoute donc en CI :

- lint-python (ruff check/format, mypy --strict, vulture, bandit,
  import-linter) et lint-js (eslint, stylelint) : bloquants des
  maintenant, memes commandes que .pre-commit-config.yaml, deja verts en
  local donc aucune raison d'attendre. djlint volontairement exclu (49
  H021 deja en backlog assume, a ajouter ici une fois ce lot traite).
  build-and-push en depend desormais (needs), en plus des tests deja en
  place.

- sonarqube : scan contre l'instance self-hebergee (sonar.forgebase.fr)
  a chaque push. continue-on-error: true au niveau du job (non-bloquant
  pendant cette premiere periode, le temps de trier le rapport deja
  analyse en Phase 3 - code smells/vulnerabilites), et absent du needs
  de build-and-push : un echec ici n'affecte jamais le reste du
  pipeline, meme si l'infra n'est pas encore prete (echec attendu tant
  que SONAR_TOKEN n'est pas configure cote Gitea).

Secret a ajouter dans Gitea (Parametres du depot > Actions > Secrets) :
SONAR_TOKEN (jeton d'analyse genere sur sonar.forgebase.fr, jamais le
mot de passe admin).

djLint (H021, styles inline) volontairement saute pour ce commit - meme
backlog assume que le commit Phase 3, aucun rapport avec ce changement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:26:21 +02:00
williamandClaude Sonnet 5 c57420c8c9 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>
2026-09-15 16:06:15 +02:00
455 changed files with 16533 additions and 6974 deletions
+6
View File
@@ -45,3 +45,9 @@ ANTHROPIC_API_KEY=
SCENARIO_API_KEY= SCENARIO_API_KEY=
SCENARIO_API_SECRET= SCENARIO_API_SECRET=
SCENARIO_MODEL_ID= SCENARIO_MODEL_ID=
# Debogueur Werkzeug (traceback interactif + auto-reload) pour le
# developpement local uniquement — ne jamais activer ailleurs qu'en local
# (app.py, python app.py direct ; sans effet en production, qui utilise
# gunicorn). Laisser vide/0 = desactive par defaut.
FORGE_DEBUG=0
+191
View File
@@ -0,0 +1,191 @@
{
"root": true,
"env": {
"browser": true,
"es2021": true
},
"extends": ["airbnb-base"],
"plugins": ["unused-imports"],
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "script"
},
"rules": {
"max-len": ["error", 120, 2, {
"ignoreUrls": true,
"ignoreComments": false,
"ignoreRegExpLiterals": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true
}],
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"error",
{
"vars": "all",
"args": "after-used",
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^(_|addClauseRow|addPersonnageCommandExtraRow|applyCameraSize|bindClicks|bindHeldKeyTriggers|bindHoverTexts|bindHoverTriggers|bindKeyboardTriggers|bindPropsAutosave|closeLeftPanel|closePropsPanel|collisionWizardAssetNext|collisionWizardAttendreNext|collisionWizardChainDone|collisionWizardChooseAction|collisionWizardChooseBranch|collisionWizardChooseTrigger|collisionWizardConditionNext|collisionWizardIndicationNext|collisionWizardObjectTargetChosen|collisionWizardValeurChosen|collisionWizardVariableNext|collisionWizardVideoModeChosen|confirmDeleteElement|createGlobalVariable|deleteGlobalVariable|deleteSceneObject|deleteUserAsset|forgeShowTab|iaChatDeleteConversation|iaChatInputKeydown|iaChatSelectConversation|initSceneBuilderPanel|injectCustomKeyframes|onCanvasDragOver|onCanvasDrop|onCollisionBoxMouseDown|onCollisionBoxResizeMouseDown|onElementMouseDown|onFilterValueFieldChange|onFilterValueModeChange|onFilterValueObjChange|onFilterValueVarChange|onGalleryTileDragStart|onResizeMouseDown|onSceneObjectMouseDown|onSceneObjectResizeMouseDown|openAddTriggerModal|openLeftPanel|openPropsPanel|openScreenAddTriggerModal|openScreenTriggerWizard|openTriggerWizard|reclassifyUserAsset|refreshRuntimeData|restartPersonnageIdlePreview|restoreFloatPanelState|runScreenHeldKeyTriggers|runScreenShowTriggers|runScreenTimerTriggers|saveDialogueBoxStyle|saveGlobalVariable|saveSceneObjectName|saveSceneObjectRole|screenIndexById|screenTriggerAppendLeaf|screenTriggerDeleteAt|screenTriggerOpenAppendActionModal|selectQuizBoxDialogTemplate|selectQuizBoxPageTemplate|showScreen|swapPersonnageCharacter|swapSceneObjectCharacter|switchBuilderTab|syncAlign|toggleDashCreate|triggerAppendLeaf|triggerClickObjectCard|triggerDeleteTrigger|triggerInlineAddBubble|triggerInlineAddQuestion|triggerInlineDeleteBubble|triggerInlineSetBubbleAudio|triggerInlineSetBubbleSpeaker|triggerInlineSetQuestionChoiceCount|triggerInlineSetQuestionChoiceText|triggerInlineSetQuestionCorrectIndex|triggerInlineSetQuestionRewardAmount|triggerInlineUpdateBubbleText|triggerInlineUpdateQuestionText|triggerMoveChainAction|triggerOpenAppendActionModal|triggerRemoveChainAction|triggerSelectObject|uploadUserAsset)$"
}
],
"no-unused-vars": "off",
"no-underscore-dangle": "off",
"func-names": "off",
"no-param-reassign": "off",
"no-cond-assign": ["error", "except-parens"],
"no-empty": ["error", { "allowEmptyCatch": true }],
"guard-for-in": "off",
"no-unused-expressions": "off",
"no-return-assign": "off",
"consistent-return": "off",
"no-bitwise": "off",
"no-plusplus": "off",
"no-restricted-syntax": "off",
"prefer-destructuring": "off",
"no-useless-concat": "off",
"no-console": "off",
"no-nested-ternary": "off",
"no-void": "off",
"no-continue": "off",
"implicit-arrow-linebreak": "off",
"no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }]
},
"overrides": [
{
"files": ["static/js/**/__tests__/**/*.test.js"],
"env": { "node": true, "browser": true },
"parserOptions": { "sourceType": "script" }
},
{
"files": ["static/js/play/**/*.js"],
"rules": {
"import/extensions": "off",
"global-require": "off"
}
}
],
"globals": {
"COLLISION_ACTION_LABELS": "readonly",
"COLLISION_CHAIN_CHOICES": "readonly",
"COLLISION_TRIGGER_LABELS": "readonly",
"CONDITION_OPERATOR_LABELS_MAP": "readonly",
"CURRENT_SCREEN_ID": "readonly",
"CURRENT_SELECTED_ID": "writable",
"DATA_OPERATION_LABELS_MAP": "readonly",
"DEFINITIONS_DATA": "readonly",
"ELEMENT_ADD_URL": "readonly",
"ELEMENT_ANIMATIONS_MAP": "readonly",
"ELEMENT_VISIBILITY_LABELS_MAP": "readonly",
"FORGE_PLAY_URLS": "readonly",
"GAME_SLUG": "readonly",
"GLOBAL_VARIABLE_NAMES": "readonly",
"SCENE_HEIGHT": "readonly",
"SCENE_OBJECT_NAMES": "writable",
"SCENE_OBJECT_NAMES_JSON": "readonly",
"SCENE_WIDTH": "readonly",
"SCREEN_EDIT_URL": "readonly",
"SCREEN_ID": "readonly",
"SURBRILLANCE_LABELS_MAP": "readonly",
"USER_ASSETS_OPTIONS": "readonly",
"VIDEO_MODE_LABELS_MAP": "readonly",
"_collisionWizard": "writable",
"_stopPersonnageIdlePreview": "readonly",
"applyObjectProperty": "readonly",
"applyOpenRowBindings": "readonly",
"applySelectionHighlight": "readonly",
"bindClicks": "readonly",
"bindHoverTexts": "readonly",
"bindHoverTriggers": "readonly",
"bindPropsAutosave": "readonly",
"clampSceneObjectPosition": "readonly",
"closeTriggerModal": "readonly",
"collisionRuleThumbHtml": "readonly",
"collisionWizardBuildLeafAction": "readonly",
"compareValues": "readonly",
"debouncedSubmitPropsForm": "readonly",
"evaluateConditionClause": "readonly",
"evaluateConditionNode": "readonly",
"forgeApplyAddRowActionOffline": "readonly",
"forgeApplyCtx": "readonly",
"forgeApplyDataActionOffline": "readonly",
"forgeApplyScoreActionOffline": "readonly",
"forgeApplyStatusActionOffline": "readonly",
"forgeApplyVariableActionOffline": "readonly",
"forgeAttrString": "readonly",
"forgeAutoId": "readonly",
"forgeCollisionRectFromBox": "readonly",
"forgeComputeNewValue": "readonly",
"forgeDecodeClauses": "readonly",
"forgeDialogueBoxState": "readonly",
"forgeEscapeHtml": "readonly",
"forgeFilterRepeaterRows": "readonly",
"forgeFilterRowsByClauses": "readonly",
"forgeHtmlEscape": "readonly",
"forgeIsElementVisibleOffline": "readonly",
"forgeParentFlexDirection": "readonly",
"forgeQuizBoxState": "readonly",
"forgeQuizTemplateEffects": "readonly",
"forgeRecomputeFullPayloadOffline": "readonly",
"forgeRenderCheckboxOrRadio": "readonly",
"forgeRenderChildren": "readonly",
"forgeRenderElementHtml": "readonly",
"forgeRenderFieldset": "readonly",
"forgeRenderIcone": "readonly",
"forgeRenderJauge": "readonly",
"forgeRenderOnglets": "readonly",
"forgeRenderOverlay": "readonly",
"forgeRenderPersonnage": "readonly",
"forgeRenderRepeater": "readonly",
"forgeRenderSelect": "readonly",
"forgeRenderTable": "readonly",
"forgeResolveFilterValue": "readonly",
"forgeResolveVariablePath": "readonly",
"forgeRunCollisionRuleAction": "readonly",
"forgeRunScreenTriggers": "readonly",
"forgeScorm2004NotifyQuestionAnswered": "readonly",
"forgeScormApi": "readonly",
"forgeShapesOverlap": "readonly",
"forgeShowDialogueBox": "readonly",
"forgeShowVideoOverlay": "readonly",
"forgeStartCollisionRuleControllers": "readonly",
"forgeStartPersonnageControllers": "readonly",
"forgeStyleString": "readonly",
"forgeUpdateAllScoreWidgets": "readonly",
"forgeVisibleAttrs": "readonly",
"forgeXapiNotifyDialogueCompleted": "readonly",
"forgeXapiNotifyQuestionAnswered": "readonly",
"forgeXapiNotifyScoreChanged": "readonly",
"forgeXapiNotifyStatusChanged": "readonly",
"gameData": "writable",
"goToSelected": "readonly",
"heldKeys": "readonly",
"initBuilderPanel": "readonly",
"initIaTab": "readonly",
"initSceneBuilderPanel": "readonly",
"initTriggersTab": "readonly",
"openPropsPanel": "readonly",
"openScreenTriggerWizard": "readonly",
"openTriggerWizard": "readonly",
"refreshRuntimeData": "readonly",
"renderCollisionWizardChainStep": "readonly",
"resolveSpriteFrames": "readonly",
"restartPersonnageIdlePreview": "readonly",
"restoreFloatPanelState": "readonly",
"restoreTreeCollapsedState": "readonly",
"runActionNode": "readonly",
"runFlowFrom": "readonly",
"runScreenHeldKeyTriggers": "readonly",
"runScreenShowTriggers": "readonly",
"runScreenTimerTriggers": "readonly",
"runSpriteAnimation": "readonly",
"saveGeometry": "readonly",
"screenIndexById": "readonly",
"screenTriggerLoadAll": "readonly",
"screensData": "writable",
"showScreen": "readonly",
"startAllPersonnagePreviews": "readonly",
"stopAllSpriteAnimations": "readonly",
"submitPropsForm": "readonly",
"triggerLoadAll": "readonly"
}
}
+8
View File
@@ -0,0 +1,8 @@
# Force LF partout, quel que soit le core.autocrlf de la machine locale
# (Windows le met souvent a true par defaut) — sans ca, un `git stash`/
# checkout (declenche par exemple par les hooks pre-commit avant de tester
# le diff stage) reconvertit les fichiers en CRLF, ce qu'ESLint refuse
# ensuite (regle linebreak-style: LF, voir .eslintrc.json) alors que le
# fichier sur disque, lui, est deja en LF — decouvert en committant la
# Phase 3 du plan qualite.
* text=auto eol=lf
+85 -7
View File
@@ -4,12 +4,26 @@ on:
push: push:
branches: [main, dev] branches: [main, dev]
# Le job "test" tourne sur CHAQUE push (main et dev) : jusqu'ici aucune # Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et
# étape de CI n'exécutait la suite de tests, rien n'empêchait un commit # dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les
# cassé d'atteindre la production (voir l'audit qualité de la Phase 0 du # outils qualité, rien n'empêchait un commit cassé d'atteindre la
# plan). "build-and-push"/"deploy", eux, restent réservés à main (via le # production (voir l'audit qualité de la Phase 0 du plan). "build-and-
# filtre "if" sur gitea.ref) — un push sur dev ne doit jamais redéployer # push"/"deploy", eux, restent réservés à main (via le filtre "if" sur
# la prod, seulement faire tourner les tests. # gitea.ref) — un push sur dev ne doit jamais redéployer la prod, seulement
# faire tourner tests/lint/sonar.
#
# lint-python/lint-js rejouent EXACTEMENT les hooks pre-commit locaux
# (.pre-commit-config.yaml) mais bloquants ici dès le départ (déjà tous
# verts en local, voir le commit "Phase 3 : hardening qualite de code") —
# les hooks pre-commit ne protègent que la machine du committeur, jamais
# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube,
# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première
# période — voir CODE_QUALITY.md pour la trajectoire vers un mode
# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt
# que de bloquer tout de suite sur des centaines de signalements pas
# encore triés.
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) : # Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com) # REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
@@ -20,6 +34,8 @@ on:
# DEPLOY_USER utilisateur SSH sur ce serveur # DEPLOY_USER utilisateur SSH sur ce serveur
# DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur # DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur
# DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine) # DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine)
# SONAR_TOKEN jeton d'analyse SonarQube (Mon compte > Security > Generate Token
# sur sonar.forgebase.fr) — jamais le mot de passe admin.
# #
# Utilise directement docker/ssh/scp en ligne de commande plutôt que des # Utilise directement docker/ssh/scp en ligne de commande plutôt que des
# actions du marketplace, pour ne pas dépendre de la disponibilité de # actions du marketplace, pour ne pas dépendre de la disponibilité de
@@ -79,8 +95,70 @@ jobs:
RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
DOCKERFILE DOCKERFILE
lint-python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Qualite Python (ruff/mypy/vulture/bandit/import-linter — memes commandes que .pre-commit-config.yaml)
run: |
# Meme neutralisation de .dockerignore que test-python ci-dessus :
# ruff/mypy typent aussi tests/ (voir le plan de typage strict),
# exclu par defaut de l'image de PROD.
mv .dockerignore .dockerignore.disabled-for-ci
docker build -f - -t forge-lint-python:${{ gitea.sha }} . <<'DOCKERFILE'
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY . .
RUN ruff check .
RUN ruff format --check .
RUN mypy .
RUN vulture
RUN bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py
RUN lint-imports
DOCKERFILE
lint-js:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Qualite JS/CSS (eslint/stylelint — memes commandes que .pre-commit-config.yaml, via les scripts npm de package.json)
run: |
docker build -f - -t forge-lint-js:${{ gitea.sha }} . <<'DOCKERFILE'
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run lint:js
RUN npm run lint:css
DOCKERFILE
sonarqube:
runs-on: ubuntu-latest
# Non-bloquant pendant cette premiere periode (voir le bloc de
# commentaires en tete de fichier) — un echec ici n'empeche jamais
# build-and-push/deploy, contrairement a lint-python/lint-js.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Analyse SonarQube (rapport seul, non-bloquant)
run: |
# Meme neutralisation de .dockerignore que test-python : sonar-
# project.properties couvre aussi tests/ (sonar.tests).
mv .dockerignore .dockerignore.disabled-for-ci
docker build -f - -t forge-sonar:${{ gitea.sha }} . <<'DOCKERFILE'
FROM sonarsource/sonar-scanner-cli:latest
WORKDIR /usr/src
COPY . .
DOCKERFILE
docker run --rm forge-sonar:${{ gitea.sha }} \
-Dsonar.host.url=https://sonar.forgebase.fr \
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
build-and-push: build-and-push:
needs: [test-python, test-js] needs: [test-python, test-js, lint-python, lint-js]
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+10
View File
@@ -4,6 +4,16 @@ __pycache__/
*.egg-info/ *.egg-info/
.pytest_cache/ .pytest_cache/
# JS (voir package.json) — installe via `npm install`, jamais committé.
node_modules/
# Rapports d'outils qualité (ruff/bandit/eslint/stylelint/mypy/vulture/
# import-linter/djlint — voir sonar-project.properties, CODE_QUALITY.md) :
# sortie regenerée à la demande avant chaque scan Sonar, jamais figée dans
# l'historique (un rapport committé devient vite mensonger — vécu : ceux du
# 14 sept re-signalaient des centaines de problèmes déjà corrigés).
reports/
# Environnements virtuels # Environnements virtuels
.venv/ .venv/
venv/ venv/
+80
View File
@@ -0,0 +1,80 @@
# Hooks locaux (language: system) plutot que les miroirs pre-commit
# habituels (astral-sh/ruff-pre-commit, pre-commit/mirrors-mypy,
# pre-commit/mirrors-eslint...) : ce projet a deja un environnement
# Python (requirements-dev.txt) et un node_modules/ (package.json) bien
# a lui — repartir sur un environnement ISOLE et RE-TELECHARGE par
# pre-commit dupliquerait exactement les memes outils/versions pour rien
# (et serait plus lent, plus fragile hors ligne). Voir CODE_QUALITY.md
# pour l'installation prealable (`pip install -r requirements-dev.txt`
# + `npm install`), necessaire pour que ces hooks trouvent les
# executables.
#
# Chaque hook est bloquant (aucune regle desactivee "pour ne pas casser
# le build", demande explicite) — un commit est refuse si un outil
# trouve un probleme, jusqu'a ce que la Phase 3 du plan qualite ait
# nettoye l'existant.
#
# eslint/stylelint : entry appelle `node <script.js>` directement plutot
# que `npx eslint` ou le shim node_modules/.bin/eslint(.cmd) — ces deux
# dernieres formes font passer pre-commit par cmd.exe pour lancer un
# executable Windows (.cmd) ou un script a shebang POSIX (#!/bin/sh),
# et pre-commit resout mal cmd.exe/sh depuis ce contexte (bug constate
# sous Windows). Appeler `node` directement sur le fichier JS du package
# evite tout intermediaire shell.
repos:
- repo: local
hooks:
- id: ruff-check
name: Ruff (lint Python)
entry: ruff check
language: system
types: [python]
- id: ruff-format
name: Ruff (formatage Python)
entry: ruff format --check
language: system
types: [python]
- id: mypy
name: Mypy (typage strict Python)
entry: mypy .
language: system
types: [python]
pass_filenames: false
- id: vulture
name: Vulture (code mort Python)
entry: vulture
language: system
pass_filenames: false
- id: bandit
name: Bandit (securite Python)
entry: bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py
language: system
pass_filenames: false
- id: import-linter
name: import-linter (contrats d'architecture Python)
entry: lint-imports
language: system
pass_filenames: false
- id: djlint
name: djLint (templates Jinja)
entry: djlint templates
language: system
pass_filenames: false
- id: eslint
name: ESLint (JS)
entry: node node_modules/eslint/bin/eslint.js
language: system
files: ^static/js/.*\.js$
- id: stylelint
name: Stylelint (CSS)
entry: node node_modules/stylelint/bin/stylelint.mjs
language: system
files: \.css$
+12
View File
@@ -0,0 +1,12 @@
{
"extends": ["stylelint-config-standard"],
"ignoreFiles": [
"static/vendor/**/*.css",
"static/style.css"
],
"rules": {
"selector-class-pattern": null,
"selector-id-pattern": null,
"no-descending-specificity": null
}
}
+11 -5
View File
@@ -3,13 +3,19 @@ screens/ (rendu/données de jeu) : ai.tools définit les outils exposés à
l'agent (Phase 2) sans dépendre du SDK anthropic lui-même, pour rester l'agent (Phase 2) sans dépendre du SDK anthropic lui-même, pour rester
testable sans clé API.""" testable sans clé API."""
from .tools import TOOLS, dispatch_tool
from .client import get_client, AnthropicNotConfiguredError, MODEL
from .scenario_client import generate_image_url, ScenarioNotConfiguredError, ScenarioGenerationError
from .chat import run_chat_turn from .chat import run_chat_turn
from .client import MODEL, AnthropicNotConfiguredError, get_client
from .scenario_client import ScenarioGenerationError, ScenarioNotConfiguredError, generate_image_url
from .tools import TOOLS, dispatch_tool
__all__ = [ __all__ = [
"TOOLS", "dispatch_tool", "get_client", "AnthropicNotConfiguredError", "MODEL", "TOOLS",
"generate_image_url", "ScenarioNotConfiguredError", "ScenarioGenerationError", "dispatch_tool",
"get_client",
"AnthropicNotConfiguredError",
"MODEL",
"generate_image_url",
"ScenarioNotConfiguredError",
"ScenarioGenerationError",
"run_chat_turn", "run_chat_turn",
] ]
+97 -65
View File
@@ -2,12 +2,14 @@
UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base : UNIQUEMENT les tools de ai/tools.py, jamais d'écriture directe en base :
tout ce qu'un tour de chat produit est donc TOUJOURS relisable/ tout ce qu'un tour de chat produit est donc TOUJOURS relisable/
modifiable dans l'éditeur normal (même garantie que la Phase 1).""" modifiable dans l'éditeur normal (même garantie que la Phase 1)."""
import json import json
from typing import Any
import db import db
import screens import screens
from .client import get_client, MODEL from .client import MODEL, get_client
from .tools import TOOLS, dispatch_tool from .tools import TOOLS, dispatch_tool
# Borne dure : jamais une boucle sans fin qui dépenserait sans fin si # Borne dure : jamais une boucle sans fin qui dépenserait sans fin si
@@ -25,10 +27,10 @@ _SYSTEM_PROMPT = (
"Réutilise une variable globale déjà existante UNIQUEMENT si elle " "Réutilise une variable globale déjà existante UNIQUEMENT si elle "
"représente EXACTEMENT la même information (ex. un score total " "représente EXACTEMENT la même information (ex. un score total "
"partagé par tout le jeu) — les variables sont globales à TOUT le " "partagé par tout le jeu) — les variables sont globales à TOUT le "
"jeu, visibles depuis n'importe quel écran, donc un drapeau \"terminé\" " 'jeu, visibles depuis n\'importe quel écran, donc un drapeau "terminé" '
"propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un " "propre à UN dialogue/quiz précis ne doit JAMAIS être partagé avec un "
"autre dialogue/quiz, même similaire, même sur un autre écran (bug " "autre dialogue/quiz, même similaire, même sur un autre écran (bug "
"corrigé : un quiz réutilisait par erreur le drapeau \"terminé\" d'un " 'corrigé : un quiz réutilisait par erreur le drapeau "terminé" d\'un '
"AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à " "AUTRE quiz sans lien, les rendant mutuellement incohérents). Donne à "
"chaque nouveau drapeau un nom qui identifie clairement CE qu'il " "chaque nouveau drapeau un nom qui identifie clairement CE qu'il "
"suit (ex. préfixé par le nom du personnage/objet concerné). " "suit (ex. préfixé par le nom du personnage/objet concerné). "
@@ -40,10 +42,10 @@ _SYSTEM_PROMPT = (
"CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique " "CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique "
"\"déclencheur -> action\" d'un objet de scène OU de l'écran entier " "\"déclencheur -> action\" d'un objet de scène OU de l'écran entier "
"(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE " "(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE "
"système, séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, " 'système, séparé, de "flow" (nœuds/liaisons) pour changer d\'écran, '
"les minuteurs récurrents, une animation de sprite — tu n'as AUCUN " "les minuteurs récurrents, une animation de sprite — tu n'as AUCUN "
"outil pour ça. Si une demande a besoin de cette partie-là (\"passer " 'outil pour ça. Si une demande a besoin de cette partie-là ("passer '
"à l'écran suivant\", \"après 5 secondes\"...), fais quand même tout " 'à l\'écran suivant", "après 5 secondes"...), fais quand même tout '
"ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse " "ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse "
"texte ce que tu n'as pas pu faire et pourquoi (cette partie doit " "texte ce que tu n'as pas pu faire et pourquoi (cette partie doit "
"être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS " "être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS "
@@ -51,29 +53,29 @@ _SYSTEM_PROMPT = (
"permet, ça n'aboutira jamais." "permet, ça n'aboutira jamais."
"\n\n" "\n\n"
"DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : " "DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : "
"\"collision\" (contact avec le personnage \"joueur\" — a besoin d'un " '"collision" (contact avec le personnage "joueur" — a besoin d\'un '
"joueur, voir JOUEUR ET PNJ), \"clic\" (l'objet est cliqué/touché, " 'joueur, voir JOUEUR ET PNJ), "clic" (l\'objet est cliqué/touché, '
"aucun joueur requis — typique d'un panneau, un bouton, un objet " "aucun joueur requis — typique d'un panneau, un bouton, un objet "
"d'interface statique), \"survol\" (le pointeur survole l'objet, " "d'interface statique), \"survol\" (le pointeur survole l'objet, "
"aucun joueur requis — typique d'une info contextuelle affichée sans " "aucun joueur requis — typique d'une info contextuelle affichée sans "
"action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS " "action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS "
"objet requis) : \"affichage\" — se déclenche dès que l'écran " 'objet requis) : "affichage" — se déclenche dès que l\'écran '
"apparaît, pour une narration/cinématique d'ouverture. " "apparaît, pour une narration/cinématique d'ouverture. "
"RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel " "RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel "
"déclencheur utiliser pour un élément donné (ex. \"ajoute un panneau " 'déclencheur utiliser pour un élément donné (ex. "ajoute un panneau '
"d'information\" sans dire si c'est au clic, au survol, ou dès " "d'information\" sans dire si c'est au clic, au survol, ou dès "
"l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta " "l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta "
"réponse texte avant d'agir. " "réponse texte avant d'agir. "
"NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, " "NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, "
"chaînables via \"then\" comme dialogue/variable) : \"surbrillance\" " 'chaînables via "then" comme dialogue/variable) : "surbrillance" '
"(met un objet en valeur, ex. pour guider l'attention du joueur vers " "(met un objet en valeur, ex. pour guider l'attention du joueur vers "
"la suite), \"visibilite\" (affiche/masque un objet, ex. débloquer un " 'la suite), "visibilite" (affiche/masque un objet, ex. débloquer un '
"élément), \"son\" (effet sonore ponctuel), \"video\" (joue une vidéo " 'élément), "son" (effet sonore ponctuel), "video" (joue une vidéo '
"de \"Mes assets\", en plein écran ou en incrustation — bloque la " 'de "Mes assets", en plein écran ou en incrustation — bloque la '
"suite de la chaîne jusqu'à la fin, comme un dialogue), \"indication\" " 'suite de la chaîne jusqu\'à la fin, comme un dialogue), "indication" '
"(bulle de texte courte près d'un objet, pour un conseil ponctuel — " "(bulle de texte courte près d'un objet, pour un conseil ponctuel — "
"PAS pour une réplique de personnage, utilise \"dialogue\" pour ça), " 'PAS pour une réplique de personnage, utilise "dialogue" pour ça), '
"\"attendre\" (suspend la chaîne \"then\" pendant data_value SECONDES " '"attendre" (suspend la chaîne "then" pendant data_value SECONDES '
"avant de continuer — utile pour laisser un temps de lecture après " "avant de continuer — utile pour laisser un temps de lecture après "
"une narration à l'affichage de l'écran, avant d'enchaîner sur un " "une narration à l'affichage de l'écran, avant d'enchaîner sur un "
"dialogue/une autre action)." "dialogue/une autre action)."
@@ -97,13 +99,13 @@ _SYSTEM_PROMPT = (
"VISIBLE (coin haut-gauche à (0,0)) — place tout objet important " "VISIBLE (coin haut-gauche à (0,0)) — place tout objet important "
"(personnages, widgets d'interface) DANS cette zone par défaut. Le " "(personnages, widgets d'interface) DANS cette zone par défaut. Le "
"moteur RAMÈNE automatiquement dans le cadre toute position qui en " "moteur RAMÈNE automatiquement dans le cadre toute position qui en "
"sortirait (voir un éventuel champ \"note\" dans le résultat de " 'sortirait (voir un éventuel champ "note" dans le résultat de '
"set_object_geometry/add_scene_object — c'est déjà corrigé, rien à " "set_object_geometry/add_scene_object — c'est déjà corrigé, rien à "
"refaire), donc vise une position raisonnable sans stresser sur le " "refaire), donc vise une position raisonnable sans stresser sur le "
"pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées " "pixel exact. Ne superpose jamais deux objets aux mêmes coordonnées "
"— espace-les clairement. Un personnage fraîchement posé fait " "— espace-les clairement. Un personnage fraîchement posé fait "
"128x128 px par défaut — garde des tailles cohérentes entre " "128x128 px par défaut — garde des tailles cohérentes entre "
"personnages sauf besoin explicite. RÈGLE FIXE pour un \"fond\" : " 'personnages sauf besoin explicite. RÈGLE FIXE pour un "fond" : '
"redimensionne-le TOUJOURS automatiquement en 2000x1000 px " "redimensionne-le TOUJOURS automatiquement en 2000x1000 px "
"(set_object_geometry, position (0,0)) juste après l'avoir posé, " "(set_object_geometry, position (0,0)) juste après l'avoir posé, "
"SANS que le créateur ait besoin de le demander à chaque fois — " "SANS que le créateur ait besoin de le demander à chaque fois — "
@@ -112,37 +114,37 @@ _SYSTEM_PROMPT = (
"grand." "grand."
"\n\n" "\n\n"
"JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle " "JOUEUR ET PNJ : un personnage fraîchement posé a TOUJOURS le rôle "
"\"pnj\" par défaut, JAMAIS \"joueur\". Un déclencheur de type " '"pnj" par défaut, JAMAIS "joueur". Un déclencheur de type '
"\"collision\" ne se déclenche QUE par le contact du personnage au " '"collision" ne se déclenche QUE par le contact du personnage au '
"rôle \"joueur\" — sans lui, la collision ne se déclenche jamais ET " 'rôle "joueur" — sans lui, la collision ne se déclenche jamais ET '
"la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un " "la caméra n'a personne à suivre. Dès qu'un écran a besoin d'un "
"déclencheur de collision (quiz, dialogue déclenché en marchant " "déclencheur de collision (quiz, dialogue déclenché en marchant "
"vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN " "vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN "
"personnage avec role=\"joueur\" (set_object_role) — jamais deux, " 'personnage avec role="joueur" (set_object_role) — jamais deux, '
"et ne pose jamais deux PNJ identiques sans que le créateur l'ait " "et ne pose jamais deux PNJ identiques sans que le créateur l'ait "
"demandé. " "demandé. "
"RÈGLE IMPORTANTE : si le créateur ne précise pas comment un " "RÈGLE IMPORTANTE : si le créateur ne précise pas comment un "
"déclencheur \"collision\" doit se comporter, demande-lui si " 'déclencheur "collision" doit se comporter, demande-lui si '
"l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le " "l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le "
"joueur doit d'abord APPUYER SUR UNE TOUCHE (action \"interagir\", " 'joueur doit d\'abord APPUYER SUR UNE TOUCHE (action "interagir", '
"qui affiche \"Appuie sur [touche]\" tant que le contact dure) avant " 'qui affiche "Appuie sur [touche]" tant que le contact dure) avant '
"que l'action ne se déclenche — ne suppose jamais l'un ou l'autre " "que l'action ne se déclenche — ne suppose jamais l'un ou l'autre "
"toi-même sur une scène avec joueur+collision." "toi-même sur une scène avec joueur+collision."
"\n\n" "\n\n"
"QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une " "QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une "
"donnée consultée plus tard (progression, un choix du joueur, un " "donnée consultée plus tard (progression, un choix du joueur, un "
"drapeau \"terminé\") — JAMAIS pour un score de quiz (voir " 'drapeau "terminé") — JAMAIS pour un score de quiz (voir '
"reward_amount plus haut, déjà automatique). Modifie une variable " "reward_amount plus haut, déjà automatique). Modifie une variable "
"(action \"variable\") quand un événement doit changer durablement " '(action "variable") quand un événement doit changer durablement '
"cet état (ex. marquer un drapeau \"xxx_termine\" à vrai une fois un " 'cet état (ex. marquer un drapeau "xxx_termine" à vrai une fois un '
"quiz fini). Utilise une CONDITION quand le comportement doit " "quiz fini). Utilise une CONDITION quand le comportement doit "
"VRAIMENT différer selon l'état actuel d'une variable — un simple " "VRAIMENT différer selon l'état actuel d'une variable — un simple "
"enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais " "enchaînement linéaire n'a besoin que d'un chaînage \"then\", jamais "
"d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à " "d'une condition. CAS CANONIQUE à connaître : un dialogue/quiz à "
"USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être " "USAGE UNIQUE (ex. un PNJ qui pose un quiz une seule fois) doit être "
"protégé par une CONDITION qui vérifie D'ABORD le drapeau " "protégé par une CONDITION qui vérifie D'ABORD le drapeau "
"\"xxx_termine\" — si faux (pas encore fait), lance le dialogue/quiz " '"xxx_termine" — si faux (pas encore fait), lance le dialogue/quiz '
"normal (branche si_faux) PUIS termine par une action \"variable\" " 'normal (branche si_faux) PUIS termine par une action "variable" '
"qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une " "qui passe ce drapeau à vrai ; si vrai (déjà fait), réponds par une "
"réplique courte différente (branche si_vrai) au lieu de rejouer " "réplique courte différente (branche si_vrai) au lieu de rejouer "
"tout le quiz à chaque collision. Utilise un DIALOGUE pour toute " "tout le quiz à chaque collision. Utilise un DIALOGUE pour toute "
@@ -152,31 +154,31 @@ _SYSTEM_PROMPT = (
"JEU : il faut TOUJOURS poser en plus le widget d'interface " "JEU : il faut TOUJOURS poser en plus le widget d'interface "
"correspondant sur l'écran via add_scene_object, sinon rien n'apparaît " "correspondant sur l'écran via add_scene_object, sinon rien n'apparaît "
"à l'écran même si le déclencheur est correctement configuré. Une " "à l'écran même si le déclencheur est correctement configuré. Une "
"action \"dialogue\" dont les lignes sont de type \"replique\" a besoin " 'action "dialogue" dont les lignes sont de type "replique" a besoin '
"d'un objet kind=\"dialogue_box\" ; une action \"dialogue\" dont les " 'd\'un objet kind="dialogue_box" ; une action "dialogue" dont les '
"lignes sont de type \"question\" (quiz) a besoin d'un objet " 'lignes sont de type "question" (quiz) a besoin d\'un objet '
"kind=\"quiz_box\" (pas dialogue_box) ; si un score/des points sont " 'kind="quiz_box" (pas dialogue_box) ; si un score/des points sont '
"utilisés, ajoute aussi un objet kind=\"score_widget\" pour qu'il soit " 'utilisés, ajoute aussi un objet kind="score_widget" pour qu\'il soit '
"visible en continu. Positionne ces widgets à un endroit raisonnable " "visible en continu. Positionne ces widgets à un endroit raisonnable "
"de l'écran (ex. centré, ou en haut) via set_object_geometry après " "de l'écran (ex. centré, ou en haut) via set_object_geometry après "
"les avoir posés." "les avoir posés."
"\n\n" "\n\n"
"QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit " "QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit "
"un besoin de quiz \"tout seul\" (pas un personnage/dialogue de jeu), " 'un besoin de quiz "tout seul" (pas un personnage/dialogue de jeu), '
"utilise set_quiz_box_config sur l'objet kind=\"quiz_box\" pour régler " 'utilise set_quiz_box_config sur l\'objet kind="quiz_box" pour régler '
"le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : " "le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : "
"jamais imposé par défaut — demande TOUJOURS si le créateur en veut un " "jamais imposé par défaut — demande TOUJOURS si le créateur en veut un "
"avant d'en activer un, \"question\" redémarre à chaque question, " 'avant d\'en activer un, "question" redémarre à chaque question, '
"\"quiz\" est un seul compte à rebours pour tout le quiz, timer_seconds " '"quiz" est un seul compte à rebours pour tout le quiz, timer_seconds '
"sa durée)." "sa durée)."
"\n\n" "\n\n"
"MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : " "MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : "
"dialog_template (modèles \"boîte de dialogue\" : \"defaut\" — Classique — " 'dialog_template (modèles "boîte de dialogue" : "defaut" — Classique — '
"ou \"manga_dialogue\", le pendant en petite carte du thème manga) ne " 'ou "manga_dialogue", le pendant en petite carte du thème manga) ne '
"s'affiche QUE si fullscreen=false. " "s'affiche QUE si fullscreen=false. "
"page_template (modèles \"page de quiz\" : \"classique\" — sobre/" 'page_template (modèles "page de quiz" : "classique" — sobre/'
"professionnel, couleurs reprises du modèle \"boîte de dialogue\" de " 'professionnel, couleurs reprises du modèle "boîte de dialogue" de '
"base — ou \"manga\", un thème dessiné/typographié entièrement à part) " 'base — ou "manga", un thème dessiné/typographié entièrement à part) '
"ne s'affiche QUE si fullscreen=true — chacun un thème complet et " "ne s'affiche QUE si fullscreen=true — chacun un thème complet et "
"autonome (police, formes, couleurs ET structure entièrement propres à " "autonome (police, formes, couleurs ET structure entièrement propres à "
"ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux " "ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux "
@@ -194,13 +196,26 @@ _SYSTEM_PROMPT = (
) )
def _describe_scene_state(slug, screen_id): class ScreenDeletedError(Exception):
"""Garde-fou défensif pour screen_id introuvable : en usage normal,
supprimer un écran (screens/screens_repo/delete_screen.py) supprime
déjà EN CASCADE ses conversations IA (ON DELETE CASCADE, voir
screens/ia/ensure_ia_chat_schema.py) — cette voie n'est donc pas
censée être atteignable via l'appli. get_screen() renvoyant
dict | None, ce garde évite quand même un crash cru (attribut sur
None) si jamais screen_id était invalide pour une autre raison,
plutôt qu'un message lisible par un humain (voir run_chat_turn)."""
def _describe_scene_state(slug: str, screen_id: int) -> str:
"""Contexte dynamique (jamais mémorisé côté Claude entre les tours, """Contexte dynamique (jamais mémorisé côté Claude entre les tours,
voir run_chat_turn — seul le texte final est persisté) : sans ça, voir run_chat_turn — seul le texte final est persisté) : sans ça,
Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des Ruby ne "voit" jamais ce qui existe déjà sur l'écran et duplique des
objets au lieu de les corriger (bug observé : 2 PNJ identiques créés objets au lieu de les corriger (bug observé : 2 PNJ identiques créés
à la place d'un seul joueur + un pnj).""" à la place d'un seul joueur + un pnj)."""
screen = screens.get_screen(slug, screen_id) screen = screens.get_screen(slug, screen_id)
if screen is None:
raise ScreenDeletedError("L'écran de cette conversation a été supprimé.")
width, height = screen["scene_width"], screen["scene_height"] width, height = screen["scene_width"], screen["scene_height"]
objects = screens.list_scene_objects(slug, screen_id) objects = screens.list_scene_objects(slug, screen_id)
# Répété ICI (pas seulement dans les instructions générales) avec les # Répété ICI (pas seulement dans les instructions générales) avec les
@@ -251,11 +266,11 @@ def _describe_scene_state(slug, screen_id):
return "\n".join(lines) return "\n".join(lines)
def _history_to_messages(history): def _history_to_messages(history: list[dict[str, Any]]) -> list[Any]:
return [{"role": m["role"], "content": m["content"]} for m in history] return [{"role": m["role"], "content": m["content"]} for m in history]
def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message): def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int, user_message: str) -> str:
"""Un tour complet : reprend l'historique persisté de CETTE """Un tour complet : reprend l'historique persisté de CETTE
conversation, ajoute le message du créateur, boucle tant que Claude conversation, ajoute le message du créateur, boucle tant que Claude
appelle des outils, et renvoie le texte final. `screen_id` reste appelle des outils, et renvoie le texte final. `screen_id` reste
@@ -264,40 +279,57 @@ def run_chat_turn(slug, screen_id, conversation_id, user_id, user_message):
d'écran). Ne persiste RIEN elle-même — voir routes/ia/ia_chat.py, d'écran). Ne persiste RIEN elle-même — voir routes/ia/ia_chat.py,
seul appelant, qui décide de ce qui est sauvegardé (même séparation seul appelant, qui décide de ce qui est sauvegardé (même séparation
que le reste du moteur : cette fonction ne fait que la logique IA).""" que le reste du moteur : cette fonction ne fait que la logique IA)."""
client = get_client() # AnthropicNotConfiguredError si pas de clé try:
messages = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
messages.append({"role": "user", "content": user_message})
# État réel de la scène RE-LU à chaque tour (jamais mémorisé par # État réel de la scène RE-LU à chaque tour (jamais mémorisé par
# Claude lui-même) — voir _describe_scene_state. # Claude lui-même) — voir _describe_scene_state. Vérifié AVANT de
system_prompt = _SYSTEM_PROMPT + "\n\n" + _describe_scene_state(slug, screen_id) # construire le client Claude : pas la peine d'appeler l'API si
# l'écran de cette conversation n'existe plus.
scene_state = _describe_scene_state(slug, screen_id)
except ScreenDeletedError:
return "Cet écran a été supprimé — cette conversation n'est plus utilisable."
client = get_client() # AnthropicNotConfiguredError si pas de clé
messages: list[Any] = _history_to_messages(screens.list_ia_chat_messages(slug, conversation_id))
messages.append({"role": "user", "content": user_message})
system_prompt = _SYSTEM_PROMPT + "\n\n" + scene_state
response = None response = None
for _ in range(_MAX_TOOL_ITERATIONS): for _ in range(_MAX_TOOL_ITERATIONS):
response = client.messages.create(
# 4096 était trop bas (bug corrigé) : la réflexion adaptative # 4096 était trop bas (bug corrigé) : la réflexion adaptative
# partage le même budget que la réponse — sur une demande # partage le même budget que la réponse — sur une demande riche
# riche (plusieurs objets + logique + texte), Claude pouvait # (plusieurs objets + logique + texte), Claude pouvait être coupé
# être coupé EN PLEINE RÉFLEXION, avant le moindre appel # EN PLEINE RÉFLEXION, avant le moindre appel d'outil (symptôme
# d'outil (symptôme observé : aucune progression du tout, # observé : aucune progression du tout, "(pas de réponse
# "(pas de réponse textuelle)" dès le premier tour). # textuelle)" dès le premier tour).
model=MODEL, max_tokens=16000, system=system_prompt, response = client.messages.create( # type: ignore[call-overload] # TOOLS/messages sont des dict Python bruts, pas les TypedDict exacts du SDK anthropic
tools=TOOLS, thinking={"type": "adaptive"}, messages=messages, model=MODEL,
max_tokens=16000,
system=system_prompt,
tools=TOOLS,
thinking={"type": "adaptive"},
messages=messages,
) )
messages.append({"role": "assistant", "content": response.content}) messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use": if response.stop_reason != "tool_use":
break break
tool_results = [] tool_results: list[Any] = []
for block in response.content: for block in response.content:
if block.type == "tool_use": if block.type == "tool_use":
try: try:
result = dispatch_tool(slug, screen_id, user_id, block.name, block.input) result = dispatch_tool(slug, screen_id, user_id, block.name, block.input)
except Exception as e: except Exception as e:
result = {"error": str(e)} result = {"error": str(e)}
tool_results.append({ tool_results.append(
"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result), {
}) "type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
}
)
messages.append({"role": "user", "content": tool_results}) messages.append({"role": "user", "content": tool_results})
assert response is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
"_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois"
)
text = next((b.text for b in response.content if b.type == "text"), "") text = next((b.text for b in response.content if b.type == "text"), "")
if text: if text:
return text return text
+3 -4
View File
@@ -1,6 +1,7 @@
"""Client Claude (voir plan Phase 2, §2) — même politique que """Client Claude (voir plan Phase 2, §2) — même politique que
auth/send_email.py::EmailNotConfiguredError : une clé absente est une auth/send_email.py::EmailNotConfiguredError : une clé absente est une
configuration incomplète, jamais un crash brut ni une clé en dur.""" configuration incomplète, jamais un crash brut ni une clé en dur."""
import os import os
import anthropic import anthropic
@@ -14,10 +15,8 @@ class AnthropicNotConfiguredError(Exception):
il faut un compte Anthropic Console (console.anthropic.com) séparé.""" il faut un compte Anthropic Console (console.anthropic.com) séparé."""
def get_client(): def get_client() -> anthropic.Anthropic:
api_key = os.environ.get("ANTHROPIC_API_KEY") api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key: if not api_key:
raise AnthropicNotConfiguredError( raise AnthropicNotConfiguredError("ANTHROPIC_API_KEY absente")
"ANTHROPIC_API_KEY absente"
)
return anthropic.Anthropic(api_key=api_key) return anthropic.Anthropic(api_key=api_key)
+9 -4
View File
@@ -9,6 +9,7 @@ Ce module reste un simple client HTTP : il renvoie l'URL de l'image,
jamais ne la télécharge/l'enregistre lui-même — ça reste le travail de jamais ne la télécharge/l'enregistre lui-même — ça reste le travail de
ai/tools.py::_dispatch_add_generated_image (voir "Mes assets", ai/tools.py::_dispatch_add_generated_image (voir "Mes assets",
auth/user_assets_dir.py), pour que ce module soit testable seul.""" auth/user_assets_dir.py), pour que ce module soit testable seul."""
import os import os
import time import time
@@ -28,7 +29,7 @@ class ScenarioGenerationError(Exception):
"""Le job Scenario a échoué ou n'a pas répondu à temps.""" """Le job Scenario a échoué ou n'a pas répondu à temps."""
def _credentials(): def _credentials() -> tuple[tuple[str, str], str]:
api_key = os.environ.get("SCENARIO_API_KEY") api_key = os.environ.get("SCENARIO_API_KEY")
api_secret = os.environ.get("SCENARIO_API_SECRET") api_secret = os.environ.get("SCENARIO_API_SECRET")
model_id = os.environ.get("SCENARIO_MODEL_ID") model_id = os.environ.get("SCENARIO_MODEL_ID")
@@ -39,14 +40,18 @@ def _credentials():
return (api_key, api_secret), model_id return (api_key, api_secret), model_id
def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval=2): def generate_image_url(
prompt: str, width: int = 768, height: int = 768, timeout: int = 120, poll_interval: int = 2
) -> str:
"""Lance une génération txt2img et attend le résultat — renvoie """Lance une génération txt2img et attend le résultat — renvoie
l'URL finale de l'image (hébergée par Scenario, à télécharger par l'URL finale de l'image (hébergée par Scenario, à télécharger par
l'appelant). Bloquant (poll_interval secondes entre chaque l'appelant). Bloquant (poll_interval secondes entre chaque
vérification), borné par `timeout` secondes au total.""" vérification), borné par `timeout` secondes au total."""
auth, model_id = _credentials() auth, model_id = _credentials()
response = requests.post( response = requests.post(
f"{_BASE_URL}/generate/txt2img", auth=auth, timeout=30, f"{_BASE_URL}/generate/txt2img",
auth=auth,
timeout=30,
json={"prompt": prompt, "modelId": model_id, "width": width, "height": height, "numSamples": 1}, json={"prompt": prompt, "modelId": model_id, "width": width, "height": height, "numSamples": 1},
) )
response.raise_for_status() response.raise_for_status()
@@ -61,7 +66,7 @@ def generate_image_url(prompt, width=768, height=768, timeout=120, poll_interval
asset_id = job["metadata"]["assetIds"][0] asset_id = job["metadata"]["assetIds"][0]
asset_response = requests.get(f"{_BASE_URL}/assets/{asset_id}", auth=auth, timeout=30) asset_response = requests.get(f"{_BASE_URL}/assets/{asset_id}", auth=auth, timeout=30)
asset_response.raise_for_status() asset_response.raise_for_status()
return asset_response.json()["asset"]["url"] return str(asset_response.json()["asset"]["url"])
if job["status"] == "failure": if job["status"] == "failure":
raise ScenarioGenerationError("Scenario a échoué à générer l'image.") raise ScenarioGenerationError("Scenario a échoué à générer l'image.")
time.sleep(poll_interval) time.sleep(poll_interval)
+204 -80
View File
@@ -20,6 +20,7 @@ DATA_OPERATION_LABELS, ...) plutôt que recopiées en dur. Un test dédié
remplacée par une copie littérale.""" remplacée par une copie littérale."""
import os import os
from typing import Any, Callable
import requests import requests
from flask import url_for from flask import url_for
@@ -28,12 +29,13 @@ import auth
import db import db
import screens import screens
from db.dialogue_lines import QUESTION_REWARD_TYPES from db.dialogue_lines import QUESTION_REWARD_TYPES
from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS, TRIGGER_TYPES
from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN
from screens.labels.data_operations import DATA_OPERATION_LABELS from screens.labels.data_operations import DATA_OPERATION_LABELS
from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS
from screens.labels.surbrillance_values import SURBRILLANCE_LABELS from screens.labels.surbrillance_values import SURBRILLANCE_LABELS
from screens.labels.video_modes import VIDEO_MODE_LABELS from screens.labels.video_modes import VIDEO_MODE_LABELS
from screens.rendering.collision_rules import ACTION_TYPES, CONDITION_OPERATOR_KEYS, LEAF_ACTION_TYPES, TRIGGER_TYPES
from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN
from .scenario_client import generate_image_url from .scenario_client import generate_image_url
_CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS) _CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS)
@@ -53,37 +55,49 @@ _GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES)
_FORGE_CHARACTERS = sorted(screens.SPRITE_LIBRARY) _FORGE_CHARACTERS = sorted(screens.SPRITE_LIBRARY)
_BACKGROUNDS = sorted(screens.BACKGROUND_LIBRARY) _BACKGROUNDS = sorted(screens.BACKGROUND_LIBRARY)
_LEAF_ACTION_SCHEMA = { _LEAF_ACTION_SCHEMA: dict[str, Any] = {
"type": "object", "type": "object",
"description": ( "description": (
"Une action FEUILLE (\"dialogue\" ou \"variable\", voir " 'Une action FEUILLE ("dialogue" ou "variable", voir '
"screens/rendering/collision_rules.py) — peut porter un champ " "screens/rendering/collision_rules.py) — peut porter un champ "
"optionnel \"then\" (une autre feuille, chaînage borné à 4)." 'optionnel "then" (une autre feuille, chaînage borné à 4).'
), ),
"properties": { "properties": {
"type": {"type": "string", "enum": list(LEAF_ACTION_TYPES)}, "type": {"type": "string", "enum": list(LEAF_ACTION_TYPES)},
"id": {"type": "string", "description": "Identifiant de cette feuille, pour l'adresser plus tard via append_action_to_trigger."}, "id": {
"type": "string",
"description": "Identifiant de cette feuille, pour l'adresser plus tard via append_action_to_trigger.",
},
"dialogue": { "dialogue": {
"type": "object", "type": "object",
"description": ( "description": (
"Pour type=dialogue : {id, lines}. Chaque élément de `lines` est SOIT une " "Pour type=dialogue : {id, lines}. Chaque élément de `lines` est SOIT une "
"RÉPLIQUE {\"type\":\"dialogue\", \"speaker\": <qui parle>, \"text\": <texte>}, " 'RÉPLIQUE {"type":"dialogue", "speaker": <qui parle>, "text": <texte>}, '
"SOIT une QUESTION DE QUIZ {\"type\":\"question\", \"text\", \"choices\": " 'SOIT une QUESTION DE QUIZ {"type":"question", "text", "choices": '
"[2 à 4 réponses], \"correct_index\": <index de la bonne réponse>, " '[2 à 4 réponses], "correct_index": <index de la bonne réponse>, '
f"\"reward_type\": {list(QUESTION_REWARD_TYPES)!r}, \"reward_amount\": <entier>}}. " f'"reward_type": {list(QUESTION_REWARD_TYPES)!r}, "reward_amount": <entier>}}. '
"reward_amount alimente le SCORE NATIF du jeu (visible via un objet " "reward_amount alimente le SCORE NATIF du jeu (visible via un objet "
"kind=\"score_widget\", nécessite aussi un objet kind=\"quiz_box\" posé pour que " 'kind="score_widget", nécessite aussi un objet kind="quiz_box" posé pour que '
"la question s'affiche) — crédité UNIQUEMENT si la réponse est correcte, sinon " "la question s'affiche) — crédité UNIQUEMENT si la réponse est correcte, sinon "
"ignoré ; le joueur avance toujours à la ligne suivante, bonne réponse ou pas. " "ignoré ; le joueur avance toujours à la ligne suivante, bonne réponse ou pas. "
"N'invente JAMAIS une variable séparée pour suivre un score de quiz : " "N'invente JAMAIS une variable séparée pour suivre un score de quiz : "
"reward_amount fait déjà tout, sans action \"variable\" supplémentaire." 'reward_amount fait déjà tout, sans action "variable" supplémentaire.'
), ),
"properties": {"id": {"type": "string"}, "lines": {"type": "array"}}, "properties": {"id": {"type": "string"}, "lines": {"type": "array"}},
}, },
"mark_completed": {"type": "boolean"}, "mark_completed": {"type": "boolean"},
"target_variable": {"type": "string", "description": "Pour type=variable : nom d'une variable globale existante."}, "target_variable": {
"type": "string",
"description": "Pour type=variable : nom d'une variable globale existante.",
},
"data_operation": {"type": "string", "enum": _DATA_OPERATIONS}, "data_operation": {"type": "string", "enum": _DATA_OPERATIONS},
"data_value": {"type": "string", "description": "Pour type=variable : absent pour definir_bool_vrai/definir_bool_faux/basculer_bool. Pour type=attendre : nombre de SECONDES à attendre avant \"then\" (ex. \"2\", \"1.5\")."}, "data_value": {
"type": "string",
"description": (
"Pour type=variable : absent pour definir_bool_vrai/definir_bool_faux/basculer_bool. "
'Pour type=attendre : nombre de SECONDES à attendre avant "then" (ex. "2", "1.5").'
),
},
"object_id": { "object_id": {
"type": "integer", "type": "integer",
"description": ( "description": (
@@ -102,7 +116,7 @@ _LEAF_ACTION_SCHEMA = {
}, },
"asset_url": { "asset_url": {
"type": "string", "type": "string",
"description": "Pour type=son/video : URL d'un fichier déjà présent dans \"Mes assets\".", "description": 'Pour type=son/video : URL d\'un fichier déjà présent dans "Mes assets".',
}, },
"mode": {"type": "string", "enum": sorted(VIDEO_MODE_LABELS), "description": "Pour type=video."}, "mode": {"type": "string", "enum": sorted(VIDEO_MODE_LABELS), "description": "Pour type=video."},
"texte": {"type": "string", "description": "Pour type=indication : le texte affiché dans la bulle."}, "texte": {"type": "string", "description": "Pour type=indication : le texte affiché dans la bulle."},
@@ -112,9 +126,11 @@ _LEAF_ACTION_SCHEMA = {
"required": ["type"], "required": ["type"],
} }
_ACTION_SCHEMA = { _ACTION_SCHEMA: dict[str, Any] = {
"type": "object", "type": "object",
"description": "Une action de déclencheur — voir screens/rendering/collision_rules.py pour la forme exacte de chaque type.", "description": (
"Une action de déclencheur — voir screens/rendering/collision_rules.py pour la forme exacte de chaque type."
),
"properties": { "properties": {
"type": {"type": "string", "enum": list(ACTION_TYPES)}, "type": {"type": "string", "enum": list(ACTION_TYPES)},
"id": {"type": "string"}, "id": {"type": "string"},
@@ -129,8 +145,12 @@ _ACTION_SCHEMA = {
"mode": _LEAF_ACTION_SCHEMA["properties"]["mode"], "mode": _LEAF_ACTION_SCHEMA["properties"]["mode"],
"texte": _LEAF_ACTION_SCHEMA["properties"]["texte"], "texte": _LEAF_ACTION_SCHEMA["properties"]["texte"],
"duree_ms": _LEAF_ACTION_SCHEMA["properties"]["duree_ms"], "duree_ms": _LEAF_ACTION_SCHEMA["properties"]["duree_ms"],
"then": {"description": "Feuille suivante (dialogue/variable/surbrillance/visibilite/son/video/indication), récursif."}, "then": {
"sub_action": {"description": "Pour type=interagir : une action (pas \"interagir\" à nouveau)."}, "description": (
"Feuille suivante (dialogue/variable/surbrillance/visibilite/son/video/indication), récursif."
),
},
"sub_action": {"description": 'Pour type=interagir : une action (pas "interagir" à nouveau).'},
"variable": {"type": "string", "description": "Pour type=condition."}, "variable": {"type": "string", "description": "Pour type=condition."},
"operateur": {"type": "string", "enum": _CONDITION_OPERATORS}, "operateur": {"type": "string", "enum": _CONDITION_OPERATORS},
"si_vrai": {"description": "Pour type=condition : null ou une feuille."}, "si_vrai": {"description": "Pour type=condition : null ou une feuille."},
@@ -139,21 +159,43 @@ _ACTION_SCHEMA = {
"required": ["type"], "required": ["type"],
} }
TOOLS = [ TOOLS: list[dict[str, Any]] = [
{ {
"name": "add_scene_object", "name": "add_scene_object",
"description": ( "description": (
"Pose un nouvel objet sur l'écran en cours (personnage, décor, fond, " "Pose un nouvel objet sur l'écran en cours (personnage, décor, fond, "
"ou widget d'interface). Pour une image générée/de \"Mes assets\", " 'ou widget d\'interface). Pour une image générée/de "Mes assets", '
"utiliser image_url (jamais générer un personnage/sprite par ce biais)." "utiliser image_url (jamais générer un personnage/sprite par ce biais)."
), ),
"input_schema": { "input_schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"kind": {"type": "string", "enum": ["personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"]}, "kind": {
"forge_character": {"type": "string", "enum": _FORGE_CHARACTERS, "description": "Pour kind=personnage : slug d'un personnage de la bibliothèque Forge existante (jamais un sprite généré)."}, "type": "string",
"background_slug": {"type": "string", "enum": _BACKGROUNDS, "description": "Pour kind=fond : slug d'une image de fond déjà existante dans la bibliothèque Forge."}, "enum": ["personnage", "decor", "fond", "dialogue_box", "quiz_box", "score_widget"],
"image_url": {"type": "string", "description": "URL d'une image déjà uploadée/générée (voir \"Mes assets\") — kind decor/fond uniquement, prioritaire sur background_slug."}, },
"forge_character": {
"type": "string",
"enum": _FORGE_CHARACTERS,
"description": (
"Pour kind=personnage : slug d'un personnage de la bibliothèque Forge "
"existante (jamais un sprite généré)."
),
},
"background_slug": {
"type": "string",
"enum": _BACKGROUNDS,
"description": (
"Pour kind=fond : slug d'une image de fond déjà existante dans la bibliothèque Forge."
),
},
"image_url": {
"type": "string",
"description": (
'URL d\'une image déjà uploadée/générée (voir "Mes assets") — kind '
"decor/fond uniquement, prioritaire sur background_slug."
),
},
}, },
"required": ["kind"], "required": ["kind"],
}, },
@@ -165,8 +207,10 @@ TOOLS = [
"type": "object", "type": "object",
"properties": { "properties": {
"object_id": {"type": "integer"}, "object_id": {"type": "integer"},
"x": {"type": "number"}, "y": {"type": "number"}, "x": {"type": "number"},
"width": {"type": "number"}, "height": {"type": "number"}, "y": {"type": "number"},
"width": {"type": "number"},
"height": {"type": "number"},
}, },
"required": ["object_id", "x", "y", "width", "height"], "required": ["object_id", "x", "y", "width", "height"],
}, },
@@ -174,8 +218,8 @@ TOOLS = [
{ {
"name": "set_object_name", "name": "set_object_name",
"description": ( "description": (
"Donne un nom à un objet de scène (ex. \"Stan\", \"Aka\") — affiché comme " 'Donne un nom à un objet de scène (ex. "Stan", "Aka") — affiché comme '
"\"qui parle\" dans les dialogues qui lui sont attachés." '"qui parle" dans les dialogues qui lui sont attachés.'
), ),
"input_schema": { "input_schema": {
"type": "object", "type": "object",
@@ -207,8 +251,10 @@ TOOLS = [
"object_id": {"type": "integer"}, "object_id": {"type": "integer"},
"enabled": {"type": "boolean"}, "enabled": {"type": "boolean"},
"shape": {"type": "string", "enum": _COLLISION_SHAPES}, "shape": {"type": "string", "enum": _COLLISION_SHAPES},
"width": {"type": "number"}, "height": {"type": "number"}, "width": {"type": "number"},
"offset_x": {"type": "number"}, "offset_y": {"type": "number"}, "height": {"type": "number"},
"offset_x": {"type": "number"},
"offset_y": {"type": "number"},
}, },
"required": ["object_id"], "required": ["object_id"],
}, },
@@ -216,13 +262,13 @@ TOOLS = [
{ {
"name": "set_quiz_box_config", "name": "set_quiz_box_config",
"description": ( "description": (
"Règle les options d'une \"❓ Boîte à quiz\" pensées pour un quiz AUTONOME " 'Règle les options d\'une "❓ Boîte à quiz" pensées pour un quiz AUTONOME '
"(RH/formation) : plein écran, minuteur (jamais imposé — \"aucun\" par défaut, " '(RH/formation) : plein écran, minuteur (jamais imposé — "aucun" par défaut, '
"au choix du créateur : \"question\" redémarre à chaque question, \"quiz\" est un " 'au choix du créateur : "question" redémarre à chaque question, "quiz" est un '
"seul compte à rebours pour tout le quiz), et DEUX modèles visuels INDÉPENDANTS " "seul compte à rebours pour tout le quiz), et DEUX modèles visuels INDÉPENDANTS "
"(les deux réglages sont conservés en même temps, jamais l'un n'écrase l'autre) : " "(les deux réglages sont conservés en même temps, jamais l'un n'écrase l'autre) : "
"dialog_template (visible SEULEMENT hors plein écran — variantes de forme/couleur " "dialog_template (visible SEULEMENT hors plein écran — variantes de forme/couleur "
"sur une structure proche de \"Classique\") et page_template (visible SEULEMENT en " 'sur une structure proche de "Classique") et page_template (visible SEULEMENT en '
"plein écran — thème complet et autonome, structure HTML propre à chaque modèle). " "plein écran — thème complet et autonome, structure HTML propre à chaque modèle). "
"En plein écran, le score se retrouve affiché directement dans la boîte." "En plein écran, le score se retrouve affiché directement dans la boîte."
), ),
@@ -232,16 +278,29 @@ TOOLS = [
"object_id": {"type": "integer"}, "object_id": {"type": "integer"},
"fullscreen": {"type": "boolean"}, "fullscreen": {"type": "boolean"},
"timer_mode": {"type": "string", "enum": _QUIZ_BOX_TIMER_MODES}, "timer_mode": {"type": "string", "enum": _QUIZ_BOX_TIMER_MODES},
"timer_seconds": {"type": "integer", "description": "Durée du minuteur (3 à 600s), ignorée si timer_mode=\"aucun\"."}, "timer_seconds": {
"dialog_template": {"type": "string", "enum": _QUIZ_BOX_DIALOG_TEMPLATES, "description": "Modèle \"boîte de dialogue\" — ne s'affiche que si fullscreen=false."}, "type": "integer",
"page_template": {"type": "string", "enum": _QUIZ_BOX_PAGE_TEMPLATES, "description": "Modèle \"page de quiz\" — ne s'affiche que si fullscreen=true."}, "description": 'Durée du minuteur (3 à 600s), ignorée si timer_mode="aucun".',
},
"dialog_template": {
"type": "string",
"enum": _QUIZ_BOX_DIALOG_TEMPLATES,
"description": 'Modèle "boîte de dialogue" — ne s\'affiche que si fullscreen=false.',
},
"page_template": {
"type": "string",
"enum": _QUIZ_BOX_PAGE_TEMPLATES,
"description": 'Modèle "page de quiz" — ne s\'affiche que si fullscreen=true.',
},
}, },
"required": ["object_id"], "required": ["object_id"],
}, },
}, },
{ {
"name": "create_global_variable", "name": "create_global_variable",
"description": "Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions.", "description": (
"Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions."
),
"input_schema": { "input_schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -256,10 +315,10 @@ TOOLS = [
{ {
"name": "set_collision_rules", "name": "set_collision_rules",
"description": ( "description": (
"Remplace TOUTES les règles \"déclencheur -> action\" d'un objet. " 'Remplace TOUTES les règles "déclencheur -> action" d\'un objet. '
"Déclencheurs disponibles : \"collision\" (contact avec le personnage " 'Déclencheurs disponibles : "collision" (contact avec le personnage '
"\"joueur\" — voir set_object_role), \"clic\" (l'objet est cliqué/touché, " '"joueur" — voir set_object_role), "clic" (l\'objet est cliqué/touché, '
"aucun joueur requis), \"survol\" (le pointeur survole l'objet, aucun " 'aucun joueur requis), "survol" (le pointeur survole l\'objet, aucun '
"joueur requis). Sanitizé côté serveur (screens.sanitize_collision_rules) : " "joueur requis). Sanitizé côté serveur (screens.sanitize_collision_rules) : "
"toute valeur invalide est silencieusement retirée plutôt que rejetée — " "toute valeur invalide est silencieusement retirée plutôt que rejetée — "
"compare le nombre de `rules` renvoyées dans le résultat à ce que tu as " "compare le nombre de `rules` renvoyées dans le résultat à ce que tu as "
@@ -316,7 +375,7 @@ TOOLS = [
"description": ( "description": (
"Génère une image (fond ou objet 2D UNIQUEMENT, JAMAIS un " "Génère une image (fond ou objet 2D UNIQUEMENT, JAMAIS un "
"personnage/sprite — la génération d'image n'est pas fiable pour " "personnage/sprite — la génération d'image n'est pas fiable pour "
"ça) via Scenario, l'ajoute à \"Mes assets\" et la pose " 'ça) via Scenario, l\'ajoute à "Mes assets" et la pose '
"automatiquement sur l'écran en cours." "automatiquement sur l'écran en cours."
), ),
"input_schema": { "input_schema": {
@@ -370,9 +429,9 @@ TOOLS = [
"description": ( "description": (
"Retire UNE action précise (leaf_id) d'un déclencheur d'OBJET, où " "Retire UNE action précise (leaf_id) d'un déclencheur d'OBJET, où "
"qu'elle soit dans l'arbre (feuille directe, maillon d'une chaîne " "qu'elle soit dans l'arbre (feuille directe, maillon d'une chaîne "
"\"then\", sous-action d'un \"interagir\", ou branche d'une " '"then", sous-action d\'un "interagir", ou branche d\'une '
"\"condition\"). Si c'était la SEULE action de la règle, la règle " '"condition"). Si c\'était la SEULE action de la règle, la règle '
"entière disparaît ; si elle avait une suite \"then\", celle-ci " 'entière disparaît ; si elle avait une suite "then", celle-ci '
"prend sa place." "prend sa place."
), ),
"input_schema": { "input_schema": {
@@ -397,7 +456,7 @@ TOOLS = [
"name": "move_trigger_action", "name": "move_trigger_action",
"description": ( "description": (
"Déplace une action précise (leaf_id) d'un cran vers le haut " "Déplace une action précise (leaf_id) d'un cran vers le haut "
"(\"up\") ou le bas (\"down\") DANS SA PROPRE CHAÎNE \"then\", pour " '("up") ou le bas ("down") DANS SA PROPRE CHAÎNE "then", pour '
"un déclencheur d'OBJET — jamais au-delà de sa branche de " "un déclencheur d'OBJET — jamais au-delà de sa branche de "
"condition/sous-action interagir." "condition/sous-action interagir."
), ),
@@ -426,7 +485,9 @@ TOOLS = [
] ]
def _clamp_to_camera(slug, screen_id, kind, x, y, width, height): def _clamp_to_camera(
slug: str, screen_id: int, kind: str | None, x: float, y: float, width: float, height: float
) -> tuple[float, float, str | None]:
"""Le placement précis par coordonnées s'est montré peu fiable pour """Le placement précis par coordonnées s'est montré peu fiable pour
l'IA malgré des instructions explicites ET un avertissement dans le l'IA malgré des instructions explicites ET un avertissement dans le
résultat de l'outil (bug observé à répétition — l'IA n'arrivait résultat de l'outil (bug observé à répétition — l'IA n'arrivait
@@ -456,7 +517,15 @@ def _clamp_to_camera(slug, screen_id, kind, x, y, width, height):
return clamped_x, clamped_y, note return clamped_x, clamped_y, note
def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=None, background_slug=None, image_url=None): def _dispatch_add_scene_object(
slug: str,
screen_id: int,
user_id: int,
kind: str,
forge_character: str | None = None,
background_slug: str | None = None,
image_url: str | None = None,
) -> dict[str, Any]:
# Même garde que la galerie manuelle (core/sprite_gate.py) — un # Même garde que la galerie manuelle (core/sprite_gate.py) — un
# compte non-admin ne doit pas pouvoir poser un sprite/fond # compte non-admin ne doit pas pouvoir poser un sprite/fond
# admin-only via l'IA alors que la galerie de l'éditeur ne les lui # admin-only via l'IA alors que la galerie de l'éditeur ne les lui
@@ -481,59 +550,90 @@ def _dispatch_add_scene_object(slug, screen_id, user_id, kind, forge_character=N
if background_slug and background_slug not in screens.BACKGROUND_LIBRARY: if background_slug and background_slug not in screens.BACKGROUND_LIBRARY:
raise ValueError(f"background_slug inconnu : {background_slug!r} (voir la liste enum du tool)") raise ValueError(f"background_slug inconnu : {background_slug!r} (voir la liste enum du tool)")
object_id = screens.add_scene_object( object_id = screens.add_scene_object(
slug, screen_id, kind=kind, forge_character=forge_character, slug,
background_slug=background_slug, image_url=image_url, screen_id,
kind=kind,
forge_character=forge_character,
background_slug=background_slug,
image_url=image_url,
) )
obj = screens.get_scene_object(slug, object_id) obj = screens.get_scene_object(slug, object_id)
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
"object_id vient d'etre cree par screens.add_scene_object juste au-dessus"
)
# Position par défaut (100,100, voir ensure_scene_schema.py) déjà # Position par défaut (100,100, voir ensure_scene_schema.py) déjà
# posée par screens.add_scene_object — reste passée par le même # posée par screens.add_scene_object — reste passée par le même
# garde-fou pour rester correcte même si ce défaut changeait un jour. # garde-fou pour rester correcte même si ce défaut changeait un jour.
clamped_x, clamped_y, note = _clamp_to_camera(slug, screen_id, kind, obj["x"], obj["y"], obj["width"], obj["height"]) clamped_x, clamped_y, note = _clamp_to_camera(
slug, screen_id, kind, obj["x"], obj["y"], obj["width"], obj["height"]
)
if note: if note:
screens.update_scene_object_geometry(slug, object_id, clamped_x, clamped_y, obj["width"], obj["height"]) screens.update_scene_object_geometry(slug, object_id, clamped_x, clamped_y, obj["width"], obj["height"])
result = {"object_id": object_id} result: dict[str, Any] = {"object_id": object_id}
if note: if note:
result["note"] = note result["note"] = note
return result return result
def _dispatch_set_object_geometry(slug, screen_id, user_id, object_id, x, y, width, height): def _dispatch_set_object_geometry(
slug: str, screen_id: int, user_id: int, object_id: int, x: float, y: float, width: float, height: float
) -> dict[str, Any]:
obj = screens.get_scene_object(slug, object_id) obj = screens.get_scene_object(slug, object_id)
x, y, note = _clamp_to_camera(slug, screen_id, obj["kind"] if obj else None, x, y, width, height) x, y, note = _clamp_to_camera(slug, screen_id, obj["kind"] if obj else None, x, y, width, height)
screens.update_scene_object_geometry(slug, object_id, x, y, width, height) screens.update_scene_object_geometry(slug, object_id, x, y, width, height)
result = {"ok": True} result: dict[str, Any] = {"ok": True}
if note: if note:
result["note"] = note result["note"] = note
return result return result
def _dispatch_set_object_name(slug, screen_id, user_id, object_id, name): def _dispatch_set_object_name(slug: str, screen_id: int, user_id: int, object_id: int, name: str) -> dict[str, Any]:
screens.set_scene_object_name(slug, object_id, name) screens.set_scene_object_name(slug, object_id, name)
return {"ok": True} return {"ok": True}
def _dispatch_set_object_role(slug, screen_id, user_id, object_id, role): def _dispatch_set_object_role(slug: str, screen_id: int, user_id: int, object_id: int, role: str) -> dict[str, Any]:
screens.set_scene_object_role(slug, object_id, role) screens.set_scene_object_role(slug, object_id, role)
return {"ok": True} return {"ok": True}
def _dispatch_set_object_collision(slug, screen_id, user_id, object_id, enabled=True, shape="rectangle", def _dispatch_set_object_collision(
width=None, height=None, offset_x=0, offset_y=0): slug: str,
screen_id: int,
user_id: int,
object_id: int,
enabled: bool = True,
shape: str = "rectangle",
width: float | None = None,
height: float | None = None,
offset_x: float = 0,
offset_y: float = 0,
) -> dict[str, Any]:
# Même forme que routes/scenes/scene_object_collision.py (remplacement # Même forme que routes/scenes/scene_object_collision.py (remplacement
# complet des réglages, jamais un merge partiel). # complet des réglages, jamais un merge partiel).
settings = { settings: dict[str, Any] = {
"enabled": bool(enabled), "enabled": bool(enabled),
"shape": shape if shape in _COLLISION_SHAPES else "rectangle", "shape": shape if shape in _COLLISION_SHAPES else "rectangle",
"width": width, "height": height, "width": width,
"offset_x": offset_x or 0, "offset_y": offset_y or 0, "height": height,
"offset_x": offset_x or 0,
"offset_y": offset_y or 0,
} }
screens.set_scene_object_collision(slug, object_id, settings) screens.set_scene_object_collision(slug, object_id, settings)
return {"ok": True} return {"ok": True}
def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscreen=False, def _dispatch_set_quiz_box_config(
timer_mode="aucun", timer_seconds=20, slug: str,
dialog_template="defaut", page_template="classique"): screen_id: int,
user_id: int,
object_id: int,
fullscreen: bool = False,
timer_mode: str = "aucun",
timer_seconds: int = 20,
dialog_template: str = "defaut",
page_template: str = "classique",
) -> dict[str, Any]:
# Même forme que screens.set_scene_object_quiz_config (remplacement # Même forme que screens.set_scene_object_quiz_config (remplacement
# complet des réglages, jamais un merge partiel) — screens.py sanitize # complet des réglages, jamais un merge partiel) — screens.py sanitize
# de toute façon toute valeur hors énumération, ce garde-fou ici sert # de toute façon toute valeur hors énumération, ce garde-fou ici sert
@@ -541,7 +641,7 @@ def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscree
# dialog_template/page_template sont conservés INDÉPENDAMMENT (voir # dialog_template/page_template sont conservés INDÉPENDAMMENT (voir
# screens/rendering/quiz_box_config.py) : Ruby peut en régler un seul # screens/rendering/quiz_box_config.py) : Ruby peut en régler un seul
# sans jamais écraser l'autre. # sans jamais écraser l'autre.
config = { config: dict[str, Any] = {
"fullscreen": bool(fullscreen), "fullscreen": bool(fullscreen),
"timer_mode": timer_mode if timer_mode in _QUIZ_BOX_TIMER_MODES else "aucun", "timer_mode": timer_mode if timer_mode in _QUIZ_BOX_TIMER_MODES else "aucun",
"timer_seconds": timer_seconds or 20, "timer_seconds": timer_seconds or 20,
@@ -552,54 +652,76 @@ def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscree
return {"ok": True} return {"ok": True}
def _dispatch_create_global_variable(slug, screen_id, user_id, name, var_type="texte", default_value="", per_player=True): def _dispatch_create_global_variable(
variable_id = db.create_global_variable(slug, name, var_type=var_type, default_value=default_value, per_player=per_player) slug: str,
screen_id: int,
user_id: int,
name: str,
var_type: str = "texte",
default_value: str = "",
per_player: bool = True,
) -> dict[str, Any]:
variable_id = db.create_global_variable(
slug, name, var_type=var_type, default_value=default_value, per_player=per_player
)
return {"variable_id": variable_id} return {"variable_id": variable_id}
def _dispatch_set_collision_rules(slug, screen_id, user_id, object_id, rules): def _dispatch_set_collision_rules(
slug: str, screen_id: int, user_id: int, object_id: int, rules: Any
) -> dict[str, Any]:
sanitized = screens.sanitize_collision_rules(rules) sanitized = screens.sanitize_collision_rules(rules)
screens.set_scene_object_collision_rules(slug, object_id, sanitized) screens.set_scene_object_collision_rules(slug, object_id, sanitized)
return {"ok": True, "rules": sanitized} return {"ok": True, "rules": sanitized}
def _dispatch_append_action_to_trigger(slug, screen_id, user_id, object_id, after_id, action): def _dispatch_append_action_to_trigger(
slug: str, screen_id: int, user_id: int, object_id: int, after_id: str, action: dict[str, Any]
) -> dict[str, Any]:
ok = screens.append_action_to_trigger(slug, object_id, after_id, action) ok = screens.append_action_to_trigger(slug, object_id, after_id, action)
return {"ok": ok} return {"ok": ok}
def _dispatch_set_screen_triggers(slug, screen_id, user_id, rules): def _dispatch_set_screen_triggers(slug: str, screen_id: int, user_id: int, rules: Any) -> dict[str, Any]:
sanitized = screens.sanitize_screen_triggers(rules) sanitized = screens.sanitize_screen_triggers(rules)
screens.set_screen_triggers(slug, screen_id, sanitized) screens.set_screen_triggers(slug, screen_id, sanitized)
return {"ok": True, "rules": sanitized} return {"ok": True, "rules": sanitized}
def _dispatch_append_action_to_screen_trigger(slug, screen_id, user_id, after_id, action): def _dispatch_append_action_to_screen_trigger(
slug: str, screen_id: int, user_id: int, after_id: str, action: dict[str, Any]
) -> dict[str, Any]:
ok = screens.append_action_to_screen_trigger(slug, screen_id, after_id, action) ok = screens.append_action_to_screen_trigger(slug, screen_id, after_id, action)
return {"ok": ok} return {"ok": ok}
def _dispatch_remove_trigger_action(slug, screen_id, user_id, object_id, leaf_id): def _dispatch_remove_trigger_action(
slug: str, screen_id: int, user_id: int, object_id: int, leaf_id: str
) -> dict[str, Any]:
ok = screens.remove_action_from_trigger(slug, object_id, leaf_id) ok = screens.remove_action_from_trigger(slug, object_id, leaf_id)
return {"ok": ok} return {"ok": ok}
def _dispatch_remove_screen_trigger_action(slug, screen_id, user_id, leaf_id): def _dispatch_remove_screen_trigger_action(slug: str, screen_id: int, user_id: int, leaf_id: str) -> dict[str, Any]:
ok = screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id) ok = screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id)
return {"ok": ok} return {"ok": ok}
def _dispatch_move_trigger_action(slug, screen_id, user_id, object_id, leaf_id, direction): def _dispatch_move_trigger_action(
slug: str, screen_id: int, user_id: int, object_id: int, leaf_id: str, direction: str
) -> dict[str, Any]:
ok = screens.move_action_in_trigger(slug, object_id, leaf_id, direction) ok = screens.move_action_in_trigger(slug, object_id, leaf_id, direction)
return {"ok": ok} return {"ok": ok}
def _dispatch_move_screen_trigger_action(slug, screen_id, user_id, leaf_id, direction): def _dispatch_move_screen_trigger_action(
slug: str, screen_id: int, user_id: int, leaf_id: str, direction: str
) -> dict[str, Any]:
ok = screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction) ok = screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction)
return {"ok": ok} return {"ok": ok}
def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt): def _dispatch_add_generated_image(slug: str, screen_id: int, user_id: int, kind: str, prompt: str) -> dict[str, Any]:
"""Scenario -> "Mes assets" (auth.save_user_asset, source="ia") -> """Scenario -> "Mes assets" (auth.save_user_asset, source="ia") ->
posée sur l'écran via LE MÊME chemin que Phase 1 posée sur l'écran via LE MÊME chemin que Phase 1
(screens.add_scene_object(image_url=...)) — jamais un chemin (screens.add_scene_object(image_url=...)) — jamais un chemin
@@ -615,7 +737,7 @@ def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt):
return {"object_id": object_id, "asset_id": asset_id} return {"object_id": object_id, "asset_id": asset_id}
_DISPATCH = { _DISPATCH: dict[str, Callable[..., dict[str, Any]]] = {
"add_scene_object": _dispatch_add_scene_object, "add_scene_object": _dispatch_add_scene_object,
"set_object_geometry": _dispatch_set_object_geometry, "set_object_geometry": _dispatch_set_object_geometry,
"set_object_name": _dispatch_set_object_name, "set_object_name": _dispatch_set_object_name,
@@ -635,7 +757,9 @@ _DISPATCH = {
} }
def dispatch_tool(slug, screen_id, user_id, tool_name, tool_input): def dispatch_tool(
slug: str, screen_id: int, user_id: int, tool_name: str, tool_input: dict[str, Any]
) -> dict[str, Any]:
"""Point d'entrée UNIQUE utilisé par la boucle tool-use (Phase 2) — """Point d'entrée UNIQUE utilisé par la boucle tool-use (Phase 2) —
lève KeyError pour un nom d'outil inconnu (jamais silencieux : un tool lève KeyError pour un nom d'outil inconnu (jamais silencieux : un tool
annoncé par TOOLS mais absent d'ici serait un bug de ce module, pas annoncé par TOOLS mais absent d'ici serait un bug de ce module, pas
+17 -8
View File
@@ -15,19 +15,23 @@ Lance un serveur web local. Fonctionnalités construites pour l'instant
une relation existante. une relation existante.
""" """
import os
import threading import threading
import webbrowser import webbrowser
from core.flask_app import app
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
import routes # noqa: F401 - enregistre toutes les routes sur `app` import routes # noqa: F401 - enregistre toutes les routes sur `app`
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes) from core import (
from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja auth_guard, # noqa: F401 - enregistre la garde de connexion (après les routes)
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF csrf, # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja csrf_guard, # noqa: F401 - enregistre la vérification du jeton CSRF
db_teardown_guard, # noqa: F401 - enregistre la fermeture des connexions SQLite fuitées
jinja_filters, # noqa: F401 - enregistre les filtres Jinja
recovery_codes_flash, # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
)
from core.flask_app import app
def _open_browser(): def _open_browser() -> None:
webbrowser.open("http://127.0.0.1:5050/") webbrowser.open("http://127.0.0.1:5050/")
@@ -41,4 +45,9 @@ if __name__ == "__main__":
# l'éditeur de scène "vide" le temps que les images finissent par # l'éditeur de scène "vide" le temps que les images finissent par
# arriver l'une après l'autre — un rechargement normal, servi surtout # arriver l'une après l'autre — un rechargement normal, servi surtout
# depuis le cache navigateur, le cachait). # depuis le cache navigateur, le cachait).
app.run(host="127.0.0.1", port=5050, debug=True, use_reloader=False, threaded=True) # debug : jamais actif par defaut (Bandit B201 - le debogueur Werkzeug
# permet l'execution de code arbitraire) — activable en local via
# FORGE_DEBUG=1 dans .env, jamais utilise en production (gunicorn y
# sert app:app directement, ce bloc __main__ n'y tourne pas).
debug = os.environ.get("FORGE_DEBUG") == "1"
app.run(host="127.0.0.1", port=5050, debug=debug, use_reloader=False, threaded=True)
+82 -51
View File
@@ -4,63 +4,94 @@ create_user.py pour le détail des règles. Base SQLite entièrement séparée
de db/ (une base par JEU) : ces comptes n'appartiennent à aucun jeu, ils de db/ (une base par JEU) : ces comptes n'appartiennent à aucun jeu, ils
en POSSÈDENT un (project_slug).""" en POSSÈDENT un (project_slug)."""
from .confirm_totp import confirm_totp
from .connection import users_db_path from .connection import users_db_path
from .count_admins import count_admins
from .create_user import UserCreationError, create_user
from .create_user_asset import create_user_asset
from .delete_user import delete_user
from .delete_user_asset import delete_user_asset
from .ensure_schema import ensure_users_schema from .ensure_schema import ensure_users_schema
from .is_first_user import is_first_user from .ensure_user_assets_schema import ensure_user_assets_schema
from .create_user import create_user, UserCreationError from .get_user_asset import get_user_asset
from .get_user_by_email import get_user_by_email from .get_user_by_email import get_user_by_email
from .get_user_by_id import get_user_by_id from .get_user_by_id import get_user_by_id
from .verify_password import verify_password
from .confirm_totp import confirm_totp
from .verify_totp import verify_totp
from .set_project_slug import set_project_slug
from .password_strength import password_strength, MIN_SCORE_REQUIRED
from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg
from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message
from .record_failed_attempt import record_failed_attempt
from .reset_failed_attempts import reset_failed_attempts
from .recovery_codes import generate_recovery_codes, verify_recovery_code
from .set_password import set_password
from .password_reset import (
create_password_reset_token, get_user_id_for_valid_token, consume_password_reset_token,
TOKEN_TTL_MINUTES,
)
from .send_email import send_password_reset_email, EmailNotConfiguredError
from .update_profile import update_profile
from .count_admins import count_admins
from .delete_user import delete_user
from .update_email import update_email, EmailUpdateError
from .ensure_user_assets_schema import ensure_user_assets_schema
from .user_assets_dir import user_assets_dir
from .create_user_asset import create_user_asset
from .list_user_assets import list_user_assets
from .get_user_asset import get_user_asset
from .delete_user_asset import delete_user_asset
from .save_user_asset import save_user_asset
from .user_asset_kind import user_asset_kind
from .validate_audio_duration import validate_audio_duration, MAX_AUDIO_SECONDS
from .validate_video_duration import validate_video_duration, MAX_VIDEO_SECONDS
from .image_dimensions import image_dimensions from .image_dimensions import image_dimensions
from .is_first_user import is_first_user
from .list_user_assets import list_user_assets
from .password_reset import (
TOKEN_TTL_MINUTES,
consume_password_reset_token,
create_password_reset_token,
get_user_id_for_valid_token,
)
from .password_strength import MIN_SCORE_REQUIRED, password_strength
from .rate_limit import lockout_message, lockout_minutes_for, seconds_locked_remaining
from .record_failed_attempt import record_failed_attempt
from .recovery_codes import generate_recovery_codes, verify_recovery_code
from .reset_failed_attempts import reset_failed_attempts
from .save_user_asset import save_user_asset
from .send_email import EmailNotConfiguredError, send_password_reset_email
from .set_password import set_password
from .set_project_slug import set_project_slug
from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg
from .update_email import EmailUpdateError, update_email
from .update_profile import update_profile
from .update_user_asset_scene_kind import update_user_asset_scene_kind from .update_user_asset_scene_kind import update_user_asset_scene_kind
from .user_asset_kind import user_asset_kind
from .user_assets_dir import user_assets_dir
from .validate_audio_duration import MAX_AUDIO_SECONDS, validate_audio_duration
from .validate_video_duration import MAX_VIDEO_SECONDS, validate_video_duration
from .verify_password import verify_password
from .verify_totp import verify_totp
__all__ = [ __all__ = [
"users_db_path", "ensure_users_schema", "is_first_user", "users_db_path",
"create_user", "UserCreationError", "ensure_users_schema",
"get_user_by_email", "get_user_by_id", "is_first_user",
"verify_password", "confirm_totp", "verify_totp", "set_project_slug", "create_user",
"password_strength", "MIN_SCORE_REQUIRED", "UserCreationError",
"totp_provisioning_uri", "totp_qrcode_svg", "get_user_by_email",
"lockout_minutes_for", "seconds_locked_remaining", "lockout_message", "get_user_by_id",
"record_failed_attempt", "reset_failed_attempts", "verify_password",
"generate_recovery_codes", "verify_recovery_code", "confirm_totp",
"set_password", "create_password_reset_token", "get_user_id_for_valid_token", "verify_totp",
"consume_password_reset_token", "TOKEN_TTL_MINUTES", "set_project_slug",
"send_password_reset_email", "EmailNotConfiguredError", "password_strength",
"update_profile", "count_admins", "delete_user", "MIN_SCORE_REQUIRED",
"update_email", "EmailUpdateError", "totp_provisioning_uri",
"ensure_user_assets_schema", "user_assets_dir", "create_user_asset", "totp_qrcode_svg",
"list_user_assets", "get_user_asset", "delete_user_asset", "save_user_asset", "user_asset_kind", "lockout_minutes_for",
"validate_audio_duration", "MAX_AUDIO_SECONDS", "seconds_locked_remaining",
"validate_video_duration", "MAX_VIDEO_SECONDS", "lockout_message",
"image_dimensions", "update_user_asset_scene_kind", "record_failed_attempt",
"reset_failed_attempts",
"generate_recovery_codes",
"verify_recovery_code",
"set_password",
"create_password_reset_token",
"get_user_id_for_valid_token",
"consume_password_reset_token",
"TOKEN_TTL_MINUTES",
"send_password_reset_email",
"EmailNotConfiguredError",
"update_profile",
"count_admins",
"delete_user",
"update_email",
"EmailUpdateError",
"ensure_user_assets_schema",
"user_assets_dir",
"create_user_asset",
"list_user_assets",
"get_user_asset",
"delete_user_asset",
"save_user_asset",
"user_asset_kind",
"validate_audio_duration",
"MAX_AUDIO_SECONDS",
"validate_video_duration",
"MAX_VIDEO_SECONDS",
"image_dimensions",
"update_user_asset_scene_kind",
] ]
+1 -1
View File
@@ -1,7 +1,7 @@
from .connection import connect from .connection import connect
def confirm_totp(user_id): def confirm_totp(user_id: int) -> None:
conn = connect() conn = connect()
conn.execute("UPDATE _users SET totp_confirmed = 1 WHERE id = ?", (user_id,)) conn.execute("UPDATE _users SET totp_confirmed = 1 WHERE id = ?", (user_id,))
conn.commit() conn.commit()
+3 -2
View File
@@ -8,6 +8,7 @@ tests/conftest.py positionne FORGE_USERS_DB_PATH vers un fichier temporaire
avant de faire tourner la suite, pour ne jamais toucher à la vraie base de avant de faire tourner la suite, pour ne jamais toucher à la vraie base de
comptes (data/users.db) ni dépendre d'un état "premier compte = admin" déjà comptes (data/users.db) ni dépendre d'un état "premier compte = admin" déjà
consommé par un run précédent.""" consommé par un run précédent."""
import os import os
import sqlite3 import sqlite3
@@ -15,11 +16,11 @@ _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_USERS_DB_PATH = os.path.join(_BASE_DIR, "data", "users.db") DEFAULT_USERS_DB_PATH = os.path.join(_BASE_DIR, "data", "users.db")
def users_db_path(): def users_db_path() -> str:
return os.environ.get("FORGE_USERS_DB_PATH") or DEFAULT_USERS_DB_PATH return os.environ.get("FORGE_USERS_DB_PATH") or DEFAULT_USERS_DB_PATH
def connect(): def connect() -> sqlite3.Connection:
path = users_db_path() path = users_db_path()
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
conn = sqlite3.connect(path, timeout=10) conn = sqlite3.connect(path, timeout=10)
+2 -2
View File
@@ -1,8 +1,8 @@
from .connection import connect from .connection import connect
def count_admins(): def count_admins() -> int:
conn = connect() conn = connect()
n = conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"] n = int(conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"])
conn.close() conn.close()
return n return n
+4 -3
View File
@@ -1,9 +1,9 @@
import pyotp import pyotp
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from .ensure_schema import ensure_users_schema
from .connection import connect from .connection import connect
from .email_validation import is_valid_email from .email_validation import is_valid_email
from .ensure_schema import ensure_users_schema
from .get_user_by_email import get_user_by_email from .get_user_by_email import get_user_by_email
from .is_first_user import is_first_user from .is_first_user import is_first_user
from .password_strength import password_strength from .password_strength import password_strength
@@ -14,7 +14,7 @@ class UserCreationError(Exception):
dans le formulaire d'inscription) — jamais un détail SQL/technique.""" dans le formulaire d'inscription) — jamais un détail SQL/technique."""
def create_user(email, password, nom, prenom): def create_user(email: str | None, password: str | None, nom: str | None, prenom: str | None) -> int:
"""Crée un compte : mot de passe fort (auth/password_strength.py) et """Crée un compte : mot de passe fort (auth/password_strength.py) et
2FA (TOTP) rendus obligatoires — le secret est généré ici mais 2FA (TOTP) rendus obligatoires — le secret est généré ici mais
totp_confirmed reste à 0 tant que confirm_totp() n'a pas vérifié un totp_confirmed reste à 0 tant que confirm_totp() n'a pas vérifié un
@@ -26,6 +26,7 @@ def create_user(email, password, nom, prenom):
email = (email or "").strip().lower() email = (email or "").strip().lower()
nom = (nom or "").strip() nom = (nom or "").strip()
prenom = (prenom or "").strip() prenom = (prenom or "").strip()
password = password or ""
if not is_valid_email(email): if not is_valid_email(email):
raise UserCreationError("Adresse email invalide.") raise UserCreationError("Adresse email invalide.")
if not nom or not prenom: if not nom or not prenom:
@@ -44,7 +45,7 @@ def create_user(email, password, nom, prenom):
VALUES (?, ?, ?, ?, ?, ?, 0)""", VALUES (?, ?, ?, ?, ?, ?, 0)""",
(email, generate_password_hash(password), nom, prenom, role, totp_secret), (email, generate_password_hash(password), nom, prenom, role, totp_secret),
) )
user_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] user_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit() conn.commit()
conn.close() conn.close()
return user_id return user_id
+8 -2
View File
@@ -2,7 +2,13 @@ from .connection import connect
from .ensure_user_assets_schema import ensure_user_assets_schema from .ensure_user_assets_schema import ensure_user_assets_schema
def create_user_asset(user_id, filename, original_name=None, source="upload", scene_kind=None): def create_user_asset(
user_id: int,
filename: str,
original_name: str | None = None,
source: str = "upload",
scene_kind: str | None = None,
) -> int:
"""Enregistre une ligne "Mes assets" pour un fichier déjà écrit sur """Enregistre une ligne "Mes assets" pour un fichier déjà écrit sur
disque (voir user_assets_dir.py) — le fichier lui-même est écrit par disque (voir user_assets_dir.py) — le fichier lui-même est écrit par
l'appelant (route d'upload, ou plus tard la génération IA), cette l'appelant (route d'upload, ou plus tard la génération IA), cette
@@ -21,7 +27,7 @@ def create_user_asset(user_id, filename, original_name=None, source="upload", sc
"INSERT INTO _user_assets (user_id, filename, original_name, source, scene_kind) VALUES (?, ?, ?, ?, ?)", "INSERT INTO _user_assets (user_id, filename, original_name, source, scene_kind) VALUES (?, ?, ?, ?, ?)",
(user_id, filename, original_name, source, scene_kind), (user_id, filename, original_name, source, scene_kind),
) )
asset_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] asset_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit() conn.commit()
conn.close() conn.close()
return asset_id return asset_id
+1 -1
View File
@@ -1,7 +1,7 @@
from .connection import connect from .connection import connect
def delete_user(user_id): def delete_user(user_id: int) -> None:
"""Supprime le compte et tout ce qui lui est directement rattaché """Supprime le compte et tout ce qui lui est directement rattaché
(codes de récupération, jetons de réinitialisation encore en cours) — (codes de récupération, jetons de réinitialisation encore en cours) —
le dossier de projet, lui, est géré par l'appelant (voir le dossier de projet, lui, est géré par l'appelant (voir
+2 -4
View File
@@ -5,16 +5,14 @@ from .ensure_user_assets_schema import ensure_user_assets_schema
from .user_assets_dir import user_assets_dir from .user_assets_dir import user_assets_dir
def delete_user_asset(asset_id, user_id): def delete_user_asset(asset_id: int, user_id: int) -> bool:
"""N'efface que si asset_id APPARTIENT à user_id (jamais fournie par """N'efface que si asset_id APPARTIENT à user_id (jamais fournie par
l'appelant sans vérification — voir get_user_asset.py) : renvoie False l'appelant sans vérification — voir get_user_asset.py) : renvoie False
sans rien faire sinon, plutôt que de lever une erreur, même patron que sans rien faire sinon, plutôt que de lever une erreur, même patron que
screens.delete_scene_object pour un id introuvable.""" screens.delete_scene_object pour un id introuvable."""
ensure_user_assets_schema() ensure_user_assets_schema()
conn = connect() conn = connect()
row = conn.execute( row = conn.execute("SELECT filename FROM _user_assets WHERE id = ? AND user_id = ?", (asset_id, user_id)).fetchone()
"SELECT filename FROM _user_assets WHERE id = ? AND user_id = ?", (asset_id, user_id)
).fetchone()
if not row: if not row:
conn.close() conn.close()
return False return False
+1 -1
View File
@@ -3,5 +3,5 @@ import re
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def is_valid_email(email): def is_valid_email(email: str | None) -> bool:
return bool(EMAIL_RE.match((email or "").strip())) return bool(EMAIL_RE.match((email or "").strip()))
+1 -1
View File
@@ -1,7 +1,7 @@
from .connection import connect from .connection import connect
def ensure_users_schema(): def ensure_users_schema() -> None:
conn = connect() conn = connect()
conn.executescript( conn.executescript(
""" """
+1 -1
View File
@@ -1,7 +1,7 @@
from .connection import connect from .connection import connect
def ensure_user_assets_schema(): def ensure_user_assets_schema() -> None:
""" "Mes assets" (voir user_assets_dir.py) : une image appartient à un """ "Mes assets" (voir user_assets_dir.py) : une image appartient à un
COMPTE, jamais à un projet — table dans la base de comptes partagée COMPTE, jamais à un projet — table dans la base de comptes partagée
(auth/connection.py), pas dans le game.db d'un jeu (db/connection.py), (auth/connection.py), pas dans le game.db d'un jeu (db/connection.py),
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from .connection import connect from .connection import connect
from .ensure_user_assets_schema import ensure_user_assets_schema from .ensure_user_assets_schema import ensure_user_assets_schema
def get_user_asset(asset_id): def get_user_asset(asset_id: int) -> dict[str, Any] | None:
"""Renvoie aussi user_id — indispensable pour vérifier la PROPRIÉTÉ """Renvoie aussi user_id — indispensable pour vérifier la PROPRIÉTÉ
avant de servir/poser un asset (voir routes/assets/serve_user_asset.py, avant de servir/poser un asset (voir routes/assets/serve_user_asset.py,
routes/scenes/scene_object_add.py), même esprit que l'isolation par routes/scenes/scene_object_add.py), même esprit que l'isolation par
+4 -2
View File
@@ -1,8 +1,10 @@
from .ensure_schema import ensure_users_schema from typing import Any
from .connection import connect from .connection import connect
from .ensure_schema import ensure_users_schema
def get_user_by_email(email): def get_user_by_email(email: str | None) -> dict[str, Any] | None:
ensure_users_schema() ensure_users_schema()
conn = connect() conn = connect()
row = conn.execute("SELECT * FROM _users WHERE email = ?", ((email or "").strip().lower(),)).fetchone() row = conn.execute("SELECT * FROM _users WHERE email = ?", ((email or "").strip().lower(),)).fetchone()
+4 -2
View File
@@ -1,8 +1,10 @@
from .ensure_schema import ensure_users_schema from typing import Any
from .connection import connect from .connection import connect
from .ensure_schema import ensure_users_schema
def get_user_by_id(user_id): def get_user_by_id(user_id: int | None) -> dict[str, Any] | None:
ensure_users_schema() ensure_users_schema()
if not user_id: if not user_id:
return None return None
+3 -4
View File
@@ -1,8 +1,7 @@
import re
import struct import struct
def image_dimensions(content): def image_dimensions(content: bytes) -> tuple[int, int] | None:
"""Largeur/hauteur RÉELLES (en pixels) d'une image, lues directement """Largeur/hauteur RÉELLES (en pixels) d'une image, lues directement
dans ses en-têtes — SANS dépendance externe (Pillow serait la solution dans ses en-têtes — SANS dépendance externe (Pillow serait la solution
habituelle, mais un simple parsing des formats courants suffit ici et habituelle, mais un simple parsing des formats courants suffit ici et
@@ -32,7 +31,7 @@ def image_dimensions(content):
return None return None
def _webp_dimensions(content): def _webp_dimensions(content: bytes) -> tuple[int, int] | None:
chunk = content[12:16] chunk = content[12:16]
if chunk == b"VP8X" and len(content) >= 30: if chunk == b"VP8X" and len(content) >= 30:
width = 1 + (content[24] | (content[25] << 8) | (content[26] << 16)) width = 1 + (content[24] | (content[25] << 8) | (content[26] << 16))
@@ -50,7 +49,7 @@ def _webp_dimensions(content):
return None return None
def _jpeg_dimensions(content): def _jpeg_dimensions(content: bytes) -> tuple[int, int] | None:
i = 2 i = 2
n = len(content) n = len(content)
while i + 9 < n: while i + 9 < n:
+3 -3
View File
@@ -1,8 +1,8 @@
from .ensure_schema import ensure_users_schema
from .connection import connect from .connection import connect
from .ensure_schema import ensure_users_schema
def is_first_user(): def is_first_user() -> bool:
"""True s'il n'existe encore AUCUN compte — le tout premier compte créé """True s'il n'existe encore AUCUN compte — le tout premier compte créé
devient automatiquement admin (voir create_user.py), pour ne jamais devient automatiquement admin (voir create_user.py), pour ne jamais
avoir besoin d'un mot de passe par défaut ou d'un script de bootstrap avoir besoin d'un mot de passe par défaut ou d'un script de bootstrap
@@ -11,4 +11,4 @@ def is_first_user():
conn = connect() conn = connect()
count = conn.execute("SELECT COUNT(*) AS c FROM _users").fetchone()["c"] count = conn.execute("SELECT COUNT(*) AS c FROM _users").fetchone()["c"]
conn.close() conn.close()
return count == 0 return bool(count == 0)
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from .connection import connect from .connection import connect
from .ensure_user_assets_schema import ensure_user_assets_schema from .ensure_user_assets_schema import ensure_user_assets_schema
def list_user_assets(user_id): def list_user_assets(user_id: int) -> list[dict[str, Any]]:
""" "Mes assets" d'un compte, plus récent en premier — voir """ "Mes assets" d'un compte, plus récent en premier — voir
templates/scene_edit.html, bloc "Mes assets" du panneau d'ajout templates/scene_edit.html, bloc "Mes assets" du panneau d'ajout
d'élément.""" d'élément."""
+5 -5
View File
@@ -7,11 +7,11 @@ from .connection import connect
TOKEN_TTL_MINUTES = 60 TOKEN_TTL_MINUTES = 60
def _hash_token(token): def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest() return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_password_reset_token(user_id): def create_password_reset_token(user_id: int) -> str:
"""Un seul jeton valide à la fois par utilisateur : en créer un """Un seul jeton valide à la fois par utilisateur : en créer un
nouveau invalide silencieusement tout jeu envoyé plus tôt (par nouveau invalide silencieusement tout jeu envoyé plus tôt (par
exemple si l'utilisateur redemande un email parce que le premier exemple si l'utilisateur redemande un email parce que le premier
@@ -29,7 +29,7 @@ def create_password_reset_token(user_id):
return token return token
def get_user_id_for_valid_token(token): def get_user_id_for_valid_token(token: str | None) -> int | None:
if not token: if not token:
return None return None
conn = connect() conn = connect()
@@ -42,10 +42,10 @@ def get_user_id_for_valid_token(token):
return None return None
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
return None return None
return row["user_id"] return int(row["user_id"])
def consume_password_reset_token(token): def consume_password_reset_token(token: str) -> None:
conn = connect() conn = connect()
conn.execute( conn.execute(
"UPDATE _password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?", "UPDATE _password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?",
+3 -2
View File
@@ -1,11 +1,12 @@
import re import re
from typing import Any, Callable
# Mêmes règles des deux côtés (ici pour le refus serveur, en JS dans # Mêmes règles des deux côtés (ici pour le refus serveur, en JS dans
# templates/auth/register.html pour le schéma visuel qui guide la saisie # templates/auth/register.html pour le schéma visuel qui guide la saisie
# en temps réel) — un mot de passe REFUSÉ côté serveur doit toujours # en temps réel) — un mot de passe REFUSÉ côté serveur doit toujours
# correspondre à une jauge/coche déjà rouge côté client, jamais une # correspondre à une jauge/coche déjà rouge côté client, jamais une
# surprise après coup. # surprise après coup.
_CHECKS = [ _CHECKS: list[tuple[str, str, Callable[[str], bool]]] = [
("longueur", "Au moins 8 caractères", lambda p: len(p) >= 8), ("longueur", "Au moins 8 caractères", lambda p: len(p) >= 8),
("majuscule", "Une majuscule", lambda p: re.search(r"[A-Z]", p) is not None), ("majuscule", "Une majuscule", lambda p: re.search(r"[A-Z]", p) is not None),
("minuscule", "Une minuscule", lambda p: re.search(r"[a-z]", p) is not None), ("minuscule", "Une minuscule", lambda p: re.search(r"[a-z]", p) is not None),
@@ -19,7 +20,7 @@ _CHECKS = [
MIN_SCORE_REQUIRED = 4 MIN_SCORE_REQUIRED = 4
def password_strength(password): def password_strength(password: str | None) -> dict[str, Any]:
"""Renvoie {"score": 0-5, "checks": [{"key","label","ok"}, ...], """Renvoie {"score": 0-5, "checks": [{"key","label","ok"}, ...],
"valid": bool} — jamais None, un mot de passe vide obtient juste un "valid": bool} — jamais None, un mot de passe vide obtient juste un
score de 0 (toutes les règles échouent), pas une erreur.""" score de 0 (toutes les règles échouent), pas une erreur."""
+8 -6
View File
@@ -3,24 +3,26 @@
que soit l'étape attaquée) : 3 essais libres, puis un temps d'attente qui que soit l'étape attaquée) : 3 essais libres, puis un temps d'attente qui
double à chaque échec supplémentaire (5 min, 10, 20, 40...), plafonné à double à chaque échec supplémentaire (5 min, 10, 20, 40...), plafonné à
1h. Remis à zéro dès une connexion réussie (voir reset_failed_attempts).""" 1h. Remis à zéro dès une connexion réussie (voir reset_failed_attempts)."""
import math import math
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from typing import Any
_FREE_ATTEMPTS = 3 _FREE_ATTEMPTS = 3
_FIRST_LOCKOUT_MINUTES = 5 _FIRST_LOCKOUT_MINUTES = 5
_MAX_LOCKOUT_MINUTES = 60 _MAX_LOCKOUT_MINUTES = 60
def lockout_minutes_for(failed_attempts): def lockout_minutes_for(failed_attempts: int) -> int:
"""0 tant qu'on est dans les 3 essais libres ; sinon 5 * 2^n, plafonné """0 tant qu'on est dans les 3 essais libres ; sinon 5 * 2^n, plafonné
à 60 — jamais négatif, jamais None.""" à 60 — jamais négatif, jamais None."""
if failed_attempts <= _FREE_ATTEMPTS: if failed_attempts <= _FREE_ATTEMPTS:
return 0 return 0
exponent = failed_attempts - _FREE_ATTEMPTS - 1 exponent = failed_attempts - _FREE_ATTEMPTS - 1
return min(_FIRST_LOCKOUT_MINUTES * (2 ** exponent), _MAX_LOCKOUT_MINUTES) return min(int(_FIRST_LOCKOUT_MINUTES * (2**exponent)), _MAX_LOCKOUT_MINUTES)
def _parse(dt_str): def _parse(dt_str: str | None) -> datetime | None:
if not dt_str: if not dt_str:
return None return None
try: try:
@@ -29,7 +31,7 @@ def _parse(dt_str):
return None return None
def lockout_message(seconds_remaining): def lockout_message(seconds_remaining: float) -> str:
"""Message affiché tel quel dans le formulaire — arrondi à la minute """Message affiché tel quel dans le formulaire — arrondi à la minute
supérieure (jamais "0 minute" alors qu'il en reste un peu, jamais un supérieure (jamais "0 minute" alors qu'il en reste un peu, jamais un
compte de secondes qui oblige à recharger sans arrêt pour voir si compte de secondes qui oblige à recharger sans arrêt pour voir si
@@ -39,7 +41,7 @@ def lockout_message(seconds_remaining):
return f"Trop de tentatives. Réessaie dans {minutes} {unit}." return f"Trop de tentatives. Réessaie dans {minutes} {unit}."
def seconds_locked_remaining(user): def seconds_locked_remaining(user: dict[str, Any] | None) -> int:
"""> 0 si le compte est actuellement verrouillé (temps restant, en """> 0 si le compte est actuellement verrouillé (temps restant, en
secondes, arrondi au-dessus pour ne jamais afficher "0 minute" alors secondes, arrondi au-dessus pour ne jamais afficher "0 minute" alors
qu'il en reste réellement un peu) ; 0 sinon.""" qu'il en reste réellement un peu) ; 0 sinon."""
+1 -1
View File
@@ -4,7 +4,7 @@ from .connection import connect
from .rate_limit import lockout_minutes_for from .rate_limit import lockout_minutes_for
def record_failed_attempt(user_id): def record_failed_attempt(user_id: int) -> int:
"""Incrémente le compteur d'échecs de CE compte et, une fois passé les """Incrémente le compteur d'échecs de CE compte et, une fois passé les
3 essais libres, pose/allonge son verrouillage (voir rate_limit.py) — 3 essais libres, pose/allonge son verrouillage (voir rate_limit.py) —
appelé sur un mot de passe OU un code 2FA incorrect, jamais sur un appelé sur un mot de passe OU un code 2FA incorrect, jamais sur un
+4 -4
View File
@@ -7,11 +7,11 @@ from .connection import connect
_CODE_COUNT = 10 _CODE_COUNT = 10
def _format_code(raw): def _format_code(raw: str) -> str:
return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4)) return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4))
def generate_recovery_codes(user_id): def generate_recovery_codes(user_id: int) -> list[str]:
"""(Re)génère les codes de récupération 2FA d'un utilisateur : un """(Re)génère les codes de récupération 2FA d'un utilisateur : un
nouvel appel invalide tout jeu de codes précédent (un seul jeu valide nouvel appel invalide tout jeu de codes précédent (un seul jeu valide
à la fois, pour ne jamais avoir à deviner lesquels tiennent encore). à la fois, pour ne jamais avoir à deviner lesquels tiennent encore).
@@ -21,7 +21,7 @@ def generate_recovery_codes(user_id):
directement réutilisables.""" directement réutilisables."""
conn = connect() conn = connect()
conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,)) conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,))
codes = [] codes: list[str] = []
for _ in range(_CODE_COUNT): for _ in range(_CODE_COUNT):
code = _format_code(secrets.token_hex(6)) code = _format_code(secrets.token_hex(6))
codes.append(code) codes.append(code)
@@ -34,7 +34,7 @@ def generate_recovery_codes(user_id):
return codes return codes
def verify_recovery_code(user_id, code): def verify_recovery_code(user_id: int, code: str | None) -> bool:
"""Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès """Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès
qu'il sert à une connexion réussie, pour qu'un code intercepté une qu'il sert à une connexion réussie, pour qu'un code intercepté une
fois (capture d'écran, historique du navigateur...) ne redonne pas un fois (capture d'écran, historique du navigateur...) ne redonne pas un
+1 -1
View File
@@ -1,7 +1,7 @@
from .connection import connect from .connection import connect
def reset_failed_attempts(user_id): def reset_failed_attempts(user_id: int) -> None:
"""Remet le compteur à zéro et lève tout verrouillage — appelé dès """Remet le compteur à zéro et lève tout verrouillage — appelé dès
qu'une étape de connexion réussit (mot de passe validé ou code 2FA qu'une étape de connexion réussit (mot de passe validé ou code 2FA
validé), pour ne jamais punir un utilisateur légitime qui s'est juste validé), pour ne jamais punir un utilisateur légitime qui s'est juste
+8 -1
View File
@@ -5,7 +5,14 @@ from .create_user_asset import create_user_asset
from .user_assets_dir import user_assets_dir from .user_assets_dir import user_assets_dir
def save_user_asset(user_id, content, ext, original_name=None, source="upload", scene_kind=None): def save_user_asset(
user_id: int,
content: bytes,
ext: str,
original_name: str | None = None,
source: str = "upload",
scene_kind: str | None = None,
) -> tuple[int, str]:
"""Écrit `content` (bytes) sur disque + crée la ligne "Mes assets" — """Écrit `content` (bytes) sur disque + crée la ligne "Mes assets" —
factorisé pour être partagé par l'upload manuel factorisé pour être partagé par l'upload manuel
(routes/assets/upload_user_asset.py) ET la génération IA (routes/assets/upload_user_asset.py) ET la génération IA
+3 -2
View File
@@ -4,6 +4,7 @@ Python. Configuré uniquement par variables d'environnement (SMTP_HOST,
SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) : ce module ne connaît et SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) : ce module ne connaît et
ne stocke jamais le mot de passe SMTP en dur, à définir sur le poste/ ne stocke jamais le mot de passe SMTP en dur, à définir sur le poste/
serveur qui fait tourner l'appli.""" serveur qui fait tourner l'appli."""
import os import os
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
@@ -19,7 +20,7 @@ class EmailNotConfiguredError(Exception):
qu'il doit finir de configurer son SMTP.""" qu'il doit finir de configurer son SMTP."""
def send_email(to_email, subject, body): def send_email(to_email: str, subject: str, body: str) -> None:
host = os.environ.get("SMTP_HOST") host = os.environ.get("SMTP_HOST")
port = int(os.environ.get("SMTP_PORT", "587")) port = int(os.environ.get("SMTP_PORT", "587"))
user = os.environ.get("SMTP_USER") user = os.environ.get("SMTP_USER")
@@ -40,7 +41,7 @@ def send_email(to_email, subject, body):
server.sendmail(sender, [to_email], msg.as_string()) server.sendmail(sender, [to_email], msg.as_string())
def send_password_reset_email(to_email, reset_url): def send_password_reset_email(to_email: str, reset_url: str) -> None:
body = ( body = (
"Une réinitialisation de mot de passe a été demandée pour ce compte " "Une réinitialisation de mot de passe a été demandée pour ce compte "
"Forge Engine.\n\n" "Forge Engine.\n\n"
+1 -1
View File
@@ -3,7 +3,7 @@ from werkzeug.security import generate_password_hash
from .connection import connect from .connection import connect
def set_password(user_id, new_password): def set_password(user_id: int, new_password: str) -> None:
"""Remet aussi le compteur anti-bruteforce à zéro (auth/rate_limit.py) : """Remet aussi le compteur anti-bruteforce à zéro (auth/rate_limit.py) :
prouver son identité par email est une voie de récupération légitime, prouver son identité par email est une voie de récupération légitime,
un compte verrouillé après trop d'échecs ne doit pas rester bloqué une un compte verrouillé après trop d'échecs ne doit pas rester bloqué une
+1 -1
View File
@@ -1,7 +1,7 @@
from .connection import connect from .connection import connect
def set_project_slug(user_id, slug): def set_project_slug(user_id: int, slug: str) -> None:
"""Enregistre le SEUL projet que ce compte possède (voir """Enregistre le SEUL projet que ce compte possède (voir
create_user.py — un compte "user" n'en aura jamais qu'un ; un compte create_user.py — un compte "user" n'en aura jamais qu'un ; un compte
"admin" n'a PAS besoin de cette colonne, il reste libre de créer "admin" n'a PAS besoin de cette colonne, il reste libre de créer
+3 -3
View File
@@ -5,11 +5,11 @@ import qrcode
import qrcode.image.svg import qrcode.image.svg
def totp_provisioning_uri(secret, email): def totp_provisioning_uri(secret: str, email: str) -> str:
return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine") return str(pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine"))
def totp_qrcode_svg(secret, email): def totp_qrcode_svg(secret: str, email: str) -> str:
"""SVG (pas PNG) : la variante "image factory" par défaut de qrcode a """SVG (pas PNG) : la variante "image factory" par défaut de qrcode a
besoin de Pillow pour produire un PNG — SvgPathImage, elle, est du pur besoin de Pillow pour produire un PNG — SvgPathImage, elle, est du pur
Python, sans dépendance supplémentaire à installer juste pour un QR Python, sans dépendance supplémentaire à installer juste pour un QR
+1 -1
View File
@@ -9,7 +9,7 @@ class EmailUpdateError(Exception):
valide, unicité), voir create_user.py.""" valide, unicité), voir create_user.py."""
def update_email(user_id, new_email): def update_email(user_id: int, new_email: str | None) -> str:
new_email = (new_email or "").strip().lower() new_email = (new_email or "").strip().lower()
if not is_valid_email(new_email): if not is_valid_email(new_email):
raise EmailUpdateError("Adresse email invalide.") raise EmailUpdateError("Adresse email invalide.")
+1 -1
View File
@@ -1,7 +1,7 @@
from .connection import connect from .connection import connect
def update_profile(user_id, nom, prenom): def update_profile(user_id: int, nom: str, prenom: str) -> None:
conn = connect() conn = connect()
conn.execute("UPDATE _users SET nom = ?, prenom = ? WHERE id = ?", (nom, prenom, user_id)) conn.execute("UPDATE _users SET nom = ?, prenom = ? WHERE id = ?", (nom, prenom, user_id))
conn.commit() conn.commit()
+1 -1
View File
@@ -4,7 +4,7 @@ from .get_user_asset import get_user_asset
from .user_asset_kind import user_asset_kind from .user_asset_kind import user_asset_kind
def update_user_asset_scene_kind(asset_id, user_id, scene_kind): def update_user_asset_scene_kind(asset_id: int, user_id: int, scene_kind: str | None) -> bool:
"""Reclasse une image déjà importée entre "Fonds" et "Décors/objets" """Reclasse une image déjà importée entre "Fonds" et "Décors/objets"
(voir create_user_asset.py) — SANS ré-upload (demande implicite, (voir create_user_asset.py) — SANS ré-upload (demande implicite,
trouvée en diagnostiquant un "décalage" : une image importée AVANT trouvée en diagnostiquant un "décalage" : une image importée AVANT
+1 -1
View File
@@ -14,7 +14,7 @@ _AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"}
_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".ogv"} _VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".ogv"}
def user_asset_kind(filename): def user_asset_kind(filename: str) -> str:
ext = os.path.splitext(filename)[1].lower() ext = os.path.splitext(filename)[1].lower()
if ext in _IMAGE_EXTENSIONS: if ext in _IMAGE_EXTENSIONS:
return "image" return "image"
+1 -1
View File
@@ -3,7 +3,7 @@ import os
from db.constants import USER_ASSETS_DIR from db.constants import USER_ASSETS_DIR
def user_assets_dir(user_id): def user_assets_dir(user_id: int) -> str:
"""Dossier disque des images d'un compte (voir ensure_user_assets_schema.py """Dossier disque des images d'un compte (voir ensure_user_assets_schema.py
pour les métadonnées) — mirroir de db/game_dir.py, mais indexé par pour les métadonnées) — mirroir de db/game_dir.py, mais indexé par
utilisateur plutôt que par slug de projet.""" utilisateur plutôt que par slug de projet."""
+1 -1
View File
@@ -9,7 +9,7 @@ from mutagen import File as MutagenFile
MAX_AUDIO_SECONDS = 180 MAX_AUDIO_SECONDS = 180
def validate_audio_duration(content, max_seconds=MAX_AUDIO_SECONDS): def validate_audio_duration(content: bytes, max_seconds: int = MAX_AUDIO_SECONDS) -> str | None:
"""Lit la durée d'un fichier audio (mp3/wav/ogg/m4a/... — mutagen """Lit la durée d'un fichier audio (mp3/wav/ogg/m4a/... — mutagen
détecte le format lui-même, pas besoin de le lui préciser) et renvoie détecte le format lui-même, pas besoin de le lui préciser) et renvoie
un message d'erreur (français, prêt à afficher) si elle dépasse un message d'erreur (français, prêt à afficher) si elle dépasse
+3 -1
View File
@@ -9,7 +9,9 @@ from mutagen import File as MutagenFile
MAX_VIDEO_SECONDS = 300 MAX_VIDEO_SECONDS = 300
def validate_video_duration(content, filename="video.mp4", max_seconds=MAX_VIDEO_SECONDS): def validate_video_duration(
content: bytes, filename: str = "video.mp4", max_seconds: int = MAX_VIDEO_SECONDS
) -> str | None:
"""Lit la durée d'un fichier vidéo MP4 (seul format accepté à l'upload, """Lit la durée d'un fichier vidéo MP4 (seul format accepté à l'upload,
voir routes/assets/upload_user_asset.py — mutagen ne sait pas lire voir routes/assets/upload_user_asset.py — mutagen ne sait pas lire
fiablement la durée de webm/mov/avi, contrairement au conteneur MP4/ fiablement la durée de webm/mov/avi, contrairement au conteneur MP4/
+4 -2
View File
@@ -1,5 +1,7 @@
from typing import Any
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
def verify_password(user, password): def verify_password(user: dict[str, Any] | None, password: str | None) -> bool:
return bool(user) and check_password_hash(user["password_hash"], password or "") return user is not None and bool(check_password_hash(user["password_hash"], password or ""))
+2 -2
View File
@@ -1,7 +1,7 @@
import pyotp import pyotp
def verify_totp(secret, code): def verify_totp(secret: str, code: str | None) -> bool:
"""valid_window=1 : tolère un léger décalage d'horloge entre le """valid_window=1 : tolère un léger décalage d'horloge entre le
serveur et le téléphone (accepte aussi le code de la période serveur et le téléphone (accepte aussi le code de la période
précédente/suivante, ±30s) — sans quoi une horloge un peu désynchronisée précédente/suivante, ±30s) — sans quoi une horloge un peu désynchronisée
@@ -9,4 +9,4 @@ def verify_totp(secret, code):
code = (code or "").strip() code = (code or "").strip()
if not code: if not code:
return False return False
return pyotp.TOTP(secret).verify(code, valid_window=1) return bool(pyotp.TOTP(secret).verify(code, valid_window=1))
+3 -2
View File
@@ -13,6 +13,7 @@ variables/règles posées par le précédent :
Lancer `python build_css.py` après toute modification sous styles/ ou Lancer `python build_css.py` après toute modification sous styles/ ou
static/vendor/bulma.min.css.""" static/vendor/bulma.min.css."""
import os import os
_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) _BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -27,8 +28,8 @@ _SOURCES = [
] ]
def build(): def build() -> None:
chunks = [] chunks: list[str] = []
for path in _SOURCES: for path in _SOURCES:
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
chunks.append(f"/* ---- {os.path.relpath(path, _BASE_DIR)} ---- */\n" + f.read()) chunks.append(f"/* ---- {os.path.relpath(path, _BASE_DIR)} ---- */\n" + f.read())
+19 -6
View File
@@ -11,7 +11,9 @@ pour que `app.url_map` connaisse déjà toutes les routes au moment où ce
module tente de résoudre request.endpoint — en pratique sans importance module tente de résoudre request.endpoint — en pratique sans importance
ici (la résolution se fait à la requête, pas à l'import), mais gardé pour ici (la résolution se fait à la requête, pas à l'import), mais gardé pour
rester cohérent avec l'ordre d'import du reste du moteur.""" rester cohérent avec l'ordre d'import du reste du moteur."""
from flask import g, redirect, request, session, url_for, abort
from flask import abort, g, redirect, request, session, url_for
from werkzeug.wrappers import Response
import auth import auth
from db.games.project_slug import split_slug from db.games.project_slug import split_slug
@@ -22,8 +24,14 @@ from .flask_app import app
# session valide. "static" (CSS/JS/images) doit rester public : la page # session valide. "static" (CSS/JS/images) doit rester public : la page
# de connexion elle-même en a besoin pour s'afficher. # de connexion elle-même en a besoin pour s'afficher.
_PUBLIC_ENDPOINTS = { _PUBLIC_ENDPOINTS = {
"static", "login", "login_2fa", "register", "register_2fa", "logout", "static",
"forgot_password", "reset_password", "login",
"login_2fa",
"register",
"register_2fa",
"logout",
"forgot_password",
"reset_password",
} }
# Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion # Endpoints de gestion de COMPTE (routes/auth/profile.py) — connexion
@@ -32,13 +40,18 @@ _PUBLIC_ENDPOINTS = {
# quand même pouvoir changer son mot de passe, son email, ou supprimer # quand même pouvoir changer son mot de passe, son email, ou supprimer
# son compte. # son compte.
_REACHABLE_WITHOUT_PROJECT = { _REACHABLE_WITHOUT_PROJECT = {
"onboarding_new", "profile", "profile_update_name", "profile_update_email", "onboarding_new",
"profile_regenerate_recovery_codes", "profile_change_password", "profile_delete", "profile",
"profile_update_name",
"profile_update_email",
"profile_regenerate_recovery_codes",
"profile_change_password",
"profile_delete",
} }
@app.before_request @app.before_request
def _require_login_and_enforce_project_isolation(): def _require_login_and_enforce_project_isolation() -> Response | None:
endpoint = request.endpoint endpoint = request.endpoint
if endpoint is None or endpoint in _PUBLIC_ENDPOINTS: if endpoint is None or endpoint in _PUBLIC_ENDPOINTS:
return None return None
+3 -2
View File
@@ -6,6 +6,7 @@ un fetch() construit à la main (screen_edit.html, game_dashboard.html,
play.html...) — voir static/csrf_fetch.js, qui l'ajoute automatiquement en play.html...) — voir static/csrf_fetch.js, qui l'ajoute automatiquement en
en-tête à CHAQUE fetch() non-GET de l'appli plutôt que de devoir modifier en-tête à CHAQUE fetch() non-GET de l'appli plutôt que de devoir modifier
individuellement chacun des nombreux appels existants.""" individuellement chacun des nombreux appels existants."""
import secrets import secrets
from flask import session from flask import session
@@ -13,12 +14,12 @@ from flask import session
from .flask_app import app from .flask_app import app
def get_csrf_token(): def get_csrf_token() -> str:
token = session.get("csrf_token") token = session.get("csrf_token")
if not token: if not token:
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
session["csrf_token"] = token session["csrf_token"] = token
return token return str(token)
app.jinja_env.globals["csrf_token"] = get_csrf_token app.jinja_env.globals["csrf_token"] = get_csrf_token
+2 -1
View File
@@ -12,6 +12,7 @@ X-CSRFToken sur toute requête non-GET de l'appli (celles de pjax.js
comprises) : pas besoin de modifier individuellement les nombreux appels comprises) : pas besoin de modifier individuellement les nombreux appels
fetch() déjà écrits à la main dans screen_edit.html/game_dashboard.html/ fetch() déjà écrits à la main dans screen_edit.html/game_dashboard.html/
play.html.""" play.html."""
from flask import abort, request, session from flask import abort, request, session
from .flask_app import app from .flask_app import app
@@ -20,7 +21,7 @@ _SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
@app.before_request @app.before_request
def _verify_csrf_token(): def _verify_csrf_token() -> None:
if request.method in _SAFE_METHODS: if request.method in _SAFE_METHODS:
return None return None
if app.config.get("TESTING"): if app.config.get("TESTING"):
+11
View File
@@ -0,0 +1,11 @@
"""Enregistre le filet de sécurité de fermeture des connexions SQLite
fuitées (voir db/connection.py::install_teardown_safety_net) sur l'app
Flask — vit dans core/ (couche de câblage qui branche les autres paquets
sur l'app), jamais l'inverse : db/ ne doit importer aucun paquet au-dessus
de lui dans la hiérarchie (voir contrat import-linter, pyproject.toml)."""
from db.connection import install_teardown_safety_net
from .flask_app import app
install_teardown_safety_net(app)
+3 -2
View File
@@ -19,10 +19,11 @@ _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates") _TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates")
_STATIC_FOLDER = os.path.join(_BASE_DIR, "static") _STATIC_FOLDER = os.path.join(_BASE_DIR, "static")
app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER) # CSRF gere par core/csrf_guard.py (garde maison globale, testee dans test_csrf.py) - pas Flask-WTF.
app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER) # NOSONAR S4502
def _load_or_create_secret_key(): def _load_or_create_secret_key() -> str:
"""Nécessaire dès qu'une session Flask (flask.session) est utilisée — """Nécessaire dès qu'une session Flask (flask.session) est utilisée —
ici pour se souvenir de qui est connecté (auth/). Généré une seule ici pour se souvenir de qui est connecté (auth/). Généré une seule
fois et PERSISTÉ dans un fichier (jamais en dur dans le code, jamais fois et PERSISTÉ dans un fichier (jamais en dur dans le code, jamais
+4 -2
View File
@@ -3,13 +3,15 @@ auth/recovery_codes.py et routes/auth/register_2fa.py) : posés en session
au moment de la redirection qui suit leur génération, puis consommés au moment de la redirection qui suit leur génération, puis consommés
(session.pop) dès le tout premier rendu de base.html qui suit — jamais (session.pop) dès le tout premier rendu de base.html qui suit — jamais
revus après ce rendu, comme un message flash à usage unique.""" revus après ce rendu, comme un message flash à usage unique."""
from flask import session from flask import session
from .flask_app import app from .flask_app import app
def pop_recovery_codes(): def pop_recovery_codes() -> list[str] | None:
return session.pop("recovery_codes_to_show", None) codes = session.pop("recovery_codes_to_show", None)
return list(codes) if codes is not None else None
app.jinja_env.globals["pop_recovery_codes"] = pop_recovery_codes app.jinja_env.globals["pop_recovery_codes"] = pop_recovery_codes
+3 -2
View File
@@ -6,12 +6,13 @@ screen_edit.py, routes/scenes/scene_edit_view.py) ne les propose déjà pas
(element_add, scene_object_add, element_set_personnage_data, (element_add, scene_object_add, element_set_personnage_data,
scene_object_personnage_data) pourrait contourner ce filtrage d'UI — ce scene_object_personnage_data) pourrait contourner ce filtrage d'UI — ce
garde-fou ferme cette brèche au moment de l'écriture.""" garde-fou ferme cette brèche au moment de l'écriture."""
from flask import abort, g from flask import abort, g
import screens import screens
def forbid_admin_only_character(forge_character): def forbid_admin_only_character(forge_character: str | None) -> None:
if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin": if forge_character in screens.ADMIN_ONLY_CHARACTER_SLUGS and g.current_user["role"] != "admin":
abort(403) abort(403)
@@ -21,6 +22,6 @@ def forbid_admin_only_character(forge_character):
# voir screens/labels/background_library.py) — TOUJOURS admin-only # voir screens/labels/background_library.py) — TOUJOURS admin-only
# aujourd'hui (un seul pack, tous sous licence CraftPix), contrairement # aujourd'hui (un seul pack, tous sous licence CraftPix), contrairement
# aux personnages Forge/Kenney (domaine public, jamais gatés). # aux personnages Forge/Kenney (domaine public, jamais gatés).
def forbid_admin_only_background(background_slug): def forbid_admin_only_background(background_slug: str | None) -> None:
if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and g.current_user["role"] != "admin": if background_slug in screens.ADMIN_ONLY_BACKGROUND_SLUGS and g.current_user["role"] != "admin":
abort(403) abort(403)
+105 -70
View File
@@ -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. 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 .connection import connect
from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
from .games.list_games import list_games from .custom_events.create_custom_event import create_custom_event
from .games.game_meta import game_meta from .custom_events.get_custom_event import get_custom_event
from .games.get_game_type import get_game_type, DEFAULT_GAME_TYPE from .custom_events.list_custom_events import list_custom_events
from .games.create_game import create_game from .custom_events.update_custom_event import update_custom_event
from .games.update_game_name import update_game_name from .db_path import db_path
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 .definitions.create_definition import create_definition from .definitions.create_definition import create_definition
from .definitions.rename_definition import rename_definition
from .definitions.definitions_referencing import definitions_referencing 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 .definitions.delete_definition import delete_definition
from .definitions.get_definition import get_definition
from .rows.list_rows import list_rows from .definitions.list_definitions import list_definitions
from .rows.relation_options import relation_options from .dialogue_lines import QUESTION_REWARD_TYPES, sanitize_dialogue_lines, sum_question_rewards
from .rows.insert_row import insert_row from .game_dir import game_dir
from .rows.get_row import get_row from .games.create_game import create_game
from .rows.update_row import update_row from .games.delete_game import delete_game
from .rows.update_row_field import update_row_field from .games.game_meta import game_meta
from .rows.delete_row import delete_row from .games.game_type_catalog import (
from .rows.rows_referencing import rows_referencing DEFAULT_ONBOARDING_TYPE,
ONBOARDING_TYPES,
from .global_vars.ensure_global_vars_schema import PLAYER_SHARED get_onboarding_type,
from .global_vars.list_global_variables import list_global_variables get_onboarding_type_raw,
from .global_vars.list_global_variables_for_player import list_global_variables_for_player is_restricted,
from .global_vars.get_global_variable import get_global_variable 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.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 import delete_global_variable
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
from .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.get_score import get_score
from .scoring.set_score_value import set_score_value from .scoring.set_score_value import set_score_value
from .scoring.set_status import set_status from .scoring.set_status import set_status
from .slugify import slugify
from .custom_events.list_custom_events import list_custom_events from .table_name_for import table_name_for
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
__all__ = [ __all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES", "PLAYER_SHARED", "PROJECTS_DIR",
"SCORE_STATUS_CHOICES", "SCORE_STATUS_LABELS", "get_score", "set_score_value", "set_status", "FIELD_TYPES",
"slugify", "table_name_for", "game_dir", "db_path", "connect", "GLOBAL_VARIABLE_TYPES",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game", "PLAYER_SHARED",
"get_game_type", "DEFAULT_GAME_TYPE", "SCORE_STATUS_CHOICES",
"ONBOARDING_TYPES", "DEFAULT_ONBOARDING_TYPE", "get_onboarding_type", "SCORE_STATUS_LABELS",
"get_onboarding_type_raw", "set_onboarding_type", "is_restricted", "get_score",
"list_definitions", "get_definition", "create_definition", "rename_definition", "set_score_value",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field", "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", "delete_definition",
"list_rows", "relation_options", "insert_row", "get_row", "update_row", "list_rows",
"update_row_field", "delete_row", "rows_referencing", "insert_row",
"list_global_variables", "list_global_variables_for_player", "get_global_variable", "create_global_variable", "get_row",
"update_global_variable_value", "update_global_variable", "delete_global_variable", "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", "delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event", "list_custom_events",
"sanitize_dialogue_lines", "QUESTION_REWARD_TYPES", "sum_question_rewards", "get_custom_event",
"create_custom_event",
"update_custom_event",
"sanitize_dialogue_lines",
"QUESTION_REWARD_TYPES",
"sum_question_rewards",
] ]
+17 -17
View File
@@ -1,9 +1,14 @@
import contextlib
import sqlite3 import sqlite3
from typing import TYPE_CHECKING
from .db_path import db_path 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 # 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 # instant (deux requêtes qui arrivent presque en même temps, ex. deux
# onglets, ou le navigateur qui recharge plusieurs ressources), sqlite3 # onglets, ou le navigateur qui recharge plusieurs ressources), sqlite3
@@ -21,7 +26,7 @@ def connect(slug):
return conn 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 """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 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 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) g._forge_db_connections.append(conn)
def _install_teardown_safety_net(): def install_teardown_safety_net(app: "Flask") -> None:
"""Appelé une seule fois (voir le bas de ce fichier) — enregistre le """Enregistre le filet de sécurité (voir _track_for_teardown) sur
filet de sécurité sur l'appli Flask. `core.flask_app` ne dépend de rien l'appli Flask passée en paramètre — jamais importée ici : `db/` est la
dans `db/`, donc cet import ne crée pas de dépendance circulaire.""" couche la plus basse du moteur (voir pyproject.toml, contrat
try: import-linter) et ne doit dépendre d'aucun autre paquet. C'est
from core.flask_app import app core/db_teardown_guard.py, dans la couche de câblage, qui appelle
except ImportError: cette fonction avec `core.flask_app.app`."""
return
@app.teardown_request @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 from flask import g
for conn in getattr(g, "_forge_db_connections", ()): for conn in getattr(g, "_forge_db_connections", ()):
try: with contextlib.suppress(sqlite3.Error):
conn.close() conn.close()
except sqlite3.Error:
pass
_install_teardown_safety_net()
+3 -3
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema 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) : """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 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 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() existing = conn.execute("SELECT id FROM _custom_events WHERE name = ?", (name,)).fetchone()
if existing: if existing:
conn.close() conn.close()
return existing["id"] return int(existing["id"])
conn.execute( conn.execute(
"INSERT INTO _custom_events (name, description) VALUES (?, ?)", "INSERT INTO _custom_events (name, description) VALUES (?, ?)",
(name, (description or "").strip()), (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.commit()
conn.close() conn.close()
return new_id return new_id
@@ -1,7 +1,7 @@
from ..connection import connect 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) : """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 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 — TOUT le jeu (pas par écran, pas par modèle) : "name" est donc UNIQUE —
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema 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: if not event_id:
return None return None
ensure_custom_events_schema(slug) ensure_custom_events_schema(slug)
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema 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) ensure_custom_events_schema(slug)
conn = connect(slug) conn = connect(slug)
rows = conn.execute("SELECT * FROM _custom_events ORDER BY name").fetchall() rows = conn.execute("SELECT * FROM _custom_events ORDER BY name").fetchall()
+1 -1
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema 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 """Le NOM reste volontairement immuable après création — comme une
variable globale (voir update_global_variable.py) : c'est par ce nom 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 qu'on désigne l'événement dans l'interface, mais surtout par son ID
+1 -1
View File
@@ -3,5 +3,5 @@ import os
from .game_dir import game_dir 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") return os.path.join(game_dir(slug), "game.db")
-44
View File
@@ -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()
+11 -6
View File
@@ -1,13 +1,15 @@
from typing import Any
from ..connection import connect from ..connection import connect
from ..constants import FIELD_TYPES from ..constants import FIELD_TYPES
from ..quote_ident import quote_ident from ..quote_ident import quote_ident
from ..slugify import slugify from ..slugify import slugify
from ..table_name_for import table_name_for from ..table_name_for import table_name_for
from .get_definition import get_definition
from .ensure_field_bounds_schema import ensure_field_bounds_schema 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 """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 exécute le vrai CREATE TABLE correspondant, avec les bons types de
colonne, y compris les colonnes de clé étrangère pour les relations 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 (?, ?, ?)", "INSERT INTO _definitions (name, table_name, per_player) VALUES (?, ?, ?)",
(name, tname, 1 if per_player else 0), (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"] columns_sql = ["id INTEGER PRIMARY KEY AUTOINCREMENT"]
for pos, f in enumerate(fields): for pos, f in enumerate(fields):
@@ -41,10 +43,13 @@ def create_definition(slug, name, fields, per_player=True):
if ftype == "relation": if ftype == "relation":
related = get_definition(slug, int(f["relation_definition_id"])) related = get_definition(slug, int(f["relation_definition_id"]))
col = f"{fname}_id" if related is None:
columns_sql.append( raise ValueError(
f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)" 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)")
relation_definition_id = related["id"] relation_definition_id = related["id"]
else: else:
sql_type = FIELD_TYPES[ftype]["sql"] sql_type = FIELD_TYPES[ftype]["sql"]
+3 -1
View File
@@ -1,7 +1,9 @@
from typing import Any
from ..connection import connect 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 """Autres définitions de CE jeu qui ont un champ de type relation
pointant vers cette définition — utilisé pour bloquer une suppression pointant vers cette définition — utilisé pour bloquer une suppression
qui casserait ces relations.""" qui casserait ces relations."""
+3 -1
View File
@@ -2,8 +2,10 @@ from ..connection import connect
from .get_definition import get_definition 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) 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 = connect(slug)
conn.execute(f"DROP TABLE IF EXISTS {definition['table_name']}") conn.execute(f"DROP TABLE IF EXISTS {definition['table_name']}")
conn.execute("DELETE FROM _fields WHERE definition_id = ?", (definition_id,)) conn.execute("DELETE FROM _fields WHERE definition_id = ?", (definition_id,))
-29
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect 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 """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 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 à les jeux créés avant le bornage automatique (2.2), et per_player à
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect from ..connection import connect
from .ensure_field_bounds_schema import ensure_field_bounds_schema 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) ensure_field_bounds_schema(slug)
conn = connect(slug) conn = connect(slug)
d = conn.execute("SELECT * FROM _definitions WHERE id = ?", (definition_id,)).fetchone() d = conn.execute("SELECT * FROM _definitions WHERE id = ?", (definition_id,)).fetchone()
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect from ..connection import connect
from .ensure_field_bounds_schema import ensure_field_bounds_schema 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) ensure_field_bounds_schema(slug)
conn = connect(slug) conn = connect(slug)
rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall() rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall()
-8
View File
@@ -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()
-49
View File
@@ -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
View File
@@ -1,3 +1,5 @@
from typing import Any
_MAX_LINES_PER_COLUMN = 200 _MAX_LINES_PER_COLUMN = 200
_MAX_SPEAKER_LENGTH = 60 _MAX_SPEAKER_LENGTH = 60
@@ -12,7 +14,7 @@ _MIN_CHOICES = 2
_MAX_CHOICES = 4 _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") speaker = raw_line.get("speaker")
text = raw_line.get("text") text = raw_line.get("text")
if not isinstance(speaker, str) or not speaker.strip(): if not isinstance(speaker, str) or not speaker.strip():
@@ -32,7 +34,7 @@ def _sanitize_dialogue_line(raw_line):
return 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") text = raw_line.get("text")
if not isinstance(text, str) or not text.strip(): if not isinstance(text, str) or not text.strip():
return None 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] choices = [c.strip() for c in raw_choices if isinstance(c, str) and c.strip()][:_MAX_CHOICES]
if len(choices) < _MIN_CHOICES: if len(choices) < _MIN_CHOICES:
return None return None
raw_correct_index = raw_line.get("correct_index")
if raw_correct_index is None:
return None
try: try:
correct_index = int(raw_line.get("correct_index")) correct_index = int(raw_correct_index)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
if not (0 <= correct_index < len(choices)): if not (0 <= correct_index < len(choices)):
@@ -56,12 +61,16 @@ def _sanitize_question_line(raw_line):
except (TypeError, ValueError): except (TypeError, ValueError):
reward_amount = 0 reward_amount = 0
return { return {
"type": "question", "text": text.strip(), "choices": choices, "type": "question",
"correct_index": correct_index, "reward_type": reward_type, "reward_amount": reward_amount, "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): if not isinstance(raw_line, dict):
return None return None
if raw_line.get("type") == "question": if raw_line.get("type") == "question":
@@ -69,7 +78,7 @@ def _sanitize_line(raw_line):
return _sanitize_dialogue_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", """Sanitize une LISTE de répliques de dialogue ({type: "dialogue",
speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de speaker, text} — le nom vient du champ "ℹ️ Informations" d'un objet de
scène, ou "Joueur", voir screens/rendering/scene_object_names.py) 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] 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- """Somme des récompenses de toutes les questions ("❓ Question" ci-
dessus) d'UNE liste de répliques/questions — utilisée pour calculer le 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 score max possible du quiz à l'export SCORM, sur TOUTES les lignes de
TOUS les dialogues du jeu (voir screens.collect_all_dialogue_lines, TOUS les dialogues du jeu (voir screens.collect_all_dialogue_lines,
publish/build_scorm_package.py).""" publish/build_scorm_package.py)."""
total = 0 total = 0
for line in (lines or []): for line in lines or []:
if isinstance(line, dict) and line.get("type") == "question": if isinstance(line, dict) and line.get("type") == "question":
total += line.get("reward_amount", 0) or 0 total += line.get("reward_amount", 0) or 0
return total return total
+1 -1
View File
@@ -4,7 +4,7 @@ from .constants import PROJECTS_DIR
from .games.project_slug import split_slug 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) # Structure de dossiers par utilisateur (voir db/games/project_slug.py)
# : un slug composé "propriétaire_projet" résout vers un vrai chemin # : un slug composé "propriétaire_projet" résout vers un vrai chemin
# imbriqué projects/<propriétaire>/<projet>/ — repli sur l'ancien # imbriqué projects/<propriétaire>/<projet>/ — repli sur l'ancien
+1 -1
View File
@@ -6,7 +6,7 @@ from ..slugify import slugify
from .project_slug import build_slug 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 """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). reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
+1 -1
View File
@@ -6,7 +6,7 @@ from ..game_dir import game_dir
from .project_slug import split_slug from .project_slug import split_slug
def delete_game(slug): def delete_game(slug: str) -> None:
shutil.rmtree(game_dir(slug)) shutil.rmtree(game_dir(slug))
# Structure de dossiers par utilisateur (voir project_slug.py) : # Structure de dossiers par utilisateur (voir project_slug.py) :
# nettoie aussi le dossier propriétaire s'il ne contient plus aucun # nettoie aussi le dossier propriétaire s'il ne contient plus aucun
+5 -2
View File
@@ -1,13 +1,16 @@
from typing import Any
from ..connection import connect from ..connection import connect
from .game_type_catalog import get_onboarding_type from .game_type_catalog import get_onboarding_type
def game_meta(slug): def game_meta(slug: str) -> dict[str, Any]:
conn = connect(slug) conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close() conn.close()
return { 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 # Onboarding guidé (voir game_type_catalog.py) : utilisé par
# templates/base.html pour cacher le lien "Tableau de bord" à un # templates/base.html pour cacher le lien "Tableau de bord" à un
# compte "restreint" (quiz/embranchement/rpg). # compte "restreint" (quiz/embranchement/rpg).
+11 -9
View File
@@ -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 game_type) retient CE choix — jamais lu par le
rendu jouable, seulement par le routage (routes/games/game_dashboard.py, rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
routes/screens/screens_new.py).""" routes/screens/screens_new.py)."""
from typing import Any
import db import db
DEFAULT_ONBOARDING_TYPE = "rpg" DEFAULT_ONBOARDING_TYPE = "rpg"
ONBOARDING_TYPES = { ONBOARDING_TYPES: dict[str, dict[str, Any]] = {
"rpg": { "rpg": {
"label": "Créer un jeu 2D ludique", "label": "Créer un jeu 2D ludique",
"tagline": "Ton monde, ton héros, ton scénario.", "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 """None si _meta['onboarding_type'] est absent — distingue un projet
JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par
l'admin via "+ Nouveau jeu") d'un projet explicitement "custom".""" 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 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 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 = db.connect(slug)
conn.execute( conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)", "INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)",
@@ -81,15 +84,14 @@ def set_onboarding_type(slug, onboarding_type):
conn.close() 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 """Accepte directement une clé de ONBOARDING_TYPES, ou un slug de
projet (résout alors son onboarding_type d'abord) — pratique aussi 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 bien pour core/auth_guard.py (a le slug) que pour un test unitaire (a
déjà la clé).""" déjà la clé)."""
onboarding_type = ( 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) 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"])
+1 -1
View File
@@ -3,7 +3,7 @@ from ..connection import connect
DEFAULT_GAME_TYPE = "jeu_2d" 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" """Type de jeu choisi à la création (voir create_game.py) : "document"
(éditeur générique actuel — quiz/formulaires/contenus, écrans = (éditeur générique actuel — quiz/formulaires/contenus, écrans =
éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié — éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié —
+2 -2
View File
@@ -4,7 +4,7 @@ DEFAULT_SCORM_VERSION = "1.2"
VALID_SCORM_VERSIONS = ("1.2", "2004") 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, """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 publish/scorm_manifest.py) — '1.2' par défaut (compatibilité la plus
large) ou '2004' (3rd/4th edition, sépare completion_status/ large) ou '2004' (3rd/4th edition, sépare completion_status/
@@ -16,4 +16,4 @@ def get_scorm_version(slug):
conn.close() conn.close()
if row is None or row["value"] not in VALID_SCORM_VERSIONS: if row is None or row["value"] not in VALID_SCORM_VERSIONS:
return DEFAULT_SCORM_VERSION return DEFAULT_SCORM_VERSION
return row["value"] return str(row["value"])
+2 -4
View File
@@ -3,16 +3,14 @@ from ..connection import connect
DEFAULT_SUCCESS_THRESHOLD_PERCENT = 70 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é """Seuil de réussite (% de bonnes réponses au quiz) de CE jeu — stocké
dans _meta (clé 'success_threshold_percent'), même convention que dans _meta (clé 'success_threshold_percent'), même convention que
get_xapi_settings.py. Détermine le statut SCORM/xAPI de fin de partie get_xapi_settings.py. Détermine le statut SCORM/xAPI de fin de partie
(reussi/echoue, voir static/js/play/dialogue-box-controller.js:: (reussi/echoue, voir static/js/play/dialogue-box-controller.js::
forgeSyncAllQuestsCompletionToScorm) : 70% par défaut si jamais réglé.""" forgeSyncAllQuestsCompletionToScorm) : 70% par défaut si jamais réglé."""
conn = connect(slug) conn = connect(slug)
row = conn.execute( row = conn.execute("SELECT value FROM _meta WHERE key = 'success_threshold_percent'").fetchone()
"SELECT value FROM _meta WHERE key = 'success_threshold_percent'"
).fetchone()
conn.close() conn.close()
if row is None or row["value"] in (None, ""): if row is None or row["value"] in (None, ""):
return DEFAULT_SUCCESS_THRESHOLD_PERCENT return DEFAULT_SUCCESS_THRESHOLD_PERCENT
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect 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 """Réglages xAPI de CE jeu (voir set_xapi_settings.py — stockés dans
_meta, même convention que 'name'/'onboarding_type', voir _meta, même convention que 'name'/'onboarding_type', voir
db/games/game_type_catalog.py) : URL du LRS (Learning Record Store) db/games/game_type_catalog.py) : URL du LRS (Learning Record Store)
+5 -4
View File
@@ -1,12 +1,13 @@
import os import os
from typing import Any
from ..constants import PROJECTS_DIR
from ..connection import connect from ..connection import connect
from ..constants import PROJECTS_DIR
from ..db_path import db_path from ..db_path import db_path
from .project_slug import build_slug 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/ """Scanne UNIQUEMENT projects/<owner_folder>/ (voir db/games/
project_slug.py — owner_folder est l'id du compte, voir project_slug.py — owner_folder est l'id du compte, voir
db/games/create_game.py) : jamais les autres comptes — 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 fonction ne lit plus jamais l'arborescence project/ en entier (avant
ce correctif, elle listait TOUS les comptes sans distinction — faille ce correctif, elle listait TOUS les comptes sans distinction — faille
corrigée, voir routes/games/index.py).""" corrigée, voir routes/games/index.py)."""
games = [] games: list[dict[str, Any]] = []
entry_path = os.path.join(PROJECTS_DIR, owner_folder) entry_path = os.path.join(PROJECTS_DIR, owner_folder)
if not os.path.isdir(entry_path): if not os.path.isdir(entry_path):
return games return games
@@ -28,7 +29,7 @@ def list_games(owner_folder):
return games return games
def _append_game(games, slug): def _append_game(games: list[dict[str, Any]], slug: str) -> None:
conn = connect(slug) conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone() row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close() conn.close()
+1 -1
View File
@@ -5,7 +5,7 @@ from ..game_dir import game_dir
from .project_slug import build_slug, split_slug 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 """Renomme le dossier PROPRIÉTAIRE d'un compte — owner_folder est
l'id du compte (voir db/games/project_slug.py, routes/games/ 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, games_new.py) : ne change donc plus jamais après coup en usage normal,
+2 -2
View File
@@ -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.""" 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}" 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 """(owner_folder, project_part) si `slug` est bien composé, sinon
(slug, None) — un slug "plat" (créé avant cette convention, pas encore (slug, None) — un slug "plat" (créé avant cette convention, pas encore
migré par scripts/migrate_flat_project_slugs.py) reste lisible tel migré par scripts/migrate_flat_project_slugs.py) reste lisible tel
+1 -1
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .get_scorm_version import VALID_SCORM_VERSIONS 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.""" """Enregistre la version SCORM exportée — voir get_scorm_version.py."""
if version not in VALID_SCORM_VERSIONS: if version not in VALID_SCORM_VERSIONS:
raise ValueError("version SCORM invalide : {!r}".format(version)) raise ValueError("version SCORM invalide : {!r}".format(version))
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect 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.""" """Enregistre le seuil de réussite (0-100) — voir get_success_threshold.py."""
percent = max(0, min(100, int(percent))) percent = max(0, min(100, int(percent)))
conn = connect(slug) conn = connect(slug)
+4 -10
View File
@@ -1,7 +1,7 @@
from ..connection import connect 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). """Enregistre les réglages xAPI de CE jeu (voir get_xapi_settings.py).
`password=None` (champ laissé vide côté formulaire, voir `password=None` (champ laissé vide côté formulaire, voir
routes/publish/xapi_settings.py) laisse le mot de passe déjà 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 que la route GET puisse ne jamais renvoyer sa valeur au navigateur
(juste un booléen "déjà configuré").""" (juste un booléen "déjà configuré")."""
conn = connect(slug) conn = connect(slug)
conn.execute( conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",))
"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_login', ?)", (login or "",)
)
if password is not None: if password is not None:
conn.execute( conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,))
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,)
)
conn.commit() conn.commit()
conn.close() conn.close()
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect 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 = connect(slug)
conn.execute("UPDATE _meta SET value = ? WHERE key = 'name'", (new_name,)) conn.execute("UPDATE _meta SET value = ? WHERE key = 'name'", (new_name,))
conn.commit() conn.commit()
+1 -1
View File
@@ -1,7 +1,7 @@
import json 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 """Pour une variable "objet"/"tableau" (voir db/constants.py), la
valeur stockée doit rester du JSON analysable — sinon la prochaine valeur stockée doit rester du JSON analysable — sinon la prochaine
lecture (_resolve_variable_path, screens/rendering/ lecture (_resolve_variable_path, screens/rendering/
+6 -4
View File
@@ -1,9 +1,11 @@
from ..connection import connect 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 .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 """Crée la ligne "modèle" d'une variable globale (toujours
player_id=PLAYER_SHARED, quel que soit per_player — voir 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 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() ).fetchone()
if existing: if existing:
conn.close() conn.close()
return existing["id"] return int(existing["id"])
conn.execute( conn.execute(
"INSERT INTO _global_variables (name, type, value, player_id, per_player) VALUES (?, ?, ?, ?, ?)", "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), (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.commit()
conn.close() conn.close()
return new_id return new_id
+1 -1
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .ensure_global_vars_schema import ensure_global_vars_schema 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 """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 PLAYER_SHARED — une variable per_player peut avoir une ligne par joueur
ayant déjà écrit dessus (voir update_global_variable_value.py).""" 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 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 """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 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é id technique mais pas nécessairement un nom encore garanti unique côté
+1 -1
View File
@@ -3,7 +3,7 @@ from ..connection import connect
PLAYER_SHARED = "__shared__" 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) : """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), 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 une variable a une ligne "modèle" créée par le créateur du jeu
+4 -2
View File
@@ -1,9 +1,11 @@
from typing import Any
from ..connection import connect 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 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 """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 partout ailleurs, condition de visibilité, action "Modifier une
variable") et par joueur (voir resolve_player_key.py) : une variable variable") et par joueur (voir resolve_player_key.py) : une variable
+4 -2
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect 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 """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 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 bord, jamais influencée par le nombre de joueurs ayant déjà leur

Some files were not shown because too many files have changed in this diff Show More