"""FlowDeck — Workspace, Comments, Favorites, History, Templates (v2.0.0).""" from __future__ import annotations import csv import io import json import logging from fastapi import APIRouter, Request, HTTPException from fastapi.responses import HTMLResponse, StreamingResponse from app.db import get_conn from app.auth.session import SessionManager logger = logging.getLogger(__name__) router = APIRouter(tags=["workspace"], prefix="/workspace") ROLES = ["admin", "editor", "commenter", "viewer"] def _current_user(request: Request) -> dict: s = request.cookies.get("flowdeck_session", "") return SessionManager.decode_session(s) or {"login": "admin", "id": 1} # ── Workspaces ── @router.get("") async def list_workspaces(request: Request): with get_conn() as conn: rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall() return {"workspaces": [dict(r) for r in rows]} @router.post("") async def create_workspace(request: Request): body = await request.json() if request.headers.get("content-type") else {} name = body.get("name", "Default Workspace") user = _current_user(request) uid = user.get("id", 1) with get_conn() as conn: uid_ensured = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone() if not uid_ensured: conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)", (uid, user.get("login", "admin"), user.get("full_name", "Admin"))) cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, uid)) ws_id = cur.lastrowid conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws_id, uid, "admin")) conn.commit() return {"id": ws_id, "name": name, "status": "created"} # ── Members ── @router.get("/{ws_id}/members") async def list_members(request: Request, ws_id: int): with get_conn() as conn: rows = conn.execute( "SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?", (ws_id,), ).fetchall() return {"members": [dict(r) for r in rows]} @router.post("/{ws_id}/members") async def add_member(request: Request, ws_id: int): body = await request.json() if request.headers.get("content-type") else {} user_id = body.get("user_id") role = body.get("role", "editor") if role not in ROLES: raise HTTPException(400, f"Invalid role: {role}") with get_conn() as conn: conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)", (user_id, f"user_{user_id}", f"User {user_id}")) conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws_id, user_id, role)) conn.commit() return {"status": "added"} @router.put("/{ws_id}/members/{user_id}") async def update_member_role(request: Request, ws_id: int, user_id: int): body = await request.json() if request.headers.get("content-type") else {} role = body.get("role", "editor") if role not in ROLES: raise HTTPException(400, f"Invalid role: {role}") with get_conn() as conn: conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, ws_id, user_id)) conn.commit() return {"status": "updated"} @router.delete("/{ws_id}/members/{user_id}") async def remove_member(request: Request, ws_id: int, user_id: int): with get_conn() as conn: conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id)) conn.commit() return {"status": "removed"} # ── Comments ── @router.get("/pages/{page_id}/comments") async def list_comments(request: Request, page_id: int): with get_conn() as conn: rows = conn.execute( "SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at", (page_id,), ).fetchall() return {"comments": [dict(r) for r in rows]} @router.post("/pages/{page_id}/comments") async def add_comment(request: Request, page_id: int): body = await request.json() if request.headers.get("content-type") else {} b = body.get("body", "").strip() if not b: raise HTTPException(400, "body required") user = _current_user(request) uid = user.get("id", 1) parent_id = body.get("parent_id") with get_conn() as conn: conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)", (uid, user.get("login", "admin"), user.get("full_name", "Admin"))) cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)", (page_id, uid, b, parent_id)) conn.commit() return {"id": cur.lastrowid, "status": "created"} @router.put("/comments/{comment_id}") async def update_comment(request: Request, comment_id: int): body = await request.json() if request.headers.get("content-type") else {} b = body.get("body") resolved = body.get("resolved") with get_conn() as conn: if b is not None: conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id)) if resolved is not None: conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id)) conn.commit() return {"status": "updated"} # ── Page History ── @router.get("/pages/{page_id}/history") async def page_history(request: Request, page_id: int): with get_conn() as conn: rows = conn.execute( "SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50", (page_id,), ).fetchall() return {"history": [dict(r) for r in rows]} @router.post("/pages/{page_id}/history") async def record_history(request: Request, page_id: int): body = await request.json() if request.headers.get("content-type") else {} user = _current_user(request) uid = user.get("id", 1) with get_conn() as conn: conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin"))) conn.execute( "INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?,?,?,?)", (page_id, uid, body.get("change_type", "updated"), json.dumps(body.get("snapshot", {}))), ) conn.commit() return {"status": "recorded"} # ── Favorites ── @router.get("/favorites") async def list_favorites(request: Request): user = _current_user(request) uid = user.get("id", 1) with get_conn() as conn: rows = conn.execute( """SELECT f.*, cp.title as page_title, c.name as collection_name FROM favorites f LEFT JOIN collection_pages cp ON f.page_id=cp.id LEFT JOIN collections c ON f.collection_id=c.id WHERE f.user_id=? ORDER BY f.position""", (uid,), ).fetchall() return {"favorites": [dict(r) for r in rows]} @router.post("/favorites") async def add_favorite(request: Request): body = await request.json() if request.headers.get("content-type") else {} user = _current_user(request) uid = user.get("id", 1) page_id = body.get("page_id") collection_id = body.get("collection_id") with get_conn() as conn: conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin"))) conn.execute( "INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)", (uid, page_id, collection_id), ) conn.commit() return {"status": "favorited"} @router.delete("/favorites/{fav_id}") async def remove_favorite(request: Request, fav_id: int): with get_conn() as conn: conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,)) conn.commit() return {"status": "removed"} # ── Templates ── @router.get("/templates/database") async def list_db_templates(request: Request): with get_conn() as conn: rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall() return {"templates": [dict(r) for r in rows]} @router.post("/templates/database") async def create_db_template(request: Request): body = await request.json() if request.headers.get("content-type") else {} cur = None with get_conn() as conn: cur = conn.execute( "INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)", (body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))), ) conn.commit() return {"id": cur.lastrowid, "status": "created"} @router.post("/templates/database/{tid}/apply") async def apply_db_template(request: Request, tid: int): body = await request.json() if request.headers.get("content-type") else {} name = body.get("name", "New Database") with get_conn() as conn: tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone() if not tmpl: raise HTTPException(404, "Template not found") cur = conn.execute( "INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)", (name, tmpl["description"], "📋", tmpl["schema_json"]), ) conn.execute( "INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cur.lastrowid, "Default View", "table", "{}"), ) conn.commit() return {"collection_id": cur.lastrowid, "name": name, "status": "created"} @router.get("/collections/{collection_id}/templates/page") async def list_page_templates(request: Request, collection_id: int): with get_conn() as conn: rows = conn.execute( "SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,) ).fetchall() return {"templates": [dict(r) for r in rows]} @router.post("/collections/{collection_id}/templates/page") async def create_page_template(request: Request, collection_id: int): body = await request.json() if request.headers.get("content-type") else {} with get_conn() as conn: cur = conn.execute( "INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)", (collection_id, body.get("name", "Default"), json.dumps(body.get("properties", {}))), ) conn.commit() return {"id": cur.lastrowid, "status": "created"} @router.post("/collections/{collection_id}/templates/page/{tid}/apply") async def apply_page_template(request: Request, collection_id: int, tid: int): 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") max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,) ).fetchone()[0] cur = conn.execute( "INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)", (collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]), ) conn.commit() 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 ── @router.post("/collections/{collection_id}/import/csv") async def import_csv(request: Request, collection_id: int): body = await request.json() if request.headers.get("content-type") else {} csv_data = body.get("csv", "") if not csv_data: raise HTTPException(400, "csv field required") reader = csv.DictReader(io.StringIO(csv_data)) rows_imported = 0 with get_conn() as conn: max_pos = conn.execute( "SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?", (collection_id,) ).fetchone()[0] for row in reader: title = row.get("title", row.get("Title", row.get("Name", "Imported"))) props = {k: v for k, v in row.items() if k.lower() != "title"} max_pos += 1 conn.execute( "INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)", (collection_id, title, max_pos, json.dumps(props)), ) rows_imported += 1 conn.commit() return {"imported": rows_imported} @router.get("/collections/{collection_id}/export/csv") async def export_csv(request: Request, collection_id: int): with get_conn() as conn: pages = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,) ).fetchall() # Collect all property keys all_keys = set() rows = [] for p in pages: props = json.loads(p["property_values_json"]) all_keys.update(props.keys()) rows.append({"title": p["title"], **props}) output = io.StringIO() fieldnames = ["title"] + sorted(all_keys) writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=export.csv"}, ) # ── Webhooks Outbound Management ── @router.get("/webhooks") async def list_webhooks(request: Request): with get_conn() as conn: rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall() return {"webhooks": [dict(r) for r in rows]} @router.post("/webhooks") async def create_webhook(request: Request): body = await request.json() if request.headers.get("content-type") else {} url = body.get("url", "").strip() event = body.get("event", "page.created") secret = body.get("secret", "") if not url: raise HTTPException(400, "url required") with get_conn() as conn: cur = conn.execute( "INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)", (url, event, secret), ) conn.commit() return {"id": cur.lastrowid, "url": url, "event": event, "status": "registered"} @router.delete("/webhooks/{wh_id}") async def delete_webhook(request: Request, wh_id: int): with get_conn() as conn: conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,)) conn.commit() return {"status": "deleted"} # ── Public Sharing ── @router.get("/public/{collection_id}") async def public_view(request: Request, collection_id: int): """Simple public read-only view — no auth required.""" with get_conn() as conn: coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(404, "Collection not found") pages = conn.execute( "SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() items = "".join( f"
  • {p['icon']} {p['title']}
  • " for p in pages ) return HTMLResponse(f""" {coll['name']} — FlowDeck Public

    {coll['icon']} {coll['name']}

    {len(pages)} items

    """)