diff --git a/app/main.py b/app/main.py index 81a6509..c785a1a 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks +from app.routers import dashboard, board, notes, api, auth, webhooks, collections from app.services.gitea_client import gitea logging.basicConfig( @@ -53,5 +53,6 @@ app.include_router(board.router) app.include_router(notes.router) app.include_router(api.router) app.include_router(webhooks.router) +app.include_router(collections.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index c27f155..62facaa 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages"} + EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages", "/db/"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/routers/collections.py b/app/routers/collections.py new file mode 100644 index 0000000..e5f9381 --- /dev/null +++ b/app/routers/collections.py @@ -0,0 +1,317 @@ +"""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"
{json.dumps(collections, indent=2, default=str)}"
+ f"{collection_dict.get('description', '')}
+View: {view_dict['name']} ({view_dict['view_type']})
+{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"}
diff --git a/tests/test_app.py b/tests/test_app.py
index ed167d4..dff623b 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,4 +1,5 @@
-"""FlowDeck — Comprehensive tests v1.0.0."""
+"""FlowDeck — Comprehensive tests v1.3.0."""
+import json
import os
import tempfile
import pytest
@@ -220,3 +221,129 @@ def test_all_view_endpoints_respond(client):
for view in views:
resp = client.get(f"/board/test/test/view/{view}")
assert resp.status_code in (200, 500), f"View {view} failed with {resp.status_code}"
+
+
+# ── v1.3.0: Database Concept ──
+
+def test_collections_api_list_empty(client):
+ """List collections API — should return empty when no collections exist."""
+ resp = client.get("/db/api")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "collections" in data
+ assert data["collections"] == []
+
+
+def test_collections_api_crud(client):
+ """Full CRUD lifecycle: create → read → update → delete."""
+ # Create
+ resp = client.post("/db/api", json={
+ "name": "Test Database",
+ "description": "A test collection",
+ "icon": "🗂️",
+ "schema": [
+ {"name": "Status", "type": "select", "options": ["Todo", "Done"]},
+ ],
+ })
+ assert resp.status_code == 200
+ created = resp.json()
+ assert created["status"] == "created"
+ assert "id" in created
+ coll_id = created["id"]
+
+ # List — should now have 1 collection
+ resp = client.get("/db/api")
+ assert resp.status_code == 200
+ assert len(resp.json()["collections"]) == 1
+
+ # Get single
+ resp = client.get(f"/db/{coll_id}/api")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["collection"]["name"] == "Test Database"
+ assert data["collection"]["icon"] == "🗂️"
+
+ # Update
+ resp = client.put(f"/db/api/{coll_id}", json={
+ "name": "Updated DB",
+ })
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "updated"
+
+ # Verify update
+ resp = client.get(f"/db/{coll_id}/api")
+ assert resp.json()["collection"]["name"] == "Updated DB"
+
+ # Delete
+ resp = client.delete(f"/db/api/{coll_id}")
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "deleted"
+
+ # Verify deletion
+ resp = client.get(f"/db/{coll_id}/api")
+ assert resp.status_code == 404
+
+
+def test_collections_pages_crud(client):
+ """CRUD for pages inside a collection."""
+ # Create collection first
+ resp = client.post("/db/api", json={"name": "Page Test DB"})
+ coll_id = resp.json()["id"]
+
+ # Create page
+ resp = client.post(f"/db/{coll_id}/pages/api", json={
+ "title": "My First Page",
+ "properties": {"Status": "Todo", "Priority": "P1"},
+ })
+ assert resp.status_code == 200
+ page_data = resp.json()
+ assert page_data["status"] == "created"
+ page_id = page_data["id"]
+
+ # Get page
+ resp = client.get(f"/db/pages/{page_id}/api")
+ assert resp.status_code == 200
+ assert resp.json()["title"] == "My First Page"
+
+ # Update page
+ resp = client.put(f"/db/pages/{page_id}/api", json={
+ "title": "Updated Page",
+ "properties": {"Status": "Done"},
+ })
+ assert resp.status_code == 200
+
+ # Verify update
+ resp = client.get(f"/db/pages/{page_id}/api")
+ data = resp.json()
+ assert data["title"] == "Updated Page"
+ props = json.loads(data["property_values_json"])
+ assert props["Status"] == "Done"
+
+ # Delete page
+ resp = client.delete(f"/db/pages/{page_id}/api")
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "deleted"
+
+ # Cleanup: delete collection
+ client.delete(f"/db/api/{coll_id}")
+
+
+def test_collections_api_validation(client):
+ """Validation: missing name should return 400."""
+ resp = client.post("/db/api", json={})
+ assert resp.status_code == 400
+ assert "name" in resp.json()["detail"].lower()
+
+
+def test_collections_db_page_renders(client):
+ """GET /db/{id} should render an HTML page."""
+ # Create collection first
+ resp = client.post("/db/api", json={"name": "Render Test"})
+ coll_id = resp.json()["id"]
+
+ resp = client.get(f"/db/{coll_id}")
+ assert resp.status_code == 200
+ assert "Render Test" in resp.text
+
+ # Cleanup
+ client.delete(f"/db/api/{coll_id}")