feat(v1.4.0): Propriétés Avancées — collection_properties, 21 types, API CRUD
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- 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
This commit is contained in:
2026-07-09 22:43:16 -04:00
parent d94817bac5
commit 16532d10bd
8 changed files with 473 additions and 22 deletions
+14
View File
@@ -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
+20 -16
View File
@@ -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
+21
View File
@@ -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:
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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,
}
+114
View File
@@ -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) ──
+187
View File
@@ -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)
+114 -3
View File
@@ -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}")