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
188 lines
6.7 KiB
Python
188 lines
6.7 KiB
Python
"""FlowDeck — Collections : views.
|
|
|
|
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
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
|
|
from app.db import get_conn
|
|
from app.services.automations import fire_event, run_event_sync
|
|
|
|
from ._common import _current_user
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["collections"], prefix="/db")
|
|
|
|
|
|
|
|
|
|
# ── v1.7.0 View Management ──
|
|
|
|
|
|
@router.get("/views/{view_id}/api")
|
|
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")
|
|
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
|
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
|
|
|
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", "sub_group_by", "wip_limits", "card_size", "cover_property",
|
|
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
|
|
"filter_conjunction", "date_property", "date_range_property",
|
|
"property_groups", "view_type"):
|
|
if key in body:
|
|
config[key] = body[key]
|
|
|
|
new_type = body.get("view_type") or existing["view_type"]
|
|
conn.execute(
|
|
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
|
|
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
|
(json.dumps(config), body.get("name"), new_type, view_id),
|
|
)
|
|
conn.commit()
|
|
|
|
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
|
|
|
|
|
|
|
|
|
|
@router.post("/{collection_id}/views/save-as")
|
|
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
|
"""API: save current view state as a new named view."""
|
|
|
|
name = body.get("name", "New View")
|
|
config = body.get("config", {})
|
|
user = _current_user(request)
|
|
user_id = user.get("id") if user else None
|
|
|
|
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]
|
|
|
|
view_type = body.get("view_type", "table")
|
|
cur = conn.execute(
|
|
"""INSERT INTO collection_views
|
|
(collection_id, name, view_type, config_json, position, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
|
|
)
|
|
conn.commit()
|
|
new_view_id = cur.lastrowid
|
|
|
|
run_event_sync(fire_event("collection.view.created", {
|
|
"view_id": new_view_id,
|
|
"collection_id": collection_id,
|
|
"name": name,
|
|
"view_type": view_type,
|
|
}))
|
|
return {"id": new_view_id, "name": name, "view_type": view_type,
|
|
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
|
|
|
|
|
|
|
|
|
@router.get("/{collection_id}/views/api")
|
|
def list_views_api(request: Request, collection_id: int):
|
|
"""API: list views for a collection visible to the current user.
|
|
|
|
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
|
personal views (``created_by = user``) only to their owner.
|
|
"""
|
|
user = _current_user(request)
|
|
user_id = user.get("id") if user else None
|
|
with get_conn() as conn:
|
|
if user_id is not None:
|
|
rows = conn.execute(
|
|
"""SELECT * FROM collection_views
|
|
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
|
|
ORDER BY position""",
|
|
(collection_id, user_id),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
|
|
(collection_id,),
|
|
).fetchall()
|
|
return {"views": [dict(r) for r in rows]}
|
|
|
|
|
|
|
|
|
|
@router.delete("/views/{view_id}/api")
|
|
def delete_view_api(request: Request, view_id: int):
|
|
"""API: delete a saved view."""
|
|
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")
|
|
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
|
conn.commit()
|
|
return {"id": view_id, "status": "deleted"}
|
|
|
|
|
|
|
|
|
|
@router.post("/views/{view_id}/duplicate")
|
|
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
|
"""API: duplicate a view (config + type), owned by the current user."""
|
|
|
|
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")
|
|
|
|
max_pos = conn.execute(
|
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
|
(existing["collection_id"],),
|
|
).fetchone()[0]
|
|
|
|
user = _current_user(request)
|
|
user_id = user.get("id") if user else None
|
|
name = body.get("name") or (existing["name"] + " copy")
|
|
cur = conn.execute(
|
|
"""INSERT INTO collection_views
|
|
(collection_id, name, view_type, config_json, position, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(existing["collection_id"], name, existing["view_type"],
|
|
existing["config_json"], max_pos, user_id),
|
|
)
|
|
conn.commit()
|
|
dup_view_id = cur.lastrowid
|
|
|
|
run_event_sync(fire_event("collection.view.created", {
|
|
"view_id": dup_view_id,
|
|
"collection_id": existing["collection_id"],
|
|
"name": name,
|
|
"view_type": existing["view_type"],
|
|
}))
|
|
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
|
"status": "duplicated"}
|
|
|
|
|
|
# ── v1.8.0 Sub-items & Dependencies ──
|