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

450 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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):
```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 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:
```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_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:
```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_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
```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_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
```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_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
```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 `VendorPlatform` junction table for platform-level queries:
```python
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:
```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 `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
## 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/vendor/company hierarchy
- [Cross-Module Import Rules](cross-module-import-rules.md) - Import constraints