feat(v1.3.0): Step 3 — Router /db avec API CRUD collections + pages
- 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:
+2
-1
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user