feat(v1.4.0): Propriétés Avancées — collection_properties, 21 types, API CRUD
- 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:
@@ -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)
|
||||
Reference in New Issue
Block a user