Découpe par concern de l'ancien app/routers/collections.py (2 622 lignes, 53 endpoints / 52 fonctions) en package `app/routers/collections/` : - 10 modules de routes : crud 337 L (6 r.), properties 322 (8), linked 286 (7), structure 267 (8), dashboard_views 214 (3), meta 197 (5), views 187 (6), pages 184 (4), data_api 122 (2), boards 61 (3) - _common.py (220 L) : 8 helpers auth/permissions/validation - _renderers.py (667 L) : 15 rendus HTML des vues + CHART_MAX_GROUPS - __init__.py : ré-exports connus (_validate_page_properties pour automations ; _chart_values/_chart_aggregate/_fmt_number/_render_chart pour les tests) + __all__ Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - docstring d'origine conservée dans le header copié → F404 (from __future__ après un statement) → slice [1:30] - décorateurs empilés (view_collection ×2) : segment sans def → skip du 2e décorateur (53 endpoints = 52 unités) - CHART_MAX_GROUPS hors détection des helpers (F821) → import ._renderers - test_csp_no_cdn_and_vendor lisait collections.py → balayage du package Reste A28 : board.py 2 101 L (lot 4). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
185 lines
6.3 KiB
Python
185 lines
6.3 KiB
Python
"""FlowDeck — Collections : pages.
|
|
|
|
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
|
|
from app.db import get_conn
|
|
from app.services.automations import fire_event, run_event_sync
|
|
from app.services.property_types import (
|
|
apply_auto_properties,
|
|
)
|
|
|
|
from ._common import (
|
|
_collection_properties,
|
|
_current_user,
|
|
_require_edit,
|
|
_require_view,
|
|
_session_user,
|
|
_validate_meta_keys,
|
|
_validate_page_properties,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["collections"], prefix="/db")
|
|
|
|
|
|
|
|
|
|
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
|
|
|
|
|
@router.get("/pages/{page_id}/api")
|
|
def get_page_api(request: Request, page_id: int):
|
|
"""API: get a single page."""
|
|
with get_conn() as conn:
|
|
page = conn.execute(
|
|
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
if not page:
|
|
raise HTTPException(status_code=404, detail="Page not found")
|
|
# v6.0.0: granular collection/page permissions.
|
|
_require_view(page["collection_id"], _session_user(request))
|
|
return dict(page)
|
|
|
|
|
|
|
|
|
|
@router.get("/pages/{page_id}/open/api")
|
|
def open_row_page_api(request: Request, page_id: int):
|
|
"""v6.5.0 — content page of a database row (lazy-created).
|
|
|
|
Any DB view (table/board/gallery/list/calendar) opens a row through
|
|
this endpoint: it returns the shadow ``pages`` id whose full page
|
|
editor carries the row's block content (synced blocks included).
|
|
"""
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT collection_id FROM collection_pages WHERE id=?",
|
|
(page_id,),
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Page not found")
|
|
coll_id = row["collection_id"]
|
|
# v6.0.0: granular collection permissions (same gate as the row itself).
|
|
_require_view(coll_id, _session_user(request))
|
|
from app.services.row_pages import ensure_row_page
|
|
try:
|
|
content_page_id = ensure_row_page(page_id)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="Page not found") from None
|
|
return {"page_id": content_page_id, "row_id": page_id}
|
|
|
|
|
|
|
|
|
|
@router.put("/pages/{page_id}/api")
|
|
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
|
"""API: update a page's properties."""
|
|
|
|
with get_conn() as conn:
|
|
existing = conn.execute(
|
|
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Page not found")
|
|
# v6.0.0: granular collection/page permissions.
|
|
_require_edit(existing["collection_id"], _session_user(request))
|
|
|
|
title = body.get("title", existing["title"])
|
|
icon = body.get("icon", existing["icon"])
|
|
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
|
|
position = body.get("position", existing["position"])
|
|
parent_id = body.get("parent_id", existing["parent_id"])
|
|
|
|
try:
|
|
stored = json.loads(existing["property_values_json"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
stored = {}
|
|
|
|
if "properties" in body:
|
|
# Partial PATCH semantics: merge submitted values over stored ones.
|
|
props = dict(stored)
|
|
props.update(body["properties"])
|
|
else:
|
|
props = stored
|
|
|
|
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
|
|
_validate_meta_keys(conn, existing["collection_id"], props)
|
|
apply_auto_properties(
|
|
_collection_properties(conn, existing["collection_id"]),
|
|
props,
|
|
_current_user(request),
|
|
is_create=False,
|
|
)
|
|
|
|
property_values = json.dumps(props)
|
|
|
|
conn.execute(
|
|
"""UPDATE collection_pages
|
|
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
|
|
updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=?""",
|
|
(title, icon, cover_url, position, parent_id, property_values, page_id),
|
|
)
|
|
# v6.5.0: keep the row's content page title in sync (row → page).
|
|
from app.services.row_pages import sync_row_title_to_page
|
|
sync_row_title_to_page(conn, page_id)
|
|
conn.commit()
|
|
|
|
run_event_sync(fire_event("page.updated", {
|
|
"page_id": page_id,
|
|
"collection_id": existing["collection_id"],
|
|
"title": title,
|
|
"icon": icon,
|
|
"properties": props,
|
|
}))
|
|
run_event_sync(fire_event("collection.page.updated", {
|
|
"page_id": page_id,
|
|
"collection_id": existing["collection_id"],
|
|
"title": title,
|
|
}))
|
|
# Notify newly assigned people (person properties) — v5.8.0.
|
|
from app.services.notifications import notify_assignment
|
|
user = _current_user(request)
|
|
notify_assignment(existing["collection_id"], page_id, title,
|
|
stored, props, user.get("id"))
|
|
return {"id": page_id, "status": "updated"}
|
|
|
|
|
|
|
|
|
|
@router.delete("/pages/{page_id}/api")
|
|
def delete_page_api(request: Request, page_id: int):
|
|
"""API: delete a page from its collection."""
|
|
with get_conn() as conn:
|
|
existing = conn.execute(
|
|
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Page not found")
|
|
# v6.0.0: granular collection/page permissions.
|
|
_require_edit(existing["collection_id"], _session_user(request))
|
|
|
|
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
|
conn.commit()
|
|
|
|
run_event_sync(fire_event("page.deleted", {
|
|
"page_id": page_id,
|
|
"collection_id": existing["collection_id"],
|
|
"title": existing["title"],
|
|
}))
|
|
run_event_sync(fire_event("collection.page.deleted", {
|
|
"page_id": page_id,
|
|
"collection_id": existing["collection_id"],
|
|
}))
|
|
return {"id": page_id, "status": "deleted"}
|
|
|
|
|
|
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|