# WCO-K07: Widget configuration API endpoints
**Area:** Widget Customization Docs
**Model:** moonshotai/kimi-k2.5
**Tokens:** 8,371
**Cost:** $0.0090
**Time:** 1399ms
**Timestamp:** 2026-02-16T14:00:25.756707

---

 Here is a complete, production-ready implementation for the Widget Configuration API endpoints.

## Project Structure

```text
sunaiva_widget/
├── __init__.py
├── models.py          # SQLAlchemy models
├── schemas.py         # Pydantic schemas
├── exceptions.py      # Custom exceptions
├── repository.py      # Database access layer
├── service.py         # Business logic layer
├── dependencies.py    # FastAPI dependencies
├── routes.py          # API endpoints
└── tests/
    ├── __init__.py
    ├── conftest.py    # Test fixtures
    └── test_config.py # Test cases
```

## 1. Database Models (`models.py`)

```python
"""SQLAlchemy models for widget configuration and theming."""

import uuid
from datetime import datetime
from typing import Optional

from sqlalchemy import (
    Column,
    DateTime,
    ForeignKey,
    Integer,
    String,
    Text,
    Boolean,
    func,
    Index,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()


class Widget(Base):
    """Represents a voice widget instance."""
    
    __tablename__ = "widgets"
    
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    business_id = Column(
        UUID(as_uuid=True), 
        ForeignKey("businesses.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )
    name = Column(String(255), nullable=False)
    is_active = Column(Boolean, default=True, nullable=False)
    
    # Current configuration (latest version)
    current_config = Column(JSONB, nullable=False, default=dict)
    config_version = Column(Integer, default=0, nullable=False)
    
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())
    
    # Relationships
    config_versions = relationship(
        "WidgetConfigVersion",
        back_populates="widget",
        order_by="desc(WidgetConfigVersion.version_number)",
        cascade="all, delete-orphan",
    )
    
    __table_args__ = (
        Index("ix_widgets_business_active", "business_id", "is_active"),
    )


class WidgetConfigVersion(Base):
    """Historical versions of widget configurations."""
    
    __tablename__ = "widget_config_versions"
    
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    widget_id = Column(
        UUID(as_uuid=True),
        ForeignKey("widgets.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )
    version_number = Column(Integer, nullable=False)
    config_data = Column(JSONB, nullable=False)
    change_summary = Column(Text, nullable=True)
    created_by = Column(String(255), nullable=True)  # User ID or API key
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    
    # Relationships
    widget = relationship("Widget", back_populates="config_versions")
    
    __table_args__ = (
        Index(
            "ix_config_versions_widget_version",
            "widget_id",
            "version_number",
            unique=True,
        ),
    )


class Theme(Base):
    """Preset and custom themes for widgets."""
    
    __tablename__ = "themes"
    
    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name = Column(String(100), nullable=False)
    category = Column(String(50), nullable=False)  # 'preset', 'custom', 'user_created'
    description = Column(Text, nullable=True)
    
    # Theme configuration template
    config_template = Column(JSONB, nullable=False)
    
    # Visual metadata
    thumbnail_url = Column(String(500), nullable=True)
    is_preset = Column(Boolean, default=False, nullable=False)
    is_active = Column(Boolean, default=True, nullable=False)
    
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())
    
    __table_args__ = (
        Index("ix_themes_category_active", "category", "is_active"),
    )
```

## 2. Pydantic Schemas (`schemas.py`)

