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:
@@ -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))
|
||||
Reference in New Issue
Block a user