"""FlowDeck — Collections : linked. 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.db_templates import materialize_properties from ._common import _apply_template logger = logging.getLogger(__name__) router = APIRouter(tags=["collections"], prefix="/db") @router.post("/{collection_id}/linked/api") def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})): """API: create a linked database view from a source collection. A linked database copies the structure (views, filters, sorts) of a source but shares the same pages — edits to pages propagate to the source. """ name = body.get("name", "").strip() body.get("workspace_id") with get_conn() as conn: source = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) ).fetchone() if not source: raise HTTPException(status_code=404, detail="Source collection not found") if not name: name = f"{source['name']} (linked)" # Create the linked collection (shallow copy of structure) # Inherit workspace_id from source for permission inheritance src_dict = dict(source) source_workspace_id = src_dict.get("workspace_id") cur = conn.execute( """INSERT INTO collections (name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", ( name, src_dict["description"], src_dict["icon"], src_dict["schema_json"], 0, # linked DB is never locked 1, # linked DB starts as inline src_dict.get("parent_page_id"), source_workspace_id, # linked DB inherits source workspace permissions ), ) linked_id = cur.lastrowid # Copy views from source views = conn.execute( "SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() for v in views: conn.execute( "INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (linked_id, v["name"], v["view_type"], v["config_json"], v["position"]), ) # Add the source as a data source with is_linked=1 conn.execute( """INSERT INTO collection_data_sources (collection_id, source_collection_id, source_name, is_linked, position) VALUES (?, ?, ?, 1, 0)""", (linked_id, collection_id, source["name"]), ) # Copy properties from source props = conn.execute( "SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,), ).fetchall() for p in props: conn.execute( """INSERT INTO collection_properties (collection_id, name, prop_type, options_json, number_format, related_collection_id, reverse_name, relation_property_id, target_property_id, rollup_function, formula_expression, position, required, visible_in_views) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( linked_id, p["name"], p["prop_type"], p["options_json"], p["number_format"], p["related_collection_id"], p["reverse_name"], p["relation_property_id"], p["target_property_id"], p["rollup_function"], p["formula_expression"], p["position"], p["required"], p["visible_in_views"], ), ) conn.commit() return { "linked_id": linked_id, "name": name, "source_collection_id": collection_id, "status": "created", } @router.post("/{collection_id}/toggle-inline/api") def toggle_inline(request: Request, collection_id: int): """API: toggle a collection between full-page and inline mode.""" with get_conn() as conn: coll = conn.execute( "SELECT id, is_inline FROM collections WHERE id=?", (collection_id,), ).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") new_inline = 0 if coll["is_inline"] else 1 conn.execute( "UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_inline, collection_id), ) conn.commit() return { "collection_id": collection_id, "is_inline": bool(new_inline), "mode": "inline" if new_inline else "full-page", } @router.post("/inline/api") def create_inline_database(request: Request, body: dict = Body(default={})): """API: create an inline database within a parent page (optionally from a template).""" name = body.get("name", "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") description = body.get("description", "") icon = body.get("icon", "📋") parent_page_id = body.get("parent_page_id") workspace_id = body.get("workspace_id") schema = body.get("schema", []) with get_conn() as conn: tpl = _apply_template(conn, body.get("template")) if tpl: if body.get("name"): name = body["name"].strip() description = tpl["description"] icon = tpl.get("icon") or icon try: schema = json.loads(tpl["schema_json"]) except (json.JSONDecodeError, TypeError): schema = [] cur = conn.execute( """INSERT INTO collections (name, description, icon, schema_json, is_inline, parent_page_id, workspace_id) VALUES (?, ?, ?, ?, 1, ?, ?)""", (name, description, icon, json.dumps(schema), parent_page_id, workspace_id), ) collection_id = cur.lastrowid materialize_properties(conn, collection_id, schema) # Create default view conn.execute( """INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)""", (collection_id, "Default View", "table", json.dumps({ "visible_properties": ["Title"], "sorts": [], "filters": [], })), ) conn.commit() return { "id": collection_id, "name": name, "icon": icon, "is_inline": True, "parent_page_id": parent_page_id, "status": "created", } # ── v4.4.0: Tasks & Dependencies ── # ── v4.4.0: Tasks & Dependencies ── @router.put("/{collection_id}/toggle-task/api") def toggle_task(request: Request, collection_id: int): """API: toggle is_task flag on a collection (Turn into Tasks). Rétrocompatible : l'activation passe désormais par la même conversion que ``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées (créées si besoin). Sans cela, cette route laissait une base « connectée » mais muette — elle n'émettait aucune tâche faute de mapping. """ from .task_db import disable_task_db_in_conn, enable_task_db_in_conn with get_conn() as conn: coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone() if not coll: raise HTTPException(status_code=404, detail="Collection not found") if coll["is_task"]: disable_task_db_in_conn(conn, collection_id) new_val = 0 else: enable_task_db_in_conn(conn, collection_id) new_val = 1 return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"} @router.get("/{collection_id}/pages/{page_id}/dependencies/api") def list_page_dependencies(request: Request, collection_id: int, page_id: int): """API: list dependencies for a page (blocks, blocked_by, related).""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at", (page_id,), ).fetchall() deps = [] for r in rows: d = dict(r) dep_page = conn.execute( "SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],) ).fetchone() if dep_page: d["dependency_title"] = dep_page["title"] deps.append(d) return {"dependencies": deps} @router.post("/{collection_id}/pages/{page_id}/dependencies/api") def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: add a dependency (blocks/blocked_by/related) between two pages.""" dependency_id = body.get("dependency_id") if not dependency_id: raise HTTPException(status_code=400, detail="dependency_id is required") dep_type = body.get("dependency_type", "blocks") auto_shift = body.get("auto_shift", "overlap") with get_conn() as conn: for pid in (page_id, dependency_id): if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone(): raise HTTPException(status_code=404, detail=f"Page {pid} not found") try: cur = conn.execute( "INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)", (page_id, dependency_id, dep_type, auto_shift), ) conn.commit() except sqlite3.IntegrityError: raise HTTPException(status_code=409, detail="This dependency already exists") from None return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"} @router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api") def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int): """API: remove a dependency.""" with get_conn() as conn: existing = conn.execute( "SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id) ).fetchone() if not existing: raise HTTPException(status_code=404, detail="Dependency not found") conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,)) conn.commit() return {"id": dep_id, "status": "removed"}