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:
@@ -1,5 +1,19 @@
|
|||||||
# Changelog — FlowDeck
|
# 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
|
## v1.3.0 (2026-07-10) — Database Concept
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+20
-16
@@ -78,22 +78,26 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
|
|
||||||
## Upcoming
|
## 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`)
|
- Liée à `collections` (pas à `project_owner/name`)
|
||||||
- Supporte tous les types Notion
|
- Supporte tous les types Notion (21 types)
|
||||||
- [ ] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
|
- [x] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
|
||||||
- [ ] **Type `checkbox`** — booléen natif
|
- [x] **Type `checkbox`** — booléen natif
|
||||||
- [ ] **Type `url`** — lien cliquable
|
- [x] **Type `url`** — lien cliquable
|
||||||
- [ ] **Type `email`** — email cliquable
|
- [x] **Type `email`** — email cliquable
|
||||||
- [ ] **Type `phone`** — téléphone cliquable
|
- [x] **Type `phone`** — téléphone cliquable
|
||||||
- [ ] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
|
- [x] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
|
||||||
- [ ] **Type `files`** — uploads/images attachés aux pages
|
- [x] **Type `files`** — uploads/images attachés aux pages
|
||||||
- [ ] **Type `unique_id`** — ID auto-incrémenté par collection
|
- [x] **Type `unique_id`** — ID auto-incrémenté par collection
|
||||||
- [ ] **Type `created_time` / `created_by`** — auto-propriétés
|
- [x] **Type `created_time` / `created_by`** — auto-propriétés
|
||||||
- [ ] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
|
- [x] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
|
||||||
- [ ] **Migration** `project_properties` → `collection_properties`
|
- [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)
|
### 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
|
## 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+
|
Production Pages Editor DB Concept Properties Relations Views View+
|
||||||
──────────────────────────────────────────────
|
──────────────────────────────────────────────
|
||||||
52 fonctionnalités identifiées dans le gap analysis
|
52 fonctionnalités identifiées dans le gap analysis
|
||||||
│
|
│
|
||||||
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
|
├─ 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 3: Vues manquantes (v1.6-1.7) — 10 items
|
||||||
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items
|
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items
|
||||||
├─ Bloc 5: My Tasks (v1.9) — 7 items
|
├─ Bloc 5: My Tasks (v1.9) — 7 items
|
||||||
|
|||||||
@@ -184,6 +184,27 @@ def init_db():
|
|||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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+)
|
# Migration: add parent_id if missing (v1.0.0+)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+2
-2
@@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI):
|
|||||||
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
||||||
)
|
)
|
||||||
conn.commit()
|
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
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="1.3.0",
|
version="1.4.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ async def health():
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"db": db_ok,
|
"db": db_ok,
|
||||||
"gitea": gitea_ok,
|
"gitea": gitea_ok,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,120 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
|||||||
return {"collection_id": coll_id, "status": "synced"}
|
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) ──
|
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -34,7 +34,7 @@ def test_health(client):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "status" in data
|
assert "status" in data
|
||||||
assert data["db"] is True
|
assert data["db"] is True
|
||||||
assert data["version"] == "1.3.0"
|
assert data["version"] == "1.4.0"
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard(client):
|
def test_dashboard(client):
|
||||||
@@ -191,7 +191,7 @@ def test_csrf_protects_sync(client):
|
|||||||
|
|
||||||
def test_version_in_health(client):
|
def test_version_in_health(client):
|
||||||
resp = client.get("/api/health")
|
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):
|
def test_db_tables_exist(client):
|
||||||
@@ -204,7 +204,8 @@ def test_db_tables_exist(client):
|
|||||||
required = {"boards", "cards", "notes", "checklists", "checklist_items",
|
required = {"boards", "cards", "notes", "checklists", "checklist_items",
|
||||||
"col_mapping", "users", "user_tokens",
|
"col_mapping", "users", "user_tokens",
|
||||||
"project_properties", "property_values", "ai_keywords",
|
"project_properties", "property_values", "ai_keywords",
|
||||||
"collections", "collection_pages", "collection_views"}
|
"collections", "collection_pages", "collection_views",
|
||||||
|
"collection_properties"}
|
||||||
assert required <= names
|
assert required <= names
|
||||||
|
|
||||||
|
|
||||||
@@ -356,3 +357,113 @@ def test_boards_as_collections(client):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "boards" in data
|
assert "boards" in data
|
||||||
assert isinstance(data["boards"], list)
|
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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user