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
198 lines
7.4 KiB
Python
198 lines
7.4 KiB
Python
"""FlowDeck — Collections : meta.
|
|
|
|
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, HTTPException, Request
|
|
|
|
from app.db import get_conn
|
|
from app.services.permission_manager import PermissionManager
|
|
from app.services.recurrence import (
|
|
RECURRENCE_KEY,
|
|
expand_rule,
|
|
parse_date,
|
|
)
|
|
|
|
from ._common import _collection_properties, _current_user, _require_view, _session_user
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["collections"], prefix="/db")
|
|
|
|
|
|
|
|
|
|
# ── Collection Properties (v1.4.0) ──
|
|
|
|
|
|
@router.get("/property-types/api")
|
|
def list_property_types_api(request: Request):
|
|
"""API: list all available property types."""
|
|
from app.services.property_types import PROPERTY_TYPES
|
|
return {"types": PROPERTY_TYPES}
|
|
|
|
|
|
|
|
|
|
@router.get("/{collection_id}/properties/api")
|
|
def list_properties_api(request: Request, collection_id: int):
|
|
"""API: list all properties visible to the current user."""
|
|
user = _session_user(request)
|
|
_require_view(collection_id, user)
|
|
with get_conn() as conn:
|
|
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
|
if not coll:
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
rows = conn.execute(
|
|
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
props = [dict(r) for r in rows]
|
|
# v6.0.0: property-level visibility — owners/editors see everything, other
|
|
# users only the properties explicitly granted or left open.
|
|
if user:
|
|
pm = PermissionManager(user["id"])
|
|
visible = pm.get_visible_properties(collection_id)
|
|
props = [p for p in props if p["id"] in visible]
|
|
return {"properties": props}
|
|
|
|
|
|
|
|
|
|
@router.get("/{collection_id}/members/api")
|
|
def list_collection_members_api(request: Request, collection_id: int):
|
|
"""API: list workspace members available for a ``person`` property.
|
|
|
|
Resolves the collection's workspace and returns its members (falling back to
|
|
every active user for standalone databases without a workspace).
|
|
"""
|
|
with get_conn() as conn:
|
|
coll = conn.execute(
|
|
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
|
).fetchone()
|
|
if not coll:
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
|
|
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
|
|
if ws_id:
|
|
rows = conn.execute(
|
|
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
|
|
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
|
|
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
|
|
(ws_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = []
|
|
if not rows:
|
|
rows = conn.execute(
|
|
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
|
|
FROM users WHERE is_active=1 ORDER BY full_name, login"""
|
|
).fetchall()
|
|
|
|
return {"members": [dict(r) for r in rows]}
|
|
|
|
|
|
|
|
|
|
@router.get("/{collection_id}/calendar/api")
|
|
def collection_calendar_api(request: Request, collection_id: int,
|
|
start: str = "", end: str = "",
|
|
date_property: str = ""):
|
|
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
|
|
|
Returns every occurrence (recurrence-aware, virtual — never persisted)
|
|
of the rows in the collection whose ``date_property`` falls inside the
|
|
inclusive window. Rows without a rule yield their base date.
|
|
"""
|
|
user = _current_user(request)
|
|
s = parse_date(start)
|
|
e = parse_date(end)
|
|
if s is None or e is None or s > e:
|
|
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
|
|
if (e - s).days > 370:
|
|
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
|
|
|
|
with get_conn() as conn:
|
|
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
|
if not coll:
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
|
|
props = _collection_properties(conn, collection_id)
|
|
date_props = [p for p in props if p["prop_type"] == "date"]
|
|
target = None
|
|
if date_property:
|
|
target = next((p for p in date_props
|
|
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
|
|
if target is None:
|
|
raise HTTPException(status_code=400, detail="Unknown date property")
|
|
elif date_props:
|
|
target = date_props[0]
|
|
if target is None:
|
|
return {"events": [], "timezone": "", "property": None}
|
|
|
|
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
|
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
|
|
|
|
rows = conn.execute(
|
|
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
|
|
(collection_id,),
|
|
).fetchall()
|
|
|
|
pid = str(target["id"])
|
|
events: list[dict] = []
|
|
for r in rows:
|
|
try:
|
|
pv = json.loads(r["property_values_json"] or "{}")
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
base_value = pv.get(pid)
|
|
if base_value is None:
|
|
base_value = pv.get(target["name"])
|
|
if parse_date(base_value) is None:
|
|
continue
|
|
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
|
|
rule = rec_all.get(pid) or rec_all.get(target["name"])
|
|
row_tz = user_tz
|
|
if isinstance(rule, dict) and rule.get("timezone"):
|
|
row_tz = rule["timezone"]
|
|
tzmap = pv.get("__timezone__")
|
|
if isinstance(tzmap, dict):
|
|
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
|
|
if ev_tz:
|
|
row_tz = str(ev_tz)
|
|
if rule:
|
|
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
|
|
else:
|
|
d = parse_date(base_value)
|
|
dates = [d.isoformat()] if d and s <= d <= e else []
|
|
for iso in dates:
|
|
events.append({
|
|
"date": iso,
|
|
"page_id": r["id"],
|
|
"title": r["title"],
|
|
"icon": r["icon"],
|
|
"recurring": bool(rule),
|
|
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
|
|
"timezone": row_tz,
|
|
})
|
|
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
|
|
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
|
|
|
|
|
|
|
|
|
|
@router.get("/timezones/api")
|
|
def timezones_api(request: Request):
|
|
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
|
user = _current_user(request)
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
|
from app.services.recurrence import common_timezones
|
|
return {
|
|
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
|
|
"zones": common_timezones(),
|
|
}
|