"""FlowDeck — Collections : views. 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, Body, HTTPException, Request from app.db import get_conn from app.services.automations import fire_event, run_event_sync from ._common import _current_user logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") # ── v1.7.0 View Management ── @router.get("/views/{view_id}/api") def get_view_api(request: Request, view_id: int): """API: get a single view config.""" with get_conn() as conn: row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not row: raise HTTPException(status_code=404, detail="View not found") return dict(row) @router.put("/views/{view_id}/config") def update_view_config(request: Request, view_id: int, body: dict = Body(default={})): """API: update view configuration (group_by, card_size, visible_properties, etc.).""" with get_conn() as conn: existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not existing: raise HTTPException(status_code=404, detail="View not found") config = json.loads(existing["config_json"]) for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "filter_conjunction", "date_property", "date_range_property", "property_groups", "view_type", # Vues Notion (Chart/Timeline/Feed/Map/Dashboard/Form) — §10.4 "version", "chart_type", "chart_property", "measure", "omit_zero", "cumulative", "hidden_groups", "group_order", "style", "order", "aggregate", "title", "center_value", "palette", "height", "data_labels", "legend", "grid", "smooth", "gradient", "color_by_value", "separate_end_property", "scale", "show_table", "table_properties", "load_limit", "show_dependencies", "sub_items", "row_order", "show_view_count", "excerpt", "place_property", "cluster", "default_style", "global_filters", "row_heights", "excluded_widget_ids", "sharing", "submit_screen", "notify_email", "description", "form_view_id"): if key in body: config[key] = body[key] new_type = body.get("view_type") or existing["view_type"] conn.execute( "UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, " "updated_at=CURRENT_TIMESTAMP WHERE id=?", (json.dumps(config), body.get("name"), new_type, view_id), ) conn.commit() return {"id": view_id, "status": "updated", "config": config, "view_type": new_type} @router.post("/{collection_id}/views/save-as") def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})): """API: save current view state as a new named view.""" name = body.get("name", "New View") config = body.get("config", {}) user = _current_user(request) user_id = user.get("id") if user else None 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") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (collection_id,), ).fetchone()[0] view_type = body.get("view_type", "table") cur = conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)""", (collection_id, name, view_type, json.dumps(config), max_pos, user_id), ) conn.commit() new_view_id = cur.lastrowid run_event_sync(fire_event("collection.view.created", { "view_id": new_view_id, "collection_id": collection_id, "name": name, "view_type": view_type, })) return {"id": new_view_id, "name": name, "view_type": view_type, "config_json": json.dumps(config), "created_by": user_id, "status": "saved"} @router.get("/{collection_id}/views/api") def list_views_api(request: Request, collection_id: int): """API: list views for a collection visible to the current user. Shared/legacy views (``created_by IS NULL``) are visible to everyone; personal views (``created_by = user``) only to their owner. """ user = _current_user(request) user_id = user.get("id") if user else None with get_conn() as conn: if user_id is not None: rows = conn.execute( """SELECT * FROM collection_views WHERE collection_id=? AND (created_by IS NULL OR created_by=?) ORDER BY position""", (collection_id, user_id), ).fetchall() else: rows = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position", (collection_id,), ).fetchall() return {"views": [dict(r) for r in rows]} @router.delete("/views/{view_id}/api") def delete_view_api(request: Request, view_id: int): """API: delete a saved view.""" with get_conn() as conn: existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not existing: raise HTTPException(status_code=404, detail="View not found") conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,)) conn.commit() return {"id": view_id, "status": "deleted"} @router.post("/views/{view_id}/duplicate") def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})): """API: duplicate a view (config + type), owned by the current user.""" with get_conn() as conn: existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not existing: raise HTTPException(status_code=404, detail="View not found") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (existing["collection_id"],), ).fetchone()[0] user = _current_user(request) user_id = user.get("id") if user else None name = body.get("name") or (existing["name"] + " copy") cur = conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)""", (existing["collection_id"], name, existing["view_type"], existing["config_json"], max_pos, user_id), ) conn.commit() dup_view_id = cur.lastrowid run_event_sync(fire_event("collection.view.created", { "view_id": dup_view_id, "collection_id": existing["collection_id"], "name": name, "view_type": existing["view_type"], })) return {"id": dup_view_id, "name": name, "view_type": existing["view_type"], "status": "duplicated"} # ── Vues Notion : donnees de vues, agregation, geocodage, dashboard, form ── def _load_view_collection(conn, collection_id: int, view_id: int): view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not view or int(view["collection_id"]) != int(collection_id): raise HTTPException(status_code=404, detail="View not found") try: config = json.loads(view["config_json"] or "{}") except (json.JSONDecodeError, TypeError): config = {} props = [dict(r) for r in conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()] pages = [dict(r) for r in conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()] return dict(view), config, props, pages @router.get("/{collection_id}/views/{view_id}/rows") def view_rows_api(request: Request, collection_id: int, view_id: int, cursor: str | None = None, limit: int = 50, window: str | None = None): """Lignes filtrees/triees/projetees d'une vue (Feed, Timeline, Map...).""" from app.services.view_query import query_view_rows with get_conn() as conn: view, config, props, pages = _load_view_collection(conn, collection_id, view_id) if window: # Fenetre temporelle Timeline : "2026-01-01..2026-12-31" — filtree cote client. config = dict(config) limit = max(1, min(int(limit or 50), 1000)) rows, next_cursor = query_view_rows(pages, props, config, limit=limit, cursor=cursor) undated = 0 date_prop = (config.get("date_property") or config.get("date_range_property")) if date_prop and (view.get("view_type") == "timeline"): from app.services.view_query import prop_value, text_of by_name = {p["name"]: p for p in props} by_id = {str(p["id"]): p for p in props} dp = by_name.get(date_prop) or by_id.get(str(date_prop)) if dp: for p in pages: if not text_of(dp, prop_value(p, dp)): undated += 1 return {"rows": rows, "next_cursor": next_cursor, "total": len(pages), "undated": undated, "view_type": view.get("view_type")} @router.post("/{collection_id}/views/{view_id}/aggregate") def view_aggregate_api(request: Request, collection_id: int, view_id: int, body: dict = Body(default={})): """Agregat groupe (Chart, KPI, widgets, drilldown) — §8.3.""" from app.services.view_aggregate import aggregate with get_conn() as conn: view, config, props, pages = _load_view_collection(conn, collection_id, view_id) spec = { "group_by": body.get("group_by", config.get("group_by")), "sub_group_by": body.get("sub_group_by", config.get("sub_group_by")), "measure": body.get("measure", config.get("measure", {"kind": "count"})), "cumulative": body.get("cumulative", config.get("cumulative", False)), "omit_zero": body.get("omit_zero", config.get("omit_zero", True)), "hidden_groups": body.get("hidden_groups", config.get("hidden_groups", [])), "order": body.get("order", config.get("order", config.get("group_order", "group_order"))), "filters": (config.get("filters") or []) + (body.get("filters") or []), "filter_conjunction": config.get("filter_conjunction", "and"), "sorts": config.get("sorts"), } # Compat : ancienne config SSR {chart_property, aggregate} if not spec["group_by"] and (config.get("chart_property") or body.get("chart_property")): spec["group_by"] = config.get("chart_property") or body.get("chart_property") if isinstance(spec["measure"], str): spec["measure"] = {"kind": spec["measure"]} if isinstance(spec["order"], str): spec["order"] = {"by": spec["order"], "direction": "asc"} return aggregate(pages, props, spec) @router.get("/{collection_id}/views/{view_id}/drilldown") def view_drilldown_api(request: Request, collection_id: int, view_id: int, group: str = "", sub: str | None = None, limit: int = 100): """Tableau restreint des lignes d'un segment de Chart (§8.4).""" from app.services.view_query import query_view_rows with get_conn() as conn: view, config, props, pages = _load_view_collection(conn, collection_id, view_id) group_by = config.get("group_by") or config.get("chart_property") extra = [{"property": group_by, "operator": "is", "value": group}] if group_by and group else [] if sub and config.get("sub_group_by"): extra.append({"property": config["sub_group_by"], "operator": "is", "value": sub}) rows, _ = query_view_rows(pages, props, config, extra_filters=extra, limit=max(1, min(int(limit or 100), 500))) return {"rows": rows, "group": group, "sub": sub, "count": len(rows)} @router.get("/geocode/search") def geocode_search_api(request: Request, q: str = "", limit: int = 5): """Autocompletion d'adresses (editeur place). Provider none -> [].""" from app.services import geocoding as _geo with get_conn() as conn: return {"suggestions": _geo.search(conn, q, limit=min(int(limit or 5), 10))} @router.post("/geocode/resolve") def geocode_resolve_api(request: Request, body: dict = Body(default={})): """Adresse -> coordonnees (cache migration 45).""" from app.services import geocoding as _geo with get_conn() as conn: result = _geo.resolve(conn, body.get("q") or body.get("address") or "") if not result: raise HTTPException(status_code=404, detail="Place not resolved") return result @router.get("/{collection_id}/views/{view_id}/widgets") def dashboard_widgets_get(request: Request, collection_id: int, view_id: int): with get_conn() as conn: view, config, _, _ = _load_view_collection(conn, collection_id, view_id) try: rows = conn.execute( "SELECT * FROM dashboard_widgets WHERE view_id=? ORDER BY row_index, position", (view_id,)).fetchall() widgets = [dict(r) for r in rows] except Exception: widgets = json.loads(config.get("widgets_json") or "[]") if isinstance(config.get("widgets_json"), str) else (config.get("widgets_json") or []) return {"widgets": widgets, "config": config} @router.put("/{collection_id}/views/{view_id}/widgets") def dashboard_widgets_put(request: Request, collection_id: int, view_id: int, body: dict = Body(default={})): from app.routers.collections._common import _require_edit from app.routers.collections._common import _session_user as _su _require_edit(collection_id, _su(request)) widgets = body.get("widgets") or [] if len(widgets) > 12: raise HTTPException(status_code=400, detail="12 widgets maximum") with get_conn() as conn: view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not view or int(view["collection_id"]) != int(collection_id): raise HTTPException(status_code=404, detail="View not found") try: conn.execute("DELETE FROM dashboard_widgets WHERE view_id=?", (view_id,)) for i, w in enumerate(widgets): conn.execute( """INSERT INTO dashboard_widgets (view_id, position, row_index, col_span, height_units, source_collection_id, source_view_id, inline_config_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (view_id, i, int(w.get("row_index", i // 4)), int(w.get("col_span", 1) or 1), int(w.get("height_units", 2) or 2), w.get("source_collection_id"), w.get("source_view_id"), json.dumps(w.get("inline_config") or w.get("inline_config_json") or {})), ) conn.commit() except HTTPException: raise except Exception: # Repli : stockage JSON dans le config (pre-migration 46). config = json.loads(view["config_json"] or "{}") config["widgets_json"] = widgets config["global_filters"] = body.get("global_filters", config.get("global_filters", [])) if body.get("global_filters") is not None: config["global_filters"] = body["global_filters"] conn.execute("UPDATE collection_views SET config_json=? WHERE id=?", (json.dumps(config), view_id)) conn.commit() return {"widgets": widgets, "fallback": "config_json"} if body.get("global_filters") is not None: config = json.loads(view["config_json"] or "{}") config["global_filters"] = body["global_filters"] conn.execute("UPDATE collection_views SET config_json=? WHERE id=?", (json.dumps(config), view_id)) conn.commit() try: run_event_sync(fire_event("dashboard.layout_updated", {"view_id": view_id})) except Exception: pass return {"widgets": widgets, "status": "saved"} @router.get("/{collection_id}/views/{view_id}/widgets/{widget_id}/data") def dashboard_widget_data(request: Request, collection_id: int, view_id: int, widget_id: int): from app.services.view_aggregate import aggregate from app.services.view_query import query_view_rows with get_conn() as conn: try: w = conn.execute("SELECT * FROM dashboard_widgets WHERE id=? AND view_id=?", (widget_id, view_id)).fetchone() widget = dict(w) if w else None except Exception: widget = None if widget is None: raise HTTPException(status_code=404, detail="Widget not found") src_cid = widget.get("source_collection_id") or collection_id props = [dict(r) for r in conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (src_cid,)).fetchall()] pages = [dict(r) for r in conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (src_cid,)).fetchall()] src_view = None if widget.get("source_view_id"): src_view = conn.execute("SELECT * FROM collection_views WHERE id=?", (widget["source_view_id"],)).fetchone() try: inline = json.loads(widget.get("inline_config_json") or "{}") except (json.JSONDecodeError, TypeError): inline = {} cfg = dict(inline) if src_view: try: base = json.loads(src_view["config_json"] or "{}") except (json.JSONDecodeError, TypeError): base = {} cfg = {**base, **inline} vtype = (cfg.get("view_type") or src_view["view_type"] if src_view else cfg.get("view_type", "table")) if (cfg.get("group_by") or cfg.get("chart_type")) and vtype in ("chart", "dashboard", None): return aggregate(pages, props, { "group_by": cfg.get("group_by"), "sub_group_by": cfg.get("sub_group_by"), "measure": cfg.get("measure", {"kind": "count"}), "omit_zero": cfg.get("omit_zero", True), "hidden_groups": cfg.get("hidden_groups", []), "order": cfg.get("order", "group_order"), "filters": cfg.get("filters") or [], }) rows, _ = query_view_rows(pages, props, cfg, limit=50) return {"rows": rows, "view_type": vtype or "table"} @router.get("/{collection_id}/views/{view_id}/form") def form_builder_get(request: Request, collection_id: int, view_id: int): from app.services.form_projection import default_questions_for with get_conn() as conn: view, config, props, _ = _load_view_collection(conn, collection_id, view_id) try: qs = [dict(r) for r in conn.execute( "SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()] except Exception: qs = [] if not qs: qs = default_questions_for([{"name": p["name"], "prop_type": p["prop_type"]} for p in props]) return {"config": config, "questions": qs} @router.put("/{collection_id}/views/{view_id}/form") def form_builder_put(request: Request, collection_id: int, view_id: int, body: dict = Body(default={})): from app.routers.collections._common import _require_edit from app.routers.collections._common import _session_user as _su from app.services.form_projection import project_form_config _require_edit(collection_id, _su(request)) questions = body.get("questions") or [] form_cfg = body.get("config") or {} with get_conn() as conn: view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not view or int(view["collection_id"]) != int(collection_id): raise HTTPException(status_code=404, detail="View not found") try: base = json.loads(view["config_json"] or "{}") except (json.JSONDecodeError, TypeError): base = {} base.update({k: v for k, v in form_cfg.items() if k in ("title", "description", "sharing", "submit_screen", "notify_email")}) base["version"] = 2 try: conn.execute("DELETE FROM form_questions WHERE view_id=?", (view_id,)) for i, q in enumerate(questions): conn.execute( """INSERT INTO form_questions (view_id, property_name, position, label_override, sync_label, help_text, required, widget, max_selections, conditional_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (view_id, q.get("property_name"), i, q.get("label_override"), 1 if q.get("sync_label", True) else 0, q.get("help_text", ""), 1 if q.get("required") else 0, q.get("widget", "auto"), q.get("max_selections"), json.dumps(q.get("conditional")) if q.get("conditional") else q.get("conditional_json")), ) stored_qs = [dict(r) for r in conn.execute( "SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()] except Exception: base["form_questions_json"] = questions stored_qs = questions conn.execute("UPDATE collection_views SET config_json=? WHERE id=?", (json.dumps(base), view_id)) conn.commit() return {"questions": stored_qs, "status": "saved", "fallback": "config_json"} projected = project_form_config(base, [{ "property_name": r["property_name"], "position": r["position"], "label_override": r["label_override"], "sync_label": r["sync_label"], "help_text": r["help_text"], "required": r["required"], "widget": r["widget"], "max_selections": r["max_selections"], "conditional_json": r["conditional_json"], } for r in stored_qs]) base["projected_form_config"] = projected # Synchro libelle -> nom de propriete (defaut Notion). for r in stored_qs: if r["sync_label"] and r["label_override"]: conn.execute( "UPDATE collection_properties SET name=? WHERE collection_id=? AND name=?", (r["label_override"], collection_id, r["property_name"])) conn.execute("UPDATE collection_views SET config_json=? WHERE id=?", (json.dumps(base), view_id)) # La projection alimente aussi le pipeline public /f/ existant (meme collection). try: conn.execute("UPDATE collections SET form_config_json=? WHERE id=?", (json.dumps(projected), collection_id)) except Exception: pass conn.commit() return {"questions": stored_qs, "status": "saved", "projected_fields": len(projected.get("fields", []))} @router.post("/{collection_id}/views/{view_id}/form/preview-token") def form_preview_token(request: Request, collection_id: int, view_id: int): """Jeton d'apercu non-soumissible (soumissions refusees cote serveur).""" import secrets return {"preview_token": "preview_" + secrets.token_hex(8), "submittable": False} # ── v1.8.0 Sub-items & Dependencies ──