Files
flowdeck/app/routers/collections.py
T
bruno b8647f1a19
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Has been skipped
feat(v1.5.0): Relations, Rollups, Formulas — FormulaEngine, RollupEngine, API
- FormulaEngine: 19 functions (prop, if, concat, round, now, today, dateAdd, replace, ...)
- RollupEngine: 12 aggregations (count, sum, avg, min, max, range, unique, percent_checked)
- API relation: POST /db/{id}/properties/relation + /link (bidirectional)
- API rollup: POST /db/rollup/compute
- API formula: POST /db/formula/evaluate
- FKs fix: related_collection_id/relation_property_id/target_property_id → ON DELETE SET NULL
- 43/43 tests passent (+5 tests v1.5)
- Version 1.4.0 → 1.5.0
- Docs: ROADMAP, CHANGELOG à jour
2026-07-09 22:48:41 -04:00

643 lines
23 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 Properties (v1.4.0) ──
@router.get("/property-types/api")
async def list_property_types_api(request: Request):
"""API: list all available property types."""
from app.services.property_types import PROPERTY_TYPES
return {"types": PROPERTY_TYPES}
@router.get("/{collection_id}/properties/api")
async def list_properties_api(request: Request, collection_id: int):
"""API: list all properties for a collection."""
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")
rows = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"properties": [dict(r) for r in rows]}
@router.post("/{collection_id}/properties/api")
async def create_property_api(request: Request, collection_id: int):
"""API: create a new property on a 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")
prop_type = body.get("prop_type", "text")
options_json = json.dumps(body.get("options", []))
number_format = body.get("number_format", "number")
required = int(body.get("required", False))
visible = int(body.get("visible_in_views", True))
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_properties WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
try:
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
position, required, visible_in_views)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos, required, visible),
)
conn.commit()
except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists")
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
@router.put("/properties/{prop_id}/api")
async def update_property_api(request: Request, prop_id: int):
"""API: update a property."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Property not found")
name = body.get("name", existing["name"])
options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
number_format = body.get("number_format", existing["number_format"])
required = int(body.get("required", existing["required"]))
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
conn.execute(
"""UPDATE collection_properties
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?
WHERE id=?""",
(name, options_json, number_format, required, visible, prop_id),
)
conn.commit()
return {"id": prop_id, "status": "updated"}
@router.delete("/properties/{prop_id}/api")
async def delete_property_api(request: Request, prop_id: int):
"""API: delete a property."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Property not found")
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
conn.commit()
return {"id": prop_id, "status": "deleted"}
# ── Relations, Rollups, Formulas (v1.5.0) ──
@router.post("/{collection_id}/properties/relation")
async def create_relation_property(request: Request, collection_id: int):
"""Create a relation property between two collections."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
related_collection_id = body.get("related_collection_id")
reverse_name = body.get("reverse_name", "").strip()
if not name or not related_collection_id:
raise HTTPException(status_code=400, detail="name and related_collection_id are required")
with get_conn() as conn:
# Verify both collections exist
for cid in (collection_id, related_collection_id):
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
VALUES (?, ?, 'relation', ?, ?, ?)""",
(collection_id, name, related_collection_id, reverse_name, max_pos),
)
prop_id = cur.lastrowid
# Create reverse relation on the related collection
if reverse_name:
max_pos2 = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
(related_collection_id,),
).fetchone()[0]
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
VALUES (?, ?, 'relation', ?, ?, ?)""",
(related_collection_id, reverse_name, collection_id, name, max_pos2),
)
conn.commit()
return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
@router.post("/{collection_id}/properties/relation/link")
async def link_pages(request: Request, collection_id: int):
"""Link two pages via a relation property."""
try:
body = await request.json()
except Exception:
body = {}
property_id = body.get("property_id")
source_page_id = body.get("source_page_id")
target_page_id = body.get("target_page_id")
if not all([property_id, source_page_id, target_page_id]):
raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
with get_conn() as conn:
# Get the relation property
prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
(property_id,),
).fetchone()
if not prop:
raise HTTPException(status_code=404, detail="Relation property not found")
# Update source page's property_values_json
source = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(source_page_id,),
).fetchone()
if not source:
raise HTTPException(status_code=404, detail="Source page not found")
props = json.loads(source["property_values_json"])
current = props.get(str(property_id), [])
if not isinstance(current, list):
current = []
if target_page_id not in current:
current.append(target_page_id)
props[str(property_id)] = current
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), source_page_id),
)
# Update reverse relation if exists
if prop["reverse_name"]:
reverse_prop = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
(prop["related_collection_id"], prop["reverse_name"]),
).fetchone()
if reverse_prop:
target = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(target_page_id,),
).fetchone()
if target:
tprops = json.loads(target["property_values_json"])
tcurrent = tprops.get(str(reverse_prop["id"]), [])
if not isinstance(tcurrent, list):
tcurrent = []
if source_page_id not in tcurrent:
tcurrent.append(source_page_id)
tprops[str(reverse_prop["id"])] = tcurrent
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps(tprops), target_page_id),
)
conn.commit()
return {"status": "linked", "source": source_page_id, "target": target_page_id}
@router.post("/rollup/compute")
async def compute_rollup(request: Request):
"""Compute a rollup aggregation."""
try:
body = await request.json()
except Exception:
body = {}
collection_id = body.get("collection_id")
relation_property_id = body.get("relation_property_id")
target_property_id = body.get("target_property_id")
page_id = body.get("page_id")
rollup_function = body.get("function", "count")
if not all([collection_id, relation_property_id, target_property_id, page_id]):
raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
from app.services.rollup_engine import RollupEngine
engine = RollupEngine()
result = engine.compute(
collection_id, relation_property_id, target_property_id, page_id, rollup_function,
)
return {"result": result, "function": rollup_function}
@router.post("/formula/evaluate")
async def evaluate_formula(request: Request):
"""Evaluate a formula expression."""
try:
body = await request.json()
except Exception:
body = {}
expression = body.get("expression", "")
context = body.get("context", {})
if not expression:
raise HTTPException(status_code=400, detail="expression is required")
from app.services.formula_engine import FormulaEngine
engine = FormulaEngine()
result = engine.evaluate(expression, context)
return {"result": result, "expression": expression}
# ── {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"}