```python
"""Pydantic models for request/response validation."""

from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Union
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, field_validator


class WidgetPosition(str, Enum):
    """Widget position options."""
    BOTTOM_RIGHT = "bottom-right"
    BOTTOM_LEFT = "bottom-left"
    TOP_RIGHT = "top-right"
    TOP_LEFT = "top-left"


class VoiceGender(str, Enum):
    """Voice gender options."""
    MALE = "male"
    FEMALE = "female"
    NEUTRAL = "neutral"


class VoiceAccent(str, Enum):
    """Australian voice accents."""
    AUSTRALIAN = "australian"
    BRITISH = "british"
    AMERICAN = "american"


class AppearanceConfig(BaseModel):
    """Widget appearance settings."""
    
    position: WidgetPosition = Field(
        default=WidgetPosition.BOTTOM_RIGHT,
        description="Screen position of the widget",
    )
    primary_color: str = Field(
        default="#FF6B00",
        pattern=r"^#[0-9A-Fa-f]{6}$",
        description="Primary brand color (hex)",
    )
    secondary_color: str = Field(
        default="#FFFFFF",
        pattern=r"^#[0-9A-Fa-f]{6}$",
        description="Secondary color (hex)",
    )
    font_family: str = Field(
        default="Inter",
        description="CSS font family",
    )
    border_radius: int = Field(
        default=12,
        ge=0,
        le=50,
        description="Border radius in pixels",
    )
    widget_size: str = Field(
        default="medium",
        pattern=r"^(small|medium|large)$",
    )
    show_branding: bool = Field(default=True)


class BehaviorConfig(BaseModel):
    """Widget behavior settings."""
    
    auto_open: bool = Field(
        default=False,
        description="Auto-open on page load",
    )
    auto_open_delay: int = Field(
        default=5,
        ge=0,
        le=300,
        description="Seconds before auto-open",
    )
    greeting_message: str = Field(
        default="G'day! How can I help you today?",
        max_length=500,
    )
    offline_message: str = Field(
        default="Sorry, we're currently offline. Leave a message!",
        max_length=500,
    )
    input_placeholder: str = Field(
        default="Type your message...",
        max_length=200,
    )
    show_email_capture: bool = Field(default=True)
    require_email_for_chat: bool = Field(default=False)


class VoiceConfig(BaseModel):
    """Voice/TTS configuration."""
    
    voice_id: str = Field(
        default="au-sophie",
        description="Telnyx voice ID",
    )
    gender: VoiceGender = Field(default=VoiceGender.FEMALE)
    accent: VoiceAccent = Field(default=VoiceAccent.AUSTRALIAN)
    speaking_rate: float = Field(
        default=1.0,
        ge=0.5,
        le=2.0,
        description="Speech speed multiplier",
    )
    pitch: int = Field(
        default=0,
        ge=-20,
        le=20,
        description="Pitch adjustment",
    )
    volume: int = Field(
        default=80,
        ge=0,
        le=100,
    )


class BusinessInfoConfig(BaseModel):
    """Business information displayed in widget."""
    
    business_name: Optional[str] = Field(default=None, max_length=200)
    phone_number: Optional[str] = Field(
        default=None,
        pattern=r"^(\+61|0)[2-4789]\d{8}$",  # Australian phone validation
    )
    email: Optional[str] = Field(default=None, max_length=255)
    abn: Optional[str] = Field(
        default=None,
        description="Australian Business Number",
        max_length=14,
    )
    operating_hours: Optional[str] = Field(
        default="Mon-Fri 8:00-17:00",
        max_length=200,
    )


class AdvancedConfig(BaseModel):
    """Advanced technical settings."""
    
    webhook_url: Optional[str] = Field(
        default=None,
        max_length=500,
        pattern=r"^https?://",
    )
    custom_css: Optional[str] = Field(
        default=None,
        max_length=10000,
        description="Custom CSS overrides",
    )
    allowed_domains: List[str] = Field(
        default_factory=list,
        description="Domain whitelist for CORS",
    )
    enable_transcription: bool = Field(default=True)
    language_code: str = Field(default="en-AU")
    session_timeout: int = Field(
        default=300,
        ge=60,
        le=3600,
        description="Session timeout in seconds",
    )


class WidgetConfigBase(BaseModel):
    """Base configuration schema."""
    
    model_config = ConfigDict(extra="forbid")
    
    appearance: AppearanceConfig = Field(default_factory=AppearanceConfig)
    behavior: BehaviorConfig = Field(default_factory=BehaviorConfig)
    voice: VoiceConfig = Field(default_factory=VoiceConfig)
    business_info: BusinessInfoConfig = Field(default_factory=BusinessInfoConfig)
    advanced: AdvancedConfig = Field(default_factory=AdvancedConfig)


class WidgetConfigUpdate(BaseModel):
    """Request model for updating configuration."""
    
    model_config = ConfigDict(extra="forbid")
    
    appearance: Optional[AppearanceConfig] = None
    behavior: Optional[BehaviorConfig] = None
    voice: Optional[VoiceConfig] = None
    business_info: Optional[BusinessInfoConfig] = None
    advanced: Optional[AdvancedConfig] = None
    change_summary: Optional[str] = Field(
        default=None,
        max_length=500,
        description="Description of changes for versioning",
    )
    
    @field_validator("change_summary")
    @classmethod
    def sanitize_summary(cls, v: Optional[str]) -> Optional[str]:
        """Basic XSS prevention."""
        if v:
            # Remove HTML tags
            import re
            return re.sub(r"<[^>]+>", "", v)
        return v


class WidgetConfigResponse(BaseModel):
    """Response model for widget configuration."""
    
    model_config = ConfigDict(from_attributes=True)
    
    widget_id: UUID
    version: int
    config: WidgetConfigBase
    created_at: datetime
    updated_at: Optional[datetime] = None
    last_modified_by: Optional[str] = None


class ConfigVersionHistory(BaseModel):
    """Historical version entry."""
    
    version_number: int
    change_summary: Optional[str]
    created_by: Optional[str]
    created_at: datetime
    config_snapshot: Dict[str, Any]


class ThemeCategory(str, Enum):
    """Theme categories."""
    PRESET = "preset"
    CUSTOM = "custom"
    USER_CREATED = "user_created"


class ThemeBase(BaseModel):
    """Base theme schema."""
    
    name: str = Field(min_length=1, max_length=100)
    description: Optional[str] = Field(None, max_length=500)
    category: ThemeCategory = Field(default=ThemeCategory.PRESET)
    config_template: WidgetConfigBase


class ThemeCreate(ThemeBase):
    """Create theme request."""
    pass


class ThemeResponse(ThemeBase):
    """Theme response."""
    
    model_config = ConfigDict(from_attributes=True)
    
    id: UUID
    thumbnail_url: Optional[str] = None
    is_preset: bool
    is_active: bool
    created_at: datetime
    updated_at: Optional[datetime] = None


class ThemeApplicationRequest(BaseModel):
    """Request to apply a theme to a widget."""
    
    theme_id: UUID
    preserve_customizations: bool = Field(
        default=False,
        description="Keep custom business info when applying theme",
    )
    custom_overrides: Optional[WidgetConfigUpdate] = None


class WidgetPreviewData(BaseModel):
    """Data returned for widget preview."""
    
    widget_id: UUID
    config: WidgetConfigBase
    theme_applied: Optional[str] = None
    generated_at: datetime
    preview_html: Optional[str] = None  # Server-side rendered preview
    meta: Dict[str, Any] = Field(default_factory=dict)


class ErrorResponse(BaseModel):
    """Standard error response."""
    
    error: str
    detail: Optional[str] = None
    code: Optional[str] = None
```

