"""FlowDeck — Collections : dashboard_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 html as _htmlmod import json import logging from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse from app.db import get_conn from ._common import _require_view, _session_user from ._renderers import CHART_MAX_GROUPS, _base_html, _render_chart, _render_view logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") @router.post("/{collection_id}/pages/{page_id}/auto-shift/api") def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: auto-shift dates based on blocking dependencies.""" from datetime import date as dt_date from datetime import timedelta skip_weekends = body.get("skip_weekends", False) with get_conn() as conn: page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone() if not page: raise HTTPException(status_code=404, detail="Page not found") # Get all blocking dependencies deps = conn.execute( "SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'", (page_id,), ).fetchall() shifted = False new_start = None for dep in deps: dep_page = conn.execute( "SELECT title, property_values_json FROM collection_pages WHERE id=?", (dep["dependency_id"],), ).fetchone() if not dep_page: continue dep_props = json.loads(dep_page["property_values_json"]) # Find the latest end date among blockers for v in dep_props.values(): if isinstance(v, str) and v.startswith("20"): end_date = v.split("...")[-1].split("→")[-1].strip()[:10] try: ed = dt_date.fromisoformat(end_date) if new_start is None or ed >= new_start: new_start = ed + timedelta(days=1) shifted = True except ValueError: continue if not shifted: return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"} # Skip weekends if requested if skip_weekends and new_start: while new_start.weekday() >= 5: # 5=Sat, 6=Sun new_start = new_start + timedelta(days=1) # Update the page's date properties props = json.loads(page["property_values_json"]) for k, v in list(props.items()): if isinstance(v, str) and v.startswith("20"): old_parts = v.split("...") old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0] try: old_start_d = dt_date.fromisoformat(old_parts[0][:10]) old_end_d = dt_date.fromisoformat(old_end[:10]) duration = (old_end_d - old_start_d).days new_end = new_start + timedelta(days=max(duration, 0)) props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}" except ValueError: props[k] = new_start.isoformat() break conn.execute( "UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (json.dumps(props), page_id), ) conn.commit() return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends} # ── {collection_id} wildcards (LAST — catches everything else) ── # ── {collection_id} wildcards (LAST — catches everything else) ── @router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse) def view_dashboard(request: Request, collection_id: int, dashboard_id: int): """v7.3.0: render a collection dashboard grid (multi-DB widgets). Widgets live in ``collection_dashboards.layout_json`` as ``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?, chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may point at *any* database (the dashboard's own collection is the default), which is what "dashboards multi-DB" means. """ uid = _session_user(request) _require_view(collection_id, uid) with get_conn() as conn: dash = conn.execute( "SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (dashboard_id, collection_id)).fetchone() if not dash: raise HTTPException(404, "Dashboard not found") layout = json.loads(dash["layout_json"] or "{}") columns = max(1, int(layout.get("columns", 1) or 1)) widgets = layout.get("widgets", []) or [] if not isinstance(widgets, list): widgets = [] rendered = [] for w in widgets[:40]: if not isinstance(w, dict): continue wc = int(w.get("collection_id") or 0) or collection_id with get_conn() as conn: coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone() if not coll: continue try: _require_view(wc, uid) except HTTPException: continue # restricted database → widget skipped, not rendered with get_conn() as conn: wpages = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?", (wc, CHART_MAX_GROUPS)).fetchall() wconfig = {k: v for k, v in w.items() if k in ("chart_type", "chart_property", "aggregate", "title")} view_type = w.get("view_type") or "chart" if view_type == "chart": body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig) else: body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig) width = int(w.get("width") or 0) span = f"grid-column: span {width};" if width and width > 0 else "" rendered.append(f'
') grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));" body = f"""Empty dashboard — add widgets to layout_json.