"""FlowDeck — cohérence collections ↔ workspace. Une database (`collections`) peut être : * **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ; * **de page** — page convertie en base (`collections.parent_page_id` + `pages.collection_id`) ou base inline insérée dans une page. Ce module centralise les deux sens de la relation : * **lecture** — quelles collections sont encore *vivantes* dans un workspace (leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et quelles lignes sont encore vivantes (leur page contenu n'est pas à la corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches d'un workspace dont tous les documents ont été supprimés ; * **écriture** — la suppression en cascade d'une collection. L'ordre des écritures est dicté par le schéma SQLite : - ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` : la page hôte doit être détachée **avant** la suppression ; - ``collection_data_sources.source_collection_id`` référence également en ``NO ACTION`` : les vues liées qui pointent vers la collection doivent être détachées ; - ``collection_pages`` (lignes), ``collection_properties``, ``collection_views``, ``collection_permissions`` et les pages contenu (``pages.collection_row_id``) partent en ``CASCADE``. Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError`` dès qu'une page hôte (ou une vue liée) existe encore. """ from __future__ import annotations import logging logger = logging.getLogger(__name__) # ── Lecture ────────────────────────────────────────────────────────────── #: SQL du workspace **effectif** d'une collection. #: #: ``collections.workspace_id`` est renseigné pour les databases créées via #: ``/db/api``, mais reste **NULL** pour une base portée par une page (page #: convertie en base, base inline insérée dans un document) : son workspace #: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id #: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN #: ``workspaces`` les exclut définitivement. EFFECTIVE_WORKSPACE_SQL = ( "COALESCE({a}.workspace_id," " (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))" ) 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) def user_workspace_ids(conn, user_id: int) -> list[int]: """IDs des workspaces accessibles à l'utilisateur. Propriétaire **ou** membre : c'est le même périmètre que la recherche globale (`app/services/search._scope_where`), à la différence près qu'on n'inclut pas les éléments sans workspace — une base non rattachable n'appartient à personne et ne doit donc apparaître chez personne. """ rows = conn.execute( "SELECT id FROM workspaces WHERE owner_id = ?" " UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?", (user_id, user_id), ).fetchall() return [r[0] for r in rows] def live_collection_ids(conn, workspace_id: int) -> list[int]: """IDs des collections rattachées au workspace qui sont encore vivantes. Une collection est exclue dès que sa page hôte a disparu : * ``parent_page_id`` renseigné mais page absente ou à la corbeille ; * une page ``content_format='collection'`` qui pointe encore vers elle mais est à la corbeille. Les collections sans page hôte (databases de workspace créées via ``/db/api``) restent toujours visibles. Le rattachement passe par le workspace **effectif** : une base créée depuis un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte. """ rows = conn.execute( f""" SELECT c.id FROM collections c WHERE {effective_workspace_sql("c")} = ? AND (c.parent_page_id IS NULL OR EXISTS (SELECT 1 FROM pages p WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL)) AND NOT EXISTS (SELECT 1 FROM pages p WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL) ORDER BY c.name """, (workspace_id,), ).fetchall() return [r["id"] for r in rows] def live_row_ids(conn, collection_id: int) -> list[int]: """IDs des lignes racines d'une collection dont la page contenu vit. La page contenu d'une ligne est créée paresseusement (``ensure_row_page``) : son absence ne dit rien, mais son passage à la corbeille signifie que l'utilisateur a supprimé le document → la ligne sort de My Tasks. """ rows = conn.execute( """ SELECT cp.id FROM collection_pages cp WHERE cp.collection_id = ? AND cp.parent_id IS NULL AND NOT EXISTS (SELECT 1 FROM pages p WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL) ORDER BY cp.position """, (collection_id,), ).fetchall() return [r["id"] for r in rows] def live_document_count(conn, workspace_id: int) -> int: """Nombre de documents visibles du workspace (mêmes règles que l'arbre de la sidebar : racines, non supprimés, hors pages contenu de ligne).""" return conn.execute( "SELECT COUNT(*) FROM pages " "WHERE workspace_id = ? AND parent_id IS NULL " "AND deleted_at IS NULL AND collection_row_id IS NULL", (workspace_id,), ).fetchone()[0] def collections_hosted_by_pages(conn, page_ids) -> list[int]: """IDs des collections dont l'une de ces pages est la page hôte. Les deux relations sont couvertes : ``collections.parent_page_id`` (page convertie en base, base inline) et ``pages.collection_id`` (page hôte). Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent aucune collection et ne remontent donc rien. À appeler **avant** de supprimer les pages si l'on veut aussi capturer la relation ``pages.collection_id``. """ ids = [int(p) for p in page_ids if p is not None] if not ids: return [] qs = ",".join("?" * len(ids)) rows = conn.execute( f""" SELECT id FROM collections WHERE parent_page_id IN ({qs}) UNION SELECT collection_id FROM pages WHERE id IN ({qs}) AND collection_id IS NOT NULL """, [*ids, *ids], ).fetchall() return [r["id"] for r in rows] def collections_hosted_by_page(conn, page_id: int) -> list[int]: """``collections_hosted_by_pages`` pour une seule page.""" return collections_hosted_by_pages(conn, [page_id]) def workspace_collection_ids(conn, workspace_id: int) -> list[int]: """IDs de **toutes** les collections du workspace (sans filtre de vivacité) — utilisé lors de la suppression du workspace.""" rows = conn.execute( "SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,) ).fetchall() return [r["id"] for r in rows] # ── Écriture ───────────────────────────────────────────────────────────── def delete_collections(conn, collection_ids) -> int: """Supprime définitivement des collections et tout ce qu'elles portent. Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des vues liées) puis supprime : le reste part en ``CASCADE``. Ne fait **pas** de ``commit`` — l'appelant garde la main sur la transaction. Retourne le nombre de collections supprimées. """ ids = [int(i) for i in collection_ids if i is not None] if not ids: return 0 qs = ",".join("?" * len(ids)) # 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le # contenu EST la base redevient une page blocs normale. conn.execute( f"""UPDATE pages SET collection_id = NULL, content_format = CASE WHEN content_format = 'collection' THEN 'blocks' ELSE content_format END WHERE collection_id IN ({qs})""", ids, ) # 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue # liée perd sa source (elle n'a plus rien à afficher) mais survit. conn.execute( f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})", ids, ) # 3. Suppression : lignes, propriétés, vues, permissions et pages contenu # suivent en CASCADE. cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids) return cur.rowcount or 0 def delete_collections_hosted_by_pages(conn, page_ids) -> int: """Supprime les databases portées par des pages définitivement supprimées (corbeille purgée, suppression définitive, workspace effacé).""" return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))