Files
orion/docs/architecture/widget-provider-pattern.md
Samir Boulahtit 3e38db79aa feat: implement DashboardWidgetProvider pattern for modular dashboard widgets
Add protocol-based widget system following the MetricsProvider pattern:

- Create DashboardWidgetProviderProtocol in contracts/widgets.py
- Add WidgetAggregatorService in core to discover and aggregate widgets
- Implement MarketplaceWidgetProvider for recent_imports widget
- Implement TenancyWidgetProvider for recent_vendors widget
- Update admin dashboard to use widget_aggregator
- Add widget_provider field to ModuleDefinition

Architecture documentation:
- Add widget-provider-pattern.md with implementation guide
- Add cross-module-import-rules.md enforcing core/optional separation
- Update module-system.md with widget_provider and import rules

This enables modules to provide rich dashboard widgets without core modules
importing from optional modules, maintaining true module independence.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 19:01:23 +01:00

16 KiB
Raw Blame History

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 to support richer widget data.

Overview

┌─────────────────────────────────────────────────────────────────────┐
│                      Dashboard Request                               │
│              (Admin Dashboard or Vendor Dashboard)                   │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    WidgetAggregatorService                          │
│            (app/modules/core/services/widget_aggregator.py)         │
│                                                                      │
│   • Discovers WidgetProviders from all enabled modules              │
│   • Calls get_vendor_widgets() or get_platform_widgets()            │
│   • Returns categorized widgets dict                                │
└─────────────────────────────────────────────────────────────────────┘
                                    │
            ┌───────────────────────┼───────────────────────┐
            ▼                       ▼                       ▼
    ┌───────────────┐       ┌───────────────┐       ┌───────────────┐
    │tenancy_widgets│       │market_widgets │       │catalog_widgets│
    │   (enabled)   │       │   (enabled)   │       │  (disabled)   │
    └───────┬───────┘       └───────┬───────┘       └───────────────┘
            │                       │                       │
            ▼                       ▼                       × (skipped)
    ┌───────────────┐       ┌───────────────┐
    │recent_vendors │       │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):

# 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:

@dataclass
class WidgetListItem:
    """Single item in a list widget (recent vendors, 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:

@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:

@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:

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

    def get_vendor_widgets(
        self, db: Session, vendor_id: int, context: WidgetContext | None = None
    ) -> list[DashboardWidget]:
        """Get widgets for vendor 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:

# 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_vendor_dashboard_widgets(
        self, db, vendor_id, platform_id, context=None
    ) -> dict[str, list[DashboardWidget]]:
        """Returns widgets grouped by category for vendor 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, vendor_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

# 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_vendor_widgets(
        self,
        db: Session,
        vendor_id: int,
        context: WidgetContext | None = None,
    ) -> list[DashboardWidget]:
        """Get marketplace widgets for a vendor dashboard."""
        from app.modules.marketplace.models import MarketplaceImportJob

        limit = context.limit if context else 5

        try:
            jobs = (
                db.query(MarketplaceImportJob)
                .filter(MarketplaceImportJob.vendor_id == vendor_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"/vendor/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 vendor 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 VendorPlatform junction table to filter by platform
        ...


# Singleton instance
marketplace_widget_provider = MarketplaceWidgetProvider()

Step 2: Register in Module Definition

# 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:

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_vendors (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_vendor_metrics, get_platform_metrics get_vendor_widgets, get_platform_widgets
Use case Numeric statistics Lists, breakdowns, rich data

Dashboard Usage Example

# 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 VendorPlatform junction table for platform-level queries:

from app.modules.tenancy.models import VendorPlatform

vendor_ids = (
    db.query(VendorPlatform.vendor_id)
    .filter(VendorPlatform.platform_id == platform_id)
    .subquery()
)

jobs = (
    db.query(MarketplaceImportJob)
    .filter(MarketplaceImportJob.vendor_id.in_(vendor_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:

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 VendorPlatform 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 Vendor.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