feat: add dashboard API with quick links and widgets
Build and Push / build (release) Successful in 1m28s
Build and Push / build (release) Successful in 1m28s
- Dashboard domain with Quick Links CRUD + reorder endpoints - Dashboard widgets management endpoints - Database migrations for quick_links and dashboard_widgets tables - Static file controller for Organizr widgets - Default local user when OIDC is disabled - Domain-based architecture refactor (src/domains/, src/shared/) - Test suite updated for new structure (285 tests passing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
e85c9a123d
commit
381d43b60b
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Dashboard Controller
|
||||
|
||||
Provides API endpoints for dashboard management including quick links.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from typing import Dict, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.shared.base import BaseController
|
||||
from src.shared.database import get_async_session
|
||||
from src.shared.logging import get_logger
|
||||
from src.domains.auth.oidc import get_current_user, get_optional_user
|
||||
from src.domains.dashboard.service import get_dashboard_service
|
||||
from src.domains.dashboard.schemas import (
|
||||
QuickLinkCreate,
|
||||
QuickLinkUpdate,
|
||||
QuickLinkResponse,
|
||||
QuickLinkListResponse,
|
||||
QuickLinkReorderRequest,
|
||||
QuickLinkReorderResponse,
|
||||
DashboardWidgetCreate,
|
||||
DashboardWidgetUpdate,
|
||||
DashboardWidgetResponse,
|
||||
DashboardWidgetListResponse,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DashboardController(BaseController):
|
||||
"""
|
||||
Controller for dashboard operations
|
||||
|
||||
Provides endpoints for:
|
||||
- Quick links CRUD
|
||||
- Quick links reordering
|
||||
- Dashboard widgets management
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the router"""
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
service = get_dashboard_service()
|
||||
|
||||
# =====================================================================
|
||||
# Quick Links
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/quick-links",
|
||||
response_model=QuickLinkListResponse,
|
||||
summary="List quick links"
|
||||
)
|
||||
async def list_quick_links(
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
include_global: bool = Query(True, description="Include global links"),
|
||||
visible_only: bool = Query(True, description="Only visible links"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Optional[Dict] = Depends(get_optional_user),
|
||||
):
|
||||
"""
|
||||
List quick links for the current user
|
||||
|
||||
Returns user-specific links plus global links (if include_global=True).
|
||||
"""
|
||||
user_id = user.get("sub") if user else None
|
||||
|
||||
links = await service.get_quick_links(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
include_global=include_global,
|
||||
category=category,
|
||||
visible_only=visible_only,
|
||||
)
|
||||
|
||||
return QuickLinkListResponse(
|
||||
links=[QuickLinkResponse.model_validate(link, from_attributes=True) for link in links],
|
||||
total=len(links)
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/quick-links/{link_id}",
|
||||
response_model=QuickLinkResponse,
|
||||
summary="Get a quick link"
|
||||
)
|
||||
async def get_quick_link(
|
||||
link_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Optional[Dict] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get a specific quick link by ID"""
|
||||
user_id = user.get("sub") if user else None
|
||||
|
||||
link = await service.get_quick_link(session, link_id, user_id)
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Quick link not found")
|
||||
|
||||
return QuickLinkResponse.model_validate(link, from_attributes=True)
|
||||
|
||||
@router.post(
|
||||
"/quick-links",
|
||||
response_model=QuickLinkResponse,
|
||||
status_code=201,
|
||||
summary="Create a quick link"
|
||||
)
|
||||
async def create_quick_link(
|
||||
data: QuickLinkCreate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Create a new quick link for the current user
|
||||
|
||||
Links are user-specific by default. Admins can create global links
|
||||
by setting user_id to null.
|
||||
"""
|
||||
user_id = user.get("sub")
|
||||
|
||||
link = await service.create_quick_link(session, data, user_id)
|
||||
|
||||
logger.info(f"Quick link created: {link.title} by user {user.get('preferred_username')}")
|
||||
|
||||
return QuickLinkResponse.model_validate(link, from_attributes=True)
|
||||
|
||||
@router.put(
|
||||
"/quick-links/{link_id}",
|
||||
response_model=QuickLinkResponse,
|
||||
summary="Update a quick link"
|
||||
)
|
||||
async def update_quick_link(
|
||||
link_id: int,
|
||||
data: QuickLinkUpdate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing quick link"""
|
||||
user_id = user.get("sub")
|
||||
|
||||
link = await service.update_quick_link(session, link_id, data, user_id)
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Quick link not found or not authorized")
|
||||
|
||||
return QuickLinkResponse.model_validate(link, from_attributes=True)
|
||||
|
||||
@router.delete(
|
||||
"/quick-links/{link_id}",
|
||||
status_code=204,
|
||||
summary="Delete a quick link"
|
||||
)
|
||||
async def delete_quick_link(
|
||||
link_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a quick link"""
|
||||
user_id = user.get("sub")
|
||||
|
||||
success = await service.delete_quick_link(session, link_id, user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Quick link not found or not authorized")
|
||||
|
||||
return None
|
||||
|
||||
@router.post(
|
||||
"/quick-links/reorder",
|
||||
response_model=QuickLinkReorderResponse,
|
||||
summary="Reorder quick links"
|
||||
)
|
||||
async def reorder_quick_links(
|
||||
data: QuickLinkReorderRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Reorder quick links by providing link IDs in desired order
|
||||
|
||||
The position of each link will be set to its index in the provided list.
|
||||
"""
|
||||
user_id = user.get("sub")
|
||||
|
||||
reordered = await service.reorder_quick_links(session, data.link_ids, user_id)
|
||||
|
||||
return QuickLinkReorderResponse(
|
||||
success=True,
|
||||
message=f"Reordered {reordered} links",
|
||||
reordered_count=reordered
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# Dashboard Widgets
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/widgets",
|
||||
response_model=DashboardWidgetListResponse,
|
||||
summary="List dashboard widgets"
|
||||
)
|
||||
async def list_widgets(
|
||||
include_defaults: bool = Query(True, description="Include default widgets"),
|
||||
visible_only: bool = Query(True, description="Only visible widgets"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Optional[Dict] = Depends(get_optional_user),
|
||||
):
|
||||
"""List dashboard widgets for the current user"""
|
||||
user_id = user.get("sub") if user else None
|
||||
|
||||
widgets = await service.get_widgets(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
include_defaults=include_defaults,
|
||||
visible_only=visible_only,
|
||||
)
|
||||
|
||||
return DashboardWidgetListResponse(
|
||||
widgets=[DashboardWidgetResponse.model_validate(w, from_attributes=True) for w in widgets],
|
||||
total=len(widgets)
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/widgets/{widget_id}",
|
||||
response_model=DashboardWidgetResponse,
|
||||
summary="Get a dashboard widget"
|
||||
)
|
||||
async def get_widget(
|
||||
widget_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Optional[Dict] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get a specific dashboard widget by ID"""
|
||||
user_id = user.get("sub") if user else None
|
||||
|
||||
widget = await service.get_widget(session, widget_id, user_id)
|
||||
if not widget:
|
||||
raise HTTPException(status_code=404, detail="Widget not found")
|
||||
|
||||
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
|
||||
|
||||
@router.post(
|
||||
"/widgets",
|
||||
response_model=DashboardWidgetResponse,
|
||||
status_code=201,
|
||||
summary="Create a dashboard widget"
|
||||
)
|
||||
async def create_widget(
|
||||
data: DashboardWidgetCreate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new dashboard widget"""
|
||||
user_id = user.get("sub")
|
||||
|
||||
widget = await service.create_widget(session, data, user_id)
|
||||
|
||||
logger.info(f"Widget created: {widget.widget_type} by user {user.get('preferred_username')}")
|
||||
|
||||
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
|
||||
|
||||
@router.put(
|
||||
"/widgets/{widget_id}",
|
||||
response_model=DashboardWidgetResponse,
|
||||
summary="Update a dashboard widget"
|
||||
)
|
||||
async def update_widget(
|
||||
widget_id: int,
|
||||
data: DashboardWidgetUpdate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update an existing dashboard widget"""
|
||||
user_id = user.get("sub")
|
||||
|
||||
widget = await service.update_widget(session, widget_id, data, user_id)
|
||||
if not widget:
|
||||
raise HTTPException(status_code=404, detail="Widget not found or not authorized")
|
||||
|
||||
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
|
||||
|
||||
@router.delete(
|
||||
"/widgets/{widget_id}",
|
||||
status_code=204,
|
||||
summary="Delete a dashboard widget"
|
||||
)
|
||||
async def delete_widget(
|
||||
widget_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: Dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a dashboard widget"""
|
||||
user_id = user.get("sub")
|
||||
|
||||
success = await service.delete_widget(session, widget_id, user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Widget not found or not authorized")
|
||||
|
||||
return None
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
dashboard_controller = DashboardController()
|
||||
Reference in New Issue
Block a user