# Widget Provider Pattern

The widget provider pattern enables modules to provide dashboard widgets (lists of recent items, breakdowns) without creating cross-module dependencies. This pattern extends the [Metrics Provider Pattern](metrics-provider-pattern.md) to support richer widget data.

## Overview

```
┌─────────────────────────────────────────────────────────────────────┐
│                      Dashboard Request                               │
│              (Admin Dashboard or Store Dashboard)                   │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    WidgetAggregatorService                          │
│            (app/modules/core/services/widget_aggregator.py)         │
│                                                                      │
│   • Discovers WidgetProviders from all enabled modules              │
│   • Calls get_store_widgets() or get_platform_widgets()            │
│   • Returns categorized widgets dict                                │
└─────────────────────────────────────────────────────────────────────┘
                                    │
            ┌───────────────────────┼───────────────────────┐
            ▼                       ▼                       ▼
    ┌───────────────┐       ┌───────────────┐       ┌───────────────┐
    │tenancy_widgets│       │market_widgets │       │catalog_widgets│
    │   (enabled)   │       │   (enabled)   │       │  (disabled)   │
    └───────┬───────┘       └───────┬───────┘       └───────────────┘
            │                       │                       │
            ▼                       ▼                       × (skipped)
    ┌───────────────┐       ┌───────────────┐
    │recent_stores │       │recent_imports │
    │  (ListWidget) │       │  (ListWidget) │
    └───────────────┘       └───────────────┘
            │                       │
            └───────────┬───────────┘
                        ▼
        ┌─────────────────────────────────┐
        │       Categorized Widgets       │
        │{"tenancy": [...], "marketplace": [...]}│
        └─────────────────────────────────┘
```

## Architectural Principle

**Core defines contracts, modules implement them, core discovers and aggregates.**

This ensures:
- Core modules **never import** from optional modules
- Optional modules remain truly optional (can be removed without breaking app)
- Dashboard always works with partial data when modules are disabled

## Key Components

### 1. WidgetContext Dataclass

Context for widget queries (date ranges, limits):

```python
# app/modules/contracts/widgets.py
from dataclasses import dataclass
from datetime import datetime

@dataclass
class WidgetContext:
    date_from: datetime | None = None
    date_to: datetime | None = None
    limit: int = 5  # Max items for list widgets
    include_details: bool = False
```

### 2. Widget Item Types

Items that populate widgets:

```python
@dataclass
class WidgetListItem:
    """Single item in a list widget (recent stores, orders, imports)."""
    id: int | str
    title: str
    subtitle: str | None = None
    status: str | None = None  # "success", "warning", "error", "neutral"
    timestamp: datetime | None = None
    url: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass
class WidgetBreakdownItem:
    """Single row in a breakdown widget (stats by marketplace, category)."""
    label: str
    value: int | float
    secondary_value: int | float | None = None
    percentage: float | None = None
    icon: str | None = None
```

### 3. Widget Containers

Containers that hold widget items:

```python
@dataclass
class ListWidget:
    """Widget containing a list of items."""
    items: list[WidgetListItem]
    total_count: int | None = None
    view_all_url: str | None = None


@dataclass
class BreakdownWidget:
    """Widget containing grouped statistics."""
    items: list[WidgetBreakdownItem]
    total: int | float | None = None

WidgetData = ListWidget | BreakdownWidget
```

### 4. DashboardWidget (Main Envelope)

The standard widget envelope with metadata:

```python
@dataclass
class DashboardWidget:
    key: str                    # "marketplace.recent_imports"
    widget_type: str            # "list" or "breakdown"
    title: str                  # "Recent Imports"
    category: str               # "marketplace"
    data: WidgetData
    icon: str | None = None     # Lucide icon name
    description: str | None = None
    order: int = 100            # Display order (lower = higher priority)
```

### 5. DashboardWidgetProviderProtocol

Protocol that modules implement:

```python
@runtime_checkable
class DashboardWidgetProviderProtocol(Protocol):
    @property
    def widgets_category(self) -> str:
        """Category name (e.g., "marketplace", "orders")."""
        ...

    def get_store_widgets(
        self, db: Session, store_id: int, context: WidgetContext | None = None
    ) -> list[DashboardWidget]:
        """Get widgets for store dashboard."""
        ...

    def get_platform_widgets(
        self, db: Session, platform_id: int, context: WidgetContext | None = None
    ) -> list[DashboardWidget]:
        """Get widgets for admin/platform dashboard."""
        ...
```