## 3. Custom Exceptions (`exceptions.py`)

```python
"""Custom exceptions for widget configuration."""

from typing import Optional


class WidgetConfigError(Exception):
    """Base exception for widget configuration errors."""
    
    def __init__(
        self,
        message: str,
        code: Optional[str] = None,
        status_code: int = 400,
    ):
        self.message = message
        self.code = code or "CONFIG_ERROR"
        self.status_code = status_code
        super().__init__(self.message)


class WidgetNotFoundError(WidgetConfigError):
    """Widget does not exist."""
    
    def __init__(self, widget_id: str):
        super().__init__(
            message=f"Widget {widget_id} not found",
            code="WIDGET_NOT_FOUND",
            status_code=404,
        )


class ThemeNotFoundError(WidgetConfigError):
    """Theme does not exist."""
    
    def __init__(self, theme_id: str):
        super().__init__(
            message=f"Theme {theme_id} not found",
            code="THEME_NOT_FOUND",
            status_code=404,
        )


class InvalidConfigError(WidgetConfigError):
    """Configuration validation failed."""
    
    def __init__(self, message: str):
        super().__init__(
            message=message,
            code="INVALID_CONFIG",
            status_code=422,
        )


class VersionConflictError(WidgetConfigError):
    """Concurrent modification detected."""
    
    def __init__(self, current_version: int, attempted_version: int):
        super().__init__(
            message=(
                f"Version conflict: current is {current_version}, "
                f"attempted {attempted_version}"
            ),
            code="VERSION_CONFLICT",
            status_code=409,
        )


class UnauthorizedError(WidgetConfigError):
    """Permission denied."""
    
    def __init__(self, message: str = "Unauthorized"):
        super().__init__(
            message=message,
            code="UNAUTHORIZED",
            status_code=403,
        )
```

## 4. Repository Layer (`repository.py`)

