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
221 lines
7.5 KiB
Python
221 lines
7.5 KiB
Python
"""FlowDeck — Collections : helpers partagés (A28).
|
|
|
|
Les 8 helpers de tête de l'ancien collections.py (auth, permissions,
|
|
validation) — ré-exportés par le package.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.services.permission_manager import PermissionManager
|
|
from app.services.property_types import (
|
|
AUTO_TYPES,
|
|
validate_property_rule,
|
|
)
|
|
from app.services.recurrence import (
|
|
RECURRENCE_KEY,
|
|
is_valid_timezone,
|
|
validate_rule,
|
|
)
|
|
from app.services.reminders import REMINDER_KEY, parse_lead
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["collections"], prefix="/db")
|
|
|
|
|
|
|
|
def _current_user(request: Request) -> dict:
|
|
"""Resolve the session user, falling back to the local admin (single-user)."""
|
|
s = request.cookies.get("flowdeck_session", "")
|
|
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _session_user(request: Request) -> dict | None:
|
|
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
|
|
s = request.cookies.get("flowdeck_session", "")
|
|
user = SessionManager.decode_session(s)
|
|
return user if user and user.get("id") else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_view(collection_id: int, user: dict | None) -> None:
|
|
"""Raise 404 when the user may not view the collection (404 hides it).
|
|
|
|
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
|
|
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
|
|
"""
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
pm = PermissionManager(user["id"])
|
|
if not pm.can_view_collection(collection_id):
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_edit(collection_id: int, user: dict | None) -> None:
|
|
"""Raise 401/403 when the user may not edit pages in the collection."""
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
pm = PermissionManager(user["id"])
|
|
if not pm.can_edit_collection(collection_id):
|
|
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _collection_properties(conn, collection_id: int) -> list[dict]:
|
|
return [
|
|
dict(r) for r in conn.execute(
|
|
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_template(conn, template_name: str) -> dict | None:
|
|
"""Resolve a database template by name (from the seeded/built-in set)."""
|
|
if not template_name:
|
|
return None
|
|
row = conn.execute(
|
|
"SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
|
|
(template_name,),
|
|
).fetchone()
|
|
if row:
|
|
return dict(row)
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
|
|
"""Validate submitted property values against the collection's schema.
|
|
|
|
Raises ``HTTPException(400)`` with a user-friendly message on the first
|
|
failure (type, required, unique, min/max).
|
|
"""
|
|
props = conn.execute(
|
|
"SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
|
|
).fetchall()
|
|
|
|
for prop in props:
|
|
ptype = prop["prop_type"]
|
|
if ptype == "title" or ptype in AUTO_TYPES:
|
|
continue
|
|
pid = prop["id"]
|
|
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
|
|
value = properties.get(str(pid))
|
|
if value is None:
|
|
value = properties.get(prop["name"])
|
|
validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
|
|
|
|
existing_values = None
|
|
try:
|
|
import json as _json
|
|
vcfg = _json.loads(validation) if validation else {}
|
|
except Exception:
|
|
vcfg = {}
|
|
if vcfg.get("unique"):
|
|
rows = conn.execute(
|
|
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
|
|
(collection_id,),
|
|
).fetchall()
|
|
existing_values = []
|
|
for r in rows:
|
|
if exclude_page_id is not None and r["id"] == exclude_page_id:
|
|
continue
|
|
try:
|
|
pv = _json.loads(r["property_values_json"] or "{}")
|
|
except Exception:
|
|
pv = {}
|
|
existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
|
|
|
|
ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
|
|
if not ok:
|
|
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
|
|
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
|
|
alongside real property values. Raises HTTPException(400) on bad shape.
|
|
|
|
Each meta key maps a date-property id to a rule/reminder object. We verify
|
|
the target is actually a date property and the payload parses.
|
|
"""
|
|
date_ids = {
|
|
str(r["id"]) for r in conn.execute(
|
|
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
|
|
(collection_id,),
|
|
).fetchall()
|
|
}
|
|
|
|
rec = properties.get(RECURRENCE_KEY)
|
|
if rec not in (None, {}):
|
|
if not isinstance(rec, dict):
|
|
raise HTTPException(status_code=400, detail="Recurrence must be an object")
|
|
for prop_id, rule in rec.items():
|
|
if rule is None:
|
|
continue
|
|
if str(prop_id) not in date_ids:
|
|
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
|
|
ok, msg = validate_rule(rule)
|
|
if not ok:
|
|
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
|
|
|
|
rem = properties.get(REMINDER_KEY)
|
|
if rem not in (None, {}):
|
|
if not isinstance(rem, dict):
|
|
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
|
for prop_id, reminder in rem.items():
|
|
if reminder is None:
|
|
continue
|
|
if str(prop_id) not in date_ids:
|
|
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
|
|
if not isinstance(reminder, dict):
|
|
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
|
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
|
|
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
|
|
if parse_lead(reminder) is None and reminder.get("unit") != "none":
|
|
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
|
|
|
|
from app.services.recurrence import TIMEZONE_KEY
|
|
tzmap = properties.get(TIMEZONE_KEY)
|
|
if tzmap not in (None, {}):
|
|
if not isinstance(tzmap, dict):
|
|
raise HTTPException(status_code=400, detail="Timezone map must be an object")
|
|
for prop_id, value in tzmap.items():
|
|
if str(prop_id) not in date_ids:
|
|
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
|
|
if value and not is_valid_timezone(str(value)):
|
|
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
|
|
|
|
|
|
# ── API: List & Create (no path params) ──
|
|
|
|
|
|
|