- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""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"}
|