```python
"""Database repository for widget configuration."""

from typing import List, Optional
from uuid import UUID

from sqlalchemy import desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from .exceptions import WidgetNotFoundError, ThemeNotFoundError
from .models import Theme, Widget, WidgetConfigVersion


class WidgetRepository:
    """Repository for widget operations."""
    
    def __init__(self, session: AsyncSession):
        self.session = session
    
    async def get_by_id(
        self,
        widget_id: UUID,
        load_versions: bool = False,
    ) -> Optional[Widget]:
        """Retrieve widget by ID."""
        query = select(Widget).where(Widget.id == widget_id)
        
        if load_versions:
            query = query.options(selectinload(Widget.config_versions))
            
        result = await self.session.execute(query)
        return result.scalar_one_or_none()
    
    async def get_by_id_or_raise(self, widget_id: UUID) -> Widget:
        """Get widget or raise WidgetNotFoundError."""
        widget = await self.get_by_id(widget_id)
        if not widget:
            raise WidgetNotFoundError(str(widget_id))
        return widget
    
    async def update_config(
        self,
        widget_id: UUID,
        config_data: dict,
        version_number: int,
    ) -> Widget:
        """Update widget configuration."""
        stmt = (
            update(Widget)
            .where(Widget.id == widget_id)
            .values(
                current_config=config_data,
                config_version=version_number,
            )
            .returning(Widget)
        )
        result = await self.session.execute(stmt)
        await self.session.commit()
        return result.scalar_one()
    
    async def create_version(
        self,
        widget_id: UUID,
        config_data: dict,
        version_number: int,
        change_summary: Optional[str] = None,
        created_by: Optional[str] = None,
    ) -> WidgetConfigVersion:
        """Create a new configuration version."""
        version = WidgetConfigVersion(
            widget_id=widget_id,
            config_data=config_data,
            version_number=version_number,
            change_summary=change_summary,
            created_by=created_by,
        )
        self.session.add(version)
        await self.session.commit()
        await self.session.refresh(version)
        return version
    
    async def get_version_history(
        self,
        widget_id: UUID,
        limit: int = 20,
        offset: int = 0,
    ) -> List[WidgetConfigVersion]:
        """Get configuration version history."""
        query = (
            select(WidgetConfigVersion)
            .where(WidgetConfigVersion.widget_id == widget_id)
            .order_by(desc(WidgetConfigVersion.version_number))
            .limit(limit)
            .offset(offset)
        )
        result = await self.session.execute(query)
        return list(result.scalars().all())
    
    async def get_specific_version(
        self,
        widget_id: UUID,
        version_number: int,
    ) -> Optional[WidgetConfigVersion]:
        """Get specific version by number."""
        query = (
            select(WidgetConfigVersion)
            .where(
                WidgetConfigVersion.widget_id == widget_id,
                WidgetConfigVersion.version_number == version_number,
            )
        )
        result = await self.session.execute(query)
        return result.scalar_one_or_none()


class ThemeRepository:
    """Repository for theme operations."""
    
    def __init__(self, session: AsyncSession):
        self.session = session
    
    async def get_by_id(self, theme_id: UUID) -> Optional[Theme]:
        """Get theme by ID."""
        result = await self.session.execute(
            select(Theme).where(Theme.id == theme_id)
        )
        return result.scalar_one_or_none()
    
    async def get_by_id_or_raise(self, theme_id: UUID) -> Theme:
        """Get theme or raise ThemeNotFoundError."""
        theme = await self.get_by_id(theme_id)
        if not theme:
            raise ThemeNotFoundError(str(theme_id))
        return theme
    
    async def list_themes(
        self,
        category: Optional[str] = None,
        is_preset: Optional[bool] = None,
        is_active: bool = True,
        limit: int = 100,
        offset: int = 0,
    ) -> List[Theme]:
        """List available themes."""
        query = select(Theme).where(Theme.is_active == is_active)
        
        if category:
            query = query.where(Theme.category == category)
        if is_preset is not None:
            query = query.where(Theme.is_preset == is_preset)
            
        query = query.order_by(Theme.category, Theme.name).limit(limit).offset(offset)
        
        result = await self.session.execute(query)
        return list(result.scalars().all())
    
    async def create(self, theme: Theme) -> Theme:
        """Create new theme."""
        self.session.add(theme)
        await self.session.commit()
        await self.session.refresh(theme)
        return theme
```

## 5. Service Layer (`service.py`)

