7.77.0 — corbeille réparée (FK favorites/recents) + MCP workspaces

- detach_page_references : nettoyage des références NO ACTION avant
  toute suppression définitive (unitaire, Empty Trash, purge 30 j)
- MCP : list_workspaces / get_workspace ; descriptions collections
  clarifiées (bases de données, pas des workspaces)
- 96 tests verts sur le périmètre (MCP, trash, cohérence)
This commit is contained in:
Spark
2026-10-22 09:03:43 -04:00
parent 215990adc0
commit 527ea9eecd
8 changed files with 141 additions and 7 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog - FlowDeck
## v7.77.0 (2026-10-22) — Corbeille réparée + MCP : les workspaces
### Corrigé
- **La suppression définitive depuis la corbeille échouait (500)**
pour toute page réelle : `favorites.page_id` et `recents.page_id`
référencent `pages(id)` sans CASCADE, avec les clés étrangères
actives — un `IntegrityError` dès que la page avait été ouverte
ou mise en favori. La purge automatique des 30 jours mourait sur
la même erreur, en silence. Nouveau nettoyage partagé
`detach_page_references`, appliqué aux trois chemins (suppression
unitaire, Empty Trash, purge planifiée). Cas réel de Bruno :
4 pages impossibles à supprimer. Test de régression ajouté.
### Ajouté
- **MCP : `list_workspaces` et `get_workspace`** — les workspaces
(conteneurs de pages : Techno, Famille Charest…) sont enfin
exposés au service d'agents, et les descriptions des outils
collections disent désormais clairement qu'une collection est une
base de données, pas un workspace (la réponse « 17 collections
(espaces de travail) » du panneau a induit Bruno en erreur).
## v7.76.1 (2026-10-22) — Correctif : le MCP était bloqué par le CSRF
### Corrigé
+1 -1
View File
@@ -1 +1 @@
7.76.1
7.77.0
+3
View File
@@ -16,6 +16,7 @@ from app.services.collection_lifecycle import (
collections_hosted_by_page,
collections_hosted_by_pages,
delete_collections,
detach_page_references,
)
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
@@ -199,6 +200,7 @@ def permanent_delete(request: Request, page_id: int):
# tâches sans document.
collections = collections_hosted_by_page(conn, page_id)
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
detach_page_references(conn, [page_id])
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
delete_collections(conn, collections)
conn.commit()
@@ -229,6 +231,7 @@ def empty_trash(request: Request):
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
)
detach_page_references(conn, trashed)
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
delete_collections(conn, collections)
conn.commit()
+41 -5
View File
@@ -10,9 +10,11 @@ Authentification : les jetons d'API personnels existants
Le service d'agents reçoit un jeton dédié créé par l'utilisateur ;
il lit et écrit en son nom.
Objet exposé : les **collections** (les projets/espaces de travail
de Flowdeck) et leurs pages, la recherche par titre, et le registre
des dépôts (table ``projects``). Doctrine du programme : Flowdeck
Objets exposés : les **workspaces** (conteneurs de pages) et leurs
pages, les **collections** (bases de données type Notion — ne pas
confondre : une collection vit dans un workspace ou une page), la
recherche par titre, et le registre des dépôts (table ``projects``).
Doctrine du programme : Flowdeck
référence les tâches, il ne les copie pas — aucune tâche TikTak ne
transite par ici. Écritures limitées à la création/mise à jour de
pages et à la création de collections ; aucune suppression.
@@ -30,9 +32,24 @@ from app.routers.public_api import _token_owner
router = APIRouter(tags=["mcp"])
OUTILS = [
{"name": "list_workspaces",
"description": "Liste les WORKSPACES Flowdeck — les conteneurs de pages "
"de l'utilisateur (ex. Techno, Famille Charest), avec leur "
"nombre de pages actives. À ne pas confondre avec les "
"collections (bases de données).",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_workspace",
"description": "Lit un workspace et l'arborescence de ses pages "
"(titres, parent, section) — pages classiques, pas les "
"lignes des collections.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "list_collections",
"description": "Liste les collections Flowdeck (projets / espaces de travail) "
"avec leur nombre de pages.",
"description": "Liste les COLLECTIONS Flowdeck — les bases de données "
"de type Notion (tables, CRM, calendriers, formulaires), "
"avec leur nombre de lignes. Ce ne sont PAS les workspaces : "
"une collection vit dans un workspace ou dans une page.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_collection",
"description": "Lit une collection et ses pages (titres et propriétés).",
@@ -97,6 +114,25 @@ def _page_resume(row) -> dict:
def _appel_outil(nom: str, args: dict):
with get_conn() as conn:
if nom == "list_workspaces":
rows = conn.execute(
"SELECT w.id, w.name, w.owner_id, "
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id "
" AND p.deleted_at IS NULL) AS page_count "
"FROM workspaces w ORDER BY w.name").fetchall()
return [dict(r) for r in rows]
if nom == "get_workspace":
ws = conn.execute("SELECT * FROM workspaces WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if ws is None:
return {"erreur": "workspace introuvable"}
pages = conn.execute(
"SELECT id, title, parent_id, parent_section, updated_at "
"FROM pages WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY parent_id, title LIMIT 300", (ws["id"],)).fetchall()
return {"workspace": {"id": ws["id"], "name": ws["name"],
"owner_id": ws["owner_id"]},
"pages": [dict(p) for p in pages]}
if nom == "list_collections":
rows = conn.execute(
"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
+20
View File
@@ -51,6 +51,26 @@ EFFECTIVE_WORKSPACE_SQL = (
)
def detach_page_references(conn, page_ids) -> None:
"""Supprime les lignes qui référencent des pages en ``NO ACTION``.
``favorites.page_id`` et ``recents.page_id`` référencent
``pages(id)`` sans CASCADE et la connexion applique
``PRAGMA foreign_keys=ON`` : supprimer définitivement une page
réelle — ouverte un jour (recents) ou mise en favori — levait un
``IntegrityError`` (500 côté API, échec silencieux de la purge
automatique des 30 jours). À appeler avant tout
``DELETE FROM pages`` définitif. (Régression du 2026-10-22 :
les pages synthétiques des tests n'avaient ni favori ni récent.)
"""
ids = [int(i) for i in page_ids]
if not ids:
return
ph = ",".join("?" * len(ids))
conn.execute(f"DELETE FROM favorites WHERE page_id IN ({ph})", ids)
conn.execute(f"DELETE FROM recents WHERE page_id IN ({ph})", ids)
def effective_workspace_sql(alias: str = "c") -> str:
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
+2
View File
@@ -16,6 +16,7 @@ from app.db import get_conn
from app.services.collection_lifecycle import (
collections_hosted_by_page,
delete_collections,
detach_page_references,
)
logger = logging.getLogger(__name__)
@@ -71,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
"WHERE parent_id=?",
(page_id, page_id),
)
detach_page_references(conn, [page_id])
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
delete_collections(conn, collections)
purged.append(page_id)
+25 -1
View File
@@ -68,11 +68,35 @@ def test_mcp_handshake_et_outils(client, jeton):
assert r.json()["result"]["serverInfo"]["name"] == "flowdeck"
r = _rpc(client, jeton, 2, "tools/list")
noms = [t["name"] for t in r.json()["result"]["tools"]]
assert noms == ["list_collections", "get_collection", "get_page",
assert noms == ["list_workspaces", "get_workspace",
"list_collections", "get_collection", "get_page",
"search_pages", "list_repos", "create_page",
"update_page", "create_collection"]
def test_mcp_workspaces(client, jeton):
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Techno', 1)")
wid = cur.lastrowid
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, parent_section) "
"VALUES ('Techno', ?, 'Page racine', 'Private')", (wid,))
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, parent_section, deleted_at) "
"VALUES ('Techno', ?, 'Page corbeille', 'Private', '2026-10-11 10:00:00')", (wid,))
conn.commit()
ws = _appel(client, jeton, 70, "list_workspaces")
techno = [w for w in ws if w["name"] == "Techno"]
assert techno and techno[0]["page_count"] == 1 # la corbeille ne compte pas
detail = _appel(client, jeton, 71, "get_workspace", {"id": wid})
assert detail["workspace"]["name"] == "Techno"
assert [p["title"] for p in detail["pages"]] == ["Page racine"]
res = _appel(client, jeton, 72, "get_workspace", {"id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_collections_et_pages(client, jeton, donnees):
cols = _appel(client, jeton, 1, "list_collections")
alpha = [c for c in cols if c["name"] == "Projet Alpha"]
+26
View File
@@ -32,6 +32,32 @@ def _row(page_id):
).fetchone()
def test_permanent_delete_page_referenced_by_favorites_and_recents(client):
"""Régression 7.77.0 (cas réel de Bruno, 2026-10-22) : une page
réelle — ouverte (recents) et/ou favorite — ne pouvait plus être
supprimée définitivement : favorites.page_id et recents.page_id
référencent pages(id) sans CASCADE, PRAGMA foreign_keys=ON →
IntegrityError → 500 « Delete failed », et Empty Trash (une seule
transaction) échouait en bloc. Les pages synthétiques des anciens
tests n'avaient ni favori ni récent : elles passaient."""
from app.db import get_conn
login_test_client(client)
pid = _mk_page(title="Page réelle de Bruno")
with get_conn() as conn:
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (1, ?, 0)", (pid,))
conn.execute("INSERT INTO recents (user_id, page_id, workspace) VALUES (1, ?, 'x')", (pid,))
conn.commit()
r = client.post(f"/api/pages/{pid}/trash")
assert r.status_code == 200
r = client.delete(f"/board/api/trash/{pid}")
assert r.status_code == 200, r.text
assert _row(pid) is None
with get_conn() as conn:
assert conn.execute("SELECT COUNT(*) FROM favorites WHERE page_id=?", (pid,)).fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM recents WHERE page_id=?", (pid,)).fetchone()[0] == 0
def test_trash_requires_session(client):
pid = _mk_page()
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403