refactor: A28 lot 3 — collections.py (2 622 L) → package 13 fichiers (v7.31.0)
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
This commit is contained in:
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.30.0",
|
||||
version="7.31.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
"""FlowDeck — Collections : bases de données façon Notion.
|
||||
|
||||
Découpe A28 : l'ancien `collections.py` (2 622 lignes, 53 routes) est
|
||||
devenu ce package — un module par concern, helpers dans `_common`
|
||||
(auth/permissions/validation) et `_renderers` (rendus HTML des vues).
|
||||
Ré-exports : automations importe `_validate_page_properties`, les
|
||||
tests importent les helpers de graphes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import ( # ordre = ordre d'enregistrement d'origine
|
||||
boards,
|
||||
crud,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
linked,
|
||||
meta,
|
||||
pages,
|
||||
properties,
|
||||
structure,
|
||||
views,
|
||||
)
|
||||
from ._common import _validate_page_properties # noqa: F401
|
||||
from ._renderers import ( # noqa: F401 — ré-exports tests
|
||||
_chart_aggregate,
|
||||
_chart_values,
|
||||
_fmt_number,
|
||||
_render_chart,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
for _mod in (
|
||||
crud,
|
||||
pages,
|
||||
boards,
|
||||
meta,
|
||||
properties,
|
||||
views,
|
||||
structure,
|
||||
linked,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"_chart_aggregate",
|
||||
"_chart_values",
|
||||
"_fmt_number",
|
||||
"_render_chart",
|
||||
"_validate_page_properties",
|
||||
]
|
||||
@@ -0,0 +1,220 @@
|
||||
"""FlowDeck — Collections : helpers partagés (A28).
|
||||
|
||||
Les 8 helpers de tête de l'ancien collections.py (auth, permissions,
|
||||
validation) — ré-exportés par le package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
AUTO_TYPES,
|
||||
validate_property_rule,
|
||||
)
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
is_valid_timezone,
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
"""Resolve the session user, falling back to the local admin (single-user)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict | None:
|
||||
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(s)
|
||||
return user if user and user.get("id") else None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 404 when the user may not view the collection (404 hides it).
|
||||
|
||||
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
|
||||
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 401/403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _collection_properties(conn, collection_id: int) -> list[dict]:
|
||||
return [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_template(conn, template_name: str) -> dict | None:
|
||||
"""Resolve a database template by name (from the seeded/built-in set)."""
|
||||
if not template_name:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
|
||||
(template_name,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
|
||||
"""Validate submitted property values against the collection's schema.
|
||||
|
||||
Raises ``HTTPException(400)`` with a user-friendly message on the first
|
||||
failure (type, required, unique, min/max).
|
||||
"""
|
||||
props = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
|
||||
).fetchall()
|
||||
|
||||
for prop in props:
|
||||
ptype = prop["prop_type"]
|
||||
if ptype == "title" or ptype in AUTO_TYPES:
|
||||
continue
|
||||
pid = prop["id"]
|
||||
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
|
||||
value = properties.get(str(pid))
|
||||
if value is None:
|
||||
value = properties.get(prop["name"])
|
||||
validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
|
||||
|
||||
existing_values = None
|
||||
try:
|
||||
import json as _json
|
||||
vcfg = _json.loads(validation) if validation else {}
|
||||
except Exception:
|
||||
vcfg = {}
|
||||
if vcfg.get("unique"):
|
||||
rows = conn.execute(
|
||||
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
existing_values = []
|
||||
for r in rows:
|
||||
if exclude_page_id is not None and r["id"] == exclude_page_id:
|
||||
continue
|
||||
try:
|
||||
pv = _json.loads(r["property_values_json"] or "{}")
|
||||
except Exception:
|
||||
pv = {}
|
||||
existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
|
||||
|
||||
ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
|
||||
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
|
||||
alongside real property values. Raises HTTPException(400) on bad shape.
|
||||
|
||||
Each meta key maps a date-property id to a rule/reminder object. We verify
|
||||
the target is actually a date property and the payload parses.
|
||||
"""
|
||||
date_ids = {
|
||||
str(r["id"]) for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
rec = properties.get(RECURRENCE_KEY)
|
||||
if rec not in (None, {}):
|
||||
if not isinstance(rec, dict):
|
||||
raise HTTPException(status_code=400, detail="Recurrence must be an object")
|
||||
for prop_id, rule in rec.items():
|
||||
if rule is None:
|
||||
continue
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
|
||||
ok, msg = validate_rule(rule)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
|
||||
|
||||
rem = properties.get(REMINDER_KEY)
|
||||
if rem not in (None, {}):
|
||||
if not isinstance(rem, dict):
|
||||
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
||||
for prop_id, reminder in rem.items():
|
||||
if reminder is None:
|
||||
continue
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
|
||||
if not isinstance(reminder, dict):
|
||||
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
||||
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
|
||||
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
|
||||
if parse_lead(reminder) is None and reminder.get("unit") != "none":
|
||||
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
|
||||
|
||||
from app.services.recurrence import TIMEZONE_KEY
|
||||
tzmap = properties.get(TIMEZONE_KEY)
|
||||
if tzmap not in (None, {}):
|
||||
if not isinstance(tzmap, dict):
|
||||
raise HTTPException(status_code=400, detail="Timezone map must be an object")
|
||||
for prop_id, value in tzmap.items():
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
|
||||
if value and not is_valid_timezone(str(value)):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
"""FlowDeck — Collections : rendus HTML des vues (A28).
|
||||
|
||||
Les 15 helpers de rendu de l'ancien collections.py (_render_view,
|
||||
_render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db import get_conn
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
CHART_MAX_GROUPS = 200
|
||||
|
||||
|
||||
|
||||
# ── View renderers (v1.6.0) ──
|
||||
|
||||
def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
if view_type == "calendar":
|
||||
return _render_calendar(view_type, collection, pages, config)
|
||||
elif view_type == "gallery":
|
||||
return _render_gallery(view_type, collection, pages, config)
|
||||
elif view_type == "list":
|
||||
return _render_list(view_type, collection, pages, config)
|
||||
elif view_type == "timeline":
|
||||
return _render_timeline(view_type, collection, pages, config)
|
||||
elif view_type == "chart":
|
||||
return _render_chart(view_type, collection, pages, config)
|
||||
elif view_type == "form":
|
||||
return _render_form(view_type, collection, pages, config)
|
||||
elif view_type == "map":
|
||||
return _render_map(view_type, collection, pages, config)
|
||||
elif view_type == "feed":
|
||||
return _render_feed(view_type, collection, pages, config)
|
||||
elif view_type == "gantt":
|
||||
return _render_gantt(view_type, collection, pages, config)
|
||||
else:
|
||||
return _render_table(view_type, collection, pages, config)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
|
||||
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view=feed">📰 Feed</a>
|
||||
</div>
|
||||
{body}
|
||||
</body></html>"""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
today = dt_date.today()
|
||||
# Determine month/year from config or current
|
||||
year = config.get("year", today.year)
|
||||
month = config.get("month", today.month)
|
||||
first = dt_date(year, month, 1)
|
||||
# Start from Monday of first week
|
||||
start = first - timedelta(days=first.weekday())
|
||||
days_in_month = []
|
||||
for i in range(42): # 6 weeks
|
||||
d = start + timedelta(days=i)
|
||||
days_in_month.append(d)
|
||||
|
||||
# Map pages to dates
|
||||
date_pages: dict[str, list[dict]] = {}
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
d = v[:10]
|
||||
date_pages.setdefault(d, []).append(p)
|
||||
break
|
||||
|
||||
cells = ""
|
||||
for d in days_in_month:
|
||||
iso = d.isoformat()
|
||||
items = date_pages.get(iso, [])
|
||||
other_month = " other-month" if d.month != month else ""
|
||||
today_class = " today" if d == today else ""
|
||||
items_html = "".join(
|
||||
f"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
|
||||
for p in items
|
||||
)
|
||||
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
|
||||
|
||||
prev = first - timedelta(days=1)
|
||||
next_month = first + timedelta(days=32)
|
||||
next_month = next_month.replace(day=1)
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
|
||||
<style>
|
||||
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
|
||||
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
|
||||
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
|
||||
.cal-day.other-month{{opacity:.35}}
|
||||
.cal-day.today{{background:#1a2744}}
|
||||
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
|
||||
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
|
||||
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
|
||||
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
||||
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
|
||||
{cells}
|
||||
</div>
|
||||
<p class="desc">{len(pages)} pages in collection</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
card_size = config.get("card_size", "medium")
|
||||
size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
|
||||
|
||||
cards = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
cover_url = config.get("cover_property")
|
||||
cover_html = ""
|
||||
if cover_url:
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, list) and len(v) > 0:
|
||||
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
|
||||
if url.startswith("http"):
|
||||
cover_html = f"<div class='gal-cover' style='background-image:url({url})'></div>"
|
||||
break
|
||||
|
||||
prop_tags = "".join(
|
||||
f"<span class='gal-prop'>{str(v)[:30]}</span>"
|
||||
for v in list(props.values())[:3] if v
|
||||
)
|
||||
|
||||
cards += f"""<div class='gal-card'>
|
||||
{cover_html}
|
||||
<div class='gal-body'>
|
||||
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
<div class='gal-props'>{prop_tags}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
|
||||
<style>
|
||||
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
|
||||
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
|
||||
.gal-card:hover{{border-color:#555}}
|
||||
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
|
||||
.gal-body{{padding:12px}}
|
||||
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
|
||||
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
|
||||
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
|
||||
</style>
|
||||
<div class="gallery">{cards}</div>
|
||||
<p class="desc">{len(pages)} cards</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
items = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
|
||||
items += f"""<div class='list-item'>
|
||||
<span class='list-icon'>{p.get('icon','📄')}</span>
|
||||
<div class='list-content'>
|
||||
<div class='list-title'>{p['title']}</div>
|
||||
<div class='list-preview'>{preview}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
|
||||
.list-item:hover{{border-color:#444}}
|
||||
.list-icon{{font-size:18px;margin-top:1px}}
|
||||
.list-content{{flex:1;min-width:0}}
|
||||
.list-title{{font-size:14px;font-weight:500}}
|
||||
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(pages)} items</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
# Find date range
|
||||
dates = []
|
||||
page_dates = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
start_val = end_val = None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
if "..." in v:
|
||||
parts = v.split("...")
|
||||
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
||||
else:
|
||||
start_val = end_val = v[:10]
|
||||
break
|
||||
if start_val:
|
||||
dates.append(start_val)
|
||||
if end_val:
|
||||
dates.append(end_val)
|
||||
page_dates.append((p, start_val, end_val or start_val))
|
||||
|
||||
if not dates:
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
|
||||
"<p class='desc'>No date data to display timeline.</p>")
|
||||
|
||||
from datetime import date as dt_date
|
||||
min_date = min(dt_date.fromisoformat(d) for d in dates)
|
||||
max_date = max(dt_date.fromisoformat(d) for d in dates)
|
||||
total = (max_date - min_date).days or 1
|
||||
|
||||
bars = ""
|
||||
for p, start, end in page_dates:
|
||||
sd = dt_date.fromisoformat(start)
|
||||
ed = dt_date.fromisoformat(end)
|
||||
left = (sd - min_date).days / total * 100
|
||||
width = max((ed - sd).days / total * 100, 1)
|
||||
bars += f"""<div class='tl-row'>
|
||||
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
|
||||
<div class='tl-track'>
|
||||
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
|
||||
<style>
|
||||
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
|
||||
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
|
||||
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
|
||||
</style>
|
||||
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
|
||||
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{bars}
|
||||
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
|
||||
""")
|
||||
|
||||
|
||||
# ── v4.3.0: New view types ──
|
||||
|
||||
# Multi-collection dashboards and chart aggregations cap the number of
|
||||
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
|
||||
|
||||
|
||||
|
||||
|
||||
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
|
||||
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
|
||||
values: list[float] = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
v = props.get(chart_property)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
values.append(float(v))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return values
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
|
||||
"""Compute count|sum|avg|min|max over a property (or row count)."""
|
||||
values = _chart_values(pages, chart_property)
|
||||
if aggregate == "count":
|
||||
return float(len(pages[:CHART_MAX_GROUPS]))
|
||||
if not values:
|
||||
return 0.0
|
||||
if aggregate == "sum":
|
||||
return float(sum(values))
|
||||
if aggregate == "avg":
|
||||
return float(sum(values) / len(values))
|
||||
if aggregate == "min":
|
||||
return float(min(values))
|
||||
if aggregate == "max":
|
||||
return float(max(values))
|
||||
return 0.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _fmt_number(value: float) -> str:
|
||||
if abs(value) >= 1e9:
|
||||
return f"{value / 1e9:.2f}B"
|
||||
if abs(value) >= 1e6:
|
||||
return f"{value / 1e6:.2f}M"
|
||||
if abs(value) >= 1e3:
|
||||
return f"{value / 1e3:.1f}K"
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return f"{value:.2f}"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
|
||||
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
|
||||
chart_type = config.get("chart_type", "bar")
|
||||
chart_property = config.get("chart_property", "")
|
||||
|
||||
if chart_type == "number":
|
||||
aggregate = config.get("aggregate", "sum" if chart_property else "count")
|
||||
if aggregate not in ("count", "sum", "avg", "min", "max"):
|
||||
aggregate = "sum" if chart_property else "count"
|
||||
num = _chart_aggregate(pages, chart_property, aggregate)
|
||||
label = config.get("title") or chart_property or collection["name"]
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
|
||||
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
|
||||
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
|
||||
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
|
||||
</style>
|
||||
<div class="kpi">
|
||||
<div class="kpi-label">{label}</div>
|
||||
<div class="kpi-value">{_fmt_number(num)}</div>
|
||||
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
|
||||
{' of ' + chart_property if chart_property else ''}</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
for p in pages[:CHART_MAX_GROUPS]:
|
||||
labels.append(str(p.get("title") or "")[:30])
|
||||
props = json.loads(p.get("property_values_json", "{}") or "{}")
|
||||
val = 0.0
|
||||
if chart_property:
|
||||
v_raw = props.get(chart_property, 0)
|
||||
try:
|
||||
val = float(v_raw) if v_raw else 0.0
|
||||
except (ValueError, TypeError):
|
||||
val = 0.0
|
||||
values.append(val)
|
||||
|
||||
labels_json = json.dumps(labels)
|
||||
values_json = json.dumps(values)
|
||||
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
|
||||
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.chart-container{{max-width:800px;margin:0 auto}}
|
||||
canvas{{max-height:400px}}
|
||||
</style>
|
||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||
<script src="/static/js/vendor/chart.umd.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
data: {{
|
||||
labels: {labels_json},
|
||||
datasets: [{{
|
||||
label: '{config.get("title") or collection["name"]}',
|
||||
data: {values_json},
|
||||
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
|
||||
}}]
|
||||
}},
|
||||
options: {{ responsive: true }}
|
||||
}});
|
||||
</script>
|
||||
<p class="desc">{subtitle}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Form view — generates an HTML form that creates new pages in the collection."""
|
||||
properties = []
|
||||
with get_conn() as conn:
|
||||
props = conn.execute(
|
||||
"SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
|
||||
(collection["id"],),
|
||||
).fetchall()
|
||||
for p in props:
|
||||
prop_dict = dict(p)
|
||||
prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
|
||||
properties.append(prop_dict)
|
||||
|
||||
fields = ""
|
||||
for prop in properties:
|
||||
name = prop["name"]
|
||||
ptype = prop["prop_type"]
|
||||
if ptype in ("text", "email", "url", "phone", "number"):
|
||||
fields += f"""<div class='form-field'><label>{name}</label><input type='{"number" if ptype=="number" else "text"}' name='prop_{name}' placeholder='{name}'></div>"""
|
||||
elif ptype in ("select", "status"):
|
||||
options = "".join(f"<option value='{o}'>{o}</option>" for o in prop.get("options", []))
|
||||
fields += f"""<div class='form-field'><label>{name}</label><select name='prop_{name}'>{options}</select></div>"""
|
||||
elif ptype == "checkbox":
|
||||
fields += f"""<div class='form-field'><label><input type='checkbox' name='prop_{name}' value='1'> {name}</label></div>"""
|
||||
elif ptype == "date":
|
||||
fields += f"""<div class='form-field'><label>{name}</label><input type='date' name='prop_{name}'></div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.form-field{{margin-bottom:12px}}
|
||||
.form-field label{{display:block;font-size:13px;color:#A0A0A0;margin-bottom:4px}}
|
||||
.form-field input,.form-field select{{width:100%;max-width:400px;padding:8px;background:#333;border:1px solid #555;border-radius:6px;color:#fff;font-size:14px}}
|
||||
.form-submit{{padding:8px 20px;background:#3366CC;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-top:8px}}
|
||||
.form-submit:hover{{background:#254E99}}
|
||||
</style>
|
||||
<div class="form-container">
|
||||
<h3>New entry in {collection['name']}</h3>
|
||||
<form id="collectionForm" onsubmit="submitForm(event)">
|
||||
{fields}
|
||||
<div class='form-field'><label>Title</label><input type='text' name='title' placeholder='Page title' required></div>
|
||||
<button type='submit' class='form-submit'>Submit</button>
|
||||
</form>
|
||||
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
||||
</div>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
async function submitForm(e) {{
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('collectionForm');
|
||||
const fd = new FormData(form);
|
||||
const properties = {{}};
|
||||
const title = fd.get('title') || 'New entry';
|
||||
fd.forEach((v,k) => {{ if(k.startsWith('prop_')) properties[k.slice(5)] = v; }});
|
||||
const resp = await fetch('/db/{collection["id"]}/pages/api', {{
|
||||
method:'POST', headers:{{'Content-Type':'application/json'}},
|
||||
body: JSON.stringify({{title, properties}})
|
||||
}});
|
||||
if(resp.ok) {{
|
||||
document.getElementById('formResult').style.display='block';
|
||||
form.reset();
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Map view — displays pages with location data on Leaflet map."""
|
||||
markers = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
lat, lng = None, None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and "," in v:
|
||||
parts = v.split(",")
|
||||
try:
|
||||
lat, lng = float(parts[0].strip()), float(parts[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
elif isinstance(v, dict):
|
||||
lat = v.get("lat")
|
||||
lng = v.get("lng")
|
||||
if lat and lng:
|
||||
markers.append({"title": p["title"], "lat": lat, "lng": lng})
|
||||
|
||||
markers_json = json.dumps(markers)
|
||||
center_lat = markers[0]["lat"] if markers else 45.5
|
||||
center_lng = markers[0]["lng"] if markers else -73.5
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
|
||||
<style>
|
||||
#map{{height:400px;border-radius:8px}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="/static/js/vendor/leaflet.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
const markers = {markers_json};
|
||||
markers.forEach(m => L.marker([m.lat, m.lng]).addTo(map).bindPopup(m.title));
|
||||
if(markers.length===0) L.marker([{center_lat},{center_lng}]).addTo(map).bindPopup('Default');
|
||||
</script>
|
||||
<p class="desc">{len(markers)} location(s) mapped</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Feed view — chronological feed of pages, newest first."""
|
||||
sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
|
||||
items = ""
|
||||
for p in sorted_pages:
|
||||
created = p.get("created_at", "")[:10] if p.get("created_at") else ""
|
||||
items += f"""<div class='feed-item'>
|
||||
<div class='feed-meta'>{created}</div>
|
||||
<div class='feed-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
|
||||
<style>
|
||||
.feed-item{{padding:12px 16px;border-left:2px solid #333;margin-bottom:8px;background:#1a1a1a;border-radius:0 8px 8px 0}}
|
||||
.feed-item:hover{{border-left-color:#3366CC}}
|
||||
.feed-meta{{font-size:11px;color:#A0A0A0;margin-bottom:4px}}
|
||||
.feed-title{{font-size:14px;font-weight:500}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(sorted_pages)} entries</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
"""Gantt view — timeline with dependencies and group_by support."""
|
||||
group_by = config.get("group_by", "")
|
||||
|
||||
gantt_data = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
group = None
|
||||
start_val = end_val = None
|
||||
for _k, v in props.items():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
if "→" in v:
|
||||
parts = v.split("→")
|
||||
start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
|
||||
elif "..." in v:
|
||||
parts = v.split("...")
|
||||
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
||||
else:
|
||||
start_val = end_val = v[:10]
|
||||
break
|
||||
if group_by:
|
||||
group = str(props.get(group_by, props.get("Status", "")))[:20]
|
||||
if start_val:
|
||||
gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
|
||||
|
||||
if not gantt_data:
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "<p class='desc'>No date data for Gantt chart.</p>")
|
||||
|
||||
from datetime import date as dt_date_2
|
||||
all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
|
||||
min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
|
||||
max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
|
||||
total_days = max((max_date - min_date).days, 1)
|
||||
|
||||
groups = {}
|
||||
for d in gantt_data:
|
||||
groups.setdefault(d["group"], []).append(d)
|
||||
if not groups or all(k == "" for k in groups):
|
||||
groups = {"": gantt_data}
|
||||
|
||||
rows = ""
|
||||
for group_name, items in sorted(groups.items()):
|
||||
if group_name:
|
||||
rows += f"<div class='gantt-group'>{group_name} ({len(items)})</div>"
|
||||
for item in items:
|
||||
sd = dt_date_2.fromisoformat(item["start"])
|
||||
ed = dt_date_2.fromisoformat(item["end"])
|
||||
left = max((sd - min_date).days / total_days * 100, 0)
|
||||
width = max((ed - sd).days / total_days * 100, 1)
|
||||
rows += f"""<div class='gantt-row'>
|
||||
<span class='gantt-label'>{item['title'][:30]}</span>
|
||||
<div class='gantt-track'><div class='gantt-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{item["start"]} → {item["end"]}'></div></div>
|
||||
<span class='gantt-dates'>{item['start']} → {item['end']}</span>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
.gantt-group{{padding:8px 12px;background:#222;font-size:13px;font-weight:600;margin:8px 0 4px;border-radius:4px}}
|
||||
.gantt-row{{display:flex;align-items:center;margin-bottom:6px;gap:8px}}
|
||||
.gantt-label{{width:180px;font-size:12px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.gantt-track{{flex:1;height:24px;background:#222;border-radius:4px;position:relative}}
|
||||
.gantt-bar{{position:absolute;top:3px;height:18px;background:linear-gradient(90deg,#3366CC,#5599EE);border-radius:4px;min-width:4px}}
|
||||
.gantt-dates{{font-size:10px;color:#A0A0A0;flex-shrink:0;min-width:140px}}
|
||||
</style>
|
||||
<div class="gantt-header" style="display:flex;margin-bottom:8px;padding-left:188px">
|
||||
<span style="flex:1;font-size:10px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:10px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{rows}
|
||||
<p class="desc">{len(gantt_data)} items · {min_date} → {max_date}</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
rows = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
prop_cells = "".join(f"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
|
||||
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
table{{width:100%;border-collapse:collapse}}
|
||||
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
|
||||
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
|
||||
tr:hover td{{background:#222}}
|
||||
</style>
|
||||
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
|
||||
<p class="desc">{len(pages)} rows</p>
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""FlowDeck — Collections : boards.
|
||||
|
||||
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 logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|
||||
|
||||
|
||||
@router.get("/boards/api")
|
||||
def list_boards_as_collections(request: Request):
|
||||
"""API: list all Gitea boards as pseudo-collections."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
boards = GiteaBoardCompat.list_boards_as_collections()
|
||||
return {"boards": boards}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/board/{owner}/{repo}/api")
|
||||
async def get_board_as_collection(request: Request, owner: str, repo: str):
|
||||
"""API: get a specific Gitea board as a pseudo-collection."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
coll = GiteaBoardCompat.get_board_as_collection(owner, repo)
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
|
||||
from app.services.gitea_client import gitea
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
cards = GiteaBoardCompat.get_board_cards(owner, repo, issues)
|
||||
|
||||
return {"collection": coll, "pages": cards}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/board/{owner}/{repo}/sync")
|
||||
async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
||||
"""Sync a Gitea board to a real collection."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
|
||||
if coll_id is None:
|
||||
raise HTTPException(status_code=404, detail="Board not found")
|
||||
|
||||
return {"collection_id": coll_id, "status": "synced"}
|
||||
|
||||
|
||||
# ── Collection Properties (v1.4.0) ──
|
||||
@@ -0,0 +1,337 @@
|
||||
"""FlowDeck — Collections : crud.
|
||||
|
||||
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 fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
from ._common import _apply_template, _require_view, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def list_collections(request: Request):
|
||||
"""Page listing all collections in the workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
collections = [dict(r) for r in rows]
|
||||
return HTMLResponse(
|
||||
f"<div class='collections-list'>"
|
||||
f"<h2>Collections ({len(collections)})</h2>"
|
||||
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def list_collections_api(request: Request):
|
||||
"""API: list all collections."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collections ORDER BY name"
|
||||
).fetchall()
|
||||
return {"collections": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api")
|
||||
def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
"""API: create a new collection, optionally from a database 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", "📋")
|
||||
gitea_owner = body.get("gitea_owner")
|
||||
gitea_repo = body.get("gitea_repo")
|
||||
schema = body.get("schema", [])
|
||||
is_locked = body.get("is_locked", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
# Apply a template if requested (provides schema + icon).
|
||||
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 = []
|
||||
|
||||
schema_json = json.dumps(schema)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
|
||||
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()
|
||||
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
# ── API: Update & Delete (no path-param conflicts) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── API: Update & Delete (no path-param conflicts) ──
|
||||
|
||||
|
||||
@router.put("/api/{collection_id}")
|
||||
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: update a collection."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
name = body.get("name", existing["name"])
|
||||
description = body.get("description", existing["description"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
is_locked = body.get("is_locked", existing["is_locked"])
|
||||
schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"])))
|
||||
gitea_owner = body.get("gitea_owner", existing["gitea_owner"])
|
||||
gitea_repo = body.get("gitea_repo", existing["gitea_repo"])
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collections SET name=?, description=?, icon=?, schema_json=?,
|
||||
is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(name, description, icon, schema_json, int(is_locked),
|
||||
gitea_owner, gitea_repo, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/api/{collection_id}")
|
||||
def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""}))
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/duplicate")
|
||||
def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
|
||||
sources) into a new collection named '<original> (copy)'."""
|
||||
with get_conn() as conn:
|
||||
src = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not src:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
new_name = (src["name"] or "Database") + " copy"
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at)
|
||||
SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at
|
||||
FROM collections WHERE id=?""",
|
||||
(new_name, collection_id),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
|
||||
# ── Properties (remap ids so relation/rollup refs stay valid) ──
|
||||
prop_map: dict[int, int] = {}
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
|
||||
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
|
||||
tuples = [
|
||||
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
|
||||
None, p["reverse_name"], None, None, p["rollup_function"],
|
||||
p["formula_expression"], p["position"], p["required"],
|
||||
p["visible_in_views"])
|
||||
for p in rows
|
||||
]
|
||||
if tuples:
|
||||
ncur = conn.executemany(
|
||||
"""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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
tuples,
|
||||
)
|
||||
new_ids = [
|
||||
r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
|
||||
(new_id,),
|
||||
).fetchall()
|
||||
]
|
||||
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
|
||||
for p, new_pid in zip(rows, new_ids, strict=True):
|
||||
prop_map[p["id"]] = new_pid
|
||||
|
||||
# Fix cross-property references after all rows exist (creates may target
|
||||
# columns not inserted yet). Related collection remapped to the copy.
|
||||
for p in rows:
|
||||
p = dict(p) # convert sqlite3.Row to dict
|
||||
new_pid = prop_map[p["id"]]
|
||||
related = p["related_collection_id"]
|
||||
related_new = new_id if related == collection_id else related
|
||||
if p["prop_type"] == "relation":
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET related_collection_id=? WHERE id=?",
|
||||
(related_new, new_pid),
|
||||
)
|
||||
if p.get("relation_property_id") and p["relation_property_id"] in prop_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET relation_property_id=? WHERE id=?",
|
||||
(prop_map[p["relation_property_id"]], new_pid),
|
||||
)
|
||||
if p.get("target_property_id") and p["target_property_id"] in prop_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET target_property_id=? WHERE id=?",
|
||||
(prop_map[p["target_property_id"]], new_pid),
|
||||
)
|
||||
|
||||
# ── Views ──
|
||||
vrows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for v in vrows:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(new_id, v["name"], v["view_type"], v["config_json"], v["position"]),
|
||||
)
|
||||
|
||||
# ── Pages (rows) with property ids remapped to the copy's properties ──
|
||||
prows = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
page_map: dict[int, int] = {}
|
||||
for p in prows:
|
||||
try:
|
||||
pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
pv_new = {}
|
||||
for k, val in pv.items():
|
||||
try:
|
||||
prop_id = int(k)
|
||||
except (ValueError, TypeError):
|
||||
prop_id = None
|
||||
new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k
|
||||
pv_new[new_key] = val
|
||||
ncur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, position, parent_id, gitea_issue_id,
|
||||
gitea_issue_number, property_values_json, created_at, updated_at)
|
||||
SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at
|
||||
FROM collection_pages WHERE id=?""",
|
||||
(new_id, json.dumps(pv_new), p["id"]),
|
||||
)
|
||||
page_map[p["id"]] = ncur.lastrowid
|
||||
|
||||
# Re-parent sub-items to the copied rows.
|
||||
for p in prows:
|
||||
if p["parent_id"] and p["parent_id"] in page_map:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET parent_id=? WHERE id=?",
|
||||
(page_map[p["parent_id"]], page_map[p["id"]]),
|
||||
)
|
||||
|
||||
# ── Data sources (linked DBs) ──
|
||||
drows = conn.execute(
|
||||
"SELECT * FROM collection_data_sources WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for d in drows:
|
||||
src_coll = d["source_collection_id"]
|
||||
src_now = new_id if src_coll == collection_id else src_coll
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_data_sources
|
||||
(collection_id, source_collection_id, source_name, is_linked, position)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(new_id, src_now, d["source_name"], d["is_linked"], d["position"]),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
|
||||
return {"id": new_id, "name": new_name, "status": "duplicated"}
|
||||
|
||||
|
||||
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
||||
@@ -0,0 +1,214 @@
|
||||
"""FlowDeck — Collections : dashboard_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 html as _htmlmod
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import _require_view, _session_user
|
||||
from ._renderers import CHART_MAX_GROUPS, _base_html, _render_chart, _render_view
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
|
||||
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: auto-shift dates based on blocking dependencies."""
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
|
||||
skip_weekends = body.get("skip_weekends", False)
|
||||
|
||||
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")
|
||||
|
||||
# Get all blocking dependencies
|
||||
deps = conn.execute(
|
||||
"SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
shifted = False
|
||||
new_start = None
|
||||
for dep in deps:
|
||||
dep_page = conn.execute(
|
||||
"SELECT title, property_values_json FROM collection_pages WHERE id=?",
|
||||
(dep["dependency_id"],),
|
||||
).fetchone()
|
||||
if not dep_page:
|
||||
continue
|
||||
dep_props = json.loads(dep_page["property_values_json"])
|
||||
# Find the latest end date among blockers
|
||||
for v in dep_props.values():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
|
||||
try:
|
||||
ed = dt_date.fromisoformat(end_date)
|
||||
if new_start is None or ed >= new_start:
|
||||
new_start = ed + timedelta(days=1)
|
||||
shifted = True
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not shifted:
|
||||
return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
|
||||
|
||||
# Skip weekends if requested
|
||||
if skip_weekends and new_start:
|
||||
while new_start.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
new_start = new_start + timedelta(days=1)
|
||||
|
||||
# Update the page's date properties
|
||||
props = json.loads(page["property_values_json"])
|
||||
for k, v in list(props.items()):
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
old_parts = v.split("...")
|
||||
old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
|
||||
try:
|
||||
old_start_d = dt_date.fromisoformat(old_parts[0][:10])
|
||||
old_end_d = dt_date.fromisoformat(old_end[:10])
|
||||
duration = (old_end_d - old_start_d).days
|
||||
new_end = new_start + timedelta(days=max(duration, 0))
|
||||
props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
|
||||
except ValueError:
|
||||
props[k] = new_start.isoformat()
|
||||
break
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||
|
||||
Widgets live in ``collection_dashboards.layout_json`` as
|
||||
``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
|
||||
chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
|
||||
point at *any* database (the dashboard's own collection is the default),
|
||||
which is what "dashboards multi-DB" means.
|
||||
"""
|
||||
uid = _session_user(request)
|
||||
_require_view(collection_id, uid)
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
|
||||
(dashboard_id, collection_id)).fetchone()
|
||||
if not dash:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
layout = json.loads(dash["layout_json"] or "{}")
|
||||
columns = max(1, int(layout.get("columns", 1) or 1))
|
||||
widgets = layout.get("widgets", []) or []
|
||||
if not isinstance(widgets, list):
|
||||
widgets = []
|
||||
|
||||
rendered = []
|
||||
for w in widgets[:40]:
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
wc = int(w.get("collection_id") or 0) or collection_id
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
|
||||
if not coll:
|
||||
continue
|
||||
try:
|
||||
_require_view(wc, uid)
|
||||
except HTTPException:
|
||||
continue # restricted database → widget skipped, not rendered
|
||||
with get_conn() as conn:
|
||||
wpages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
|
||||
(wc, CHART_MAX_GROUPS)).fetchall()
|
||||
wconfig = {k: v for k, v in w.items()
|
||||
if k in ("chart_type", "chart_property", "aggregate", "title")}
|
||||
view_type = w.get("view_type") or "chart"
|
||||
if view_type == "chart":
|
||||
body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
else:
|
||||
body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
|
||||
width = int(w.get("width") or 0)
|
||||
span = f"grid-column: span {width};" if width and width > 0 else ""
|
||||
rendered.append(f'<div class="dash-widget" style="{span}">{body}</div>')
|
||||
|
||||
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
|
||||
body = f"""
|
||||
<style>
|
||||
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
|
||||
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
|
||||
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
|
||||
</style>
|
||||
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
|
||||
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
|
||||
"""
|
||||
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
|
||||
(collection_id, view_type),
|
||||
).fetchone()
|
||||
if not view:
|
||||
view = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
collection_dict = dict(collection)
|
||||
pages_list = [dict(p) for p in pages]
|
||||
config = json.loads(view["config_json"]) if view else {}
|
||||
|
||||
if "year" in request.query_params:
|
||||
config["year"] = int(request.query_params["year"])
|
||||
if "month" in request.query_params:
|
||||
config["month"] = int(request.query_params["month"])
|
||||
|
||||
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
|
||||
|
||||
|
||||
# ── View renderers (v1.6.0) ──
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Collections : data_api.
|
||||
|
||||
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 app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
_collection_properties,
|
||||
_current_user,
|
||||
_require_edit,
|
||||
_require_view,
|
||||
_session_user,
|
||||
_validate_meta_keys,
|
||||
_validate_page_properties,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
views = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"collection": dict(collection),
|
||||
"pages": [dict(p) for p in pages],
|
||||
"views": [dict(v) for v in views],
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a page in a collection."""
|
||||
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
icon = body.get("icon", "📄")
|
||||
property_values = body.get("properties", {})
|
||||
cover_url = body.get("cover_url", "")
|
||||
gitea_issue_id = body.get("gitea_issue_id")
|
||||
gitea_issue_number = body.get("gitea_issue_number")
|
||||
|
||||
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")
|
||||
|
||||
_validate_page_properties(conn, collection_id, property_values)
|
||||
_validate_meta_keys(conn, collection_id, property_values)
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, collection_id),
|
||||
property_values,
|
||||
_current_user(request),
|
||||
is_create=True,
|
||||
)
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
|
||||
json.dumps(property_values)),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
}))
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
@@ -0,0 +1,286 @@
|
||||
"""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)."""
|
||||
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")
|
||||
new_val = 0 if coll["is_task"] else 1
|
||||
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
|
||||
conn.commit()
|
||||
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"}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""FlowDeck — Collections : meta.
|
||||
|
||||
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, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
expand_rule,
|
||||
parse_date,
|
||||
)
|
||||
|
||||
from ._common import _collection_properties, _current_user, _require_view, _session_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Collection Properties (v1.4.0) ──
|
||||
|
||||
|
||||
@router.get("/property-types/api")
|
||||
def list_property_types_api(request: Request):
|
||||
"""API: list all available property types."""
|
||||
from app.services.property_types import PROPERTY_TYPES
|
||||
return {"types": PROPERTY_TYPES}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
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_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
props = [dict(r) for r in rows]
|
||||
# v6.0.0: property-level visibility — owners/editors see everything, other
|
||||
# users only the properties explicitly granted or left open.
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
props = [p for p in props if p["id"] in visible]
|
||||
return {"properties": props}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
def list_collection_members_api(request: Request, collection_id: int):
|
||||
"""API: list workspace members available for a ``person`` property.
|
||||
|
||||
Resolves the collection's workspace and returns its members (falling back to
|
||||
every active user for standalone databases without a workspace).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
|
||||
if ws_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
|
||||
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
|
||||
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = []
|
||||
if not rows:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
|
||||
FROM users WHERE is_active=1 ORDER BY full_name, login"""
|
||||
).fetchall()
|
||||
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{collection_id}/calendar/api")
|
||||
def collection_calendar_api(request: Request, collection_id: int,
|
||||
start: str = "", end: str = "",
|
||||
date_property: str = ""):
|
||||
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
||||
|
||||
Returns every occurrence (recurrence-aware, virtual — never persisted)
|
||||
of the rows in the collection whose ``date_property`` falls inside the
|
||||
inclusive window. Rows without a rule yield their base date.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
s = parse_date(start)
|
||||
e = parse_date(end)
|
||||
if s is None or e is None or s > e:
|
||||
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
|
||||
if (e - s).days > 370:
|
||||
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
|
||||
|
||||
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")
|
||||
|
||||
props = _collection_properties(conn, collection_id)
|
||||
date_props = [p for p in props if p["prop_type"] == "date"]
|
||||
target = None
|
||||
if date_property:
|
||||
target = next((p for p in date_props
|
||||
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=400, detail="Unknown date property")
|
||||
elif date_props:
|
||||
target = date_props[0]
|
||||
if target is None:
|
||||
return {"events": [], "timezone": "", "property": None}
|
||||
|
||||
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
||||
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
pid = str(target["id"])
|
||||
events: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
base_value = pv.get(pid)
|
||||
if base_value is None:
|
||||
base_value = pv.get(target["name"])
|
||||
if parse_date(base_value) is None:
|
||||
continue
|
||||
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
|
||||
rule = rec_all.get(pid) or rec_all.get(target["name"])
|
||||
row_tz = user_tz
|
||||
if isinstance(rule, dict) and rule.get("timezone"):
|
||||
row_tz = rule["timezone"]
|
||||
tzmap = pv.get("__timezone__")
|
||||
if isinstance(tzmap, dict):
|
||||
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
|
||||
if ev_tz:
|
||||
row_tz = str(ev_tz)
|
||||
if rule:
|
||||
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
|
||||
else:
|
||||
d = parse_date(base_value)
|
||||
dates = [d.isoformat()] if d and s <= d <= e else []
|
||||
for iso in dates:
|
||||
events.append({
|
||||
"date": iso,
|
||||
"page_id": r["id"],
|
||||
"title": r["title"],
|
||||
"icon": r["icon"],
|
||||
"recurring": bool(rule),
|
||||
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
|
||||
"timezone": row_tz,
|
||||
})
|
||||
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
|
||||
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/timezones/api")
|
||||
def timezones_api(request: Request):
|
||||
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
||||
from app.services.recurrence import common_timezones
|
||||
return {
|
||||
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
|
||||
"zones": common_timezones(),
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""FlowDeck — Collections : pages.
|
||||
|
||||
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 app.services.property_types import (
|
||||
apply_auto_properties,
|
||||
)
|
||||
|
||||
from ._common import (
|
||||
_collection_properties,
|
||||
_current_user,
|
||||
_require_edit,
|
||||
_require_view,
|
||||
_session_user,
|
||||
_validate_meta_keys,
|
||||
_validate_page_properties,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/api")
|
||||
def get_page_api(request: Request, page_id: int):
|
||||
"""API: get a single page."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_view(page["collection_id"], _session_user(request))
|
||||
return dict(page)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/open/api")
|
||||
def open_row_page_api(request: Request, page_id: int):
|
||||
"""v6.5.0 — content page of a database row (lazy-created).
|
||||
|
||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||
this endpoint: it returns the shadow ``pages`` id whose full page
|
||||
editor carries the row's block content (synced blocks included).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT collection_id FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
coll_id = row["collection_id"]
|
||||
# v6.0.0: granular collection permissions (same gate as the row itself).
|
||||
_require_view(coll_id, _session_user(request))
|
||||
from app.services.row_pages import ensure_row_page
|
||||
try:
|
||||
content_page_id = ensure_row_page(page_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Page not found") from None
|
||||
return {"page_id": content_page_id, "row_id": page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""API: update a page's properties."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
title = body.get("title", existing["title"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
|
||||
position = body.get("position", existing["position"])
|
||||
parent_id = body.get("parent_id", existing["parent_id"])
|
||||
|
||||
try:
|
||||
stored = json.loads(existing["property_values_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
stored = {}
|
||||
|
||||
if "properties" in body:
|
||||
# Partial PATCH semantics: merge submitted values over stored ones.
|
||||
props = dict(stored)
|
||||
props.update(body["properties"])
|
||||
else:
|
||||
props = stored
|
||||
|
||||
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
|
||||
_validate_meta_keys(conn, existing["collection_id"], props)
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, existing["collection_id"]),
|
||||
props,
|
||||
_current_user(request),
|
||||
is_create=False,
|
||||
)
|
||||
|
||||
property_values = json.dumps(props)
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages
|
||||
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(title, icon, cover_url, position, parent_id, property_values, page_id),
|
||||
)
|
||||
# v6.5.0: keep the row's content page title in sync (row → page).
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
}))
|
||||
# Notify newly assigned people (person properties) — v5.8.0.
|
||||
from app.services.notifications import notify_assignment
|
||||
user = _current_user(request)
|
||||
notify_assignment(existing["collection_id"], page_id, title,
|
||||
stored, props, user.get("id"))
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/api")
|
||||
def delete_page_api(request: Request, page_id: int):
|
||||
"""API: delete a page from its collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
}))
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
|
||||
@@ -0,0 +1,322 @@
|
||||
"""FlowDeck — Collections : properties.
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: (re)assign properties to collapsible groups in the table header.
|
||||
|
||||
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
|
||||
omitted from any group have their group cleared. Empty group names clear.
|
||||
"""
|
||||
groups = body.get("groups", [])
|
||||
|
||||
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")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET group_name='' WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
)
|
||||
for grp in groups:
|
||||
gname = (grp.get("name") or "").strip()
|
||||
if not gname:
|
||||
continue
|
||||
for pid in grp.get("property_ids", []) or []:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
|
||||
(gname, pid, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a new property on a collection."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
prop_type = body.get("prop_type", "text")
|
||||
options_json = json.dumps(body.get("options", []))
|
||||
number_format = body.get("number_format", "number")
|
||||
required = int(body.get("required", False))
|
||||
visible = int(body.get("visible_in_views", True))
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
group_name = (body.get("group_name") or "").strip()
|
||||
|
||||
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_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
position, required, visible_in_views, validation_json, group_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, prop_type, options_json, number_format, max_pos,
|
||||
required, visible, validation_json, group_name),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
|
||||
"group_name": group_name, "status": "created"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
|
||||
"""API: update a property."""
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
|
||||
name = body.get("name", existing["name"])
|
||||
options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
|
||||
number_format = body.get("number_format", existing["number_format"])
|
||||
required = int(body.get("required", existing["required"]))
|
||||
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
|
||||
if "validation" in body:
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
else:
|
||||
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
|
||||
group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_properties
|
||||
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
|
||||
validation_json=?, group_name=?
|
||||
WHERE id=?""",
|
||||
(name, options_json, number_format, required, visible, validation_json,
|
||||
group_name, prop_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}/api")
|
||||
def delete_property_api(request: Request, prop_id: int):
|
||||
"""API: delete a property."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Relations, Rollups, Formulas (v1.5.0) ──
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Relations, Rollups, Formulas (v1.5.0) ──
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a relation property between two collections."""
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
related_collection_id = body.get("related_collection_id")
|
||||
reverse_name = body.get("reverse_name", "").strip()
|
||||
|
||||
if not name or not related_collection_id:
|
||||
raise HTTPException(status_code=400, detail="name and related_collection_id are required")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify both collections exist
|
||||
for cid in (collection_id, related_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_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
|
||||
VALUES (?, ?, 'relation', ?, ?, ?)""",
|
||||
(collection_id, name, related_collection_id, reverse_name, max_pos),
|
||||
)
|
||||
prop_id = cur.lastrowid
|
||||
|
||||
# Create reverse relation on the related collection
|
||||
if reverse_name:
|
||||
max_pos2 = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(related_collection_id,),
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
|
||||
VALUES (?, ?, 'relation', ?, ?, ?)""",
|
||||
(related_collection_id, reverse_name, collection_id, name, max_pos2),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation/link")
|
||||
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Link two pages via a relation property."""
|
||||
|
||||
property_id = body.get("property_id")
|
||||
source_page_id = body.get("source_page_id")
|
||||
target_page_id = body.get("target_page_id")
|
||||
|
||||
if not all([property_id, source_page_id, target_page_id]):
|
||||
raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get the relation property
|
||||
prop = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
|
||||
(property_id,),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Relation property not found")
|
||||
|
||||
# Update source page's property_values_json
|
||||
source = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(source_page_id,),
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source page not found")
|
||||
|
||||
props = json.loads(source["property_values_json"])
|
||||
current = props.get(str(property_id), [])
|
||||
if not isinstance(current, list):
|
||||
current = []
|
||||
if target_page_id not in current:
|
||||
current.append(target_page_id)
|
||||
props[str(property_id)] = current
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(props), source_page_id),
|
||||
)
|
||||
|
||||
# Update reverse relation if exists
|
||||
if prop["reverse_name"]:
|
||||
reverse_prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
|
||||
(prop["related_collection_id"], prop["reverse_name"]),
|
||||
).fetchone()
|
||||
if reverse_prop:
|
||||
target = conn.execute(
|
||||
"SELECT property_values_json FROM collection_pages WHERE id=?",
|
||||
(target_page_id,),
|
||||
).fetchone()
|
||||
if target:
|
||||
tprops = json.loads(target["property_values_json"])
|
||||
tcurrent = tprops.get(str(reverse_prop["id"]), [])
|
||||
if not isinstance(tcurrent, list):
|
||||
tcurrent = []
|
||||
if source_page_id not in tcurrent:
|
||||
tcurrent.append(source_page_id)
|
||||
tprops[str(reverse_prop["id"])] = tcurrent
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(tprops), target_page_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"status": "linked", "source": source_page_id, "target": target_page_id}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/rollup/compute")
|
||||
def compute_rollup(request: Request, body: dict = Body(default={})):
|
||||
"""Compute a rollup aggregation."""
|
||||
|
||||
collection_id = body.get("collection_id")
|
||||
relation_property_id = body.get("relation_property_id")
|
||||
target_property_id = body.get("target_property_id")
|
||||
page_id = body.get("page_id")
|
||||
rollup_function = body.get("function", "count")
|
||||
|
||||
if not all([collection_id, relation_property_id, target_property_id, page_id]):
|
||||
raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
|
||||
|
||||
from app.services.rollup_engine import RollupEngine
|
||||
engine = RollupEngine()
|
||||
result = engine.compute(
|
||||
collection_id, relation_property_id, target_property_id, page_id, rollup_function,
|
||||
)
|
||||
|
||||
return {"result": result, "function": rollup_function}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/formula/evaluate")
|
||||
def evaluate_formula(request: Request, body: dict = Body(default={})):
|
||||
"""Evaluate a formula expression."""
|
||||
|
||||
expression = body.get("expression", "")
|
||||
context = body.get("context", {})
|
||||
|
||||
if not expression:
|
||||
raise HTTPException(status_code=400, detail="expression is required")
|
||||
|
||||
from app.services.formula_engine import FormulaEngine
|
||||
engine = FormulaEngine()
|
||||
result = engine.evaluate(expression, context)
|
||||
|
||||
return {"result": result, "expression": expression}
|
||||
|
||||
|
||||
# ── v1.7.0 View Management ──
|
||||
@@ -0,0 +1,267 @@
|
||||
"""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"}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""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 ──
|
||||
Reference in New Issue
Block a user