```python
"""Business logic for widget configuration."""

import copy
import json
from datetime import datetime
from typing import Dict, List, Optional, Any
from uuid import UUID

from .exceptions import (
    InvalidConfigError,
    ThemeNotFoundError,
    VersionConflictError,
    WidgetNotFoundError,
)
from .models import Theme, Widget
from .repository import ThemeRepository, WidgetRepository
from .schemas import (
    ThemeApplicationRequest,
    WidgetConfigBase,
    WidgetConfigUpdate,
    WidgetPreviewData,
)


class WidgetConfigService:
    """Service for widget configuration management."""
    
    def __init__(
        self,
        widget_repo: WidgetRepository,
        theme_repo: ThemeRepository,
    ):
        self.widget_repo = widget_repo
        self.theme_repo = theme_repo
    
    async def get_config(self, widget_id: UUID) -> Dict[str, Any]:
        """Retrieve current widget configuration."""
        widget = await self.widget_repo.get_by_id_or_raise(widget_id)
        
        # Merge with defaults if missing fields
        config = widget.current_config or {}
        full_config = self._apply_defaults(config)
        
        return {
            "widget_id": widget.id,
            "version": widget.config_version,
            "config": full_config,
            "created_at": widget.created_at,
            "updated_at": widget.updated_at,
            "last_modified_by": None,  # Could be tracked in metadata
        }
    
    async def update_config(
        self,
        widget_id: UUID,
        update_data: WidgetConfigUpdate,
        user_id: Optional[str] = None,
        expected_version: Optional[int] = None,
    ) -> Dict[str, Any]:
        """
        Update widget configuration with versioning.
        
        Args:
            widget_id: Target widget UUID
            update_data: Configuration updates
            user_id: ID of user making changes (for audit)
            expected_version: Optimistic locking version check
            
        Returns:
            Updated configuration with metadata
        """
        widget = await self.widget_repo.get_by_id_or_raise(widget_id)
        
        # Optimistic locking check
        if expected_version is not None and widget.config_version != expected_version:
            raise VersionConflictError(
                current_version=widget.config_version,
                attempted_version=expected_version,
            )
        
        # Merge configurations
        current_config = copy.deepcopy(widget.current_config or {})
        new_config = self._merge_config_update(current_config, update_data)
        
        # Validate merged config
        validated = WidgetConfigBase.model_validate(new_config)
        
        # Increment version
        new_version = (widget.config_version or 0) + 1
        
        # Create version record first (for rollback capability)
        await self.widget_repo.create_version(
            widget_id=widget_id,
            config_data=new_config,
            version_number=new_version,
            change_summary=update_data.change_summary,
            created_by=user_id,
        )
        
        # Update widget current config
        updated_widget = await self.widget_repo.update_config(
            widget_id=widget_id,
            config_data=new_config,
            version_number=new_version,
        )
        
        return {
            "widget_id": updated_widget.id,
            "version": new_version,
            "config": validated,
            "created_at": widget.created_at,
            "updated_at": datetime.utcnow(),
            "last_modified_by": user_id,
        }
    
    async def get_preview_data(
        self,
        widget_id: UUID,
        theme_id: Optional[UUID] = None,
    ) -> WidgetPreviewData:
        """
        Generate preview data for widget rendering.
        
        If theme_id provided, applies theme to current config for preview.
        """
        widget = await self.widget_repo.get_by_id_or_raise(widget_id)
        config_data = widget.current_config or {}
        
        theme_name = None
        if theme_id:
            theme = await self.theme_repo.get_by_id(theme_id)
            if theme:
                # Merge theme template with current config
                config_data = self._apply_theme_config(
                    config_data,
                    theme.config_template,
                    preserve_business_info=True,
                )
                theme_name = theme.name
        
        # Ensure all defaults are present
        full_config = self._apply_defaults(config_data)
        
        # Generate preview HTML (simplified version)
        preview_html = self._generate_preview_html(full_config)
        
        return WidgetPreviewData(
            widget_id=widget_id,
            config=WidgetConfigBase.model_validate(full_config),
            theme_applied=theme_name,
            generated_at=datetime.utcnow(),
            preview_html=preview_html,
            meta={
                "version": widget.config_version,
                "is_active": widget.is_active,
            },
        )
    
    async def apply_theme(
        self,
        widget_id: UUID,
        request: ThemeApplicationRequest,
        user_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Apply a theme to widget configuration."""
        widget = await self.widget_repo.get_by_id_or_raise(widget_id)
        theme = await self.theme_repo.get_by_id_or_raise(request.theme_id)
        
        current_config = widget.current_config or {}
        
        # Apply theme template
        new_config = self._apply_theme_config(
            current_config,
            theme.config_template,
            preserve_business_info=request.preserve_customizations,
        )
        
        # Apply custom overrides if provided
        if request.custom_overrides:
            new_config = self._merge_config_update(new_config, request.custom_overrides)
        
        # Validate
        validated = WidgetConfigBase.model_validate(new_config)
        
        # Version increment
        new_version = (widget.config_version or 0) + 1
        
        # Save version
        await self.widget_repo.create_version(
            widget_id=widget_id,
            config_data=new_config,
            version_number=new_version,
            change_summary=f"Applied theme: {theme.name}",
            created_by=user_id,
        )
        
        # Update widget
        await self.widget_repo.update_config(
            widget_id=widget_id,
            config_data=new_config,
            version_number=new_version,
        )
        
        return {
            "widget_id": widget_id,
            "version": new_version,
            "theme_applied": theme.name,
            "config": validated,
        }
    
    async def rollback_to_version(
        self,
        widget_id: UUID,
        version_number: int,
        user_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Rollback widget to a specific version."""
        widget = await self.widget_repo.get_by_id_or_raise(widget_id)
        version = await self.widget_repo.get_specific_version(
            widget_id, version_number
        )
        
        if not version:
            raise InvalidConfigError(f"Version {version_number} not found")
        
        new_version = (widget.config_version or 0) + 1
        
        # Create new version entry for rollback (for audit trail)
        await self.widget_repo.create_version(
            widget_id=widget_id,
            config_data=version.config_data,
            version_number=new_version,
            change_summary=f"Rolled back to version {version_number}",
            created_by=user_id,
        )
        
        await self.widget_repo.update_config(
            widget_id=widget_id,
            config_data=version.config_data,
            version_number=new_version,
        )
        
        return {
            "widget_id": widget_id,
            "version": new_version,
            "rolled_back_from": widget.config_version,
            "rolled_back_to": version_number,
        }
    
    def _apply_defaults(self, config: Dict[str, Any]) -> Dict[str, Any]:
        """Apply default values to missing configuration sections."""
        defaults = WidgetConfigBase().model_dump()
        
        result = copy.deepcopy(defaults)
        result.update(config)
        
        # Deep merge for nested sections
        for key in ["appearance", "behavior", "voice", "business_info", "advanced"]:
            if key in config and isinstance(config[key], dict):
                result[key] = {**defaults.get(key, {}), **config[key]}
        
        return result
    
    def _merge_config_update(
        self,
        current: Dict[str, Any],
        update: WidgetConfigUpdate,
    ) -> Dict[str, Any]:
        """Merge update into current configuration."""
        result = copy.deepcopy(current)
        
        update_dict = update.model_dump(exclude_unset=True, exclude={"change_summary"})
        
        for key, value in update_dict.items():
            if value is not None:
                if isinstance(value, dict) and key in result:
                    result[key] = {**result[key], **value}
                else:
                    result[key] = value
        
        return result
    
    def _apply_theme_config(
        self,
        current: Dict[str, Any],
        theme_template: Dict[str, Any],
        preserve_business_info: bool = False,
    ) -> Dict[str, Any]:
        """Apply theme template to configuration."""
        result = copy.deepcopy(theme_template)
        
        if preserve_business_info and "business_info" in current:
            result["business_info"] = current["business_info"]
        
        # Preserve advanced settings usually
        if "advanced" in current:
            result["advanced"] = {**result.get("advanced", {}), **current["advanced"]}
        
        return result
    
    def _generate_preview_html(self, config: Dict[str, Any]) -> str:
        """Generate server-side preview HTML."""
        # Simplified HTML generation for preview purposes
        appearance = config.get("appearance", {})
        colors = {
            "primary": appearance.get("primary_color", "#FF6B00"),
            "secondary": appearance.get("secondary_color", "#FFFFFF"),
        }
        
        return f"""
        <div style="background: {colors['primary']}; color: {colors['secondary']}; 
                    padding: 20px; border-radius: 12px; font-family: sans-serif;">
            <h3>Widget Preview</h3>
            <p>Position: {appearance.get('position', 'bottom-right')}</p>
            <p>Size: {appearance.get('widget_size', 'medium')}</p>
        </div>
        """


class ThemeService:
    """Service for theme management."""
    
    def __init__(self, theme_repo: ThemeRepository):
        self.theme_repo = theme_repo
    
    async def list_presets(
        self,
        category: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> List[Theme]:
        """List available preset themes."""
        return await self.theme_repo.list_themes(
            category=category,
            is_preset=True,
            is_active=True,
            limit=limit,
            offset=offset,
        )
    
    async def create_custom_theme(
        self,
        name: str,
        config_template: Dict[str, Any],
        description: Optional[str] = None,
    ) -> Theme:
        """Create a custom theme."""
        theme = Theme(
            name=name,
            description=description,
            category="custom",
            config_template=config_template,
            is_preset=False,
            is_active=True,
        )
        return await self.theme_repo.create(theme)
```

