feat(templates): refonte complète des templates façon Notion + vues/agents-skills
Templates (v7.71.x) : - registre unifié \ emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler) - sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document - 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée - récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates - correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title - tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8) Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
This commit is contained in:
@@ -48,7 +48,18 @@ def update_view_config(request: Request, view_id: int, body: dict = Body(default
|
||||
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"):
|
||||
"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]
|
||||
|
||||
@@ -184,4 +195,321 @@ def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default
|
||||
"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 ──
|
||||
|
||||
Reference in New Issue
Block a user