Files
flowdeck/app/routers/collections/meta.py
T
bruno adf56a2dd8
FlowDeck CI / docker (push) Successful in 1m54s
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m28s
refactor: A28 lot 3 — collections.py (2 622 L) → package 13 fichiers (v7.31.0)
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
2026-10-02 08:16:03 -04:00

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(),
}