Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
Clear filters), toasts sur chaque action.
Bugs trouvés en route et corrigés :
1. Restore/Delete de la page ne marchaient JAMAIS : getCsrf() renvoie la chaîne
du jeton mais le code faisait csrf?.[1] → 2e caractère → 403 CSRF silencieux
avalé par if (r.ok).
2. « Move to Trash » de l'éditeur = 404 permanent : route appelée
/board/api/pages/{id}/trash (inexistante) alors que la route réelle est
/api/pages/{id}/trash, et le .then() naviguait quand même → la page partait
à l'accueil SANS être mise à la poubelle. URL corrigée + r.ok vérifié.
3. Page restaurée invisible en Library/Recents/Private : la suppression éditeur
réécrivait parent_section='Trash' et la restauration ne le remettait pas,
alors que tous les listings filtrent parent_section != 'Trash'. Écriture
retirée (deleted_at = source de vérité unique) + réparation idempotente au
boot dans db.init_db + requête sidebar /trash alignée sur deleted_at.
4. Routes trash sans aucune authentification (le CSRF ne protège pas : cookie
lisible + en-tête forgé) : 401 ajouté sur list/restore/delete/empty et sur
POST /api/pages/{id}/trash — vérifié anonyme → 401.
Gates : tests/test_trash_api.py (5) · e2e/trash_ui.spec.js (1, avec garde « on
ne vide jamais la poubelle d'autrui ») · pytest 1099 passed · ruff OK ·
e2e probe_nav_perf + partial_nav + editor_mount + logout_dnd + smoke = 10 passed ·
OpenAPI 510 chemins / 7.45.5.
250 lines
8.5 KiB
Python
250 lines
8.5 KiB
Python
"""FlowDeck — Dashboard : pages_api.
|
|
|
|
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["dashboard"])
|
|
|
|
|
|
|
|
|
|
# ═══════════ Library page actions API ═══════════
|
|
|
|
@router.get("/api/pages/{page_id:int}/content")
|
|
def api_page_content(page_id: int):
|
|
"""Get page content for side peek preview."""
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
|
|
(page_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return JSONResponse({"error": "Not found"}, status_code=404)
|
|
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
|
from app.services.synced_blocks import resolve_content_json
|
|
return {
|
|
"title": row["title"],
|
|
"content": resolve_content_json(row["content"], row["content_format"]),
|
|
"format": row["content_format"] or "blocks",
|
|
}
|
|
|
|
|
|
|
|
|
|
@router.put("/api/pages/{page_id:int}/rename")
|
|
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
|
|
"""Inline rename a page title."""
|
|
title = (body.get("title") or "").strip()
|
|
if not title:
|
|
return JSONResponse({"error": "Title required"}, status_code=400)
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
|
(title, page_id),
|
|
)
|
|
# v6.5.0: renaming a database row's content page updates the row.
|
|
from app.services.row_pages import sync_page_title_to_row
|
|
sync_page_title_to_row(conn, page_id)
|
|
conn.commit()
|
|
return {"status": "ok", "title": title}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/pages/{page_id:int}/trash")
|
|
def api_trash_page(page_id: int, request: Request):
|
|
"""Soft-delete a page (move to trash).
|
|
|
|
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
|
|
est la seule source de vérité. L'ancienne écriture marquait définitivement
|
|
la page : à la restauration elle restait 'Trash' et disparaissait des
|
|
listings Library / Recents / Private (``parent_section != 'Trash'``).
|
|
"""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
raise HTTPException(401, "Authentication required")
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(page_id,),
|
|
)
|
|
conn.commit()
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
|
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
|
|
"""Convert a page into a full-page database (Notion-style).
|
|
|
|
Creates a collection linked to this page, adds the default 'Name' property,
|
|
and sets the page's content_format to 'collection'.
|
|
"""
|
|
import json as _json
|
|
db_name = (body.get("name") or "").strip()
|
|
|
|
with get_conn() as conn:
|
|
page = conn.execute(
|
|
"SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL",
|
|
(page_id,),
|
|
).fetchone()
|
|
if not page:
|
|
return JSONResponse({"error": "Page not found"}, status_code=404)
|
|
|
|
if not db_name:
|
|
db_name = page["title"] or "New Database"
|
|
|
|
# Create the collection
|
|
cur = conn.execute(
|
|
"""INSERT INTO collections
|
|
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
|
VALUES (?, '', '📋', '[]', 0, ?, ?)""",
|
|
(db_name, page_id, page["workspace_id"]),
|
|
)
|
|
collection_id = cur.lastrowid
|
|
|
|
# Create default "Name" property (text, position 0)
|
|
conn.execute(
|
|
"""INSERT INTO collection_properties
|
|
(collection_id, name, prop_type, position, required, visible_in_views)
|
|
VALUES (?, 'Name', 'title', 0, 1, 1)""",
|
|
(collection_id,),
|
|
)
|
|
|
|
# Create default "Table" view
|
|
conn.execute(
|
|
"""INSERT INTO collection_views
|
|
(collection_id, name, view_type, config_json, position)
|
|
VALUES (?, 'Table', 'table', ?, 0)""",
|
|
(collection_id, _json.dumps({"visible_properties": ["Name"]})),
|
|
)
|
|
|
|
# Update the page to be a database page
|
|
conn.execute(
|
|
"UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(collection_id, page_id),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return {
|
|
"status": "converted",
|
|
"collection_id": collection_id,
|
|
"name": db_name,
|
|
"view_url": f"/pages/{page_id}",
|
|
}
|
|
|
|
|
|
|
|
|
|
@router.get("/api/collections/{collection_id:int}/table-data")
|
|
def api_collection_table_data(collection_id: int):
|
|
"""Get collection properties + pages for rendering the table view."""
|
|
with get_conn() as conn:
|
|
coll = conn.execute(
|
|
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
|
).fetchone()
|
|
if not coll:
|
|
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
|
|
|
properties = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
|
|
pages = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
|
|
views = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
|
|
return {
|
|
"collection": dict(coll),
|
|
"properties": properties,
|
|
"pages": pages,
|
|
"views": views,
|
|
}
|
|
|
|
|
|
|
|
|
|
@router.post("/api/collections/{collection_id:int}/pages")
|
|
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
|
|
"""Create a new page (row) in a collection."""
|
|
import json as _json
|
|
title = body.get("title", "New page").strip() or "New page"
|
|
icon = body.get("icon", "file")
|
|
|
|
with get_conn() as conn:
|
|
coll = conn.execute(
|
|
"SELECT id FROM collections WHERE id=?", (collection_id,)
|
|
).fetchone()
|
|
if not coll:
|
|
return JSONResponse({"error": "Collection not found"}, status_code=404)
|
|
|
|
# Get next position
|
|
max_pos = conn.execute(
|
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
|
(collection_id,),
|
|
).fetchone()[0]
|
|
|
|
# Load default property values from collection properties
|
|
props = [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
default_values = {}
|
|
for p in props:
|
|
if p["prop_type"] == "title":
|
|
default_values[str(p["id"])] = title
|
|
# Caller-provided values (e.g. board "add card in column X") win.
|
|
incoming = body.get("properties") or {}
|
|
if isinstance(incoming, dict):
|
|
default_values.update(incoming)
|
|
|
|
from app.services.property_types import apply_auto_properties
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
|
|
apply_auto_properties(props, default_values, user, is_create=True)
|
|
|
|
cur = conn.execute(
|
|
"""INSERT INTO collection_pages
|
|
(collection_id, title, icon, cover_url, position, property_values_json)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
|
|
_json.dumps(default_values)),
|
|
)
|
|
page_id = cur.lastrowid
|
|
conn.commit()
|
|
|
|
return {
|
|
"id": page_id,
|
|
"title": title,
|
|
"icon": icon,
|
|
"position": max_pos,
|
|
"property_values_json": default_values,
|
|
"status": "created",
|
|
}
|