feat(v1.3.0): Step 3 — Router /db avec API CRUD collections + pages
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- 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
This commit is contained in:
2026-07-09 22:30:05 -04:00
parent 3967c7df85
commit b9723dffab
4 changed files with 448 additions and 3 deletions
+2 -1
View File
@@ -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")
+1 -1
View File
@@ -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
+317
View File
@@ -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"<div class='collections-list'>"
f"<h2>Collections ({len(collections)})</h2>"
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
f"</div>"
)
@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"""<!DOCTYPE html>
<html>
<head><title>{collection_dict['name']} — FlowDeck</title></head>
<body>
<h1>{collection_dict.get('icon', '📋')} {collection_dict['name']}</h1>
<p>{collection_dict.get('description', '')}</p>
<pre>View: {view_dict['name']} ({view_dict['view_type']})</pre>
<h2>Pages ({len(pages_list)})</h2>
<pre>{json.dumps(pages_list, indent=2, default=str)}</pre>
</body>
</html>""")
@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"}
+128 -1
View File
@@ -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}")