Files
HartOMat/backend/app/domains/admin/dashboard_router.py
T
Hartmut bfc0050580 feat(L+M): configurable dashboard widget system + test framework
Phase L: Dashboard widget system
- Migration 046: dashboard_configs table (user/tenant/role fallback cascade)
- DashboardConfig model + dashboard_service with get/upsert per-user and tenant-default
- API router: GET/PUT /api/dashboard/config, GET/PUT /api/dashboard/tenant-default
- Frontend: 5 widget components (ProductionStats, QueueStatus, RecentRenders, CostOverview, WorkerStatus)
- DashboardGrid with API-backed config, DashboardCustomizeModal (checkbox selection, save/cancel)
- Dashboard.tsx: widget grid + analytics section (privileged users)
- Admin.tsx: restructured with new section order and Maintenance 2-col grid

Phase M: Test framework
- Backend: pytest-asyncio + pytest-cov + factory-boy in pyproject.toml
- conftest.py: excel_dir fixtures + async DB fixtures + mock storage/celery stubs
- Domain tests: billing_service, cache_service, notifications_service, imports_sanity
- Integration: test_api_health.py smoke test (requires running backend)
- Frontend: vitest + @testing-library/react + msw added to package.json
- vite.config.ts: test block (jsdom + globals + setupFiles)
- tsconfig.json: exclude src/__tests__ from main tsc (test runner handles its own types)
- MSW handlers for /api/auth/me, Billing.test.tsx, formatters.test.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 21:50:07 +01:00

153 lines
4.7 KiB
Python

"""Dashboard widget configuration endpoints."""
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.domains.admin.dashboard_service import (
get_user_dashboard_config,
upsert_user_dashboard_config,
upsert_tenant_default,
)
from app.utils.auth import get_current_user, require_admin
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class WidgetPosition(BaseModel):
col: int
row: int
w: int
h: int
class WidgetConfig(BaseModel):
widget_type: str
position: WidgetPosition
config: dict[str, Any] | None = None
class DashboardConfigPayload(BaseModel):
widgets: list[WidgetConfig]
class DashboardConfigResponse(BaseModel):
widgets: list[WidgetConfig]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _to_response(widgets: list[dict]) -> DashboardConfigResponse:
parsed = []
for w in widgets:
pos = w.get("position", {})
parsed.append(
WidgetConfig(
widget_type=w.get("widget_type", ""),
position=WidgetPosition(
col=pos.get("col", 0),
row=pos.get("row", 0),
w=pos.get("w", 1),
h=pos.get("h", 1),
),
config=w.get("config"),
)
)
return DashboardConfigResponse(widgets=parsed)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/config", response_model=DashboardConfigResponse)
async def get_config(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> DashboardConfigResponse:
"""Load the current user's dashboard widget config (with fallback cascade)."""
widgets = await get_user_dashboard_config(
db=db,
user_id=current_user.id,
tenant_id=current_user.tenant_id,
role=current_user.role.value,
)
return _to_response(widgets)
@router.put("/config", response_model=DashboardConfigResponse)
async def update_config(
payload: DashboardConfigPayload,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> DashboardConfigResponse:
"""Save or update the current user's dashboard widget config."""
widgets_raw = [w.model_dump() for w in payload.widgets]
config = await upsert_user_dashboard_config(
db=db,
user_id=current_user.id,
tenant_id=current_user.tenant_id,
widgets=widgets_raw,
)
return _to_response(list(config.widgets))
@router.get("/tenant-default", response_model=DashboardConfigResponse)
async def get_tenant_default(
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> DashboardConfigResponse:
"""Load the tenant-default dashboard widget config (admin only)."""
from sqlalchemy import select
from app.domains.admin.models import DashboardConfig
if current_user.tenant_id is None:
return DashboardConfigResponse(widgets=[])
result = await db.execute(
select(DashboardConfig).where(
DashboardConfig.tenant_id == current_user.tenant_id,
DashboardConfig.is_tenant_default.is_(True),
)
)
config = result.scalar_one_or_none()
if config is None:
return DashboardConfigResponse(widgets=[])
return _to_response(list(config.widgets))
@router.put("/tenant-default", response_model=DashboardConfigResponse)
async def update_tenant_default(
payload: DashboardConfigPayload,
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> DashboardConfigResponse:
"""Set the tenant-default widget config (admin only)."""
if current_user.tenant_id is None:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Admin user has no tenant_id assigned.",
)
widgets_raw = [w.model_dump() for w in payload.widgets]
config = await upsert_tenant_default(
db=db,
tenant_id=current_user.tenant_id,
widgets=widgets_raw,
)
return _to_response(list(config.widgets))