Files
flowdeck/app/routers/collections/dashboard_views.py
T
bruno adf56a2dd8
FlowDeck CI / docker (push) Successful in 1m54s
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m28s
refactor: A28 lot 3 — collections.py (2 622 L) → package 13 fichiers (v7.31.0)
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
2026-10-02 08:16:03 -04:00

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