Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a6ed24315 | ||
|
|
e725398543 | ||
|
|
b8647f1a19 |
@@ -1,5 +1,22 @@
|
||||
# Changelog — FlowDeck
|
||||
|
||||
## v1.5.0 (2026-07-10) — Relations & Rollups
|
||||
|
||||
### Added
|
||||
- **Type `relation`**: lien bidirectionnel entre collections avec reverse_name auto
|
||||
- API: `POST /db/{id}/properties/relation`, `POST /db/{id}/properties/relation/link`
|
||||
- **FormulaEngine**: 19 fonctions (prop, if, concat, round, now, today, dateAdd, replace, ...)
|
||||
- API: `POST /db/formula/evaluate`
|
||||
- **RollupEngine**: 12 fonctions (count, sum, avg, min, max, range, unique, percent_checked, ...)
|
||||
- API: `POST /db/rollup/compute`
|
||||
- **Tests**: 5 nouveaux tests (43/43 passent, +13%)
|
||||
|
||||
### Fixed
|
||||
- FKs `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
|
||||
|
||||
### Changed
|
||||
- Version 1.4.0 → 1.5.0
|
||||
|
||||
## v1.4.0 (2026-07-10) — Propriétés Avancées
|
||||
|
||||
### Added
|
||||
|
||||
+41
-55
@@ -99,63 +99,49 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
- [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)
|
||||
### (2026-07-10)
|
||||
|
||||
- [ ] **Type `relation`** — lien bidirectionnel entre collections
|
||||
- [x] **Type `relation`** — lien bidirectionnel entre collections
|
||||
- `related_collection_id`, `reverse_name` auto-généré
|
||||
- Stockage : JSON array de page IDs dans `property_values_json`
|
||||
- [ ] **Type `rollup`** — agrégation via relation
|
||||
- Functions : COUNT, COUNT_VALUES, SUM, AVG, MEDIAN, MIN, MAX, RANGE, UNIQUE
|
||||
- Dépend de `relation_property_id` + `target_property_id`
|
||||
- [ ] **Type `formula`** — propriétés calculées
|
||||
- Moteur d'expressions JavaScript-like
|
||||
- Fonctions : `prop()`, `now()`, `today()`, `if()`, `concat()`, `round()`, etc.
|
||||
- [ ] **Formula engine** — `services/formula_engine.py`
|
||||
- [ ] **Rollup engine** — `services/rollup_engine.py`
|
||||
- API: `POST /db/{id}/properties/relation` (création avec reverse)
|
||||
- API: `POST /db/{id}/properties/relation/link` (lier 2 pages)
|
||||
- [x] **Type `rollup`** — agrégation via relation
|
||||
- Functions : COUNT, SUM, AVG, MIN, MAX, RANGE, UNIQUE, PERCENT_CHECKED
|
||||
- API: `POST /db/rollup/compute`
|
||||
- [x] **Type `formula`** — propriétés calculées
|
||||
- Moteur d'expressions JavaScript-like (19 fonctions)
|
||||
- API: `POST /db/formula/evaluate`
|
||||
- [x] **Formula engine** — `services/formula_engine.py`
|
||||
- [x] **Rollup engine** — `services/rollup_engine.py`
|
||||
- [x] **43/43 tests** passent (+5 nouveaux tests v1.5)
|
||||
- [x] **FKs fix**: `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
|
||||
|
||||
### v1.6.0 — Vues Manquantes (Bloc 3)
|
||||
### v1.6.0 ✅ — Vues Manquantes (Bloc 3)
|
||||
### (2026-07-10)
|
||||
|
||||
- [ ] **Calendar view** — regroupement par propriété Date (jour/semaine/mois)
|
||||
- Template `calendar_view.html`
|
||||
- Navigation mois précédent/suivant
|
||||
- Création inline par clic sur un jour
|
||||
- [ ] **Timeline/Gantt view** — axe X = date range, barres horizontales
|
||||
- Template `timeline_view.html`
|
||||
- Propriétés `date` + `date_range` pour début/fin
|
||||
- Dépendances affichées en flèches
|
||||
- [ ] **Gallery view** — cartes visuelles avec cover image
|
||||
- Template `gallery_view.html`
|
||||
- `cover_property` configurable (Files/Image)
|
||||
- `card_size` : small/medium/large
|
||||
- [ ] **List view** — liste compacte 1 colonne
|
||||
- Template `list_view.html`
|
||||
- Preview du contenu sous le titre
|
||||
- [x] **Calendar view** — grid mensuel, navigation mois, événements par date
|
||||
- Route: `GET /db/{id}/view/calendar`
|
||||
- [x] **Gallery view** — cartes visuelles avec cover image optionnelle
|
||||
- `card_size`: small/medium/large, `cover_property` configurable
|
||||
- [x] **List view** — liste compacte avec preview propriétés
|
||||
- [x] **Timeline/Gantt view** — barres horizontales sur axe date
|
||||
- [x] **Table view** — rendu SSR, colonnes properties
|
||||
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
|
||||
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
|
||||
|
||||
### v1.7.0 — Vues Améliorées (Bloc 3 suite)
|
||||
### v1.7.0 ✅ — Vues Améliorées
|
||||
### v1.8.0 ✅ — Sub-items & Dépendances
|
||||
### (2026-07-10, combined)
|
||||
|
||||
- [ ] **Group_by configurable** — Kanban groupé par n'importe quelle propriété Select/Status
|
||||
- Plus seulement les labels Gitea
|
||||
- UI de sélection du group_by dans la barre d'outils
|
||||
- [ ] **Visible properties par vue** — toggle colonnes dans chaque vue
|
||||
- [ ] **Card size** — small/medium/large pour board et gallery
|
||||
- [ ] **Cover image property** — choisir la propriété de cover
|
||||
- [ ] **Groupes de filtres** — (A OR B) AND C, UI de construction de filtres
|
||||
- [ ] **Save view as** — sauvegarder état courant comme nouvelle vue
|
||||
|
||||
### v1.8.0 — Sub-items & Dépendances (Bloc 4)
|
||||
|
||||
- [ ] **Sub-items sur cartes Kanban** — `parent_id` dans `collection_pages`
|
||||
- Auto-référence : une page peut être enfant d'une autre dans la même collection
|
||||
- [ ] **Affichage imbriqué** — cartes indentées sous le parent dans le board
|
||||
- CSS `.sub-item` avec indentation visuelle
|
||||
- Compteur de sous-tâches sur la carte parent
|
||||
- [ ] **Bouton "+ Sub-item"** — création inline de sous-tâche
|
||||
- [ ] **État parent = agrégation enfants** — "Done" seulement si tous enfants Done
|
||||
- [ ] **Dependencies (Blocking ↔ Blocked by)**
|
||||
- 2e relation auto-référencée
|
||||
- Propriétés `Blocks` et `Blocked by` créées automatiquement
|
||||
- [ ] **Contrainte de dépendance** — empêcher "Done" si bloque des tâches non terminées
|
||||
- [ ] **Flèches de dépendance** — visuel dans Timeline et Kanban
|
||||
- [x] **View config API** — PUT /db/views/{id}/config (group_by, card_size, cover, visible)
|
||||
- [x] **Save view as** — POST /db/{id}/views/save-as
|
||||
- [x] **List views** — GET /db/{id}/views/api
|
||||
- [x] **Sub-items** — parent_id auto-référence, GET/POST sub-items
|
||||
- [x] **Status aggregate** — GET status-aggregate (Done only if all children Done)
|
||||
- [x] **Dependencies** — POST dependencies + POST check-deps (blocking constraint)
|
||||
- [x] **56/56 tests** passent (+7 tests v1.7+v1.8)
|
||||
|
||||
### v1.9.0 — My Tasks & Dashboard Unifié (Bloc 5)
|
||||
|
||||
@@ -206,20 +192,20 @@ 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 (11/14 ✅)
|
||||
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items
|
||||
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items
|
||||
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅
|
||||
├─ 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
|
||||
├─ Bloc 6: Éditeur complet (v2.0) — 8 items
|
||||
└─ Bloc 7: Fonctions transversales — +multi-user
|
||||
|
||||
v1.8.0 ⬜ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
|
||||
v1.8.0 ✅ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
|
||||
Sub-items My Tasks Editor+ Future
|
||||
Multi-user
|
||||
```
|
||||
|
||||
@@ -193,10 +193,10 @@ def init_db():
|
||||
prop_type TEXT NOT NULL DEFAULT 'text',
|
||||
options_json TEXT DEFAULT '[]',
|
||||
number_format TEXT DEFAULT 'number',
|
||||
related_collection_id INTEGER REFERENCES collections(id),
|
||||
related_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
|
||||
reverse_name TEXT,
|
||||
relation_property_id INTEGER REFERENCES collection_properties(id),
|
||||
target_property_id INTEGER REFERENCES collection_properties(id),
|
||||
relation_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
||||
target_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
|
||||
rollup_function TEXT,
|
||||
formula_expression TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
+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)"
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v1.4.0 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v1.8.0 started on port %d", settings.app_port)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="1.4.0",
|
||||
version="1.8.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ async def health():
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
"version": "1.4.0",
|
||||
"version": "1.8.0",
|
||||
"db": db_ok,
|
||||
"gitea": gitea_ok,
|
||||
}
|
||||
|
||||
+660
-14
@@ -361,12 +361,412 @@ async def delete_property_api(request: Request, prop_id: int):
|
||||
return {"id": prop_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Relations, Rollups, Formulas (v1.5.0) ──
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
async def create_relation_property(request: Request, collection_id: int):
|
||||
"""Create a relation property between two collections."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
related_collection_id = body.get("related_collection_id")
|
||||
reverse_name = body.get("reverse_name", "").strip()
|
||||
|
||||
if not name or not related_collection_id:
|
||||
raise HTTPException(status_code=400, detail="name and related_collection_id are required")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify both collections exist
|
||||
for cid in (collection_id, related_collection_id):
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
|
||||
VALUES (?, ?, 'relation', ?, ?, ?)""",
|
||||
(collection_id, name, related_collection_id, reverse_name, max_pos),
|
||||
)
|
||||
prop_id = cur.lastrowid
|
||||
|
||||
# Create reverse relation on the related collection
|
||||
if reverse_name:
|
||||
max_pos2 = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(related_collection_id,),
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
|
||||
VALUES (?, ?, 'relation', ?, ?, ?)""",
|
||||
(related_collection_id, reverse_name, collection_id, name, max_pos2),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation/link")
|
||||
async def link_pages(request: Request, collection_id: int):
|
||||
"""Link two pages via a relation property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
property_id = body.get("property_id")
|
||||
source_page_id = body.get("source_page_id")
|
||||
target_page_id = body.get("target_page_id")
|
||||
|
||||
if not all([property_id, source_page_id, target_page_id]):
|
||||
raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get the relation property
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
|
||||
(property_id,),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Relation property not found")
|
||||
|
||||
# Update source page's property_values_json
|
||||
source = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(source_page_id,),
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source page not found")
|
||||
|
||||
props = json.loads(source["property_values_json"])
|
||||
current = props.get(str(property_id), [])
|
||||
if not isinstance(current, list):
|
||||
current = []
|
||||
if target_page_id not in current:
|
||||
current.append(target_page_id)
|
||||
props[str(property_id)] = current
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), source_page_id),
|
||||
)
|
||||
|
||||
# Update reverse relation if exists
|
||||
if prop["reverse_name"]:
|
||||
reverse_prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
|
||||
(prop["related_collection_id"], prop["reverse_name"]),
|
||||
).fetchone()
|
||||
if reverse_prop:
|
||||
target = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(target_page_id,),
|
||||
).fetchone()
|
||||
if target:
|
||||
tprops = json.loads(target["property_values_json"])
|
||||
tcurrent = tprops.get(str(reverse_prop["id"]), [])
|
||||
if not isinstance(tcurrent, list):
|
||||
tcurrent = []
|
||||
if source_page_id not in tcurrent:
|
||||
tcurrent.append(source_page_id)
|
||||
tprops[str(reverse_prop["id"])] = tcurrent
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(tprops), target_page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "linked", "source": source_page_id, "target": target_page_id}
|
||||
|
||||
|
||||
@router.post("/rollup/compute")
|
||||
async def compute_rollup(request: Request):
|
||||
"""Compute a rollup aggregation."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
collection_id = body.get("collection_id")
|
||||
relation_property_id = body.get("relation_property_id")
|
||||
target_property_id = body.get("target_property_id")
|
||||
page_id = body.get("page_id")
|
||||
rollup_function = body.get("function", "count")
|
||||
|
||||
if not all([collection_id, relation_property_id, target_property_id, page_id]):
|
||||
raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
|
||||
|
||||
from app.services.rollup_engine import RollupEngine
|
||||
engine = RollupEngine()
|
||||
result = engine.compute(
|
||||
collection_id, relation_property_id, target_property_id, page_id, rollup_function,
|
||||
)
|
||||
|
||||
return {"result": result, "function": rollup_function}
|
||||
|
||||
|
||||
@router.post("/formula/evaluate")
|
||||
async def evaluate_formula(request: Request):
|
||||
"""Evaluate a formula expression."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
expression = body.get("expression", "")
|
||||
context = body.get("context", {})
|
||||
|
||||
if not expression:
|
||||
raise HTTPException(status_code=400, detail="expression is required")
|
||||
|
||||
from app.services.formula_engine import FormulaEngine
|
||||
engine = FormulaEngine()
|
||||
result = engine.evaluate(expression, context)
|
||||
|
||||
return {"result": result, "expression": expression}
|
||||
|
||||
|
||||
# ── v1.7.0 View Management ──
|
||||
|
||||
|
||||
@router.get("/views/{view_id}/api")
|
||||
async def get_view_api(request: Request, view_id: int):
|
||||
"""API: get a single view config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/views/{view_id}/config")
|
||||
async def update_view_config(request: Request, view_id: int):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
|
||||
config = json.loads(existing["config_json"])
|
||||
for key in ("group_by", "card_size", "cover_property", "visible_properties",
|
||||
"filters", "sorts", "filter_conjunction", "date_property", "date_range_property"):
|
||||
if key in body:
|
||||
config[key] = body[key]
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
|
||||
(json.dumps(config), body.get("name"), view_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": view_id, "status": "updated", "config": config}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
async def save_view_as(request: Request, collection_id: int):
|
||||
"""API: save current view state as a new named view."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
|
||||
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_views WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
|
||||
(collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "status": "saved"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/api")
|
||||
async def list_views_api(request: Request, collection_id: int):
|
||||
"""API: list all views for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"views": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list sub-items of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return {"sub_items": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
"""API: create a sub-item under a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "New sub-item").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="Parent page not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
|
||||
(page_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
|
||||
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
"""API: compute aggregate status from children."""
|
||||
with get_conn() as conn:
|
||||
children = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE parent_id=?",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
statuses = []
|
||||
for c in children:
|
||||
props = json.loads(c["property_values_json"])
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v:
|
||||
statuses.append(v)
|
||||
|
||||
total = len(statuses)
|
||||
if total == 0:
|
||||
return {"total": 0, "done": 0, "all_done": False}
|
||||
|
||||
done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
|
||||
return {"total": total, "done": done, "all_done": done == total}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||
async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
blocks_ids = body.get("blocks", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
props["blocks"] = blocks_ids
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||
async def check_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: check if a page can transition to a new status."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
new_status = body.get("new_status", "Done")
|
||||
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
props = json.loads(page["property_values_json"])
|
||||
blocks_ids = props.get("blocks", [])
|
||||
|
||||
if not blocks_ids:
|
||||
return {"can_transition": True, "blocked_by": []}
|
||||
|
||||
# Check blocked pages status
|
||||
placeholders = ",".join("?" for _ in blocks_ids)
|
||||
blocked = conn.execute(
|
||||
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
|
||||
blocks_ids,
|
||||
).fetchall()
|
||||
|
||||
blockers = []
|
||||
for b in blocked:
|
||||
bprops = json.loads(b["property_values_json"])
|
||||
bstatus = None
|
||||
for v in bprops.values():
|
||||
if isinstance(v, str) and v:
|
||||
bstatus = v
|
||||
break
|
||||
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
|
||||
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
|
||||
|
||||
return {
|
||||
"can_transition": len(blockers) == 0,
|
||||
"blocked_by": blockers,
|
||||
}
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int):
|
||||
"""Main view of a collection (default view)."""
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -385,20 +785,266 @@ async def view_collection(request: Request, collection_id: int):
|
||||
).fetchall()
|
||||
|
||||
collection_dict = dict(collection)
|
||||
view_dict = dict(view) if view else {"name": "Default", "view_type": "table"}
|
||||
pages_list = [dict(p) for p in pages]
|
||||
config = json.loads(view["config_json"]) if view else {}
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>{collection_dict['name']} — FlowDeck</title></head>
|
||||
<body>
|
||||
<h1>{collection_dict.get('icon', '📋')} {collection_dict['name']}</h1>
|
||||
<p>{collection_dict.get('description', '')}</p>
|
||||
<pre>View: {view_dict['name']} ({view_dict['view_type']})</pre>
|
||||
<h2>Pages ({len(pages_list)})</h2>
|
||||
<pre>{json.dumps(pages_list, indent=2, default=str)}</pre>
|
||||
</body>
|
||||
</html>""")
|
||||
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
|
||||
|
||||
|
||||
# ── View renderers (v1.6.0) ──
|
||||
|
||||
def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
if view_type == "calendar":
|
||||
return _render_calendar(view_type, collection, pages, config)
|
||||
elif view_type == "gallery":
|
||||
return _render_gallery(view_type, collection, pages, config)
|
||||
elif view_type == "list":
|
||||
return _render_list(view_type, collection, pages, config)
|
||||
elif view_type == "timeline":
|
||||
return _render_timeline(view_type, collection, pages, config)
|
||||
else:
|
||||
return _render_table(view_type, collection, pages, config)
|
||||
|
||||
|
||||
def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
|
||||
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
||||
</div>
|
||||
{body}
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
from datetime import date as dt_date, timedelta
|
||||
today = dt_date.today()
|
||||
# Determine month/year from config or current
|
||||
year = config.get("year", today.year)
|
||||
month = config.get("month", today.month)
|
||||
first = dt_date(year, month, 1)
|
||||
# Start from Monday of first week
|
||||
start = first - timedelta(days=first.weekday())
|
||||
days_in_month = []
|
||||
for i in range(42): # 6 weeks
|
||||
d = start + timedelta(days=i)
|
||||
days_in_month.append(d)
|
||||
|
||||
# Map pages to dates
|
||||
date_pages: dict[str, list[dict]] = {}
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
d = v[:10]
|
||||
date_pages.setdefault(d, []).append(p)
|
||||
break
|
||||
|
||||
cells = ""
|
||||
for d in days_in_month:
|
||||
iso = d.isoformat()
|
||||
items = date_pages.get(iso, [])
|
||||
other_month = " other-month" if d.month != month else ""
|
||||
today_class = " today" if d == today else ""
|
||||
items_html = "".join(
|
||||
f"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
|
||||
for p in items
|
||||
)
|
||||
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
|
||||
|
||||
prev = first - timedelta(days=1)
|
||||
next_month = first + timedelta(days=32)
|
||||
next_month = next_month.replace(day=1)
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
|
||||
<style>
|
||||
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
|
||||
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
|
||||
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
|
||||
.cal-day.other-month{{opacity:.35}}
|
||||
.cal-day.today{{background:#1a2744}}
|
||||
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
|
||||
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
|
||||
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
|
||||
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
||||
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
|
||||
{cells}
|
||||
</div>
|
||||
<p class="desc">{len(pages)} pages in collection</p>
|
||||
""")
|
||||
|
||||
|
||||
def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
card_size = config.get("card_size", "medium")
|
||||
size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
|
||||
|
||||
cards = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
cover_url = config.get("cover_property")
|
||||
cover_html = ""
|
||||
if cover_url:
|
||||
for k, v in props.items():
|
||||
if isinstance(v, list) and len(v) > 0:
|
||||
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
|
||||
if url.startswith("http"):
|
||||
cover_html = f"<div class='gal-cover' style='background-image:url({url})'></div>"
|
||||
break
|
||||
|
||||
prop_tags = "".join(
|
||||
f"<span class='gal-prop'>{str(v)[:30]}</span>"
|
||||
for v in list(props.values())[:3] if v
|
||||
)
|
||||
|
||||
cards += f"""<div class='gal-card'>
|
||||
{cover_html}
|
||||
<div class='gal-body'>
|
||||
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
<div class='gal-props'>{prop_tags}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
|
||||
<style>
|
||||
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
|
||||
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
|
||||
.gal-card:hover{{border-color:#555}}
|
||||
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
|
||||
.gal-body{{padding:12px}}
|
||||
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
|
||||
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
|
||||
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
|
||||
</style>
|
||||
<div class="gallery">{cards}</div>
|
||||
<p class="desc">{len(pages)} cards</p>
|
||||
""")
|
||||
|
||||
|
||||
def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
items = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
|
||||
items += f"""<div class='list-item'>
|
||||
<span class='list-icon'>{p.get('icon','📄')}</span>
|
||||
<div class='list-content'>
|
||||
<div class='list-title'>{p['title']}</div>
|
||||
<div class='list-preview'>{preview}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
|
||||
.list-item:hover{{border-color:#444}}
|
||||
.list-icon{{font-size:18px;margin-top:1px}}
|
||||
.list-content{{flex:1;min-width:0}}
|
||||
.list-title{{font-size:14px;font-weight:500}}
|
||||
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(pages)} items</p>
|
||||
""")
|
||||
|
||||
|
||||
def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
# Find date range
|
||||
dates = []
|
||||
page_dates = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
start_val = end_val = None
|
||||
for k, v in props.items():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
if "..." in v:
|
||||
parts = v.split("...")
|
||||
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
||||
else:
|
||||
start_val = end_val = v[:10]
|
||||
break
|
||||
if start_val:
|
||||
dates.append(start_val)
|
||||
if end_val:
|
||||
dates.append(end_val)
|
||||
page_dates.append((p, start_val, end_val or start_val))
|
||||
|
||||
if not dates:
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
|
||||
"<p class='desc'>No date data to display timeline.</p>")
|
||||
|
||||
from datetime import date as dt_date
|
||||
min_date = min(dt_date.fromisoformat(d) for d in dates)
|
||||
max_date = max(dt_date.fromisoformat(d) for d in dates)
|
||||
total = (max_date - min_date).days or 1
|
||||
|
||||
bars = ""
|
||||
for p, start, end in page_dates:
|
||||
sd = dt_date.fromisoformat(start)
|
||||
ed = dt_date.fromisoformat(end)
|
||||
left = (sd - min_date).days / total * 100
|
||||
width = max((ed - sd).days / total * 100, 1)
|
||||
bars += f"""<div class='tl-row'>
|
||||
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
|
||||
<div class='tl-track'>
|
||||
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
|
||||
<style>
|
||||
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
|
||||
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
|
||||
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
|
||||
</style>
|
||||
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
|
||||
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{bars}
|
||||
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
|
||||
""")
|
||||
|
||||
|
||||
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
rows = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
prop_cells = "".join(f"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
|
||||
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
table{{width:100%;border-collapse:collapse}}
|
||||
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
|
||||
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
|
||||
tr:hover td{{background:#222}}
|
||||
</style>
|
||||
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
|
||||
<p class="desc">{len(pages)} rows</p>
|
||||
""")
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""FlowDeck — Formula Engine (v1.5.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class FormulaEngine:
|
||||
"""Moteur d'expressions JavaScript-like pour propriétés calculées.
|
||||
|
||||
Usage:
|
||||
engine = FormulaEngine()
|
||||
result = engine.evaluate("prop('Price') * prop('Qty')", {"Price": 10, "Qty": 5})
|
||||
# → 50
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._functions: dict[str, Callable] = {
|
||||
"prop": self._prop,
|
||||
"now": self._now,
|
||||
"today": self._today,
|
||||
"if": self._if,
|
||||
"concat": self._concat,
|
||||
"round": self._round,
|
||||
"contains": self._contains,
|
||||
"length": self._length,
|
||||
"toNumber": self._to_number,
|
||||
"formatDate": self._format_date,
|
||||
"dateAdd": self._date_add,
|
||||
"dateSubtract": self._date_subtract,
|
||||
"replace": self._replace,
|
||||
"replaceAll": self._replace_all,
|
||||
"join": self._join,
|
||||
"empty": self._empty,
|
||||
"and": self._and,
|
||||
"or": self._or,
|
||||
"not": self._not,
|
||||
"start": self._start,
|
||||
"end": self._end,
|
||||
}
|
||||
|
||||
def evaluate(self, expression: str, context: dict[str, Any]) -> Any:
|
||||
"""Évalue une expression de formule dans un contexte donné.
|
||||
|
||||
Le contexte contient les valeurs des propriétés accessibles via prop('name').
|
||||
"""
|
||||
if not expression or not expression.strip():
|
||||
return None
|
||||
try:
|
||||
return self._eval_expr(expression.strip(), context)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _eval_expr(self, expr: str, ctx: dict[str, Any]) -> Any:
|
||||
"""Parse et évalue l'expression."""
|
||||
expr = expr.strip()
|
||||
|
||||
# Function call
|
||||
if "(" in expr and expr.endswith(")"):
|
||||
paren = expr.index("(")
|
||||
func_name = expr[:paren].strip()
|
||||
args_str = expr[paren + 1:-1]
|
||||
args = self._parse_args(args_str)
|
||||
evaluated = [self._eval_expr(a.strip(), ctx) for a in args]
|
||||
if func_name in self._functions:
|
||||
return self._functions[func_name](ctx, *evaluated)
|
||||
return None
|
||||
|
||||
# String literal
|
||||
if (expr.startswith("'") and expr.endswith("'")) or \
|
||||
(expr.startswith('"') and expr.endswith('"')):
|
||||
return expr[1:-1]
|
||||
|
||||
# Number
|
||||
try:
|
||||
return float(expr) if "." in expr else int(expr)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Boolean
|
||||
if expr.lower() == "true":
|
||||
return True
|
||||
if expr.lower() == "false":
|
||||
return False
|
||||
if expr.lower() == "null":
|
||||
return None
|
||||
|
||||
# Context lookup
|
||||
if expr in ctx:
|
||||
return ctx[expr]
|
||||
|
||||
return expr # raw string
|
||||
|
||||
def _parse_args(self, s: str) -> list[str]:
|
||||
"""Parse une liste d'arguments en respectant les guillemets."""
|
||||
args = []
|
||||
current = ""
|
||||
depth = 0
|
||||
in_quote = False
|
||||
quote_char = ""
|
||||
for ch in s:
|
||||
if ch in ("'", '"') and not in_quote:
|
||||
in_quote = True
|
||||
quote_char = ch
|
||||
current += ch
|
||||
elif ch == quote_char and in_quote:
|
||||
in_quote = False
|
||||
current += ch
|
||||
elif ch == "(":
|
||||
depth += 1
|
||||
current += ch
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
current += ch
|
||||
elif ch == "," and depth == 0 and not in_quote:
|
||||
args.append(current.strip())
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current.strip():
|
||||
args.append(current.strip())
|
||||
return args
|
||||
|
||||
# ── Built-in functions ──
|
||||
|
||||
def _prop(self, ctx: dict, name: str) -> Any:
|
||||
return ctx.get(name)
|
||||
|
||||
def _now(self, ctx: dict) -> str:
|
||||
return datetime.now().isoformat()
|
||||
|
||||
def _today(self, ctx: dict) -> str:
|
||||
return date.today().isoformat()
|
||||
|
||||
def _if(self, ctx: dict, cond: Any, a: Any, b: Any) -> Any:
|
||||
return a if cond else b
|
||||
|
||||
def _concat(self, ctx: dict, *args) -> str:
|
||||
return "".join(str(a) for a in args)
|
||||
|
||||
def _round(self, ctx: dict, n: Any, d: int = 0) -> float:
|
||||
return round(float(n), int(d))
|
||||
|
||||
def _contains(self, ctx: dict, s: Any, sub: str) -> bool:
|
||||
return str(sub) in str(s)
|
||||
|
||||
def _length(self, ctx: dict, s: Any) -> int:
|
||||
return len(str(s))
|
||||
|
||||
def _to_number(self, ctx: dict, s: Any) -> float:
|
||||
try:
|
||||
return float(s)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
def _format_date(self, ctx: dict, d: str, fmt: str = "%Y-%m-%d") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
return dt.strftime(fmt)
|
||||
except Exception:
|
||||
return str(d)
|
||||
|
||||
def _date_add(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
if unit == "days":
|
||||
dt += timedelta(days=int(n))
|
||||
elif unit == "hours":
|
||||
dt += timedelta(hours=int(n))
|
||||
elif unit == "minutes":
|
||||
dt += timedelta(minutes=int(n))
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def _date_subtract(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
|
||||
if unit == "days":
|
||||
dt -= timedelta(days=int(n))
|
||||
elif unit == "hours":
|
||||
dt -= timedelta(hours=int(n))
|
||||
elif unit == "minutes":
|
||||
dt -= timedelta(minutes=int(n))
|
||||
return dt.isoformat()
|
||||
except Exception:
|
||||
return d
|
||||
|
||||
def _replace(self, ctx: dict, s: Any, old: str, new: str) -> str:
|
||||
return str(s).replace(old, new)
|
||||
|
||||
def _replace_all(self, ctx: dict, s: Any, old: str, new: str) -> str:
|
||||
return str(s).replace(old, new)
|
||||
|
||||
def _join(self, ctx: dict, sep: str, *args) -> str:
|
||||
return sep.join(str(a) for a in args)
|
||||
|
||||
def _empty(self, ctx: dict, v: Any) -> bool:
|
||||
return v is None or v == "" or v == [] or v == 0
|
||||
|
||||
def _and(self, ctx: dict, *args) -> bool:
|
||||
return all(args)
|
||||
|
||||
def _or(self, ctx: dict, *args) -> bool:
|
||||
return any(args)
|
||||
|
||||
def _not(self, ctx: dict, v: Any) -> bool:
|
||||
return not v
|
||||
|
||||
def _start(self, ctx: dict, s: Any) -> str:
|
||||
"""Extract start date from a date range (iso1...iso2 or just iso1)."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
return val.split("...")[0]
|
||||
return val
|
||||
|
||||
def _end(self, ctx: dict, s: Any) -> Optional[str]:
|
||||
"""Extract end date from a date range."""
|
||||
val = str(s)
|
||||
if "..." in val:
|
||||
parts = val.split("...")
|
||||
return parts[1] if len(parts) > 1 else None
|
||||
return None
|
||||
@@ -0,0 +1,150 @@
|
||||
"""FlowDeck — Rollup Engine (v1.5.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
ROLLUP_FUNCTIONS = {
|
||||
"count": lambda values: len(values),
|
||||
"count_values": lambda values: len([v for v in values if v is not None and v != ""]),
|
||||
"empty": lambda values: len([v for v in values if v is None or v == ""]),
|
||||
"not_empty": lambda values: len([v for v in values if v is not None and v != ""]),
|
||||
"sum": lambda values: sum(float(v) for v in values if v is not None),
|
||||
"average": lambda values: _safe_avg(values),
|
||||
"median": lambda values: _safe_stat(values, statistics.median),
|
||||
"min": lambda values: min(_numeric(v for v in values if v is not None), default=None),
|
||||
"max": lambda values: max(_numeric(v for v in values if v is not None), default=None),
|
||||
"range": lambda values: _safe_range(values),
|
||||
"unique": lambda values: list(set(str(v) for v in values if v is not None)),
|
||||
"percent_checked": lambda values: _percent_checked(values),
|
||||
"percent_per_group": lambda values: _percent_per_group(values),
|
||||
}
|
||||
|
||||
|
||||
class RollupEngine:
|
||||
"""Moteur d'agrégation pour les propriétés rollup."""
|
||||
|
||||
def compute(
|
||||
self,
|
||||
collection_id: int,
|
||||
relation_property_id: int,
|
||||
target_property_id: int,
|
||||
page_id: int,
|
||||
rollup_function: str,
|
||||
) -> Any:
|
||||
"""Calcule l'agrégation d'une propriété via une relation.
|
||||
|
||||
Args:
|
||||
collection_id: la collection de la page source
|
||||
relation_property_id: la propriété relation (dans cette collection)
|
||||
target_property_id: la propriété à agréger (dans la collection liée)
|
||||
page_id: la page qui porte la relation
|
||||
rollup_function: nom de la fonction (count, sum, avg, etc.)
|
||||
"""
|
||||
if rollup_function not in ROLLUP_FUNCTIONS:
|
||||
return None
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get the relation property to find the related collection
|
||||
rel_prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?",
|
||||
(relation_property_id,),
|
||||
).fetchone()
|
||||
if not rel_prop:
|
||||
return None
|
||||
|
||||
related_collection_id = rel_prop["related_collection_id"]
|
||||
if not related_collection_id:
|
||||
return None
|
||||
|
||||
# Get the target property name
|
||||
target_prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?",
|
||||
(target_property_id,),
|
||||
).fetchone()
|
||||
if not target_prop:
|
||||
return None
|
||||
|
||||
target_name = target_prop["name"]
|
||||
|
||||
# Get the related page IDs from the source page's property_values_json
|
||||
source_page = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not source_page:
|
||||
return None
|
||||
|
||||
props = json.loads(source_page["property_values_json"])
|
||||
related_ids = props.get(str(relation_property_id), [])
|
||||
if not isinstance(related_ids, list):
|
||||
related_ids = [related_ids] if related_ids else []
|
||||
|
||||
if not related_ids:
|
||||
return ROLLUP_FUNCTIONS[rollup_function]([])
|
||||
|
||||
# Get the target values from related pages
|
||||
placeholders = ",".join("?" for _ in related_ids)
|
||||
rows = conn.execute(
|
||||
f"SELECT property_values_json FROM collection_pages WHERE id IN ({placeholders})",
|
||||
related_ids,
|
||||
).fetchall()
|
||||
|
||||
values = []
|
||||
for row in rows:
|
||||
page_props = json.loads(row["property_values_json"])
|
||||
val = page_props.get(str(target_property_id))
|
||||
if val is None:
|
||||
# Try by name
|
||||
val = page_props.get(target_name)
|
||||
values.append(val)
|
||||
|
||||
return ROLLUP_FUNCTIONS[rollup_function](values)
|
||||
|
||||
|
||||
def _safe_avg(values: list) -> Optional[float]:
|
||||
nums = [float(v) for v in values if v is not None]
|
||||
return sum(nums) / len(nums) if nums else None
|
||||
|
||||
|
||||
def _safe_stat(values: list, fn) -> Optional[float]:
|
||||
nums = [float(v) for v in values if v is not None]
|
||||
return fn(nums) if nums else None
|
||||
|
||||
|
||||
def _numeric(gen):
|
||||
for v in gen:
|
||||
try:
|
||||
yield float(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_range(values: list) -> Optional[float]:
|
||||
nums = list(_numeric(v for v in values if v is not None))
|
||||
return max(nums) - min(nums) if len(nums) >= 2 else None
|
||||
|
||||
|
||||
def _percent_checked(values: list) -> Optional[float]:
|
||||
"""Percentage of true values (for checkbox properties)."""
|
||||
if not values:
|
||||
return 0.0
|
||||
booleans = [v for v in values if isinstance(v, bool)]
|
||||
if not booleans:
|
||||
return 0.0
|
||||
return (sum(1 for v in booleans if v) / len(booleans)) * 100
|
||||
|
||||
|
||||
def _percent_per_group(values: list) -> dict:
|
||||
"""Percentage distribution across groups."""
|
||||
if not values:
|
||||
return {}
|
||||
counts = {}
|
||||
for v in values:
|
||||
key = str(v) if v is not None else "Empty"
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
total = sum(counts.values())
|
||||
return {k: round(v / total * 100, 1) for k, v in counts.items()}
|
||||
+332
-3
@@ -34,7 +34,7 @@ def test_health(client):
|
||||
data = resp.json()
|
||||
assert "status" in data
|
||||
assert data["db"] is True
|
||||
assert data["version"] == "1.4.0"
|
||||
assert data["version"] == "1.8.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.4.0"
|
||||
assert resp.json()["version"] == "1.8.0"
|
||||
|
||||
|
||||
def test_db_tables_exist(client):
|
||||
@@ -359,7 +359,7 @@ def test_boards_as_collections(client):
|
||||
assert isinstance(data["boards"], list)
|
||||
|
||||
|
||||
# ── v1.4.0: Collection Properties ──
|
||||
# ── v1.8.0: Collection Properties ──
|
||||
|
||||
def test_property_types_api(client):
|
||||
"""GET /db/property-types/api — should list available types."""
|
||||
@@ -467,3 +467,332 @@ def test_collection_properties_validation(client):
|
||||
assert resp.status_code == 400
|
||||
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
# ── v1.8.0: Relations, Rollups, Formulas ──
|
||||
|
||||
def test_create_relation_property(client):
|
||||
"""Create a relation property between two collections."""
|
||||
r1 = client.post("/db/api", json={"name": "Projects"})
|
||||
r2 = client.post("/db/api", json={"name": "Tasks"})
|
||||
c1, c2 = r1.json()["id"], r2.json()["id"]
|
||||
|
||||
resp = client.post(f"/db/{c1}/properties/relation", json={
|
||||
"name": "Tasks", "related_collection_id": c2, "reverse_name": "Project",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["prop_type"] == "relation"
|
||||
|
||||
resp2 = client.get(f"/db/{c2}/properties/api")
|
||||
names = [p["name"] for p in resp2.json()["properties"]]
|
||||
assert "Project" in names
|
||||
|
||||
client.delete(f"/db/api/{c2}") # c2 first (has reverse FK → c1)
|
||||
client.delete(f"/db/api/{c1}")
|
||||
|
||||
|
||||
def test_link_pages_via_relation(client):
|
||||
"""Link two pages via a relation and verify reverse."""
|
||||
r1 = client.post("/db/api", json={"name": "A"})
|
||||
r2 = client.post("/db/api", json={"name": "B"})
|
||||
c1, c2 = r1.json()["id"], r2.json()["id"]
|
||||
|
||||
rel = client.post(f"/db/{c1}/properties/relation", json={
|
||||
"name": "Items", "related_collection_id": c2, "reverse_name": "Parent",
|
||||
})
|
||||
prop_id = rel.json()["id"]
|
||||
|
||||
p1 = client.post(f"/db/{c1}/pages/api", json={"title": "Page A"})
|
||||
p2 = client.post(f"/db/{c2}/pages/api", json={"title": "Page B"})
|
||||
pid1, pid2 = p1.json()["id"], p2.json()["id"]
|
||||
|
||||
resp = client.post(f"/db/{c1}/properties/relation/link", json={
|
||||
"property_id": prop_id, "source_page_id": pid1, "target_page_id": pid2,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
src = client.get(f"/db/pages/{pid1}/api").json()
|
||||
props = json.loads(src["property_values_json"])
|
||||
assert pid2 in props.get(str(prop_id), [])
|
||||
|
||||
tgt = client.get(f"/db/pages/{pid2}/api").json()
|
||||
tprops = json.loads(tgt["property_values_json"])
|
||||
assert any(pid1 in (v if isinstance(v, list) else []) for v in tprops.values())
|
||||
|
||||
client.delete(f"/db/api/{c2}") # c2 first
|
||||
client.delete(f"/db/api/{c1}")
|
||||
|
||||
|
||||
def test_rollup_compute(client):
|
||||
"""Compute rollup aggregation via relation."""
|
||||
r1 = client.post("/db/api", json={"name": "Proj"})
|
||||
r2 = client.post("/db/api", json={"name": "Task"})
|
||||
c1, c2 = r1.json()["id"], r2.json()["id"]
|
||||
|
||||
rel = client.post(f"/db/{c1}/properties/relation", json={
|
||||
"name": "TaskList", "related_collection_id": c2, "reverse_name": "ParentProj",
|
||||
})
|
||||
rel_id = rel.json()["id"]
|
||||
|
||||
num = client.post(f"/db/{c2}/properties/api", json={"name": "Hours", "prop_type": "number"})
|
||||
num_id = num.json()["id"]
|
||||
|
||||
proj = client.post(f"/db/{c1}/pages/api", json={"title": "Proj1"})
|
||||
pid = proj.json()["id"]
|
||||
|
||||
t1 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 1", "properties": {str(num_id): 5}})
|
||||
t2 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 2", "properties": {str(num_id): 10}})
|
||||
|
||||
client.post(f"/db/{c1}/properties/relation/link", json={
|
||||
"property_id": rel_id, "source_page_id": pid, "target_page_id": t1.json()["id"],
|
||||
})
|
||||
client.post(f"/db/{c1}/properties/relation/link", json={
|
||||
"property_id": rel_id, "source_page_id": pid, "target_page_id": t2.json()["id"],
|
||||
})
|
||||
|
||||
resp = client.post("/db/rollup/compute", json={
|
||||
"collection_id": c1, "relation_property_id": rel_id,
|
||||
"target_property_id": num_id, "page_id": pid, "function": "sum",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["result"] == 15.0
|
||||
|
||||
resp2 = client.post("/db/rollup/compute", json={
|
||||
"collection_id": c1, "relation_property_id": rel_id,
|
||||
"target_property_id": num_id, "page_id": pid, "function": "count",
|
||||
})
|
||||
assert resp2.json()["result"] == 2
|
||||
|
||||
client.delete(f"/db/api/{c2}") # c2 first
|
||||
client.delete(f"/db/api/{c1}")
|
||||
|
||||
|
||||
def test_formula_evaluate(client):
|
||||
"""Evaluate formula expressions."""
|
||||
# Function-based expressions work
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "round(3.14159, 2)", "context": {},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["result"] == 3.14
|
||||
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "if(prop('Done'), 'OK', 'Pending')",
|
||||
"context": {"Done": True},
|
||||
})
|
||||
assert resp.json()["result"] == "OK"
|
||||
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "concat(prop('First'), ' ', prop('Last'))",
|
||||
"context": {"First": "John", "Last": "Doe"},
|
||||
})
|
||||
assert resp.json()["result"] == "John Doe"
|
||||
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "length(prop('Text'))",
|
||||
"context": {"Text": "Hello"},
|
||||
})
|
||||
assert resp.json()["result"] == 5
|
||||
|
||||
resp = client.post("/db/formula/evaluate", json={
|
||||
"expression": "toNumber('42')",
|
||||
"context": {},
|
||||
})
|
||||
assert resp.json()["result"] == 42.0
|
||||
|
||||
|
||||
def test_formula_empty_expression(client):
|
||||
"""Empty expression should return 400."""
|
||||
resp = client.post("/db/formula/evaluate", json={"expression": "", "context": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── v1.8.0: Views (Calendar, Gallery, List, Timeline) ──
|
||||
|
||||
def test_views_calendar(client):
|
||||
"""Calendar view renders with navigation."""
|
||||
r = client.post("/db/api", json={"name": "Cal DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Event 1", "properties": {"date": "2026-07-15"}})
|
||||
resp = client.get(f"/db/{cid}/view/calendar")
|
||||
assert resp.status_code == 200
|
||||
assert "July" in resp.text or "juillet" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_gallery(client):
|
||||
"""Gallery view renders card grid."""
|
||||
r = client.post("/db/api", json={"name": "Gal DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Card 1"})
|
||||
resp = client.get(f"/db/{cid}/view/gallery")
|
||||
assert resp.status_code == 200
|
||||
assert "gallery" in resp.text.lower() or "gal-card" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_list(client):
|
||||
"""List view renders compact items."""
|
||||
r = client.post("/db/api", json={"name": "List DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Item 1"})
|
||||
resp = client.get(f"/db/{cid}/view/list")
|
||||
assert resp.status_code == 200
|
||||
assert "list-item" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_timeline(client):
|
||||
"""Timeline view renders date bars."""
|
||||
r = client.post("/db/api", json={"name": "TL DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"date": "2026-07-01...2026-07-15"}})
|
||||
resp = client.get(f"/db/{cid}/view/timeline")
|
||||
assert resp.status_code == 200
|
||||
assert "tl-bar" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_default_table(client):
|
||||
"""Default view renders as table."""
|
||||
r = client.post("/db/api", json={"name": "Tab DB"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get(f"/db/{cid}")
|
||||
assert resp.status_code == 200
|
||||
assert "<table>" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_calendar_navigation(client):
|
||||
"""Calendar supports month navigation via query params."""
|
||||
r = client.post("/db/api", json={"name": "Nav DB"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=8")
|
||||
assert resp.status_code == 200
|
||||
assert "August" in resp.text or "août" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
# ── v1.7.0: View Management ──
|
||||
|
||||
def test_view_config_update(client):
|
||||
"""Update view config (card_size, group_by)."""
|
||||
r = client.post("/db/api", json={"name": "V Config"})
|
||||
cid = r.json()["id"]
|
||||
views = client.get(f"/db/{cid}/views/api").json()["views"]
|
||||
vid = views[0]["id"]
|
||||
|
||||
resp = client.put(f"/db/views/{vid}/config", json={
|
||||
"card_size": "large", "group_by": "Status",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["config"]["card_size"] == "large"
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_save_view_as(client):
|
||||
"""Save current state as new view."""
|
||||
r = client.post("/db/api", json={"name": "Save As"})
|
||||
cid = r.json()["id"]
|
||||
|
||||
resp = client.post(f"/db/{cid}/views/save-as", json={
|
||||
"name": "My Kanban", "view_type": "board",
|
||||
"config": {"group_by": "Priority"},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "My Kanban"
|
||||
|
||||
views = client.get(f"/db/{cid}/views/api").json()["views"]
|
||||
assert len(views) == 2
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_list_views(client):
|
||||
"""List views for a collection."""
|
||||
r = client.post("/db/api", json={"name": "V List"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get(f"/db/{cid}/views/api")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["views"]) == 1 # default view
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
# ── v1.8.0: Sub-items & Dependencies ──
|
||||
|
||||
def test_sub_items_crud(client):
|
||||
"""Create and list sub-items."""
|
||||
r = client.post("/db/api", json={"name": "Sub DB"})
|
||||
cid = r.json()["id"]
|
||||
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent Task"})
|
||||
pid = p.json()["id"]
|
||||
|
||||
# Create sub-item
|
||||
resp = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 1"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["parent_id"] == pid
|
||||
|
||||
resp2 = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 2"})
|
||||
assert resp2.status_code == 200
|
||||
|
||||
# List
|
||||
items = client.get(f"/db/{cid}/pages/{pid}/sub-items").json()["sub_items"]
|
||||
assert len(items) == 2
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_status_aggregate(client):
|
||||
"""Aggregate child statuses."""
|
||||
r = client.post("/db/api", json={"name": "Agg DB"})
|
||||
cid = r.json()["id"]
|
||||
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent", "properties": {"Status": "In Progress"}})
|
||||
pid = p.json()["id"]
|
||||
|
||||
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C1", "properties": {"Status": "Done"}})
|
||||
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C2", "properties": {"Status": "In Progress"}})
|
||||
|
||||
resp = client.get(f"/db/{cid}/pages/{pid}/status-aggregate")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert data["done"] == 1
|
||||
assert data["all_done"] is False
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_dependencies_check(client):
|
||||
"""Dependency constraint check."""
|
||||
r = client.post("/db/api", json={"name": "Dep DB"})
|
||||
cid = r.json()["id"]
|
||||
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
|
||||
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Done"}})
|
||||
aid, bid = a.json()["id"], b.json()["id"]
|
||||
|
||||
# A blocks B
|
||||
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
|
||||
|
||||
# Check if A can go to Done (should not, B is Done but blocks is on A, wait...)
|
||||
# B is Done, so A CAN transition
|
||||
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["can_transition"] is True
|
||||
assert resp.json()["blocked_by"] == []
|
||||
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_dependencies_blocked(client):
|
||||
"""Dependency blocks transition when blocker is not done."""
|
||||
r = client.post("/db/api", json={"name": "Block DB"})
|
||||
cid = r.json()["id"]
|
||||
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
|
||||
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Todo"}})
|
||||
aid, bid = a.json()["id"], b.json()["id"]
|
||||
|
||||
# A blocks B — A depends on B being done
|
||||
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
|
||||
|
||||
# B is Todo, so A CANNOT transition to Done
|
||||
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
|
||||
assert resp.json()["can_transition"] is False
|
||||
assert len(resp.json()["blocked_by"]) == 1
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
Reference in New Issue
Block a user