Files
leocrm/app/routes/dashboards.py
T

317 lines
10 KiB
Python
Raw Normal View History

"""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")
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)
]
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)