"""FlowDeck — Collections router: Notion-style databases (v1.3.0).""" from __future__ import annotations import json import logging from fastapi import APIRouter, Request, HTTPException, Query from fastapi.responses import HTMLResponse, RedirectResponse from app.db import get_conn from app.auth.session import SessionManager logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") def _get_workspace(request: Request) -> str: """Returns the current workspace name from session.""" user = SessionManager.decode_session( request.cookies.get("flowdeck_session", "") ) return user.get("login", "Default") if user else "Default" # ── Collection CRUD ── @router.get("", response_class=HTMLResponse) async def list_collections(request: Request): """Page listing all collections in the workspace.""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM collections ORDER BY name" ).fetchall() collections = [dict(r) for r in rows] return HTMLResponse( f"
" f"

Collections ({len(collections)})

" f"
{json.dumps(collections, indent=2, default=str)}
" f"
" ) @router.get("/api") async def list_collections_api(request: Request): """API: list all collections.""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM collections ORDER BY name" ).fetchall() return {"collections": [dict(r) for r in rows]} @router.post("/api") async def create_collection_api(request: Request): """API: create a new collection.""" try: body = await request.json() except Exception: body = {} name = body.get("name", "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") description = body.get("description", "") icon = body.get("icon", "📋") gitea_owner = body.get("gitea_owner") gitea_repo = body.get("gitea_repo") schema_json = json.dumps(body.get("schema", [])) is_locked = body.get("is_locked", False) with get_conn() as conn: cur = conn.execute( """INSERT INTO collections (name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked) VALUES (?, ?, ?, ?, ?, ?, ?)""", (name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)), ) collection_id = cur.lastrowid # Create a default table view conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)""", (collection_id, "Default View", "table", json.dumps({ "visible_properties": ["Title"], "sorts": [], "filters": [], })), ) conn.commit() return {"id": collection_id, "name": name, "status": "created"} @router.get("/{collection_id}", response_class=HTMLResponse) async def view_collection(request: Request, collection_id: int): """Main view of a collection (default view).""" with get_conn() as conn: collection = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not collection: raise HTTPException(status_code=404, detail="Collection not found") view = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1", (collection_id,), ).fetchone() pages = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() collection_dict = dict(collection) view_dict = dict(view) if view else {"name": "Default", "view_type": "table"} pages_list = [dict(p) for p in pages] return HTMLResponse(f""" {collection_dict['name']} — FlowDeck

{collection_dict.get('icon', '📋')} {collection_dict['name']}

{collection_dict.get('description', '')}

View: {view_dict['name']} ({view_dict['view_type']})

Pages ({len(pages_list)})

{json.dumps(pages_list, indent=2, default=str)}
""") @router.get("/{collection_id}/api") async def get_collection_api(request: Request, collection_id: int): """API: get a single collection with its pages.""" with get_conn() as conn: collection = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not collection: raise HTTPException(status_code=404, detail="Collection not found") pages = conn.execute( "SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() views = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() return { "collection": dict(collection), "pages": [dict(p) for p in pages], "views": [dict(v) for v in views], } @router.put("/api/{collection_id}") async def update_collection_api(request: Request, collection_id: int): """API: update a collection.""" try: body = await request.json() except Exception: body = {} with get_conn() as conn: existing = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Collection not found") name = body.get("name", existing["name"]) description = body.get("description", existing["description"]) icon = body.get("icon", existing["icon"]) is_locked = body.get("is_locked", existing["is_locked"]) schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"]))) gitea_owner = body.get("gitea_owner", existing["gitea_owner"]) gitea_repo = body.get("gitea_repo", existing["gitea_repo"]) conn.execute( """UPDATE collections SET name=?, description=?, icon=?, schema_json=?, is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP WHERE id=?""", (name, description, icon, schema_json, int(is_locked), gitea_owner, gitea_repo, collection_id), ) conn.commit() return {"id": collection_id, "status": "updated"} @router.delete("/api/{collection_id}") async def delete_collection_api(request: Request, collection_id: int): """API: delete a collection and its pages (CASCADE).""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Collection not found") conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) conn.commit() return {"id": collection_id, "status": "deleted"} # ── Collection Pages CRUD ── @router.post("/{collection_id}/pages/api") async def create_page_api(request: Request, collection_id: int): """API: create a page in a collection.""" try: body = await request.json() except Exception: body = {} title = body.get("title", "").strip() if not title: raise HTTPException(status_code=400, detail="title is required") icon = body.get("icon", "📄") property_values = json.dumps(body.get("properties", {})) gitea_issue_id = body.get("gitea_issue_id") gitea_issue_number = body.get("gitea_issue_number") with get_conn() as conn: # Verify collection exists coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") # Get next position 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, icon, position, gitea_issue_id, gitea_issue_number, property_values_json) VALUES (?, ?, ?, ?, ?, ?, ?)""", (collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number, property_values), ) conn.commit() page_id = cur.lastrowid return {"id": page_id, "title": title, "status": "created"} @router.get("/pages/{page_id}/api") async def get_page_api(request: Request, page_id: int): """API: get a single page.""" with get_conn() as conn: page = conn.execute( "SELECT * FROM collection_pages WHERE id=?", (page_id,) ).fetchone() if not page: raise HTTPException(status_code=404, detail="Page not found") return dict(page) @router.put("/pages/{page_id}/api") async def update_page_api(request: Request, page_id: int): """API: update a page's properties.""" try: body = await request.json() except Exception: body = {} with get_conn() as conn: existing = conn.execute( "SELECT * FROM collection_pages WHERE id=?", (page_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Page not found") title = body.get("title", existing["title"]) icon = body.get("icon", existing["icon"]) position = body.get("position", existing["position"]) parent_id = body.get("parent_id", existing["parent_id"]) property_values = json.dumps( body.get("properties", json.loads(existing["property_values_json"])) ) conn.execute( """UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?""", (title, icon, position, parent_id, property_values, page_id), ) conn.commit() return {"id": page_id, "status": "updated"} @router.delete("/pages/{page_id}/api") async def delete_page_api(request: Request, page_id: int): """API: delete a page from its collection.""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM collection_pages WHERE id=?", (page_id,) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Page not found") conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) conn.commit() return {"id": page_id, "status": "deleted"}