bfc0050580
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>
153 lines
4.7 KiB
Python
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))
|