## 6. Dependencies (`dependencies.py`)

```python
"""FastAPI dependencies."""

from typing import AsyncGenerator

from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession

from .repository import ThemeRepository, WidgetRepository
from .service import ThemeService, WidgetConfigService


async def get_db_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
    """Get database session from request app state."""
    async_session_factory = request.app.state.async_session_factory
    async with async_session_factory() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
        finally:
            await session.close()


async def get_widget_repo(
    session: AsyncSession = Depends(get_db_session),
) -> WidgetRepository:
    """Dependency for widget repository."""
    return WidgetRepository(session)


async def get_theme_repo(
    session: AsyncSession = Depends(get_db_session),
) -> ThemeRepository:
    """Dependency for theme repository."""
    return ThemeRepository(session)


async def get_widget_config_service(
    widget_repo: WidgetRepository = Depends(get_widget_repo),
    theme_repo: ThemeRepository = Depends(get_theme_repo),
) -> WidgetConfigService:
    """Dependency for widget config service."""
    return WidgetConfigService(widget_repo, theme_repo)


async def get_theme_service(
    theme_repo: ThemeRepository = Depends(get_theme_repo),
) -> ThemeService:
    """Dependency for theme service."""
    return ThemeService(theme_repo)


# Authentication placeholder (implement based on your auth strategy)
async def get_current_user_id(request: Request) -> str:
    """Extract current user ID from request (JWT, session, etc.)."""
    # Implement your auth logic here
    # For now, return header or anonymous
    return request.headers.get("x-user-id", "anonymous")
```

