"""FlowDeck — Public API v2 : properties. Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency). """ from __future__ import annotations import json import logging from fastapi import APIRouter, Body, Header, HTTPException, Request from app.db import get_conn from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers audit_log, check_idempotency, check_v2_rate_limit, get_bearer_user, has_scope, paginate_headers, parse_pagination, require_scope, row_to_dict, store_idempotency, to_iso8601, validate_scopes_input, ) from ._common import _v2_rate_check logger = logging.getLogger(__name__) router = APIRouter(tags=["api-v2"]) @router.post("/collections/{collection_id}/properties") def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) name = (body.get("name") or "").strip() if not name: raise HTTPException(400, "name is required") ptype = body.get("prop_type") or body.get("type") or "text" with get_conn() as conn: if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone(): raise HTTPException(404, "Collection not found") max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchone()[0] try: cur = conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, number_format, position, required, visible_in_views, validation_json, group_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (collection_id, name, ptype, json.dumps(body.get("options") or []), body.get("number_format") or "number", max_pos, int(bool(body.get("required"))), int(bool(body.get("visible_in_views", True))), json.dumps(body.get("validation") or {}), (body.get("group_name") or "").strip())) conn.commit() pid = cur.lastrowid except Exception as e: raise HTTPException(409, f"Property exists: {e}") from None audit_log(user, "property.create", "property", pid, name, request) return {"id": pid, "name": name, "prop_type": ptype, "status": "created"} @router.patch("/properties/{prop_id}") def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone() if not row: raise HTTPException(404, "Property not found") name = body.get("name", row["name"]) opts = json.dumps(body.get("options", json.loads(row["options_json"] or "[]"))) nf = body.get("number_format", row["number_format"]) req = int(bool(body.get("required", row["required"]))) vis = int(bool(body.get("visible_in_views", row["visible_in_views"]))) vj = json.dumps(body.get("validation", json.loads(row["validation_json"] or "{}"))) if "validation" in body else (row["validation_json"] if "validation_json" in row.keys() else "{}") grp = body.get("group_name", row["group_name"] if "group_name" in row.keys() else "") conn.execute("UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?, group_name=? WHERE id=?", (name, opts, nf, req, vis, vj, grp, prop_id)) conn.commit() audit_log(user, "property.update", "property", prop_id, "", request) return {"id": prop_id, "status": "updated"} @router.delete("/properties/{prop_id}") def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone(): raise HTTPException(404, "Property not found") conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,)) conn.commit() audit_log(user, "property.delete", "property", prop_id, "", request) return {"id": prop_id, "status": "deleted"} @router.post("/properties/{prop_id}/relation") def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) related_id = body.get("related_collection_id") reverse = (body.get("reverse_name") or "").strip() if not related_id: raise HTTPException(400, "related_collection_id required") with get_conn() as conn: row = conn.execute("SELECT collection_id FROM collection_properties WHERE id=?", (prop_id,)).fetchone() if not row: raise HTTPException(404, "Property not found") conn.execute("UPDATE collection_properties SET prop_type='relation', related_collection_id=?, reverse_name=? WHERE id=?", (related_id, reverse, prop_id)) if reverse: max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (related_id,)).fetchone()[0] try: conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos)) except Exception: logger.exception("create_relation_v2") conn.commit() return {"id": prop_id, "status": "updated"} @router.post("/properties/evaluate-formula") def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) expr = body.get("expression") or body.get("formula") if not expr: raise HTTPException(400, "expression required") ctx = body.get("context") or {} try: from app.services.formula_engine import FormulaEngine res = FormulaEngine().evaluate(expr, ctx) except Exception as e: raise HTTPException(400, f"Formula error: {e}") from None return {"result": res, "expression": expr} @router.post("/properties/compute-rollup") def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"): if k not in body: raise HTTPException(400, f"{k} required") try: from app.services.rollup_engine import RollupEngine res = RollupEngine().compute(body["collection_id"], body["relation_property_id"], body["target_property_id"], body["page_id"], body.get("function", "count")) except Exception as e: raise HTTPException(400, f"Rollup error: {e}") from None return {"result": res} @router.get("/collections/{collection_id}/views") def list_views_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) with get_conn() as conn: rows = conn.execute("SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall() return {"views": [row_to_dict(r) for r in rows]}