### 6. WidgetAggregatorService

Central service in core that discovers and aggregates widgets:

```python
# app/modules/core/services/widget_aggregator.py
class WidgetAggregatorService:
    def _get_enabled_providers(self, db, platform_id):
        # Iterate MODULES registry
        # Skip modules without widget_provider
        # Check module enablement (except core)
        # Return (module, provider) tuples

    def get_store_dashboard_widgets(
        self, db, store_id, platform_id, context=None
    ) -> dict[str, list[DashboardWidget]]:
        """Returns widgets grouped by category for store dashboard."""

    def get_admin_dashboard_widgets(
        self, db, platform_id, context=None
    ) -> dict[str, list[DashboardWidget]]:
        """Returns widgets grouped by category for admin dashboard."""

    def get_widgets_flat(
        self, db, platform_id, store_id=None, context=None
    ) -> list[DashboardWidget]:
        """Returns flat list sorted by order."""

widget_aggregator = WidgetAggregatorService()
```

## Implementing a Widget Provider

### Step 1: Create the Widget Provider Class

```python
# app/modules/marketplace/services/marketplace_widgets.py
import logging
from sqlalchemy.orm import Session

from app.modules.contracts.widgets import (
    DashboardWidget,
    ListWidget,
    WidgetContext,
    WidgetListItem,
)

logger = logging.getLogger(__name__)


class MarketplaceWidgetProvider:
    """Widget provider for marketplace module."""

    @property
    def widgets_category(self) -> str:
        return "marketplace"

    def _map_status_to_display(self, status: str) -> str:
        """Map job status to widget status indicator."""
        status_map = {
            "pending": "neutral",
            "processing": "warning",
            "completed": "success",
            "completed_with_errors": "warning",
            "failed": "error",
        }
        return status_map.get(status, "neutral")

    def get_store_widgets(
        self,
        db: Session,
        store_id: int,
        context: WidgetContext | None = None,
    ) -> list[DashboardWidget]:
        """Get marketplace widgets for a store dashboard."""
        from app.modules.marketplace.models import MarketplaceImportJob

        limit = context.limit if context else 5

        try:
            jobs = (
                db.query(MarketplaceImportJob)
                .filter(MarketplaceImportJob.store_id == store_id)
                .order_by(MarketplaceImportJob.created_at.desc())
                .limit(limit)
                .all()
            )

            items = [
                WidgetListItem(
                    id=job.id,
                    title=f"Import #{job.id}",
                    subtitle=f"{job.marketplace} - {job.language.upper()}",
                    status=self._map_status_to_display(job.status),
                    timestamp=job.created_at,
                    url=f"/store/marketplace/imports/{job.id}",
                    metadata={
                        "total_processed": job.total_processed or 0,
                        "imported_count": job.imported_count or 0,
                        "error_count": job.error_count or 0,
                    },
                )
                for job in jobs
            ]

            return [
                DashboardWidget(
                    key="marketplace.recent_imports",
                    widget_type="list",
                    title="Recent Imports",
                    category="marketplace",
                    data=ListWidget(items=items),
                    icon="download",
                    order=20,
                )
            ]
        except Exception as e:
            logger.warning(f"Failed to get marketplace store widgets: {e}")
            return []

    def get_platform_widgets(
        self,
        db: Session,
        platform_id: int,
        context: WidgetContext | None = None,
    ) -> list[DashboardWidget]:
        """Get marketplace widgets for the admin dashboard."""
        # Similar implementation for platform-wide metrics
        # Uses StorePlatform junction table to filter by platform
        ...


# Singleton instance
marketplace_widget_provider = MarketplaceWidgetProvider()
```

### Step 2: Register in Module Definition

```python
# app/modules/marketplace/definition.py
from app.modules.base import ModuleDefinition


def _get_widget_provider():
    """Lazy import to avoid circular imports."""
    from app.modules.marketplace.services.marketplace_widgets import marketplace_widget_provider
    return marketplace_widget_provider


marketplace_module = ModuleDefinition(
    code="marketplace",
    name="Marketplace (Letzshop)",
    # ... other config ...

    # Register the widget provider
    widget_provider=_get_widget_provider,
)
```

