- 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
643 lines
23 KiB
Python
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"}
|