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>
This commit is contained in:
348
app/modules/contracts/widgets.py
Normal file
348
app/modules/contracts/widgets.py
Normal file
@@ -0,0 +1,348 @@
|
||||
# app/modules/contracts/widgets.py
|
||||
"""
|
||||
Dashboard widget provider protocol for cross-module widget aggregation.
|
||||
|
||||
This module defines the protocol that modules implement to expose dashboard widgets.
|
||||
The core module's WidgetAggregator discovers and aggregates all providers.
|
||||
|
||||
Benefits:
|
||||
- Each module owns its widgets (no cross-module coupling)
|
||||
- Dashboards always work (aggregator is in core)
|
||||
- Optional modules are truly optional (can be removed without breaking app)
|
||||
- Easy to add new widgets (just implement protocol in your module)
|
||||
|
||||
Widget Types:
|
||||
- ListWidget: Displays a list of items (recent imports, recent vendors, etc.)
|
||||
- BreakdownWidget: Displays grouped statistics (by category, by status, etc.)
|
||||
|
||||
Usage:
|
||||
# 1. Implement the protocol in your module
|
||||
class OrderWidgetProvider:
|
||||
@property
|
||||
def widgets_category(self) -> str:
|
||||
return "orders"
|
||||
|
||||
def get_vendor_widgets(self, db, vendor_id, context=None) -> list[DashboardWidget]:
|
||||
return [
|
||||
DashboardWidget(
|
||||
key="orders.recent",
|
||||
widget_type="list",
|
||||
title="Recent Orders",
|
||||
category="orders",
|
||||
data=ListWidget(items=[...]),
|
||||
)
|
||||
]
|
||||
|
||||
# 2. Register in module definition
|
||||
def _get_widget_provider():
|
||||
from app.modules.orders.services.order_widgets import order_widget_provider
|
||||
return order_widget_provider
|
||||
|
||||
orders_module = ModuleDefinition(
|
||||
code="orders",
|
||||
widget_provider=_get_widget_provider,
|
||||
# ...
|
||||
)
|
||||
|
||||
# 3. Widgets appear automatically in dashboards when module is enabled
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Context
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetContext:
|
||||
"""
|
||||
Context for widget data collection.
|
||||
|
||||
Provides filtering and scoping options for widget providers.
|
||||
|
||||
Attributes:
|
||||
date_from: Start of date range filter
|
||||
date_to: End of date range filter
|
||||
limit: Maximum number of items for list widgets
|
||||
include_details: Whether to include extra details (may be expensive)
|
||||
"""
|
||||
|
||||
date_from: datetime | None = None
|
||||
date_to: datetime | None = None
|
||||
limit: int = 5
|
||||
include_details: bool = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Widget Item Types
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetListItem:
|
||||
"""
|
||||
Single item in a list widget (recent vendors, orders, imports).
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for the item
|
||||
title: Primary display text
|
||||
subtitle: Secondary display text (optional)
|
||||
status: Status indicator ("success", "warning", "error", "neutral")
|
||||
timestamp: When the item was created/updated
|
||||
url: Link to item details (optional)
|
||||
metadata: Additional data for rendering
|
||||
"""
|
||||
|
||||
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).
|
||||
|
||||
Attributes:
|
||||
label: Display label for the row
|
||||
value: Primary numeric value
|
||||
secondary_value: Optional secondary value (e.g., comparison)
|
||||
percentage: Percentage representation
|
||||
icon: Lucide icon name (optional)
|
||||
"""
|
||||
|
||||
label: str
|
||||
value: int | float
|
||||
secondary_value: int | float | None = None
|
||||
percentage: float | None = None
|
||||
icon: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Widget Containers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListWidget:
|
||||
"""
|
||||
Widget containing a list of items.
|
||||
|
||||
Used for: recent imports, recent vendors, recent orders, etc.
|
||||
|
||||
Attributes:
|
||||
items: List of WidgetListItem objects
|
||||
total_count: Total number of items (for "X more" display)
|
||||
view_all_url: Link to view all items
|
||||
"""
|
||||
|
||||
items: list[WidgetListItem]
|
||||
total_count: int | None = None
|
||||
view_all_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BreakdownWidget:
|
||||
"""
|
||||
Widget containing grouped statistics.
|
||||
|
||||
Used for: status breakdown, category distribution, etc.
|
||||
|
||||
Attributes:
|
||||
items: List of WidgetBreakdownItem objects
|
||||
total: Sum of all values
|
||||
"""
|
||||
|
||||
items: list[WidgetBreakdownItem]
|
||||
total: int | float | None = None
|
||||
|
||||
|
||||
# Type alias for widget data
|
||||
WidgetData = ListWidget | BreakdownWidget
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Widget Envelope
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class DashboardWidget:
|
||||
"""
|
||||
Standard widget envelope with metadata.
|
||||
|
||||
This is the main unit of data returned by widget providers.
|
||||
It contains both the widget data and metadata for display.
|
||||
|
||||
Attributes:
|
||||
key: Unique identifier for this widget (e.g., "marketplace.recent_imports")
|
||||
Format: "{category}.{widget_name}" for consistency
|
||||
widget_type: Type of widget ("list" or "breakdown")
|
||||
title: Human-readable title for display
|
||||
category: Grouping category (should match widgets_category of provider)
|
||||
data: The actual widget data (ListWidget or BreakdownWidget)
|
||||
icon: Optional Lucide icon name for UI display
|
||||
description: Optional longer description
|
||||
order: Display order (lower = higher priority)
|
||||
|
||||
Example:
|
||||
DashboardWidget(
|
||||
key="marketplace.recent_imports",
|
||||
widget_type="list",
|
||||
title="Recent Imports",
|
||||
category="marketplace",
|
||||
data=ListWidget(
|
||||
items=[
|
||||
WidgetListItem(id=1, title="Import #1", status="success")
|
||||
],
|
||||
),
|
||||
icon="download",
|
||||
order=10,
|
||||
)
|
||||
"""
|
||||
|
||||
key: str
|
||||
widget_type: str # "list" or "breakdown"
|
||||
title: str
|
||||
category: str
|
||||
data: WidgetData
|
||||
icon: str | None = None
|
||||
description: str | None = None
|
||||
order: int = 100
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Protocol Interface
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DashboardWidgetProviderProtocol(Protocol):
|
||||
"""
|
||||
Protocol for modules that provide dashboard widgets.
|
||||
|
||||
Each module implements this to expose its own widgets.
|
||||
The core module's WidgetAggregator discovers and aggregates all providers.
|
||||
|
||||
Implementation Notes:
|
||||
- Providers should be stateless (all data via db session)
|
||||
- Return empty list if no widgets available (don't raise)
|
||||
- Use consistent key format: "{category}.{widget_name}"
|
||||
- Include icon hints for UI rendering
|
||||
- Be mindful of query performance (use efficient queries)
|
||||
- Translations are handled by the module itself
|
||||
|
||||
Example Implementation:
|
||||
class MarketplaceWidgetProvider:
|
||||
@property
|
||||
def widgets_category(self) -> str:
|
||||
return "marketplace"
|
||||
|
||||
def get_vendor_widgets(
|
||||
self, db: Session, vendor_id: int, context: WidgetContext | None = None
|
||||
) -> list[DashboardWidget]:
|
||||
from app.modules.marketplace.models import MarketplaceImportJob
|
||||
limit = context.limit if context else 5
|
||||
jobs = db.query(MarketplaceImportJob).filter(...).limit(limit).all()
|
||||
return [
|
||||
DashboardWidget(
|
||||
key="marketplace.recent_imports",
|
||||
widget_type="list",
|
||||
title="Recent Imports",
|
||||
category="marketplace",
|
||||
data=ListWidget(items=[...]),
|
||||
icon="download"
|
||||
)
|
||||
]
|
||||
|
||||
def get_platform_widgets(
|
||||
self, db: Session, platform_id: int, context: WidgetContext | None = None
|
||||
) -> list[DashboardWidget]:
|
||||
# Aggregate across all vendors in platform
|
||||
...
|
||||
"""
|
||||
|
||||
@property
|
||||
def widgets_category(self) -> str:
|
||||
"""
|
||||
Category name for this provider's widgets.
|
||||
|
||||
Should be a short, lowercase identifier matching the module's domain.
|
||||
Examples: "orders", "inventory", "customers", "marketplace"
|
||||
|
||||
Returns:
|
||||
Category string used for grouping widgets
|
||||
"""
|
||||
...
|
||||
|
||||
def get_vendor_widgets(
|
||||
self,
|
||||
db: "Session",
|
||||
vendor_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[DashboardWidget]:
|
||||
"""
|
||||
Get widgets for a specific vendor dashboard.
|
||||
|
||||
Called by the vendor dashboard to display vendor-scoped widgets.
|
||||
Should only include data belonging to the specified vendor.
|
||||
|
||||
Args:
|
||||
db: Database session for queries
|
||||
vendor_id: ID of the vendor to get widgets for
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
List of DashboardWidget objects for this vendor
|
||||
"""
|
||||
...
|
||||
|
||||
def get_platform_widgets(
|
||||
self,
|
||||
db: "Session",
|
||||
platform_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[DashboardWidget]:
|
||||
"""
|
||||
Get widgets aggregated for a platform.
|
||||
|
||||
Called by the admin dashboard to display platform-wide widgets.
|
||||
Should aggregate data across all vendors in the platform.
|
||||
|
||||
Args:
|
||||
db: Database session for queries
|
||||
platform_id: ID of the platform to get widgets for
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
List of DashboardWidget objects aggregated for the platform
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Context
|
||||
"WidgetContext",
|
||||
# Item types
|
||||
"WidgetListItem",
|
||||
"WidgetBreakdownItem",
|
||||
# Containers
|
||||
"ListWidget",
|
||||
"BreakdownWidget",
|
||||
"WidgetData",
|
||||
# Main envelope
|
||||
"DashboardWidget",
|
||||
# Protocol
|
||||
"DashboardWidgetProviderProtocol",
|
||||
]
|
||||
Reference in New Issue
Block a user