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>
This commit is contained in:
2026-03-06 21:50:07 +01:00
parent 19c15adbee
commit bfc0050580
38 changed files with 4210 additions and 13 deletions
@@ -0,0 +1,152 @@
"""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))