"""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) ──