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))
@@ -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
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
if TYPE_CHECKING:
pass
class DashboardConfig(Base):
__tablename__ = "dashboard_configs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
widgets: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
is_tenant_default: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)