### Step 3: Widgets Appear Automatically

When the module is enabled, its widgets automatically appear in dashboards.

## Translation Handling

Translations are handled **by the optional module itself**, not by core:

```python
from app.core.i18n import gettext as _

def get_platform_widgets(self, db, platform_id, context=None):
    return [
        DashboardWidget(
            key="marketplace.recent_imports",
            title=_("Recent Imports"),           # Module translates
            description=_("Latest import jobs"),  # Module translates
            # ...
        )
    ]
```

Core receives already-translated strings and doesn't need translation logic.

## Available Widget Providers

| Module | Category | Widgets Provided |
|--------|----------|------------------|
| **tenancy** | `tenancy` | recent_stores (ListWidget) |
| **marketplace** | `marketplace` | recent_imports (ListWidget) |
| **orders** | `orders` | recent_orders (ListWidget) - future |
| **customers** | `customers` | recent_customers (ListWidget) - future |

## Comparison with Metrics Provider

| Aspect | MetricsProvider | DashboardWidgetProvider |
|--------|-----------------|------------------------|
| Protocol location | `contracts/metrics.py` | `contracts/widgets.py` |
| Data unit | `MetricValue` | `DashboardWidget` |
| Context | `MetricsContext` | `WidgetContext` |
| Aggregator | `StatsAggregatorService` | `WidgetAggregatorService` |
| Registration field | `metrics_provider` | `widget_provider` |
| Scope methods | `get_store_metrics`, `get_platform_metrics` | `get_store_widgets`, `get_platform_widgets` |
| Use case | Numeric statistics | Lists, breakdowns, rich data |

## Dashboard Usage Example

```python
# app/modules/core/routes/api/admin_dashboard.py
from app.modules.core.services.widget_aggregator import widget_aggregator

@admin_dashboard_router.get("")
def get_admin_dashboard(...):
    # Get widgets from all enabled modules
    widgets = widget_aggregator.get_admin_dashboard_widgets(db, platform_id)

    # Extract for backward compatibility
    recent_imports = []
    if "marketplace" in widgets:
        for widget in widgets["marketplace"]:
            if widget.key == "marketplace.recent_imports":
                recent_imports = [item_to_dict(i) for i in widget.data.items]

    return AdminDashboardResponse(
        # ... existing fields ...
        recent_imports=recent_imports,
    )
```

## Multi-Platform Architecture

Always use `StorePlatform` junction table for platform-level queries:

```python
from app.modules.tenancy.models import StorePlatform

store_ids = (
    db.query(StorePlatform.store_id)
    .filter(StorePlatform.platform_id == platform_id)
    .subquery()
)

jobs = (
    db.query(MarketplaceImportJob)
    .filter(MarketplaceImportJob.store_id.in_(store_ids))
    .order_by(MarketplaceImportJob.created_at.desc())
    .limit(limit)
    .all()
)
```

## Error Handling

Widget providers are wrapped in try/except to prevent one failing module from breaking the entire dashboard:

```python
try:
    widgets = provider.get_platform_widgets(db, platform_id, context)
except Exception as e:
    logger.warning(f"Failed to get {provider.widgets_category} widgets: {e}")
    widgets = []  # Continue with empty widgets for this module
```

## Best Practices

### Do

- Use lazy imports inside widget methods to avoid circular imports
- Always use `StorePlatform` junction table for platform-level queries
- Return empty list on error, don't raise exceptions
- Log warnings for debugging but don't crash
- Include helpful descriptions and icons for UI
- Handle translations in the module, not in core

### Don't

- Import from optional modules at the top of core module files
- Assume `Store.platform_id` exists (it doesn't!)
- Let exceptions propagate from widget providers
- Create hard dependencies between core and optional modules
- Rely on core to translate widget strings

## Related Documentation

- [Metrics Provider Pattern](metrics-provider-pattern.md) - Numeric statistics architecture
- [Module System Architecture](module-system.md) - Module structure and auto-discovery
- [Multi-Tenant Architecture](multi-tenant.md) - Platform/store/merchant hierarchy
- [Cross-Module Import Rules](cross-module-import-rules.md) - Import constraints