## 7. API Routes (`routes.py`)

```python
"""FastAPI routes for widget configuration."""

from typing import List, Optional
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import JSONResponse

from .dependencies import (
    get_current_user_id,
    get_theme_service,
    get_widget_config_service,
)
from .exceptions import WidgetConfigError
from .schemas import (
    ErrorResponse,
    ThemeApplicationRequest,
    ThemeResponse,
    WidgetConfigResponse,
    WidgetConfigUpdate,
    WidgetPreviewData,
)
from .service import ThemeService, WidgetConfigService

router = APIRouter(prefix="/widgets", tags=["widget-config"])


@router.get(
    "/{widget_id}/config",
    response_model=WidgetConfigResponse,
    responses={
        404: {"model": ErrorResponse, "description": "Widget not found"},
        500: {"model": ErrorResponse},
    },
)
async def get_widget_config(
    widget_id: UUID,
    service: WidgetConfigService = Depends(get_widget_config_service),
) -> WidgetConfigResponse:
    """
    Retrieve current configuration for a widget.
    
    Returns the complete configuration including defaults for unset fields.
    """
    try:
        result = await service.get_config(widget_id)
        return WidgetConfigResponse.model_validate(result)
    except WidgetConfigError as e:
        raise HTTPException(status_code=e.status_code, detail=e.message)


@router.put(
    "/{widget_id}/config",
    response_model=WidgetConfigResponse,
    responses={
        404: {"model": ErrorResponse},
        409: {"model": ErrorResponse, "description": "Version conflict"},
        422: {"model": ErrorResponse, "description": "Validation error"},
    },
)
async def update_widget_config(
    widget_id: UUID,
    config_update: WidgetConfigUpdate,
    expected_version: Optional[int] = Query(
        None,
        description="Current version for optimistic locking",
    ),
    service: WidgetConfigService = Depends(get_widget_config_service),
    user_id: str = Depends(get_current_user_id),
) -> WidgetConfigResponse:
    """
    Update widget configuration.
    
    Creates a new version entry for audit trail. Supports optimistic locking
    via expected_version parameter to prevent concurrent modification conflicts.
    
    - **config_update**: Partial or full configuration update
    - **expected_version**: Current version number