Découpe par concern de l'ancien app/routers/collections.py (2 622 lignes, 53 endpoints / 52 fonctions) en package `app/routers/collections/` : - 10 modules de routes : crud 337 L (6 r.), properties 322 (8), linked 286 (7), structure 267 (8), dashboard_views 214 (3), meta 197 (5), views 187 (6), pages 184 (4), data_api 122 (2), boards 61 (3) - _common.py (220 L) : 8 helpers auth/permissions/validation - _renderers.py (667 L) : 15 rendus HTML des vues + CHART_MAX_GROUPS - __init__.py : ré-exports connus (_validate_page_properties pour automations ; _chart_values/_chart_aggregate/_fmt_number/_render_chart pour les tests) + __all__ Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - docstring d'origine conservée dans le header copié → F404 (from __future__ après un statement) → slice [1:30] - décorateurs empilés (view_collection ×2) : segment sans def → skip du 2e décorateur (53 endpoints = 52 unités) - CHART_MAX_GROUPS hors détection des helpers (F821) → import ._renderers - test_csp_no_cdn_and_vendor lisait collections.py → balayage du package Reste A28 : board.py 2 101 L (lot 4). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
215 lines
8.8 KiB
Python
215 lines
8.8 KiB
Python
"""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'<div class="dash-widget" style="{span}">{body}</div>')
|
|
|
|
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
|
|
body = f"""
|
|
<style>
|
|
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
|
|
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
|
|
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
|
|
</style>
|
|
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
|
|
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
|
|
"""
|
|
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
|
|
|
|
|
|
|
|
|
|
@router.get("/{collection_id}", response_class=HTMLResponse)
|
|
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
|
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
|
"""Main view — renders collection in the requested view type."""
|
|
# v6.0.0: granular collection permissions — hide restricted collections.
|
|
_require_view(collection_id, _session_user(request))
|
|
with get_conn() as conn:
|
|
collection = conn.execute(
|
|
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
|
).fetchone()
|
|
if not collection:
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
|
|
view = conn.execute(
|
|
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
|
|
(collection_id, view_type),
|
|
).fetchone()
|
|
if not view:
|
|
view = conn.execute(
|
|
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
|
(collection_id,),
|
|
).fetchone()
|
|
|
|
pages = conn.execute(
|
|
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
|
|
collection_dict = dict(collection)
|
|
pages_list = [dict(p) for p in pages]
|
|
config = json.loads(view["config_json"]) if view else {}
|
|
|
|
if "year" in request.query_params:
|
|
config["year"] = int(request.query_params["year"])
|
|
if "month" in request.query_params:
|
|
config["month"] = int(request.query_params["month"])
|
|
|
|
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
|
|
|
|
|
|
# ── View renderers (v1.6.0) ──
|