Corrige un plantage à la suppression d'un élément référencé par la Logique de la scène

Bug remonté : supprimer un élément plantait avec sqlite3.IntegrityError:
FOREIGN KEY constraint failed.

Cause : trigger_element_id/target_element_id (_flow_nodes, voir
ensure_flow_schema.py - un nœud "clic sur cet élément" ou "Modifier cet
élément"/"Activer cet onglet") et target_element_id (l'ancien système
_actions, conservé pour compatibilité) référencent _screen_elements(id)
SANS ON DELETE CASCADE - volontairement, un élément ne doit pas pouvoir
disparaître "par erreur" en cascade depuis un nœud de logique qu'on
modifie ailleurs. Mais ça veut dire que delete_element.py, qui ne
supprimait jusqu'ici que la ligne elle-même, faisait échouer PRAGMA
foreign_keys=ON (db/connection.py) dès qu'un nœud de logique existant
référençait encore l'élément.

Fix : delete_element.py nettoie maintenant ces références AVANT de
supprimer l'élément - pas seulement pour l'élément explicitement supprimé,
mais pour tous ses DESCENDANTS aussi (leur suppression est cascadée
automatiquement au niveau SQL via parent_id, sans repasser par ce
fichier, donc sans ce nettoyage si on ne le fait pas explicitement).

Ajoute tests/test_delete_element_referenced_by_flow.py (élément
référencé comme déclencheur, comme cible d'action, et cas d'un conteneur
supprimé dont un descendant est référencé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-25 16:19:52 +02:00
co-authored by Claude Sonnet 5
parent 4dc42a7f3b
commit 1e86077e45
2 changed files with 127 additions and 0 deletions
+48
View File
@@ -1,8 +1,56 @@
import db import db
from ..flow.ensure_flow_schema import ensure_flow_schema
def _collect_ids(conn, screen_id, root_id):
"""Rassemble l'id de cet élément et de TOUS ses descendants — ceux
qu'une suppression en cascade (parent_id ON DELETE CASCADE, voir
ensure_schema.py) va de toute façon supprimer automatiquement en même
temps, sans passer par ce fichier. Nécessaire pour nettoyer AUSSI les
références de la Logique de la scène vers un simple DESCENDANT, pas
seulement vers l'élément explicitement supprimé."""
rows = conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,)
).fetchall()
children_of = {}
for r in rows:
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
ids = [root_id]
stack = [root_id]
while stack:
current = stack.pop()
for child_id in children_of.get(current, []):
ids.append(child_id)
stack.append(child_id)
return ids
def delete_element(slug, element_id): def delete_element(slug, element_id):
ensure_flow_schema(slug)
conn = db.connect(slug) conn = db.connect(slug)
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
ids = _collect_ids(conn, row["screen_id"], element_id)
placeholders = ",".join("?" * len(ids))
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
# élément"/action "Modifier cet élément"...) ou une ancienne action du
# système _actions (conservé pour compatibilité) qui référence
# l'élément supprimé OU L'UN DE SES DESCENDANTS n'a plus aucun sens une
# fois l'élément disparu — et ces colonnes (trigger_element_id/
# target_element_id) n'ont volontairement PAS de ON DELETE CASCADE (un
# élément ne doit pas pouvoir être supprimé "par erreur" en cascade
# depuis un nœud de logique qu'on modifie). Sans ce nettoyage préalable,
# PRAGMA foreign_keys=ON (voir db/connection.py) fait échouer la
# suppression elle-même avec "FOREIGN KEY constraint failed".
conn.execute(
f"DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})",
ids + ids,
)
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,)) conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -0,0 +1,79 @@
"""Régression : supprimer un élément référencé par un nœud de la Logique
de la scène (comme déclencheur "clic sur cet élément", ou comme cible
d'une action "Modifier cet élément"/"Activer cet onglet") plantait avec
sqlite3.IntegrityError: FOREIGN KEY constraint failed — trigger_element_id
et target_element_id (voir screens/flow/ensure_flow_schema.py) n'ont pas
de ON DELETE CASCADE."""
import json
import re
import screens
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_element(client, slug, screen_id, widget="conteneur"):
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def test_deleting_an_element_used_as_a_flow_trigger_does_not_crash(client, game):
screen_id = _create_screen(client, game)
element_id = _add_element(client, game, screen_id, "bouton")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_event": "clic", "trigger_element_id": element_id}),
content_type="application/json",
)
assert resp.status_code == 200
resp = client.post(f"/game/{game}/elements/{element_id}/delete", follow_redirects=False)
assert resp.status_code == 302
assert screens.get_element(game, element_id) is None
def test_deleting_an_element_used_as_a_flow_action_target_does_not_crash(client, game):
screen_id = _create_screen(client, game)
other_element_id = _add_element(client, game, screen_id, "bouton")
target_element_id = _add_element(client, game, screen_id, "texte")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_element", "target_element_id": target_element_id,
"element_property": "visibilite", "element_value": "cache",
}),
content_type="application/json",
)
assert resp.status_code == 200
resp = client.post(f"/game/{game}/elements/{target_element_id}/delete", follow_redirects=False)
assert resp.status_code == 302
assert screens.get_element(game, target_element_id) is None
# L'autre élément (non concerné) reste intact.
assert screens.get_element(game, other_element_id) is not None
def test_deleting_a_container_cleans_up_flow_references_to_its_descendants(client, game):
"""Un conteneur supprimé cascade la suppression de ses enfants au
niveau SQL (parent_id ON DELETE CASCADE) - sans passer par
delete_element.py pour chacun d'eux. Le nettoyage doit donc couvrir
aussi les DESCENDANTS, pas seulement l'élément explicitement supprimé."""
screen_id = _create_screen(client, game)
container_id = _add_element(client, game, screen_id, "conteneur")
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "bouton"}, follow_redirects=False)
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_event": "clic", "trigger_element_id": child_id}),
content_type="application/json",
)
assert resp.status_code == 200
resp = client.post(f"/game/{game}/elements/{container_id}/delete", follow_redirects=False)
assert resp.status_code == 302
assert screens.get_element(game, container_id) is None
assert screens.get_element(game, child_id) is None