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/.
516 lines
24 KiB
Python
516 lines
24 KiB
Python
"""FlowDeck — Collections : 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 json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
|
|
from app.db import get_conn
|
|
from app.services.automations import fire_event, run_event_sync
|
|
|
|
from ._common import _current_user
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["collections"], prefix="/db")
|
|
|
|
|
|
|
|
|
|
# ── v1.7.0 View Management ──
|
|
|
|
|
|
@router.get("/views/{view_id}/api")
|
|
def get_view_api(request: Request, view_id: int):
|
|
"""API: get a single view config."""
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="View not found")
|
|
return dict(row)
|
|
|
|
|
|
|
|
|
|
@router.put("/views/{view_id}/config")
|
|
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
|
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
|
|
|
with get_conn() as conn:
|
|
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="View not found")
|
|
|
|
config = json.loads(existing["config_json"])
|
|
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",
|
|
# 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]
|
|
|
|
new_type = body.get("view_type") or existing["view_type"]
|
|
conn.execute(
|
|
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
|
|
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(json.dumps(config), body.get("name"), new_type, view_id),
|
|
)
|
|
conn.commit()
|
|
|
|
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
|
|
|
|
|
|
|
|
|
|
@router.post("/{collection_id}/views/save-as")
|
|
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
|
"""API: save current view state as a new named view."""
|
|
|
|
name = body.get("name", "New View")
|
|
config = body.get("config", {})
|
|
user = _current_user(request)
|
|
user_id = user.get("id") if user else None
|
|
|
|
with get_conn() as conn:
|
|
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
|
if not coll:
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
|
|
max_pos = conn.execute(
|
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
|
(collection_id,),
|
|
).fetchone()[0]
|
|
|
|
view_type = body.get("view_type", "table")
|
|
cur = conn.execute(
|
|
"""INSERT INTO collection_views
|
|
(collection_id, name, view_type, config_json, position, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
|
|
)
|
|
conn.commit()
|
|
new_view_id = cur.lastrowid
|
|
|
|
run_event_sync(fire_event("collection.view.created", {
|
|
"view_id": new_view_id,
|
|
"collection_id": collection_id,
|
|
"name": name,
|
|
"view_type": view_type,
|
|
}))
|
|
return {"id": new_view_id, "name": name, "view_type": view_type,
|
|
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
|
|
|
|
|
|
|
|
|
@router.get("/{collection_id}/views/api")
|
|
def list_views_api(request: Request, collection_id: int):
|
|
"""API: list views for a collection visible to the current user.
|
|
|
|
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
|
personal views (``created_by = user``) only to their owner.
|
|
"""
|
|
user = _current_user(request)
|
|
user_id = user.get("id") if user else None
|
|
with get_conn() as conn:
|
|
if user_id is not None:
|
|
rows = conn.execute(
|
|
"""SELECT * FROM collection_views
|
|
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
|
|
ORDER BY position""",
|
|
(collection_id, user_id),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
return {"views": [dict(r) for r in rows]}
|
|
|
|
|
|
|
|
|
|
@router.delete("/views/{view_id}/api")
|
|
def delete_view_api(request: Request, view_id: int):
|
|
"""API: delete a saved view."""
|
|
with get_conn() as conn:
|
|
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="View not found")
|
|
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
|
conn.commit()
|
|
return {"id": view_id, "status": "deleted"}
|
|
|
|
|
|
|
|
|
|
@router.post("/views/{view_id}/duplicate")
|
|
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
|
"""API: duplicate a view (config + type), owned by the current user."""
|
|
|
|
with get_conn() as conn:
|
|
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="View not found")
|
|
|
|
max_pos = conn.execute(
|
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
|
(existing["collection_id"],),
|
|
).fetchone()[0]
|
|
|
|
user = _current_user(request)
|
|
user_id = user.get("id") if user else None
|
|
name = body.get("name") or (existing["name"] + " copy")
|
|
cur = conn.execute(
|
|
"""INSERT INTO collection_views
|
|
(collection_id, name, view_type, config_json, position, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(existing["collection_id"], name, existing["view_type"],
|
|
existing["config_json"], max_pos, user_id),
|
|
)
|
|
conn.commit()
|
|
dup_view_id = cur.lastrowid
|
|
|
|
run_event_sync(fire_event("collection.view.created", {
|
|
"view_id": dup_view_id,
|
|
"collection_id": existing["collection_id"],
|
|
"name": name,
|
|
"view_type": existing["view_type"],
|
|
}))
|
|
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
|
"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 ──
|