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:
@@ -203,3 +203,144 @@ async def import_csv_v2(collection_id: int, request: Request, authorization: str
|
||||
created += 1
|
||||
conn.commit()
|
||||
return {"imported": created, "status": "ok"}
|
||||
|
||||
|
||||
# ── Registre unifié (§10.2) : liste / détail / instantiate / export / import ──
|
||||
# (chemins /fd-templates/* : les /templates/* historiques restent inchangés)
|
||||
|
||||
def _v2_actor(user) -> dict:
|
||||
return {"id": user.get("id"), "login": user.get("login", ""),
|
||||
"full_name": user.get("full_name", ""),
|
||||
"is_admin": bool(user.get("is_admin"))}
|
||||
|
||||
|
||||
@router.get("/fd-templates")
|
||||
def list_fd_templates_v2(request: Request, authorization: str | None = Header(default=None),
|
||||
kind: str | None = None, q: str | None = None):
|
||||
from app.services.fd_templates import TemplateService
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
items = TemplateService.list_templates(conn, _v2_actor(user), kind=kind, query=q)
|
||||
return {"templates": items}
|
||||
|
||||
|
||||
@router.get("/fd-templates/{template_id}")
|
||||
def get_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
|
||||
from app.services.fd_templates import TemplateService, _row_to_dto
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
|
||||
return _row_to_dto(conn, row)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
|
||||
|
||||
@router.post("/fd-templates/{template_id}/instantiate")
|
||||
def instantiate_fd_template_v2(template_id: str, request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
from app.services.fd_templates import TemplateService
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
|
||||
if idem:
|
||||
hit = check_idempotency(request, user["id"])
|
||||
if hit:
|
||||
return hit["data"]
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.instantiate(
|
||||
conn, _v2_actor(user), template_id, body.get("variables") or {},
|
||||
body.get("context") or {}, idempotency_key=idem, run_source="api")
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
audit_log(user, "template.instantiate", "template", template_id, "")
|
||||
if idem:
|
||||
store_idempotency(idem, user["id"], result, 200)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/fd-templates/{template_id}/export")
|
||||
def export_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Export au format manifeste flowdeck-template v1 (annexe B)."""
|
||||
from app.services.fd_templates import TemplateService, _read_holder_blocks, _row_to_dto
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
|
||||
dto = _row_to_dto(conn, row)
|
||||
blocks, _w = _read_holder_blocks(conn, row)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
manifest = {
|
||||
"format": "flowdeck-template", "version": 1, "kind": dto["kind"],
|
||||
"name": dto["name"], "description": dto["description"], "icon": dto["icon"],
|
||||
"category": dto["category"], "variables": dto["variables"],
|
||||
"manifest": dto["manifest"], "content": {"content_format": "blocks", "blocks": blocks},
|
||||
}
|
||||
if dto.get("recurrence"):
|
||||
manifest["recurrence"] = {"rrule": dto["recurrence"].get("rrule"),
|
||||
"timezone": dto["recurrence"].get("timezone", "")}
|
||||
return manifest
|
||||
|
||||
|
||||
@router.post("/fd-templates/import")
|
||||
def import_fd_template_v2(request: Request, authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
"""Import d'un manifeste flowdeck-template v1 (scope personnel, §14)."""
|
||||
from app.services.fd_templates import KINDS, TemplateService
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if body.get("format") != "flowdeck-template":
|
||||
raise HTTPException(400, "format must be flowdeck-template")
|
||||
try:
|
||||
version = int(body.get("version") or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "version invalide")
|
||||
if version > 1:
|
||||
raise HTTPException(400, "version de manifeste non supportée")
|
||||
kind = body.get("kind") or "page"
|
||||
if kind not in KINDS:
|
||||
raise HTTPException(400, "kind inconnu")
|
||||
blocks = ((body.get("content") or {}).get("blocks") or [])
|
||||
if not isinstance(blocks, list) or len(blocks) > 2000:
|
||||
raise HTTPException(400, "content.blocks invalide ou trop volumineux")
|
||||
target = body.get("target") or {}
|
||||
spec = {"kind": kind, "name": (body.get("name") or "Sans titre")[:200],
|
||||
"description": (body.get("description") or "")[:2000],
|
||||
"icon": body.get("icon") or "📄", "category": (body.get("category") or "")[:120],
|
||||
"scope": "personal", "blocks": blocks,
|
||||
"manifest": body.get("manifest") or {}, "variables": body.get("variables") or [],
|
||||
"title_template": (body.get("manifest") or {}).get("title_template", "")}
|
||||
if kind == "row":
|
||||
# La base cible est résolue par nom dans le workspace, jamais par id
|
||||
# externe ; si absente, le template est créé « orphelin » et signalé.
|
||||
cname = target.get("collection_name", "")
|
||||
with get_conn() as conn:
|
||||
actor = _v2_actor(user)
|
||||
row = conn.execute("SELECT id FROM collections WHERE name=?", (cname,)).fetchone() if cname else None
|
||||
if row:
|
||||
spec["target_collection_id"] = row["id"]
|
||||
dto = TemplateService.create_template(conn, actor, spec)
|
||||
conn.commit()
|
||||
return {"id": dto["id"], "status": "imported"}
|
||||
# Orphelin personnel : on choisit la première base comme pis-aller ? Non :
|
||||
# le contrat exige un signalement — on refuse proprement.
|
||||
raise HTTPException(400, "collection cible introuvable : match_by=collection_name")
|
||||
with get_conn() as conn:
|
||||
dto = TemplateService.create_template(conn, _v2_actor(user), spec)
|
||||
conn.commit()
|
||||
audit_log(user, "template.import", "template", dto["id"], "")
|
||||
return {"id": dto["id"], "status": "imported"}
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""FlowDeck — Templates unifiés : gestionnaire SSR + API interne (cookie).
|
||||
|
||||
Cahier des charges : ``docs/architecture-templates-notion-flowdeck.md`` §10.1.
|
||||
Toutes les surfaces (UI, API v2, agent, scheduler) passent par le même
|
||||
``TemplateService`` — jamais d'accès direct aux silos legacy (P4, brief §0).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.fd_templates import TemplateService, ensure_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["fd-templates"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> dict | None:
|
||||
try:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
except Exception:
|
||||
user = None
|
||||
return user
|
||||
|
||||
|
||||
def _require_actor(request: Request) -> dict:
|
||||
user = _actor(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _as_http(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, LookupError):
|
||||
return HTTPException(404, str(exc) or "Template introuvable")
|
||||
if isinstance(exc, PermissionError):
|
||||
return HTTPException(403, str(exc) or "Accès refusé")
|
||||
if isinstance(exc, ValueError):
|
||||
return HTTPException(400, str(exc) or "Requête invalide")
|
||||
logger.exception("templates API")
|
||||
return HTTPException(500, "Erreur interne")
|
||||
|
||||
|
||||
# ═══════════ Page SSR — gestionnaire /templates (§7.2) ═══════════
|
||||
|
||||
@router.get("/templates", response_class=HTMLResponse)
|
||||
def templates_manager_page(request: Request):
|
||||
"""Gestionnaire de templates : liste filtrable + side peek d'aperçu."""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
|
||||
sidebar = board_sidebar(request, "", "")
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ensure_registry(conn)
|
||||
except Exception:
|
||||
logger.exception("ensure_registry /templates")
|
||||
template = ENV.get_template("fd_templates_manager.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
# ═══════════ API interne — lecture ═══════════
|
||||
|
||||
@router.get("/api/templates")
|
||||
def list_templates(
|
||||
request: Request,
|
||||
kind: str | None = Query(default=None),
|
||||
scope: str | None = Query(default=None),
|
||||
target_collection_id: int | None = Query(default=None),
|
||||
q: str | None = Query(default=None),
|
||||
include_archived: bool = Query(default=False),
|
||||
):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
items = TemplateService.list_templates(
|
||||
conn, actor, kind=kind, scope=scope,
|
||||
target_collection_id=target_collection_id, query=q,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"templates": items}
|
||||
|
||||
|
||||
@router.get("/api/templates/{template_id}")
|
||||
def get_template(request: Request, template_id: str):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, actor, template_id)
|
||||
from app.services.fd_templates import _row_to_dto
|
||||
|
||||
dto = _row_to_dto(conn, row)
|
||||
preview = TemplateService.preview(conn, actor, template_id)
|
||||
dto["blocks_preview"] = preview.get("blocks_preview", [])
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id}/templates")
|
||||
def list_collection_templates(request: Request, collection_id: int):
|
||||
"""Menu *New ▾* d'une base : templates de lignes + défaut (§7.3)."""
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
items = TemplateService.list_templates(
|
||||
conn, actor, kind="row", target_collection_id=collection_id
|
||||
)
|
||||
default_id = next(
|
||||
(t["id"] for t in items if t.get("is_default")), None
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"templates": items, "default_id": default_id}
|
||||
|
||||
|
||||
@router.get("/api/templates/{template_id}/runs")
|
||||
def list_template_runs(request: Request, template_id: str, limit: int = Query(default=20)):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
TemplateService.get_template(conn, actor, template_id)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM template_runs WHERE template_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
(template_id, max(1, min(int(limit), 100))),
|
||||
).fetchall()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ═══════════ API interne — écriture ═══════════
|
||||
|
||||
@router.post("/api/templates")
|
||||
def create_template(request: Request, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
# Création depuis une source existante (create_from_*, §2.1).
|
||||
if body.get("from_page_id"):
|
||||
dto = _create_from_page(conn, actor, int(body["from_page_id"]), body)
|
||||
elif body.get("from_collection_id"):
|
||||
dto = _create_from_collection(
|
||||
conn, actor, int(body["from_collection_id"]), body
|
||||
)
|
||||
elif body.get("from_row"):
|
||||
dto = _create_from_row(conn, actor, body["from_row"], body)
|
||||
else:
|
||||
dto = TemplateService.create_template(conn, actor, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.patch("/api/templates/{template_id}")
|
||||
def update_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(conn, actor, template_id, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/duplicate")
|
||||
def duplicate_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.duplicate_template(conn, actor, template_id, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.delete("/api/templates/{template_id}")
|
||||
def delete_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.delete_template(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/archive")
|
||||
def archive_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.archive_template(conn, actor, template_id, archived=True)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/restore")
|
||||
def restore_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.archive_template(conn, actor, template_id, archived=False)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/property-defaults")
|
||||
def set_property_defaults(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(
|
||||
conn, actor, template_id,
|
||||
{"property_defaults": body.get("property_defaults") or body},
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/variables")
|
||||
def set_variables(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
variables = body.get("variables") if isinstance(body.get("variables"), list) else body.get("variables_json")
|
||||
if variables is None and isinstance(body, list):
|
||||
variables = body
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(
|
||||
conn, actor, template_id, {"variables": variables or []}
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/set-default")
|
||||
def set_default(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.set_default(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/unset-default")
|
||||
def unset_default(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.unset_default(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/recurrence")
|
||||
def set_recurrence(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
spec = None
|
||||
if body and body.get("rrule"):
|
||||
spec = {
|
||||
"rrule": body.get("rrule"),
|
||||
"timezone": body.get("timezone") or "",
|
||||
"enabled": body.get("enabled", True),
|
||||
}
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
recurrence = TemplateService.set_recurrence(conn, actor, template_id, spec)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"template_id": template_id, "recurrence": recurrence}
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/preview")
|
||||
def preview_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.preview(
|
||||
conn, actor, template_id, body.get("variables") or {},
|
||||
title=(body.get("title") or ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/instantiate")
|
||||
def instantiate_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _actor(request)
|
||||
idempotency_key = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
|
||||
context = body.get("context") or {}
|
||||
# La cible « page courante » porte l'id de page pour ajout/remplacement.
|
||||
if body.get("page_id") and "target" not in context:
|
||||
context["target"] = {"type": "current_page", "page_id": body.get("page_id"),
|
||||
"mode": body.get("mode") or "fill"}
|
||||
if body.get("collection_id") and "collection_id" not in context:
|
||||
context["collection_id"] = body.get("collection_id")
|
||||
if body.get("parent_page_id") and "parent_page_id" not in context:
|
||||
context["parent_page_id"] = body.get("parent_page_id")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.instantiate(
|
||||
conn, actor, template_id, body.get("variables") or {}, context,
|
||||
idempotency_key=idempotency_key,
|
||||
run_source="api" if request.headers.get("authorization") else "ui",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/runs/{run_id}/undo")
|
||||
def undo_run(request: Request, run_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.mark_undone(conn, actor, run_id)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════ Création depuis l'existant (create_from_*) ═══════════
|
||||
|
||||
def _create_from_page(conn, actor, page_id: int, spec: dict) -> dict:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise LookupError("Page source introuvable")
|
||||
blocks: list = []
|
||||
try:
|
||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||
blocks = json.loads(row["content"]) or []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocks = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": spec.get("kind") or "page",
|
||||
"name": spec.get("name") or f"{row['title'] or 'Sans titre'} (template)",
|
||||
"description": spec.get("description") or "",
|
||||
"icon": spec.get("icon") or (row["page_icon"] if "page_icon" in row.keys() else "📄") or "📄",
|
||||
"category": spec.get("category") or "",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"target_collection_id": spec.get("target_collection_id"),
|
||||
"blocks": blocks if isinstance(blocks, list) else [],
|
||||
"variables": spec.get("variables") or [],
|
||||
"title_template": spec.get("title_template") or f"{row['title'] or ''}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _create_from_collection(conn, actor, collection_id: int, spec: dict) -> dict:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise LookupError("Base source introuvable")
|
||||
props = [
|
||||
dict(p)
|
||||
for p in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
schema = [
|
||||
{
|
||||
"name": p["name"],
|
||||
"type": p["prop_type"] if "prop_type" in p else p.get("type", "text"),
|
||||
"options": json.loads(p.get("options_json") or "[]"),
|
||||
}
|
||||
for p in props
|
||||
]
|
||||
manifest = {
|
||||
"format": "flowdeck-template",
|
||||
"version": 1,
|
||||
"collection": {
|
||||
"name": collection["name"],
|
||||
"icon": collection["icon"] if "icon" in collection.keys() else "📋",
|
||||
"schema": schema,
|
||||
},
|
||||
}
|
||||
if spec.get("with_seed_rows"):
|
||||
manifest["collection"]["seed_rows"] = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": "database",
|
||||
"name": spec.get("name") or f"{collection['name']} (template)",
|
||||
"description": spec.get("description") or (collection["description"] or ""),
|
||||
"icon": (collection["icon"] if "icon" in collection.keys() else "") or "🗂",
|
||||
"category": spec.get("category") or "Base",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"manifest": manifest,
|
||||
"variables": spec.get("variables") or [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _create_from_row(conn, actor, from_row: dict | int, spec: dict) -> dict:
|
||||
if isinstance(from_row, dict):
|
||||
collection_id = from_row.get("collection_id")
|
||||
row_id = from_row.get("row_id")
|
||||
else:
|
||||
collection_id, row_id = spec.get("target_collection_id"), from_row
|
||||
if not collection_id or not row_id:
|
||||
raise ValueError("from_row exige {collection_id, row_id}")
|
||||
source = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=? AND collection_id=?",
|
||||
(row_id, collection_id),
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise LookupError("Ligne source introuvable")
|
||||
try:
|
||||
pv = json.loads(source["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
# Les valeurs sont re-cléées par nom de propriété pour survivre à l'export.
|
||||
props = {
|
||||
str(p["id"]): p["name"]
|
||||
for p in conn.execute(
|
||||
"SELECT id, name FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
defaults = {props.get(k, k): v for k, v in (pv if isinstance(pv, dict) else {}).items()}
|
||||
shadow_blocks: list = []
|
||||
try:
|
||||
shadow = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE collection_row_id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if shadow and (shadow["content_format"] or "") == "blocks" and shadow["content"]:
|
||||
shadow_blocks = json.loads(shadow["content"]) or []
|
||||
except Exception:
|
||||
shadow_blocks = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": "row",
|
||||
"name": spec.get("name") or f"{source['title'] or 'Sans titre'} (template)",
|
||||
"description": spec.get("description") or "",
|
||||
"icon": spec.get("icon") or "📄",
|
||||
"category": spec.get("category") or "",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"target_collection_id": collection_id,
|
||||
"blocks": shadow_blocks if isinstance(shadow_blocks, list) else [],
|
||||
"manifest": {"property_defaults": defaults},
|
||||
"title_template": spec.get("title_template") or (source["title"] or ""),
|
||||
"variables": spec.get("variables") or [],
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user