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