Découpe par concern de l'ancien app/routers/collections.py (2 622 lignes, 53 endpoints / 52 fonctions) en package `app/routers/collections/` : - 10 modules de routes : crud 337 L (6 r.), properties 322 (8), linked 286 (7), structure 267 (8), dashboard_views 214 (3), meta 197 (5), views 187 (6), pages 184 (4), data_api 122 (2), boards 61 (3) - _common.py (220 L) : 8 helpers auth/permissions/validation - _renderers.py (667 L) : 15 rendus HTML des vues + CHART_MAX_GROUPS - __init__.py : ré-exports connus (_validate_page_properties pour automations ; _chart_values/_chart_aggregate/_fmt_number/_render_chart pour les tests) + __all__ Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - docstring d'origine conservée dans le header copié → F404 (from __future__ après un statement) → slice [1:30] - décorateurs empilés (view_collection ×2) : segment sans def → skip du 2e décorateur (53 endpoints = 52 unités) - CHART_MAX_GROUPS hors détection des helpers (F821) → import ._renderers - test_csp_no_cdn_and_vendor lisait collections.py → balayage du package Reste A28 : board.py 2 101 L (lot 4). suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
268 lines
9.1 KiB
Python
268 lines
9.1 KiB
Python
"""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"}
|