Database foundation: - New tables: collections, collection_pages, collection_views - Router /db with full CRUD API - GiteaBoardCompat adapter for legacy board compatibility - CSRF exemption for /db/ routes - 34/34 tests pass - Version bump 1.0.0 → 1.3.0 - Deployed and verified on Docker port 8080
353 lines
12 KiB
Python
353 lines
12 KiB
Python
"""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
|
|
|
|
from app.db import get_conn
|
|
from app.auth.session import SessionManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["collections"], prefix="/db")
|
|
|
|
|
|
# ── API: List & Create (no path params) ──
|
|
|
|
|
|
@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
|
|
|
|
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"}
|
|
|
|
|
|
# ── API: Update & Delete (no path-param conflicts) ──
|
|
|
|
|
|
@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"}
|
|
|
|
|
|
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
|
|
|
|
|
@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"}
|
|
|
|
|
|
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|
|
|
|
|
|
@router.get("/boards/api")
|
|
async def list_boards_as_collections(request: Request):
|
|
"""API: list all Gitea boards as pseudo-collections."""
|
|
from app.services.collection_adapter import GiteaBoardCompat
|
|
boards = GiteaBoardCompat.list_boards_as_collections()
|
|
return {"boards": boards}
|
|
|
|
|
|
@router.get("/board/{owner}/{repo}/api")
|
|
async def get_board_as_collection(request: Request, owner: str, repo: str):
|
|
"""API: get a specific Gitea board as a pseudo-collection."""
|
|
from app.services.collection_adapter import GiteaBoardCompat
|
|
coll = GiteaBoardCompat.get_board_as_collection(owner, repo)
|
|
if not coll:
|
|
raise HTTPException(status_code=404, detail="Board not found")
|
|
|
|
from app.services.gitea_client import gitea
|
|
issues = await gitea.get_issues(owner, repo, state="all")
|
|
cards = GiteaBoardCompat.get_board_cards(owner, repo, issues)
|
|
|
|
return {"collection": coll, "pages": cards}
|
|
|
|
|
|
@router.post("/board/{owner}/{repo}/sync")
|
|
async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
|
"""Sync a Gitea board to a real collection."""
|
|
from app.services.collection_adapter import GiteaBoardCompat
|
|
from app.services.gitea_client import gitea
|
|
|
|
issues = await gitea.get_issues(owner, repo, state="all")
|
|
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
|
|
if coll_id is None:
|
|
raise HTTPException(status_code=404, detail="Board not found")
|
|
|
|
return {"collection_id": coll_id, "status": "synced"}
|
|
|
|
|
|
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
|
|
|
|
|
@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.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:
|
|
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
|
if not coll:
|
|
raise HTTPException(status_code=404, detail="Collection not found")
|
|
|
|
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"}
|