Files
flowdeck/app/services/collection_lifecycle.py
T
bruno 1d1cdbd618
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped
fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
2026-10-05 22:47:36 -04:00

223 lines
9.1 KiB
Python

"""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 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))