7.77.0 — corbeille réparée (FK favorites/recents) + MCP workspaces
FlowDeck CI / lint (push) Failing after 1m28s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h18m54s

- 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 f6782ee5b3
8 changed files with 141 additions and 7 deletions
+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)