feat(db): v5.3.0 inline databases, predefined templates & property validation
- Slash command /database (DATA group) -> template picker -> inline DB embed block rendered by FlowDeckDB. 'Get Started > Database' uses it too. - 6 seeded database templates (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) with icon + schema; new service app/services/db_templates.py (materialize_properties, create_from_template). - POST /db/api and /db/inline/api accept 'template' and materialize collection_properties. database_templates gets an icon column (migration v4). - Property validation: collection_properties.validation_json (migration v4); validate_property_rule() in property_types (required/unique/min/max/ min_length/max_length); enforced in create/update page API (400 + message, unique excludes current row); property API accepts 'validation'. - UI: add-property modal exposes Required/Unique/Min/Max; cell edit shows validation errors (red outline + toast) and reverts. - VERSION + app.main -> 5.1.0; CHANGELOG + ROADMAP updated. - +9 tests (tests/test_db_advanced.py). 271 passed.
This commit is contained in:
+127
-16
@@ -10,11 +10,73 @@ from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.property_types import validate_property_rule
|
||||
from app.services.db_templates import materialize_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
def _apply_template(conn, template_name: str) -> dict | None:
|
||||
"""Resolve a database template by name (from the seeded/built-in set)."""
|
||||
if not template_name:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
|
||||
(template_name,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
|
||||
"""Validate submitted property values against the collection's schema.
|
||||
|
||||
Raises ``HTTPException(400)`` with a user-friendly message on the first
|
||||
failure (type, required, unique, min/max).
|
||||
"""
|
||||
props = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
|
||||
).fetchall()
|
||||
|
||||
for prop in props:
|
||||
ptype = prop["prop_type"]
|
||||
if ptype == "title":
|
||||
continue
|
||||
pid = prop["id"]
|
||||
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
|
||||
value = properties.get(str(pid))
|
||||
if value is None:
|
||||
value = properties.get(prop["name"])
|
||||
validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
|
||||
|
||||
existing_values = None
|
||||
try:
|
||||
import json as _json
|
||||
vcfg = _json.loads(validation) if validation else {}
|
||||
except Exception:
|
||||
vcfg = {}
|
||||
if vcfg.get("unique"):
|
||||
rows = conn.execute(
|
||||
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
existing_values = []
|
||||
for r in rows:
|
||||
if exclude_page_id is not None and r["id"] == exclude_page_id:
|
||||
continue
|
||||
try:
|
||||
pv = _json.loads(r["property_values_json"] or "{}")
|
||||
except Exception:
|
||||
pv = {}
|
||||
existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
|
||||
|
||||
ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
@@ -46,7 +108,7 @@ async def list_collections_api(request: Request):
|
||||
|
||||
@router.post("/api")
|
||||
async def create_collection_api(request: Request):
|
||||
"""API: create a new collection."""
|
||||
"""API: create a new collection, optionally from a database template."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -60,10 +122,24 @@ async def create_collection_api(request: Request):
|
||||
icon = body.get("icon", "📋")
|
||||
gitea_owner = body.get("gitea_owner")
|
||||
gitea_repo = body.get("gitea_repo")
|
||||
schema_json = json.dumps(body.get("schema", []))
|
||||
schema = body.get("schema", [])
|
||||
is_locked = body.get("is_locked", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
# Apply a template if requested (provides schema + icon).
|
||||
tpl = _apply_template(conn, body.get("template"))
|
||||
if tpl:
|
||||
if body.get("name"):
|
||||
name = body["name"].strip()
|
||||
description = tpl["description"]
|
||||
icon = tpl.get("icon") or icon
|
||||
try:
|
||||
schema = json.loads(tpl["schema_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
schema_json = json.dumps(schema)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
|
||||
@@ -72,6 +148,8 @@ async def create_collection_api(request: Request):
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json)
|
||||
@@ -176,9 +254,18 @@ async def update_page_api(request: Request, page_id: int):
|
||||
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"]))
|
||||
)
|
||||
|
||||
if "properties" in body:
|
||||
props = body["properties"]
|
||||
else:
|
||||
try:
|
||||
props = json.loads(existing["property_values_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
props = {}
|
||||
|
||||
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
|
||||
|
||||
property_values = json.dumps(props)
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages
|
||||
@@ -289,6 +376,7 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
number_format = body.get("number_format", "number")
|
||||
required = int(body.get("required", False))
|
||||
visible = int(body.get("visible_in_views", True))
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -304,9 +392,10 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
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),
|
||||
position, required, visible_in_views, validation_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, prop_type, options_json, number_format, max_pos,
|
||||
required, visible, validation_json),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
@@ -335,12 +424,16 @@ async def update_property_api(request: Request, prop_id: int):
|
||||
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"]))
|
||||
if "validation" in body:
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
else:
|
||||
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_properties
|
||||
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?
|
||||
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?
|
||||
WHERE id=?""",
|
||||
(name, options_json, number_format, required, visible, prop_id),
|
||||
(name, options_json, number_format, required, visible, validation_json, prop_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -964,7 +1057,7 @@ async def toggle_inline(request: Request, collection_id: int):
|
||||
|
||||
@router.post("/inline/api")
|
||||
async def create_inline_database(request: Request):
|
||||
"""API: create an inline database within a parent page."""
|
||||
"""API: create an inline database within a parent page (optionally from a template)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -978,16 +1071,30 @@ async def create_inline_database(request: Request):
|
||||
icon = body.get("icon", "📋")
|
||||
parent_page_id = body.get("parent_page_id")
|
||||
workspace_id = body.get("workspace_id")
|
||||
schema = body.get("schema", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
tpl = _apply_template(conn, body.get("template"))
|
||||
if tpl:
|
||||
if body.get("name"):
|
||||
name = body["name"].strip()
|
||||
description = tpl["description"]
|
||||
icon = tpl.get("icon") or icon
|
||||
try:
|
||||
schema = json.loads(tpl["schema_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
schema = []
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id)
|
||||
VALUES (?, ?, ?, '[]', 1, ?)""",
|
||||
(name, description, icon, parent_page_id),
|
||||
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)""",
|
||||
(name, description, icon, json.dumps(schema), parent_page_id, workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
# Create default view
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
@@ -1004,6 +1111,7 @@ async def create_inline_database(request: Request):
|
||||
return {
|
||||
"id": collection_id,
|
||||
"name": name,
|
||||
"icon": icon,
|
||||
"is_inline": True,
|
||||
"parent_page_id": parent_page_id,
|
||||
"status": "created",
|
||||
@@ -1763,7 +1871,7 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
icon = body.get("icon", "📄")
|
||||
property_values = json.dumps(body.get("properties", {}))
|
||||
property_values = body.get("properties", {})
|
||||
gitea_issue_id = body.get("gitea_issue_id")
|
||||
gitea_issue_number = body.get("gitea_issue_number")
|
||||
|
||||
@@ -1772,6 +1880,8 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
_validate_page_properties(conn, collection_id, property_values)
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
@@ -1781,7 +1891,8 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
"""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),
|
||||
(collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number,
|
||||
json.dumps(property_values)),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
Reference in New Issue
Block a user