feat(v4.2.0): Database Templates & Dashboards
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- Enhance page_templates: add description, is_recurring, recurrence_rule columns
- New table collection_dashboards for widget-based dashboard layouts
- API: PUT/DELETE /workspace/collections/{id}/templates/page/{tid} — template CRUD
- API: CRUD /workspace/collections/{id}/dashboards (create/list/update/delete)
- Dashboard layout_json supports columns + widgets with view_id/x/y/w/h
- 6 new tests (159 total)
This commit is contained in:
2026-07-21 22:09:48 -04:00
parent ec96619341
commit 10cbd7f52e
3 changed files with 284 additions and 0 deletions
+28
View File
@@ -487,6 +487,34 @@ def init_db():
pass pass
conn.commit() conn.commit()
# v4.2.0: Collection Templates (enhanced) + Dashboards
# Add description, is_recurring, recurrence_rule to page_templates
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
# Dashboard: combinaison de vues/widgets sur une page
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_dashboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Dashboard',
layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
@contextmanager @contextmanager
def get_conn(): def get_conn():
+109
View File
@@ -296,6 +296,115 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
return {"id": cur.lastrowid, "status": "created"} return {"id": cur.lastrowid, "status": "created"}
@router.put("/collections/{collection_id}/templates/page/{tid}")
async def update_page_template(request: Request, collection_id: int, tid: int):
"""Update a page template — name, properties, content, recurrence."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
name = body.get("name", tmpl["name"])
tmpl_dict = dict(tmpl)
description = body.get("description", tmpl_dict.get("description", ""))
property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"])))
content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]"))))
is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0)))
recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", ""))
conn.execute(
"""UPDATE page_templates SET name=?, description=?, property_values_json=?,
content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""",
(name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid),
)
conn.commit()
return {"id": tid, "status": "updated"}
@router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template."""
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
conn.execute("DELETE FROM page_templates WHERE id=?", (tid,))
conn.commit()
return {"id": tid, "status": "deleted"}
# ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"dashboards": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard(request: Request, collection_id: int):
"""Create a new dashboard for a collection."""
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "Dashboard").strip()
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
cur = conn.execute(
"INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)",
(collection_id, name, layout),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/dashboards/{did}")
async def update_dashboard(request: Request, collection_id: int, did: int):
"""Update a dashboard — name or layout (widgets grid)."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
name = body.get("name", dash["name"])
layout = json.dumps(body.get("layout", json.loads(dash["layout_json"])))
conn.execute(
"UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(name, layout, did),
)
conn.commit()
return {"id": did, "status": "updated"}
@router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,))
conn.commit()
return {"id": did, "status": "deleted"}
# ── CSV Import/Export ── # ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv") @router.post("/collections/{collection_id}/import/csv")
+147
View File
@@ -2228,3 +2228,150 @@ def test_remove_data_source_not_found(client):
resp = client.delete(f"/db/{coll_id}/sources/99999/api") resp = client.delete(f"/db/{coll_id}/sources/99999/api")
assert resp.status_code == 404 assert resp.status_code == 404
# ── v4.2.0: Templates & Dashboards ──
def test_page_template_update(client):
"""Update a page template with recurrence settings."""
resp = client.post("/db/api", json={"name": "Template Update DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Weekly Report",
"properties": {"Status": "Todo", "Priority": "P1"},
})
assert resp.status_code == 200
tid = resp.json()["id"]
resp = client.put(f"/workspace/collections/{coll_id}/templates/page/{tid}", json={
"name": "Weekly Report v2",
"description": "Updated weekly report template",
"is_recurring": True,
"recurrence_rule": "weekly",
"properties": {"Status": "In Progress", "Priority": "P2"},
})
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
tmpls = resp.json()["templates"]
assert len(tmpls) == 1
assert tmpls[0]["name"] == "Weekly Report v2"
assert tmpls[0]["is_recurring"] == 1
assert tmpls[0]["recurrence_rule"] == "weekly"
def test_page_template_delete(client):
"""Delete a page template."""
resp = client.post("/db/api", json={"name": "Template Delete DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "To Delete",
})
tid = resp.json()["id"]
resp = client.delete(f"/workspace/collections/{coll_id}/templates/page/{tid}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
assert len(resp.json()["templates"]) == 0
def test_page_template_apply_with_content(client):
"""Apply a page template that has content_json."""
resp = client.post("/db/api", json={"name": "Content Template DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Meeting Notes",
"properties": {"Status": "Todo"},
"content": [{"type": "heading", "text": "Meeting Notes"}],
})
tid = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page/{tid}/apply", json={
"title": "Sprint Review 2026-07-21",
})
assert resp.status_code == 200
page_id = resp.json()["id"]
resp = client.get(f"/db/pages/{page_id}/api")
assert resp.status_code == 200
assert resp.json()["title"] == "Sprint Review 2026-07-21"
def test_dashboard_crud(client):
"""Full CRUD lifecycle for dashboards."""
resp = client.post("/db/api", json={"name": "Dashboard CRUD DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/dashboards", json={
"name": "Project Dashboard",
"layout": {
"columns": 2,
"widgets": [
{"view_id": 1, "x": 0, "y": 0, "width": 1, "height": 1},
{"view_id": 2, "x": 1, "y": 0, "width": 1, "height": 1},
],
},
})
assert resp.status_code == 200
assert resp.json()["status"] == "created"
did = resp.json()["id"]
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
dashboards = resp.json()["dashboards"]
assert len(dashboards) == 1
assert dashboards[0]["name"] == "Project Dashboard"
resp = client.put(f"/workspace/collections/{coll_id}/dashboards/{did}", json={
"name": "Project Dashboard v2",
"layout": {"columns": 3, "widgets": []},
})
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
assert resp.json()["dashboards"][0]["name"] == "Project Dashboard v2"
resp = client.delete(f"/workspace/collections/{coll_id}/dashboards/{did}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
assert len(resp.json()["dashboards"]) == 0
def test_template_recurrence_defaults(client):
"""New templates default to non-recurring with empty recurrence_rule."""
resp = client.post("/db/api", json={"name": "Recur Defaults DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/templates/page", json={
"name": "Default Template",
})
resp = client.get(f"/workspace/collections/{coll_id}/templates/page")
tmpl = resp.json()["templates"][0]
assert tmpl["is_recurring"] == 0
assert tmpl["recurrence_rule"] == ""
def test_dashboard_create_default_layout(client):
"""Dashboard creation with no layout parameter gets default grid."""
resp = client.post("/db/api", json={"name": "Default Layout DB"})
coll_id = resp.json()["id"]
resp = client.post(f"/workspace/collections/{coll_id}/dashboards", json={
"name": "Auto Layout",
})
assert resp.status_code == 200
resp = client.get(f"/workspace/collections/{coll_id}/dashboards")
dash = resp.json()["dashboards"][0]
layout = json.loads(dash["layout_json"])
assert layout["columns"] == 1
assert layout["widgets"] == []