"""FlowDeck — Collections : structure. Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé. """ from __future__ import annotations import json import logging import sqlite3 from fastapi import APIRouter, Body, HTTPException, Request from app.db import get_conn from app.services.automations import fire_event, run_event_sync from app.services.property_types import ( apply_auto_properties, ) from ._common import _collection_properties, _current_user logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") # ── v1.8.0 Sub-items & Dependencies ── @router.get("/{collection_id}/pages/{page_id}/sub-items") 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") def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: create a sub-item under a page.""" 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] sub_props = body.get("properties", {}) or {} apply_auto_properties( _collection_properties(conn, collection_id), sub_props, _current_user(request), is_create=True, ) 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(sub_props)), ) conn.commit() new_id = cur.lastrowid run_event_sync(fire_event("page.created", { "page_id": new_id, "collection_id": collection_id, "parent_id": page_id, "title": title, "properties": body.get("properties", {}), })) run_event_sync(fire_event("collection.page.created", { "page_id": new_id, "collection_id": collection_id, "title": title, })) return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"} @router.get("/{collection_id}/pages/{page_id}/status-aggregate") 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") def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: set blocking dependencies for a page (stored as 'blocks' property).""" 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") def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: check if a page can transition to a 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, } # ── v4.1.0: Data Sources & Linked Databases ── # ── v4.1.0: Data Sources & Linked Databases ── @router.get("/{collection_id}/sources/api") def list_data_sources(request: Request, collection_id: int): """API: list all data sources 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_data_sources WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() return {"sources": [dict(r) for r in rows]} @router.post("/{collection_id}/sources/api") def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})): """API: add a data source to a collection.""" source_collection_id = body.get("source_collection_id") if not source_collection_id: raise HTTPException(status_code=400, detail="source_collection_id is required") source_name = body.get("source_name", "").strip() is_linked = body.get("is_linked", False) with get_conn() as conn: # Verify both collections exist for cid in (collection_id, source_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_data_sources WHERE collection_id=?", (collection_id,), ).fetchone()[0] try: cur = conn.execute( """INSERT INTO collection_data_sources (collection_id, source_collection_id, source_name, is_linked, position) VALUES (?, ?, ?, ?, ?)""", (collection_id, source_collection_id, source_name, int(is_linked), max_pos), ) conn.commit() except sqlite3.IntegrityError: raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None return { "id": cur.lastrowid, "collection_id": collection_id, "source_collection_id": source_collection_id, "status": "added", } @router.delete("/{collection_id}/sources/{source_id}/api") def remove_data_source(request: Request, collection_id: int, source_id: int): """API: remove a data source from a collection.""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?", (source_id, collection_id), ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Data source not found") conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,)) conn.commit() return {"id": source_id, "status": "removed"}