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