diff --git a/ROADMAP.md b/ROADMAP.md index faf89b4..e54a8a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,31 +131,17 @@ Propriétés custom, AI keywords, sync API, 12 tables DB - [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 ✅ (complété) - ├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items (6/10 ✅) - ├─ 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 ``` diff --git a/app/main.py b/app/main.py index ba36b6c..1820e30 100644 --- a/app/main.py +++ b/app/main.py @@ -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.6.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.6.0", + version="1.8.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/api.py b/app/routers/api.py index d4d9a8d..a297668 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -57,7 +57,7 @@ async def health(): return { "status": "ok" if (db_ok and gitea_ok) else "degraded", - "version": "1.6.0", + "version": "1.8.0", "db": db_ok, "gitea": gitea_ok, } diff --git a/app/routers/collections.py b/app/routers/collections.py index bf3e144..4510f26 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -537,6 +537,229 @@ async def evaluate_formula(request: Request): 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) ── diff --git a/tests/test_app.py b/tests/test_app.py index 627838d..0f3db02 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -34,7 +34,7 @@ def test_health(client): data = resp.json() assert "status" in data assert data["db"] is True - assert data["version"] == "1.6.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.6.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.6.0: Collection Properties ── +# ── v1.8.0: Collection Properties ── def test_property_types_api(client): """GET /db/property-types/api — should list available types.""" @@ -469,7 +469,7 @@ def test_collection_properties_validation(client): client.delete(f"/db/api/{coll_id}") -# ── v1.6.0: Relations, Rollups, Formulas ── +# ── v1.8.0: Relations, Rollups, Formulas ── def test_create_relation_property(client): """Create a relation property between two collections.""" @@ -607,7 +607,7 @@ def test_formula_empty_expression(client): assert resp.status_code == 400 -# ── v1.6.0: Views (Calendar, Gallery, List, Timeline) ── +# ── v1.8.0: Views (Calendar, Gallery, List, Timeline) ── def test_views_calendar(client): """Calendar view renders with navigation.""" @@ -671,3 +671,128 @@ def test_views_calendar_navigation(client): 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}")