feat(db): v5.3.0 inline databases, predefined templates & property validation
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Skipped

- 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:
2026-09-07 00:53:13 -04:00
parent 0c271d5972
commit e4b196a528
13 changed files with 759 additions and 51 deletions
+127 -16
View File
@@ -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
+6 -11
View File
@@ -231,8 +231,9 @@ async def create_db_template(request: Request):
cur = None
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
"INSERT INTO database_templates (name, description, icon, schema_json) VALUES (?,?,?,?)",
(body.get("name", "Template"), body.get("description", ""),
body.get("icon", "📋"), json.dumps(body.get("schema", []))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@@ -240,22 +241,16 @@ async def create_db_template(request: Request):
@router.post("/templates/database/{tid}/apply")
async def apply_db_template(request: Request, tid: int):
from app.services.db_templates import create_from_template
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "New Database")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
(name, tmpl["description"], "📋", tmpl["schema_json"]),
)
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cur.lastrowid, "Default View", "table", "{}"),
)
collection_id = create_from_template(conn, name, dict(tmpl))
conn.commit()
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
return {"collection_id": collection_id, "name": name, "status": "created"}
@router.get("/collections/{collection_id}/templates/page")