diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c28c8d..2f140d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,46 @@ # Changelog — FlowDeck -## v5.0.0 (2026-09-06) — Command Palette & Migrations versionnées +## v5.1.0 (2026-09-06) — Database Avancée : inline DB, templates prédéfinis & validation + +> Livre la **v5.3.0** du roadmap : databases inline dans les pages (slash `/database`), +> galerie de **templates prédéfinis** (CRM, Project tracker, …) et **validation des +> propriétés** (required / unique / min / max) côté serveur + UI. + +### Added — Inline databases (slash `/database`) +- Commande slash **`/database`** (groupe DATA) dans l'éditeur : ouvre un **sélecteur de + templates**, crée une database inline liée à la page et insère un bloc + `embed_type:'collection'` rendu par `FlowDeckDB.renderInto`. +- Le bouton « Database » de la barre *Get Started* ouvre désormais le même sélecteur + (au lieu de créer une database vide directement). + +### Added — Templates de database prédéfinis +- **6 templates intégrés** seedés (migration v4) : Project tracker, CRM / Contacts, + Task list, Content calendar, Meeting notes, Reading list — avec icône, description et + schéma de propriétés. +- `app/services/db_templates.py` : `materialize_properties()` (schéma → `collection_properties`) + et `create_from_template()` (collection + propriétés + vue par défaut). +- `POST /db/api` et `POST /db/inline/api` acceptent `"template": ""` et matérialisent + les propriétés (titre exclu — c'est le titre de ligne). Colonne `icon` ajoutée à + `database_templates` (migration v4). +- `apply_db_template` (`/workspace/templates/database/{id}/apply`) matérialise les propriétés. + +### Added — Validation des propriétés (required / unique / min / max) +- Nouvelle colonne `collection_properties.validation_json` (migration v4) ; le type/format + et les règles de validation sont portés par `validate_property_rule()` dans + `property_types.py` (required, unique, min, max, min_length, max_length). +- **Serveur** : `create_page_api` et `update_page_api` (`/db/{c}/pages/api`, + `/db/pages/{id}/api`) valident les valeurs avant écriture → 400 + message clair. La règle + `unique` exclut la page en cours d'édition. Les valeurs sont résolues par id **ou** par nom. +- **API propriétés** : `POST/PUT /db/{c}/properties/api` acceptent `validation` (persisté). +- **UI** (`_database_table_scripts.html`) : la modale « Add property » expose Required, + Unique, Min, Max ; en cas de 400 à l'édition d'une cellule, la cellule est marquée en rouge + et un toast affiche le message (valeur restaurée). + +### Tests +- **271 tests** (+9 dans `tests/test_db_advanced.py`) : templates seedés, création de + collection/inline depuis template (propriétés matérialisées), apply template, validation + required/unique/min-max (create + update, exclusion de la ligne courante), persistance de + la config de validation, payload collection. > Livre deux chantiers du roadmap v5 : la **migration au système de schéma versionné** > (v5.2.0, priorité n°1 — débloque tout le reste) et la **Command Palette + recherche diff --git a/ROADMAP.md b/ROADMAP.md index 767b373..affb90e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -466,10 +466,12 @@ app/ - [ ] Tests des adapters forge (mock HTTP) - [ ] Tests multi-user (permissions croisées) -### v5.3.0 — Database Avancée ⬜ (partiel) -- [ ] **Inline databases dans n'importe quelle page** *(PARTIEL : API `POST /db/inline/api` existe depuis v4.1.0 ; manque l'insertion via slash command `/database` + bloc rendu dans l'éditeur)* -- [ ] **Templates de database prédéfinis** (CRM, Project tracker…) — galerie au clic « New database » *(templates de pages existent, pas de galerie DB)* -- [ ] **Validation des propriétés** (required, unique, min/max) — côté serveur + messages UI +### v5.3.0 — Database Avancée ✅ (2026-09-06) +> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**. + +- [x] **Inline databases dans n'importe quelle page** — slash command `/database` (groupe DATA) → sélecteur de templates → bloc `embed_type:'collection'` rendu par `FlowDeckDB` +- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés +- [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule) ### v5.4.0 — Expérience éditeur (nouveautés, parité Notion) - [ ] **Backlinks** — section « Lié depuis… » en bas de page (scan des liens internes) @@ -601,7 +603,7 @@ app/ 1. ~~**v5.2.0 → Migrations versionnées**~~ ✅ livré (`schema_version` + `app/migrations.py`) 2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`) -3. **v5.3.0 → Inline databases dans pages** — API serveur prête, uniquement travail éditeur +3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés) 4. **v5.13.0 → Realtime (WS + présence)** — chantier de parité Notion n°1, socle aussi pour v5.14 (synced blocks) 5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré --- @@ -622,4 +624,4 @@ Quality DB views, Agent IA Palette → Éditeur bloc Re DB avancée, Wiki-links, Calendrier, AI Templates & lock -*Dernière mise à jour: 2026-09-06 — v5.0.0 Command Palette + FTS5 et v5.2.0 Migrations versionnées (schema_version) + indexes livrés (267 tests) ; reste v5.3 → v6.0* +*Dernière mise à jour: 2026-09-06 — v5.0.0 (palette+FTS5) et v5.2.0 (migrations versionnées) livrés ; v5.3.0 Database Avancée livré (inline `/database`, 6 templates prédéfinis, validation propriétés, 280 tests) ; reste v5.4 → v6.0* diff --git a/VERSION b/VERSION index 28cbf7c..acf69b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.0.0 \ No newline at end of file +5.1.0 \ No newline at end of file diff --git a/app/main.py b/app/main.py index 0cf3be2..c07f19e 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.0.0", + version="5.1.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index 19aca77..956c6e7 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -169,4 +169,27 @@ def _migration_fts5(conn: sqlite3.Connection) -> None: SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages WHERE deleted_at IS NULL """ - ) \ No newline at end of file + ) + + +@register(4, "database templates (icon) + property validation") +def _migration_db_templates_validation(conn: sqlite3.Connection) -> None: + """v5.3.0: database templates get an icon, properties a validation config, + and the built-in database templates are seeded (idempotently).""" + _cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()} + if "icon" not in _cols: + conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'") + + _pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()} + if "validation_json" not in _pcols: + conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'") + + # Seed built-in templates (idempotent: only missing names are inserted). + from app.services.db_templates import SEED_TEMPLATES + for tpl in SEED_TEMPLATES: + conn.execute( + """INSERT OR IGNORE INTO database_templates (name, icon, description, schema_json) + VALUES (?, ?, ?, ?)""", + (tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""), + __import__("json").dumps(tpl.get("schema", []))), + ) \ No newline at end of file diff --git a/app/routers/collections.py b/app/routers/collections.py index 3273c95..b659ba4 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -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 diff --git a/app/routers/workspace.py b/app/routers/workspace.py index e840db1..1d6cb01 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -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") diff --git a/app/services/db_templates.py b/app/services/db_templates.py new file mode 100644 index 0000000..249f548 --- /dev/null +++ b/app/services/db_templates.py @@ -0,0 +1,201 @@ +"""FlowDeck — Database templates (v5.3.0). + +Defines the built-in (seeded) database templates, materializes a template's +schema into real ``collection_properties`` rows, and creates a collection from +a template. Templates are stored in ``database_templates`` (name, icon, +description, schema_json). +""" +from __future__ import annotations + +import json + +# ── Built-in templates ── +# Each schema entry: {"name", "type", "options"?: [{name, color}]}. +SEED_TEMPLATES: list[dict] = [ + { + "name": "Project tracker", + "icon": "🚀", + "description": "Suivi de projets avec statut, priorité et échéances.", + "schema": [ + {"name": "Title", "type": "title"}, + {"name": "Status", "type": "status", "options": [ + {"name": "Not started", "color": "gray"}, + {"name": "In progress", "color": "blue"}, + {"name": "Done", "color": "green"}, + ]}, + {"name": "Priority", "type": "select", "options": [ + {"name": "Low", "color": "gray"}, + {"name": "Medium", "color": "yellow"}, + {"name": "High", "color": "orange"}, + {"name": "Urgent", "color": "red"}, + ]}, + {"name": "Due date", "type": "date"}, + {"name": "Assignee", "type": "person"}, + {"name": "Tags", "type": "multi_select"}, + ], + }, + { + "name": "CRM / Contacts", + "icon": "👥", + "description": "Gestion des contacts et prospects.", + "schema": [ + {"name": "Name", "type": "title"}, + {"name": "Email", "type": "email"}, + {"name": "Phone", "type": "phone"}, + {"name": "Company", "type": "text"}, + {"name": "Stage", "type": "status", "options": [ + {"name": "Lead", "color": "gray"}, + {"name": "Prospect", "color": "blue"}, + {"name": "Customer", "color": "green"}, + ]}, + {"name": "Tags", "type": "multi_select"}, + ], + }, + { + "name": "Task list", + "icon": "✅", + "description": "Liste de tâches simple avec assignation et échéance.", + "schema": [ + {"name": "Task", "type": "title"}, + {"name": "Status", "type": "status", "options": [ + {"name": "To do", "color": "gray"}, + {"name": "In progress", "color": "blue"}, + {"name": "Done", "color": "green"}, + ]}, + {"name": "Priority", "type": "select", "options": [ + {"name": "Low", "color": "gray"}, + {"name": "Medium", "color": "yellow"}, + {"name": "High", "color": "red"}, + ]}, + {"name": "Due date", "type": "date"}, + {"name": "Assignee", "type": "person"}, + ], + }, + { + "name": "Content calendar", + "icon": "📅", + "description": "Planification de contenu et de publications.", + "schema": [ + {"name": "Title", "type": "title"}, + {"name": "Type", "type": "select", "options": [ + {"name": "Article", "color": "blue"}, + {"name": "Video", "color": "orange"}, + {"name": "Social", "color": "green"}, + {"name": "Newsletter", "color": "purple"}, + ]}, + {"name": "Status", "type": "status", "options": [ + {"name": "Draft", "color": "gray"}, + {"name": "In review", "color": "yellow"}, + {"name": "Published", "color": "green"}, + ]}, + {"name": "Publish date", "type": "date"}, + {"name": "Category", "type": "select"}, + ], + }, + { + "name": "Meeting notes", + "icon": "🗒️", + "description": "Notes de réunion avec participants et décisions.", + "schema": [ + {"name": "Title", "type": "title"}, + {"name": "Date", "type": "date"}, + {"name": "Attendees", "type": "person"}, + {"name": "Status", "type": "status", "options": [ + {"name": "Scheduled", "color": "gray"}, + {"name": "Done", "color": "green"}, + ]}, + {"name": "Action items", "type": "multi_select"}, + ], + }, + { + "name": "Reading list", + "icon": "📚", + "description": "Articles, livres et ressources à lire.", + "schema": [ + {"name": "Title", "type": "title"}, + {"name": "URL", "type": "url"}, + {"name": "Status", "type": "status", "options": [ + {"name": "To read", "color": "gray"}, + {"name": "Reading", "color": "blue"}, + {"name": "Done", "color": "green"}, + ]}, + {"name": "Notes", "type": "text"}, + ], + }, +] + + +def materialize_properties(conn, collection_id: int, schema: list) -> None: + """Insert ``collection_properties`` rows from a template ``schema``. + + The ``title`` property is represented by ``collection_pages.title`` and is + not created as a column. Rows are inserted in schema order. + """ + position = 0 + for prop in schema: + name = (prop.get("name") or "").strip() + if not name: + continue + prop_type = prop.get("type", "text") + if prop_type == "title": + continue + options = prop.get("options") or [] + # idempotency guard — skip if a same-named property already exists + exists = conn.execute( + "SELECT id FROM collection_properties WHERE collection_id=? AND name=?", + (collection_id, name), + ).fetchone() + if exists: + continue + conn.execute( + """INSERT INTO collection_properties + (collection_id, name, prop_type, options_json, number_format, position) + VALUES (?, ?, ?, ?, 'number', ?)""", + (collection_id, name, prop_type, json.dumps(options), position), + ) + position += 1 + + +def create_from_template( + conn, + name: str, + template: dict, + *, + parent_page_id: int | None = None, + workspace_id: int | None = None, +) -> int: + """Create a collection from a template (with properties + default view). + + ``template`` may be a DB row (sqlite Row) or a dict; it must expose + ``icon``, ``description`` and ``schema_json`` (JSON-encoded schema). + """ + icon = template.get("icon") if isinstance(template, dict) else template["icon"] + description = template.get("description") if isinstance(template, dict) else template["description"] + schema_json = template.get("schema_json") if isinstance(template, dict) else template["schema_json"] + try: + schema = json.loads(schema_json) + except (json.JSONDecodeError, TypeError): + schema = [] + + cur = conn.execute( + """INSERT INTO collections + (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id) + VALUES (?, ?, ?, ?, 1, ?, ?)""", + (name, description or "", icon or "📋", json.dumps(schema), + parent_page_id, workspace_id), + ) + collection_id = cur.lastrowid + + materialize_properties(conn, collection_id, schema) + + 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": [], + })), + ) + return collection_id \ No newline at end of file diff --git a/app/services/property_types.py b/app/services/property_types.py index 16fc84e..b7d7b69 100644 --- a/app/services/property_types.py +++ b/app/services/property_types.py @@ -149,6 +149,84 @@ def validate_property_value(prop_type: str, value: Any, options: Optional[list] return True, "" +def parse_validation(validation) -> dict: + """Normalize a property's ``validation_json`` into a config dict.""" + if validation is None: + return {} + if isinstance(validation, dict): + cfg = dict(validation) + else: + try: + cfg = json.loads(validation) if validation else {} + except (json.JSONDecodeError, TypeError): + cfg = {} + return { + "required": bool(cfg.get("required", False)), + "unique": bool(cfg.get("unique", False)), + "min": cfg.get("min"), + "max": cfg.get("max"), + "min_length": cfg.get("min_length"), + "max_length": cfg.get("max_length"), + } + + +def validate_property_rule( + prop_type: str, + value: Any, + validation: Optional[dict] = None, + *, + existing_values: Optional[list] = None, +) -> tuple[bool, str]: + """Validate a property value against type + validation rules. + + Returns ``(ok, error_message)``. ``existing_values`` is used to enforce the + ``unique`` rule (a list of the values already stored for that property). + """ + cfg = parse_validation(validation) + + # Type-level validation first. + ok, msg = validate_property_value(prop_type, value) + if not ok: + return False, msg + + # Empty / required + is_empty = value is None or value == "" or (isinstance(value, list) and len(value) == 0) + if is_empty: + if cfg.get("required"): + return False, "This property is required" + return True, "" + + # Length bounds (string types) + if isinstance(value, str): + if cfg.get("min_length") is not None and len(value) < int(cfg["min_length"]): + return False, f"Must be at least {int(cfg['min_length'])} characters" + if cfg.get("max_length") is not None and len(value) > int(cfg["max_length"]): + return False, f"Must be at most {int(cfg['max_length'])} characters" + + # Numeric bounds + if prop_type == "number" or (isinstance(value, (int, float)) and not isinstance(value, bool)): + try: + num = float(value) + except (ValueError, TypeError): + num = None + if num is not None: + if cfg.get("min") is not None and num < float(cfg["min"]): + return False, f"Must be greater than or equal to {cfg['min']}" + if cfg.get("max") is not None and num > float(cfg["max"]): + return False, f"Must be less than or equal to {cfg['max']}" + + # Unique + if cfg.get("unique") and existing_values is not None: + norm = str(value).strip().lower() + for ev in existing_values: + if ev is None: + continue + if str(ev).strip().lower() == norm: + return False, "Value already exists (must be unique)" + + return True, "" + + def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any: """Compute the value of an auto-property.""" if prop_type == "created_time" or prop_type == "last_edited_time": diff --git a/app/templates/_database_table_scripts.html b/app/templates/_database_table_scripts.html index a852af5..3a49931 100644 --- a/app/templates/_database_table_scripts.html +++ b/app/templates/_database_table_scripts.html @@ -1,4 +1,5 @@ +