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,148 @@
"""Dashboard widget configuration service.
Provides async functions for loading and persisting per-user and
tenant-default dashboard widget layouts.
"""
import logging
import uuid
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domains.admin.models import DashboardConfig
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Widget type literals
# ---------------------------------------------------------------------------
WIDGET_TYPES = (
"ProductionStats",
"QueueStatus",
"RecentRenders",
"CostOverview",
"WorkerStatus",
)
# Default layouts per role
_DEFAULT_ADMIN_WIDGETS: list[dict] = [
{"widget_type": "ProductionStats", "position": {"col": 0, "row": 0, "w": 1, "h": 1}},
{"widget_type": "QueueStatus", "position": {"col": 1, "row": 0, "w": 1, "h": 1}},
{"widget_type": "WorkerStatus", "position": {"col": 2, "row": 0, "w": 1, "h": 1}},
{"widget_type": "RecentRenders", "position": {"col": 0, "row": 1, "w": 2, "h": 1}},
{"widget_type": "CostOverview", "position": {"col": 2, "row": 1, "w": 1, "h": 1}},
]
_DEFAULT_CLIENT_WIDGETS: list[dict] = [
{"widget_type": "RecentRenders", "position": {"col": 0, "row": 0, "w": 2, "h": 1}},
{"widget_type": "ProductionStats", "position": {"col": 2, "row": 0, "w": 1, "h": 1}},
]
def get_default_widgets_for_role(role: str) -> list[dict]:
"""Return systemwide default widget layout for a given role.
admin / project_manager: all 5 widget types.
client: RecentRenders + ProductionStats only.
"""
if role in ("admin", "project_manager"):
return [w.copy() for w in _DEFAULT_ADMIN_WIDGETS]
return [w.copy() for w in _DEFAULT_CLIENT_WIDGETS]
async def get_user_dashboard_config(
db: AsyncSession,
user_id: uuid.UUID,
tenant_id: uuid.UUID | None,
role: str,
) -> list[dict]:
"""Load widget config with fallback cascade.
1. User-specific config (user_id match).
2. Tenant-default config (is_tenant_default=True for the tenant).
3. System default based on role.
"""
# 1. User-specific
result = await db.execute(
select(DashboardConfig).where(DashboardConfig.user_id == user_id)
)
config = result.scalar_one_or_none()
if config is not None:
return list(config.widgets) if config.widgets else []
# 2. Tenant default
if tenant_id is not None:
result = await db.execute(
select(DashboardConfig).where(
DashboardConfig.tenant_id == tenant_id,
DashboardConfig.is_tenant_default.is_(True),
)
)
tenant_config = result.scalar_one_or_none()
if tenant_config is not None:
return list(tenant_config.widgets) if tenant_config.widgets else []
# 3. System default
return get_default_widgets_for_role(role)
async def upsert_user_dashboard_config(
db: AsyncSession,
user_id: uuid.UUID,
tenant_id: uuid.UUID | None,
widgets: list[dict],
) -> DashboardConfig:
"""Save or update the user-specific widget config."""
result = await db.execute(
select(DashboardConfig).where(DashboardConfig.user_id == user_id)
)
config = result.scalar_one_or_none()
if config is None:
config = DashboardConfig(
user_id=user_id,
tenant_id=tenant_id,
widgets=widgets,
is_tenant_default=False,
)
db.add(config)
else:
config.widgets = widgets
config.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(config)
return config
async def upsert_tenant_default(
db: AsyncSession,
tenant_id: uuid.UUID,
widgets: list[dict],
) -> DashboardConfig:
"""Set the tenant-default widget config (admin only)."""
result = await db.execute(
select(DashboardConfig).where(
DashboardConfig.tenant_id == tenant_id,
DashboardConfig.is_tenant_default.is_(True),
)
)
config = result.scalar_one_or_none()
if config is None:
config = DashboardConfig(
tenant_id=tenant_id,
user_id=None,
widgets=widgets,
is_tenant_default=True,
)
db.add(config)
else:
config.widgets = widgets
config.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(config)
return config