From 16532d10bd7f0f8df81b44a5995b9301ccaa5ff6 Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 9 Jul 2026 22:43:16 -0400 Subject: [PATCH] =?UTF-8?q?feat(v1.4.0):=20Propri=C3=A9t=C3=A9s=20Avanc?= =?UTF-8?q?=C3=A9es=20=E2=80=94=20collection=5Fproperties,=2021=20types,?= =?UTF-8?q?=20API=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table collection_properties (21 types Notion: number, checkbox, url, email, phone, status, files, unique_id, ...) - Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by - Service property_types.py: validation, formatage, auto-values - API: GET /db/property-types/api, CRUD /db/{id}/properties/api, PUT/DELETE /db/properties/{id}/api - 38/38 tests passent (+4 nouveaux tests v1.4) - Version 1.3.0 → 1.4.0 - Docs: ROADMAP, CHANGELOG, WORKLOAD, README à jour --- CHANGELOG.md | 14 +++ ROADMAP.md | 36 ++++--- app/db.py | 21 ++++ app/main.py | 4 +- app/routers/api.py | 2 +- app/routers/collections.py | 114 ++++++++++++++++++++ app/services/property_types.py | 187 +++++++++++++++++++++++++++++++++ tests/test_app.py | 117 ++++++++++++++++++++- 8 files changed, 473 insertions(+), 22 deletions(-) create mode 100644 app/services/property_types.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b183ab0..2736364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog — FlowDeck +## v1.4.0 (2026-07-10) — Propriétés Avancées + +### Added +- **Table `collection_properties`**: remplace `project_properties`, liée aux collections + - Types: title, text, number, select, multi_select, status, date, person, checkbox, url, email, phone, files, unique_id + - Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by + - Relations/Rollups/Formulas: schéma prêt (colonnes dispo, implémentation v1.5) +- **Service `property_types.py`**: 21 types définis, validation, formatage, auto-values +- **API propriétés**: `GET /db/property-types/api`, CRUD `/{id}/properties/api`, `PUT/DELETE /properties/{id}/api` +- **Tests**: 4 nouveaux tests (38/38 passent, +12%) + +### Changed +- Version 1.3.0 → 1.4.0 + ## v1.3.0 (2026-07-10) — Database Concept ### Added diff --git a/ROADMAP.md b/ROADMAP.md index c1f8a28..43634c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,22 +78,26 @@ Propriétés custom, AI keywords, sync API, 12 tables DB ## Upcoming -### v1.4.0 — Propriétés Avancées (Bloc 2) +### v1.4.0 ✅ — Propriétés Avancées (Bloc 2) +### (2026-07-10) -- [ ] **Table `collection_properties`** — remplace `project_properties` +- [x] **Table `collection_properties`** — remplace `project_properties` - Liée à `collections` (pas à `project_owner/name`) - - Supporte tous les types Notion -- [ ] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥) -- [ ] **Type `checkbox`** — booléen natif -- [ ] **Type `url`** — lien cliquable -- [ ] **Type `email`** — email cliquable -- [ ] **Type `phone`** — téléphone cliquable -- [ ] **Type `status`** — select avec couleurs forcées (9 couleurs Notion) -- [ ] **Type `files`** — uploads/images attachés aux pages -- [ ] **Type `unique_id`** — ID auto-incrémenté par collection -- [ ] **Type `created_time` / `created_by`** — auto-propriétés -- [ ] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés -- [ ] **Migration** `project_properties` → `collection_properties` + - Supporte tous les types Notion (21 types) +- [x] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥) +- [x] **Type `checkbox`** — booléen natif +- [x] **Type `url`** — lien cliquable +- [x] **Type `email`** — email cliquable +- [x] **Type `phone`** — téléphone cliquable +- [x] **Type `status`** — select avec couleurs forcées (9 couleurs Notion) +- [x] **Type `files`** — uploads/images attachés aux pages +- [x] **Type `unique_id`** — ID auto-incrémenté par collection +- [x] **Type `created_time` / `created_by`** — auto-propriétés +- [x] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés +- [x] **Service `property_types.py`** — validation, formatage, auto-values +- [x] **API CRUD** — `/db/property-types/api` + CRUD `/db/{id}/properties/api` +- [x] **38/38 tests** passent (+4 nouveaux tests v1.4) +- [ ] **Migration** `project_properties` → `collection_properties` (v1.5) ### v1.5.0 — Relations & Rollups (Bloc 2 suite) @@ -202,13 +206,13 @@ Propriétés custom, AI keywords, sync API, 12 tables DB ## Résumé des phases ``` -v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ⬜ v1.5.0 ⬜ v1.6.0 ⬜ v1.7.0 ⬜ +v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ⬜ v1.6.0 ⬜ v1.7.0 ⬜ Production Pages Editor DB Concept Properties Relations Views View+ ────────────────────────────────────────────── 52 fonctionnalités identifiées dans le gap analysis │ ├─ Bloc 1: Database Concept (v1.3) — 6 items ✅ - ├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items + ├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items (11/14 ✅) ├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items ├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ├─ Bloc 5: My Tasks (v1.9) — 7 items diff --git a/app/db.py b/app/db.py index de4f6b6..4d30857 100644 --- a/app/db.py +++ b/app/db.py @@ -184,6 +184,27 @@ def init_db(): position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + + -- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties) + CREATE TABLE IF NOT EXISTS collection_properties ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + prop_type TEXT NOT NULL DEFAULT 'text', + options_json TEXT DEFAULT '[]', + number_format TEXT DEFAULT 'number', + related_collection_id INTEGER REFERENCES collections(id), + reverse_name TEXT, + relation_property_id INTEGER REFERENCES collection_properties(id), + target_property_id INTEGER REFERENCES collection_properties(id), + rollup_function TEXT, + formula_expression TEXT, + position INTEGER NOT NULL DEFAULT 0, + required BOOLEAN NOT NULL DEFAULT 0, + visible_in_views BOOLEAN NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, name) + ); """) # Migration: add parent_id if missing (v1.0.0+) try: diff --git a/app/main.py b/app/main.py index a3c9e57..8c884eb 100644 --- a/app/main.py +++ b/app/main.py @@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI): "INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)" ) conn.commit() - logger.info("FlowDeck v1.3.0 started on port %d", settings.app_port) + logger.info("FlowDeck v1.4.0 started on port %d", settings.app_port) yield app = FastAPI( title="FlowDeck", - version="1.3.0", + version="1.4.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/api.py b/app/routers/api.py index ccde543..4def0b9 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -57,7 +57,7 @@ async def health(): return { "status": "ok" if (db_ok and gitea_ok) else "degraded", - "version": "1.3.0", + "version": "1.4.0", "db": db_ok, "gitea": gitea_ok, } diff --git a/app/routers/collections.py b/app/routers/collections.py index c3daf1b..a586b23 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -247,6 +247,120 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str): 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"} + + # ── {collection_id} wildcards (LAST — catches everything else) ── diff --git a/app/services/property_types.py b/app/services/property_types.py new file mode 100644 index 0000000..16fc84e --- /dev/null +++ b/app/services/property_types.py @@ -0,0 +1,187 @@ +"""FlowDeck — Property types & validation (v1.4.0).""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Optional + +# ── Property type definitions ── + +PROPERTY_TYPES: dict[str, dict] = { + "title": { + "storage": "string", + "validation": "max 2000 chars", + "default": "", + }, + "text": { + "storage": "string", + "default": "", + }, + "number": { + "storage": "float", + "validation": "numeric", + "format_options": ["number", "percent", "dollar", "euro", "pound", "yen"], + "default": None, + }, + "select": { + "storage": "string (from options_json)", + "default": None, + }, + "multi_select": { + "storage": "json array of strings", + "default": [], + }, + "status": { + "storage": "string (from options_json) + color", + "default": None, + "colors": ["gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red"], + }, + "date": { + "storage": "ISO 8601 string", + "default": None, + }, + "person": { + "storage": "json array of {id, login, avatar_url}", + "default": [], + }, + "checkbox": { + "storage": "boolean", + "default": False, + }, + "url": { + "storage": "string", + "default": "", + }, + "email": { + "storage": "string", + "default": "", + }, + "phone": { + "storage": "string", + "default": "", + }, + "files": { + "storage": "json array of {url, name}", + "default": [], + }, + "unique_id": { + "storage": "integer (auto-increment per collection)", + "default": None, + }, + "relation": { + "storage": "json array of page IDs", + "default": [], + }, + "rollup": { + "storage": "computed — not stored", + "default": None, + }, + "formula": { + "storage": "computed — not stored", + "default": None, + }, + "created_time": { + "storage": "auto — ISO 8601", + "default": None, + }, + "created_by": { + "storage": "auto — {id, login}", + "default": None, + }, + "last_edited_time": { + "storage": "auto — ISO 8601", + "default": None, + }, + "last_edited_by": { + "storage": "auto — {id, login}", + "default": None, + }, +} + +# CSV-friendly subset (no relation/rollup/formula) +SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status", + "date", "person", "checkbox", "url", "email", "phone", "files"] + +# Auto-property types (computed server-side) +AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"] + + +def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]: + """Validate a property value against its type. Returns (ok, error_message).""" + if value is None: + return True, "" + + if prop_type == "number": + try: + float(value) + except (ValueError, TypeError): + return False, f"'{value}' is not a valid number" + elif prop_type == "checkbox": + if not isinstance(value, bool): + return False, "Checkbox must be true or false" + elif prop_type in ("select", "status"): + if options and value not in [o.get("name", o) if isinstance(o, dict) else o for o in options]: + return False, f"'{value}' is not a valid option" + elif prop_type == "multi_select": + if not isinstance(value, list): + return False, "Multi-select must be a list" + if options: + valid_names = {o.get("name", o) if isinstance(o, dict) else o for o in options} + for v in value: + if v not in valid_names: + return False, f"'{v}' is not a valid option" + elif prop_type == "date": + if isinstance(value, str) and value: + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, TypeError): + return False, f"'{value}' is not a valid ISO 8601 date" + elif prop_type == "url": + if not isinstance(value, str): + return False, "URL must be a string" + elif prop_type == "email": + if not isinstance(value, str) or ("@" not in value if value else False): + return False, "Invalid email format" + elif prop_type == "phone": + if not isinstance(value, str): + return False, "Phone must be a string" + + 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": + return datetime.now(timezone.utc).isoformat() + if prop_type == "created_by" or prop_type == "last_edited_by": + if user: + return {"id": user.get("id"), "login": user.get("login")} + return None + return None + + +def get_next_unique_id(collection_id: int, conn) -> int: + """Get the next unique_id for a collection (max + 1).""" + row = conn.execute( + """SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1 + FROM collection_pages WHERE collection_id=?""", + (collection_id,), + ).fetchone() + return row[0] if row else 1 + + +def format_number(value: float, fmt: str = "number") -> str: + """Format a number value for display.""" + if value is None: + return "" + if fmt == "percent": + return f"{value}%" + elif fmt == "dollar": + return f"${value:,.2f}" + elif fmt == "euro": + return f"€{value:,.2f}" + elif fmt == "pound": + return f"£{value:,.2f}" + elif fmt == "yen": + return f"¥{value:,.0f}" + return str(value) diff --git a/tests/test_app.py b/tests/test_app.py index 7bb6986..3129be1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -34,7 +34,7 @@ def test_health(client): data = resp.json() assert "status" in data assert data["db"] is True - assert data["version"] == "1.3.0" + assert data["version"] == "1.4.0" def test_dashboard(client): @@ -191,7 +191,7 @@ def test_csrf_protects_sync(client): def test_version_in_health(client): resp = client.get("/api/health") - assert resp.json()["version"] == "1.3.0" + assert resp.json()["version"] == "1.4.0" def test_db_tables_exist(client): @@ -204,7 +204,8 @@ def test_db_tables_exist(client): required = {"boards", "cards", "notes", "checklists", "checklist_items", "col_mapping", "users", "user_tokens", "project_properties", "property_values", "ai_keywords", - "collections", "collection_pages", "collection_views"} + "collections", "collection_pages", "collection_views", + "collection_properties"} assert required <= names @@ -356,3 +357,113 @@ def test_boards_as_collections(client): data = resp.json() assert "boards" in data assert isinstance(data["boards"], list) + + +# ── v1.4.0: Collection Properties ── + +def test_property_types_api(client): + """GET /db/property-types/api — should list available types.""" + resp = client.get("/db/property-types/api") + assert resp.status_code == 200 + data = resp.json() + assert "types" in data + assert "text" in data["types"] + assert "number" in data["types"] + assert "checkbox" in data["types"] + assert "status" in data["types"] + + +def test_collection_properties_crud(client): + """Full CRUD on collection properties.""" + # Create collection + resp = client.post("/db/api", json={"name": "Props Test DB"}) + coll_id = resp.json()["id"] + + # List properties (empty) + resp = client.get(f"/db/{coll_id}/properties/api") + assert resp.status_code == 200 + assert resp.json()["properties"] == [] + + # Create a text property + resp = client.post(f"/db/{coll_id}/properties/api", json={ + "name": "Description", + "prop_type": "text", + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "created" + prop_id = resp.json()["id"] + + # Create a number property + resp = client.post(f"/db/{coll_id}/properties/api", json={ + "name": "Estimation", + "prop_type": "number", + "number_format": "number", + }) + assert resp.status_code == 200 + + # Create a status property with options + resp = client.post(f"/db/{coll_id}/properties/api", json={ + "name": "State", + "prop_type": "status", + "options": [ + {"name": "Todo", "color": "gray"}, + {"name": "Done", "color": "green"}, + ], + }) + assert resp.status_code == 200 + + # Create a checkbox property + resp = client.post(f"/db/{coll_id}/properties/api", json={ + "name": "Verified", + "prop_type": "checkbox", + }) + assert resp.status_code == 200 + + # List — should have 4 + resp = client.get(f"/db/{coll_id}/properties/api") + assert len(resp.json()["properties"]) == 4 + + # Update a property + resp = client.put(f"/db/properties/{prop_id}/api", json={ + "name": "Description Longue", + }) + assert resp.status_code == 200 + + # Verify update + resp = client.get(f"/db/{coll_id}/properties/api") + names = [p["name"] for p in resp.json()["properties"]] + assert "Description Longue" in names + + # Delete a property + resp = client.delete(f"/db/properties/{prop_id}/api") + assert resp.status_code == 200 + + # Verify deletion + resp = client.get(f"/db/{coll_id}/properties/api") + assert len(resp.json()["properties"]) == 3 + + # Cleanup + client.delete(f"/db/api/{coll_id}") + + +def test_collection_properties_duplicate(client): + """Creating duplicate property name should return 409.""" + resp = client.post("/db/api", json={"name": "Dup Test"}) + coll_id = resp.json()["id"] + + client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"}) + resp = client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"}) + assert resp.status_code == 409 + + client.delete(f"/db/api/{coll_id}") + + +def test_collection_properties_validation(client): + """Validation: missing name should return 400.""" + resp = client.post("/db/api", json={"name": "Val Test"}) + coll_id = resp.json()["id"] + + resp = client.post(f"/db/{coll_id}/properties/api", json={}) + assert resp.status_code == 400 + + client.delete(f"/db/api/{coll_id}")