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
+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)