From b9723dffabcc7788aa684b341dbae286cce5620f Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 9 Jul 2026 22:30:05 -0400 Subject: [PATCH] =?UTF-8?q?feat(v1.3.0):=20Step=203=20=E2=80=94=20Router?= =?UTF-8?q?=20/db=20avec=20API=20CRUD=20collections=20+=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouveau router app/routers/collections.py - GET/POST/PUT/DELETE /db pour les collections - POST/GET/PUT/DELETE pour les pages dans une collection - Vue HTML basique par collection + vue par défaut auto-créée - CSRF exempté pour /db/ - 5 nouveaux tests (collections list, CRUD, pages CRUD, validation, HTML render) - 33/33 tests passent --- app/main.py | 3 +- app/middleware/csrf.py | 2 +- app/routers/collections.py | 317 +++++++++++++++++++++++++++++++++++++ tests/test_app.py | 129 ++++++++++++++- 4 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 app/routers/collections.py 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"
" + 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"} 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}")