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,8 @@
|
||||
"""
|
||||
Dashboard Domain
|
||||
|
||||
Provides dashboard management endpoints including quick links.
|
||||
"""
|
||||
from src.domains.dashboard.controller import dashboard_controller
|
||||
|
||||
__all__ = ["dashboard_controller"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Dashboard Domain Models
|
||||
|
||||
SQLAlchemy models for dashboard-related data.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from src.shared.database import Base
|
||||
|
||||
|
||||
class QuickLink(Base):
|
||||
"""Quick link for dashboard jump pad"""
|
||||
__tablename__ = "quick_links"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Link content
|
||||
title = Column(String(100), nullable=False)
|
||||
url = Column(String(500), nullable=False)
|
||||
icon = Column(String(100), nullable=True) # Icon name or URL
|
||||
description = Column(String(255), nullable=True)
|
||||
|
||||
# Categorization
|
||||
category = Column(String(50), nullable=True) # e.g., "services", "tools", "docs"
|
||||
|
||||
# User association - nullable for global links
|
||||
user_id = Column(String(255), nullable=True, index=True) # Authentik user ID
|
||||
|
||||
# Ordering and display
|
||||
position = Column(Integer, default=0)
|
||||
is_visible = Column(Boolean, default=True)
|
||||
|
||||
# Styling
|
||||
color = Column(String(20), nullable=True) # Hex color for the link card
|
||||
background_color = Column(String(20), nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<QuickLink(id={self.id}, title='{self.title}', user_id='{self.user_id}')>"
|
||||
|
||||
|
||||
class DashboardWidget(Base):
|
||||
"""Dashboard widget configuration"""
|
||||
__tablename__ = "dashboard_widgets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Widget identification
|
||||
widget_type = Column(String(50), nullable=False) # e.g., "quick_links", "service_status", "weather"
|
||||
|
||||
# User association - nullable for default widgets
|
||||
user_id = Column(String(255), nullable=True, index=True)
|
||||
|
||||
# Position and sizing
|
||||
position_x = Column(Integer, default=0)
|
||||
position_y = Column(Integer, default=0)
|
||||
width = Column(Integer, default=1)
|
||||
height = Column(Integer, default=1)
|
||||
|
||||
# Widget-specific configuration (JSON)
|
||||
config = Column(Text, nullable=True) # JSON string for widget-specific settings
|
||||
|
||||
# Display
|
||||
is_visible = Column(Boolean, default=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DashboardWidget(id={self.id}, type='{self.widget_type}', user_id='{self.user_id}')>"
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Dashboard Domain Schemas
|
||||
|
||||
Pydantic schemas for dashboard endpoints.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import Field
|
||||
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
# Quick Link Schemas
|
||||
class QuickLinkBase(BaseSchema):
|
||||
"""Base schema for quick links"""
|
||||
title: str = Field(..., min_length=1, max_length=100, description="Link title")
|
||||
url: str = Field(..., min_length=1, max_length=500, description="Link URL")
|
||||
icon: Optional[str] = Field(None, max_length=100, description="Icon name or URL")
|
||||
description: Optional[str] = Field(None, max_length=255, description="Link description")
|
||||
category: Optional[str] = Field(None, max_length=50, description="Link category")
|
||||
color: Optional[str] = Field(None, max_length=20, description="Hex color for link card")
|
||||
background_color: Optional[str] = Field(None, max_length=20, description="Background hex color")
|
||||
|
||||
|
||||
class QuickLinkCreate(QuickLinkBase):
|
||||
"""Schema for creating a quick link"""
|
||||
position: Optional[int] = Field(0, ge=0, description="Display position")
|
||||
is_visible: Optional[bool] = Field(True, description="Whether link is visible")
|
||||
|
||||
|
||||
class QuickLinkUpdate(BaseSchema):
|
||||
"""Schema for updating a quick link"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
url: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
icon: Optional[str] = Field(None, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=255)
|
||||
category: Optional[str] = Field(None, max_length=50)
|
||||
position: Optional[int] = Field(None, ge=0)
|
||||
is_visible: Optional[bool] = None
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
background_color: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
|
||||
class QuickLinkResponse(QuickLinkBase):
|
||||
"""Schema for quick link response"""
|
||||
id: int
|
||||
user_id: Optional[str] = None
|
||||
position: int
|
||||
is_visible: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class QuickLinkListResponse(BaseSchema):
|
||||
"""Response for list of quick links"""
|
||||
links: List[QuickLinkResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class QuickLinkReorderRequest(BaseSchema):
|
||||
"""Request to reorder quick links"""
|
||||
link_ids: List[int] = Field(..., description="List of link IDs in desired order")
|
||||
|
||||
|
||||
class QuickLinkReorderResponse(BaseSchema):
|
||||
"""Response after reordering"""
|
||||
success: bool
|
||||
message: str
|
||||
reordered_count: int
|
||||
|
||||
|
||||
# Dashboard Widget Schemas
|
||||
class DashboardWidgetBase(BaseSchema):
|
||||
"""Base schema for dashboard widgets"""
|
||||
widget_type: str = Field(..., min_length=1, max_length=50, description="Widget type identifier")
|
||||
position_x: int = Field(0, ge=0, description="X position on grid")
|
||||
position_y: int = Field(0, ge=0, description="Y position on grid")
|
||||
width: int = Field(1, ge=1, le=12, description="Widget width in grid units")
|
||||
height: int = Field(1, ge=1, le=12, description="Widget height in grid units")
|
||||
config: Optional[str] = Field(None, description="JSON config for widget")
|
||||
is_visible: bool = Field(True, description="Whether widget is visible")
|
||||
|
||||
|
||||
class DashboardWidgetCreate(DashboardWidgetBase):
|
||||
"""Schema for creating a widget"""
|
||||
pass
|
||||
|
||||
|
||||
class DashboardWidgetUpdate(BaseSchema):
|
||||
"""Schema for updating a widget"""
|
||||
position_x: Optional[int] = Field(None, ge=0)
|
||||
position_y: Optional[int] = Field(None, ge=0)
|
||||
width: Optional[int] = Field(None, ge=1, le=12)
|
||||
height: Optional[int] = Field(None, ge=1, le=12)
|
||||
config: Optional[str] = None
|
||||
is_visible: Optional[bool] = None
|
||||
|
||||
|
||||
class DashboardWidgetResponse(DashboardWidgetBase):
|
||||
"""Schema for widget response"""
|
||||
id: int
|
||||
user_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DashboardWidgetListResponse(BaseSchema):
|
||||
"""Response for list of widgets"""
|
||||
widgets: List[DashboardWidgetResponse]
|
||||
total: int
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Dashboard Domain Service
|
||||
|
||||
Business logic for dashboard operations.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.shared.logging import get_logger
|
||||
from src.domains.dashboard.models import QuickLink, DashboardWidget
|
||||
from src.domains.dashboard.schemas import (
|
||||
QuickLinkCreate,
|
||||
QuickLinkUpdate,
|
||||
QuickLinkResponse,
|
||||
DashboardWidgetCreate,
|
||||
DashboardWidgetUpdate,
|
||||
DashboardWidgetResponse,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Service for dashboard operations"""
|
||||
|
||||
# =========================================================================
|
||||
# Quick Links
|
||||
# =========================================================================
|
||||
|
||||
async def get_quick_links(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: Optional[str] = None,
|
||||
include_global: bool = True,
|
||||
category: Optional[str] = None,
|
||||
visible_only: bool = True,
|
||||
) -> List[QuickLink]:
|
||||
"""
|
||||
Get quick links for a user
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
user_id: User ID to filter by (None for global only)
|
||||
include_global: Whether to include global links (user_id=None)
|
||||
category: Optional category filter
|
||||
visible_only: Only return visible links
|
||||
"""
|
||||
conditions = []
|
||||
|
||||
if user_id:
|
||||
if include_global:
|
||||
from sqlalchemy import or_
|
||||
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
|
||||
else:
|
||||
conditions.append(QuickLink.user_id == user_id)
|
||||
else:
|
||||
conditions.append(QuickLink.user_id.is_(None))
|
||||
|
||||
if category:
|
||||
conditions.append(QuickLink.category == category)
|
||||
|
||||
if visible_only:
|
||||
conditions.append(QuickLink.is_visible == True)
|
||||
|
||||
stmt = select(QuickLink).where(*conditions).order_by(QuickLink.position, QuickLink.id)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_quick_link(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
link_id: int,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[QuickLink]:
|
||||
"""Get a specific quick link by ID"""
|
||||
conditions = [QuickLink.id == link_id]
|
||||
|
||||
if user_id:
|
||||
from sqlalchemy import or_
|
||||
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
|
||||
|
||||
stmt = select(QuickLink).where(*conditions)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_quick_link(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
data: QuickLinkCreate,
|
||||
user_id: Optional[str] = None,
|
||||
) -> QuickLink:
|
||||
"""Create a new quick link"""
|
||||
# Get max position for this user
|
||||
stmt = select(QuickLink.position).where(
|
||||
QuickLink.user_id == user_id if user_id else QuickLink.user_id.is_(None)
|
||||
).order_by(QuickLink.position.desc()).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
max_pos = result.scalar_one_or_none() or -1
|
||||
|
||||
link = QuickLink(
|
||||
title=data.title,
|
||||
url=data.url,
|
||||
icon=data.icon,
|
||||
description=data.description,
|
||||
category=data.category,
|
||||
position=data.position if data.position > 0 else max_pos + 1,
|
||||
is_visible=data.is_visible,
|
||||
color=data.color,
|
||||
background_color=data.background_color,
|
||||
user_id=user_id,
|
||||
)
|
||||
session.add(link)
|
||||
await session.commit()
|
||||
await session.refresh(link)
|
||||
|
||||
logger.info(f"Created quick link: {link.title} (id={link.id}, user={user_id})")
|
||||
return link
|
||||
|
||||
async def update_quick_link(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
link_id: int,
|
||||
data: QuickLinkUpdate,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[QuickLink]:
|
||||
"""Update a quick link"""
|
||||
link = await self.get_quick_link(session, link_id, user_id)
|
||||
if not link:
|
||||
return None
|
||||
|
||||
# Only allow updating own links or global links for admins
|
||||
if link.user_id and link.user_id != user_id:
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(link, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(link)
|
||||
|
||||
logger.info(f"Updated quick link: {link.title} (id={link.id})")
|
||||
return link
|
||||
|
||||
async def delete_quick_link(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
link_id: int,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Delete a quick link"""
|
||||
link = await self.get_quick_link(session, link_id, user_id)
|
||||
if not link:
|
||||
return False
|
||||
|
||||
# Only allow deleting own links
|
||||
if link.user_id and link.user_id != user_id:
|
||||
return False
|
||||
|
||||
await session.delete(link)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Deleted quick link: id={link_id}")
|
||||
return True
|
||||
|
||||
async def reorder_quick_links(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
link_ids: List[int],
|
||||
user_id: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Reorder quick links by updating positions"""
|
||||
reordered = 0
|
||||
|
||||
for position, link_id in enumerate(link_ids):
|
||||
conditions = [QuickLink.id == link_id]
|
||||
if user_id:
|
||||
from sqlalchemy import or_
|
||||
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
|
||||
|
||||
stmt = update(QuickLink).where(*conditions).values(position=position)
|
||||
result = await session.execute(stmt)
|
||||
reordered += result.rowcount
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Reordered {reordered} quick links for user {user_id}")
|
||||
return reordered
|
||||
|
||||
# =========================================================================
|
||||
# Dashboard Widgets
|
||||
# =========================================================================
|
||||
|
||||
async def get_widgets(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: Optional[str] = None,
|
||||
include_defaults: bool = True,
|
||||
visible_only: bool = True,
|
||||
) -> List[DashboardWidget]:
|
||||
"""Get dashboard widgets for a user"""
|
||||
conditions = []
|
||||
|
||||
if user_id:
|
||||
if include_defaults:
|
||||
from sqlalchemy import or_
|
||||
conditions.append(or_(DashboardWidget.user_id == user_id, DashboardWidget.user_id.is_(None)))
|
||||
else:
|
||||
conditions.append(DashboardWidget.user_id == user_id)
|
||||
else:
|
||||
conditions.append(DashboardWidget.user_id.is_(None))
|
||||
|
||||
if visible_only:
|
||||
conditions.append(DashboardWidget.is_visible == True)
|
||||
|
||||
stmt = select(DashboardWidget).where(*conditions).order_by(
|
||||
DashboardWidget.position_y, DashboardWidget.position_x
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_widget(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[DashboardWidget]:
|
||||
"""Get a specific widget by ID"""
|
||||
conditions = [DashboardWidget.id == widget_id]
|
||||
|
||||
if user_id:
|
||||
from sqlalchemy import or_
|
||||
conditions.append(or_(DashboardWidget.user_id == user_id, DashboardWidget.user_id.is_(None)))
|
||||
|
||||
stmt = select(DashboardWidget).where(*conditions)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_widget(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
data: DashboardWidgetCreate,
|
||||
user_id: Optional[str] = None,
|
||||
) -> DashboardWidget:
|
||||
"""Create a new dashboard widget"""
|
||||
widget = DashboardWidget(
|
||||
widget_type=data.widget_type,
|
||||
position_x=data.position_x,
|
||||
position_y=data.position_y,
|
||||
width=data.width,
|
||||
height=data.height,
|
||||
config=data.config,
|
||||
is_visible=data.is_visible,
|
||||
user_id=user_id,
|
||||
)
|
||||
session.add(widget)
|
||||
await session.commit()
|
||||
await session.refresh(widget)
|
||||
|
||||
logger.info(f"Created widget: {widget.widget_type} (id={widget.id}, user={user_id})")
|
||||
return widget
|
||||
|
||||
async def update_widget(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
data: DashboardWidgetUpdate,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[DashboardWidget]:
|
||||
"""Update a dashboard widget"""
|
||||
widget = await self.get_widget(session, widget_id, user_id)
|
||||
if not widget:
|
||||
return None
|
||||
|
||||
if widget.user_id and widget.user_id != user_id:
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(widget, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(widget)
|
||||
|
||||
logger.info(f"Updated widget: id={widget.id}")
|
||||
return widget
|
||||
|
||||
async def delete_widget(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Delete a dashboard widget"""
|
||||
widget = await self.get_widget(session, widget_id, user_id)
|
||||
if not widget:
|
||||
return False
|
||||
|
||||
if widget.user_id and widget.user_id != user_id:
|
||||
return False
|
||||
|
||||
await session.delete(widget)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Deleted widget: id={widget_id}")
|
||||
return True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_dashboard_service: Optional[DashboardService] = None
|
||||
|
||||
|
||||
def get_dashboard_service() -> DashboardService:
|
||||
"""Get singleton dashboard service instance"""
|
||||
global _dashboard_service
|
||||
if _dashboard_service is None:
|
||||
_dashboard_service = DashboardService()
|
||||
return _dashboard_service
|
||||
Reference in New Issue
Block a user