2026-08-30 16:21:34 +02:00
|
|
|
"""Personal dashboards routes — CRUD for per-user dashboard layouts (Phase M2).
|
|
|
|
|
|
|
|
|
|
Dashboards are personal (saved_views precedent): every query is scoped to
|
|
|
|
|
the current tenant AND user, so foreign dashboards answer 404. The lazy
|
|
|
|
|
default seed on first GET derives the widget list from the MiniApp registry
|
|
|
|
|
(permission-filtered, registry order, 12-column flow).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.audit import log_audit
|
|
|
|
|
from app.core.db import get_db
|
|
|
|
|
from app.deps import require_permission
|
|
|
|
|
from app.models.dashboard import Dashboard
|
|
|
|
|
from app.plugins.miniapp_registry import get_miniapp_registry, user_permits
|
|
|
|
|
from app.schemas.dashboard import DashboardCreate, DashboardUpdate
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/dashboards", tags=["dashboards"])
|
|
|
|
|
|
|
|
|
|
SEED_NAME = "Mein Dashboard"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _to_dict(d: Dashboard) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": str(d.id),
|
|
|
|
|
"name": d.name,
|
|
|
|
|
"layout": d.layout,
|
|
|
|
|
"is_default": d.is_default,
|
|
|
|
|
"user_id": str(d.user_id),
|
|
|
|
|
"created_at": d.created_at.isoformat() if d.created_at else None,
|
|
|
|
|
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_uuid(value: str, field: str) -> uuid.UUID:
|
|
|
|
|
try:
|
|
|
|
|
return uuid.UUID(value)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
|
|
|
|
|
) from None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_owned(
|
|
|
|
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, dash_id: str
|
|
|
|
|
) -> Dashboard:
|
|
|
|
|
did = _parse_uuid(dash_id, "dashboard_id")
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Dashboard).where(
|
|
|
|
|
Dashboard.id == did,
|
|
|
|
|
Dashboard.tenant_id == tenant_id,
|
|
|
|
|
Dashboard.user_id == user_id,
|
|
|
|
|
Dashboard.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
dashboard = result.scalar_one_or_none()
|
|
|
|
|
if dashboard is None:
|
|
|
|
|
raise HTTPException(404, detail={"detail": "Dashboard not found", "code": "not_found"})
|
|
|
|
|
return dashboard
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _count_active(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID) -> int:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(func.count()).select_from(Dashboard).where(
|
|
|
|
|
Dashboard.tenant_id == tenant_id,
|
|
|
|
|
Dashboard.user_id == user_id,
|
|
|
|
|
Dashboard.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return int(result.scalar() or 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _name_taken(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
tenant_id: uuid.UUID,
|
|
|
|
|
user_id: uuid.UUID,
|
|
|
|
|
name: str,
|
|
|
|
|
exclude_id: uuid.UUID | None = None,
|
|
|
|
|
) -> bool:
|
|
|
|
|
query = select(func.count()).select_from(Dashboard).where(
|
|
|
|
|
Dashboard.tenant_id == tenant_id,
|
|
|
|
|
Dashboard.user_id == user_id,
|
|
|
|
|
Dashboard.name == name,
|
|
|
|
|
Dashboard.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
if exclude_id is not None:
|
|
|
|
|
query = query.where(Dashboard.id != exclude_id)
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
return int(result.scalar() or 0) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _seed_default_dashboard(
|
|
|
|
|
db: AsyncSession, current_user: dict[str, Any]
|
|
|
|
|
) -> Dashboard:
|
|
|
|
|
"""Create the first personal dashboard from the MiniApp registry.
|
|
|
|
|
|
|
|
|
|
Widgets are placed in registry order using a 12-column flow: an app
|
|
|
|
|
that no longer fits into the current row wraps to the next one.
|
|
|
|
|
Apps the user has no permission for are filtered out (fail-closed).
|
|
|
|
|
"""
|
|
|
|
|
registry = get_miniapp_registry()
|
|
|
|
|
apps = [
|
|
|
|
|
a
|
|
|
|
|
for a in registry.list_apps(host="dashboard")
|
2026-08-30 22:22:20 +02:00
|
|
|
if user_permits(current_user, a)
|
|
|
|
|
and "dashboard" in (a.get("hosts") or [])
|
|
|
|
|
and a.get("component") # renderable only (M4: chat apps stay off layouts)
|
2026-08-30 16:21:34 +02:00
|
|
|
]
|
|
|
|
|
apps.sort(key=lambda a: a.get("order", 100))
|
|
|
|
|
|
|
|
|
|
widgets: list[dict[str, Any]] = []
|
|
|
|
|
col = 0
|
|
|
|
|
row = 0
|
|
|
|
|
for app in apps:
|
|
|
|
|
col_span = max(1, min(12, int(app.get("col_span") or 1)))
|
|
|
|
|
row_span = max(1, min(12, int(app.get("row_span") or 1)))
|
|
|
|
|
if col + col_span > 12:
|
|
|
|
|
row += 1
|
|
|
|
|
col = 0
|
|
|
|
|
widgets.append(
|
|
|
|
|
{
|
|
|
|
|
"app_id": app["app_id"],
|
|
|
|
|
"settings": {},
|
|
|
|
|
"col": col,
|
|
|
|
|
"row": row,
|
|
|
|
|
"col_span": col_span,
|
|
|
|
|
"row_span": row_span,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
col += col_span
|
|
|
|
|
|
|
|
|
|
dashboard = Dashboard(
|
|
|
|
|
tenant_id=uuid.UUID(current_user["tenant_id"]),
|
|
|
|
|
user_id=uuid.UUID(current_user["user_id"]),
|
|
|
|
|
name=SEED_NAME,
|
|
|
|
|
layout={
|
|
|
|
|
"version": 1,
|
|
|
|
|
"tabs": [{"id": "start", "name": "Start", "widgets": widgets}],
|
|
|
|
|
},
|
|
|
|
|
is_default=True,
|
|
|
|
|
)
|
|
|
|
|
db.add(dashboard)
|
|
|
|
|
await db.flush()
|
|
|
|
|
await log_audit(
|
|
|
|
|
db,
|
|
|
|
|
dashboard.tenant_id,
|
|
|
|
|
dashboard.user_id,
|
|
|
|
|
"create",
|
|
|
|
|
"dashboard",
|
|
|
|
|
dashboard.id,
|
|
|
|
|
details={"seeded": True, "widgets": len(widgets)},
|
|
|
|
|
)
|
|
|
|
|
return dashboard
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("")
|
|
|
|
|
async def list_dashboards(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(require_permission("dashboard:read")),
|
|
|
|
|
):
|
|
|
|
|
"""List the current user's dashboards (lazy seed when none exist)."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Dashboard)
|
|
|
|
|
.where(
|
|
|
|
|
Dashboard.tenant_id == tenant_id,
|
|
|
|
|
Dashboard.user_id == user_id,
|
|
|
|
|
Dashboard.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
.order_by(Dashboard.created_at, Dashboard.id)
|
|
|
|
|
)
|
|
|
|
|
dashboards = list(result.scalars().all())
|
|
|
|
|
if not dashboards:
|
|
|
|
|
dashboards = [await _seed_default_dashboard(db, current_user)]
|
|
|
|
|
return [_to_dict(d) for d in dashboards]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
|
|
|
async def create_dashboard(
|
|
|
|
|
body: DashboardCreate,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(require_permission("dashboard:write")),
|
|
|
|
|
):
|
|
|
|
|
"""Create a new personal dashboard (starts with one empty tab)."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
|
|
|
|
|
if await _name_taken(db, tenant_id, user_id, body.name):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
409, detail={"detail": "Dashboard name already exists", "code": "duplicate"}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
is_default = await _count_active(db, tenant_id, user_id) == 0
|
|
|
|
|
dashboard = Dashboard(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
name=body.name,
|
|
|
|
|
layout={"version": 1, "tabs": [{"id": "start", "name": "Start", "widgets": []}]},
|
|
|
|
|
is_default=is_default,
|
|
|
|
|
)
|
|
|
|
|
db.add(dashboard)
|
|
|
|
|
await db.flush()
|
|
|
|
|
await log_audit(db, tenant_id, user_id, "create", "dashboard", dashboard.id)
|
|
|
|
|
return _to_dict(dashboard)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{dashboard_id}")
|
|
|
|
|
async def get_dashboard(
|
|
|
|
|
dashboard_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(require_permission("dashboard:read")),
|
|
|
|
|
):
|
|
|
|
|
"""Get one dashboard (owner-only; foreign ids answer 404)."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
|
|
|
|
|
return _to_dict(dashboard)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{dashboard_id}")
|
|
|
|
|
async def update_dashboard(
|
|
|
|
|
dashboard_id: str,
|
|
|
|
|
body: DashboardUpdate,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(require_permission("dashboard:write")),
|
|
|
|
|
):
|
|
|
|
|
"""Update name and/or layout (layout is validated -> 422 on bad input)."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
|
|
|
|
|
|
|
|
|
|
if body.name is not None and body.name != dashboard.name:
|
|
|
|
|
if await _name_taken(db, tenant_id, user_id, body.name, exclude_id=dashboard.id):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
409, detail={"detail": "Dashboard name already exists", "code": "duplicate"}
|
|
|
|
|
)
|
|
|
|
|
dashboard.name = body.name
|
|
|
|
|
if body.layout is not None:
|
|
|
|
|
dashboard.layout = body.layout.model_dump()
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(dashboard) # onupdate expires attributes (Phase L fix)
|
|
|
|
|
await log_audit(db, tenant_id, user_id, "update", "dashboard", dashboard.id)
|
|
|
|
|
return _to_dict(dashboard)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{dashboard_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
async def delete_dashboard(
|
|
|
|
|
dashboard_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(require_permission("dashboard:write")),
|
|
|
|
|
):
|
|
|
|
|
"""Soft-delete a dashboard; deleting the default promotes the next one."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
|
|
|
|
|
|
|
|
|
|
was_default = dashboard.is_default
|
|
|
|
|
dashboard.deleted_at = datetime.now(UTC)
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
if was_default:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Dashboard)
|
|
|
|
|
.where(
|
|
|
|
|
Dashboard.tenant_id == tenant_id,
|
|
|
|
|
Dashboard.user_id == user_id,
|
|
|
|
|
Dashboard.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
.order_by(Dashboard.created_at, Dashboard.id)
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
successor = result.scalar_one_or_none()
|
|
|
|
|
if successor is not None:
|
|
|
|
|
successor.is_default = True
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
await log_audit(db, tenant_id, user_id, "delete", "dashboard", dashboard.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{dashboard_id}/set-default")
|
|
|
|
|
async def set_default_dashboard(
|
|
|
|
|
dashboard_id: str,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
current_user: dict = Depends(require_permission("dashboard:write")),
|
|
|
|
|
):
|
|
|
|
|
"""Mark a dashboard as the user's default (exactly one default)."""
|
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
|
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
|
|
|
|
|
|
|
|
|
|
if not dashboard.is_default:
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(Dashboard).where(
|
|
|
|
|
Dashboard.tenant_id == tenant_id,
|
|
|
|
|
Dashboard.user_id == user_id,
|
|
|
|
|
Dashboard.deleted_at.is_(None),
|
|
|
|
|
Dashboard.is_default.is_(True),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for other in result.scalars().all():
|
|
|
|
|
other.is_default = False
|
|
|
|
|
dashboard.is_default = True
|
|
|
|
|
await db.flush()
|
|
|
|
|
await db.refresh(dashboard) # onupdate expires updated_at (Phase L fix)
|
|
|
|
|
await log_audit(db, tenant_id, user_id, "update", "dashboard", dashboard.id)
|
|
|
|
|
return _to_dict(dashboard)
|