# 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