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:
@@ -39,6 +39,10 @@ from app.modules.core.services.storage_service import (
|
||||
get_storage_backend,
|
||||
reset_storage_backend,
|
||||
)
|
||||
from app.modules.core.services.widget_aggregator import (
|
||||
WidgetAggregatorService,
|
||||
widget_aggregator,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Auth
|
||||
@@ -71,4 +75,7 @@ __all__ = [
|
||||
# Stats aggregator
|
||||
"StatsAggregatorService",
|
||||
"stats_aggregator",
|
||||
# Widget aggregator
|
||||
"WidgetAggregatorService",
|
||||
"widget_aggregator",
|
||||
]
|
||||
|
||||
256
app/modules/core/services/widget_aggregator.py
Normal file
256
app/modules/core/services/widget_aggregator.py
Normal file
@@ -0,0 +1,256 @@
|
||||
# app/modules/core/services/widget_aggregator.py
|
||||
"""
|
||||
Widget aggregator service for collecting dashboard widgets from all modules.
|
||||
|
||||
This service lives in core because dashboards are core functionality that should
|
||||
always be available. It discovers and aggregates DashboardWidgetProviders from all
|
||||
enabled modules, providing a unified interface for dashboard widgets.
|
||||
|
||||
Benefits:
|
||||
- Dashboards always work (aggregator is in core)
|
||||
- Each module owns its widgets (no cross-module coupling)
|
||||
- Optional modules are truly optional (can be removed without breaking app)
|
||||
- Easy to add new widgets (just implement DashboardWidgetProviderProtocol in your module)
|
||||
|
||||
Usage:
|
||||
from app.modules.core.services.widget_aggregator import widget_aggregator
|
||||
|
||||
# Get vendor dashboard widgets
|
||||
widgets = widget_aggregator.get_vendor_dashboard_widgets(
|
||||
db=db, vendor_id=123, platform_id=1
|
||||
)
|
||||
|
||||
# Get admin dashboard widgets
|
||||
widgets = widget_aggregator.get_admin_dashboard_widgets(
|
||||
db=db, platform_id=1
|
||||
)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.contracts.widgets import (
|
||||
DashboardWidget,
|
||||
DashboardWidgetProviderProtocol,
|
||||
WidgetContext,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.modules.base import ModuleDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WidgetAggregatorService:
|
||||
"""
|
||||
Aggregates widgets from all module providers.
|
||||
|
||||
This service discovers DashboardWidgetProviders from enabled modules and provides
|
||||
a unified interface for dashboard widgets. It handles graceful degradation
|
||||
when modules are disabled or providers fail.
|
||||
"""
|
||||
|
||||
def _get_enabled_providers(
|
||||
self, db: Session, platform_id: int
|
||||
) -> list[tuple["ModuleDefinition", DashboardWidgetProviderProtocol]]:
|
||||
"""
|
||||
Get widget providers from enabled modules.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
platform_id: Platform ID to check module enablement
|
||||
|
||||
Returns:
|
||||
List of (module, provider) tuples for enabled modules with providers
|
||||
"""
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
|
||||
providers: list[tuple[ModuleDefinition, DashboardWidgetProviderProtocol]] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
# Skip modules without widget providers
|
||||
if not module.has_widget_provider():
|
||||
continue
|
||||
|
||||
# Core modules are always enabled, check others
|
||||
if not module.is_core:
|
||||
try:
|
||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get the provider instance
|
||||
try:
|
||||
provider = module.get_widget_provider_instance()
|
||||
if provider is not None:
|
||||
providers.append((module, provider))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get widget provider for module {module.code}: {e}"
|
||||
)
|
||||
|
||||
return providers
|
||||
|
||||
def get_vendor_dashboard_widgets(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
platform_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> dict[str, list[DashboardWidget]]:
|
||||
"""
|
||||
Get all widgets for a vendor, grouped by category.
|
||||
|
||||
Called by the vendor dashboard to display vendor-scoped widgets.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: ID of the vendor to get widgets for
|
||||
platform_id: Platform ID (for module enablement check)
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
Dict mapping category name to list of DashboardWidget objects
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
result: dict[str, list[DashboardWidget]] = {}
|
||||
|
||||
for module, provider in providers:
|
||||
try:
|
||||
widgets = provider.get_vendor_widgets(db, vendor_id, context)
|
||||
if widgets:
|
||||
result[provider.widgets_category] = widgets
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get vendor widgets from module {module.code}: {e}"
|
||||
)
|
||||
# Continue with other providers - graceful degradation
|
||||
|
||||
return result
|
||||
|
||||
def get_admin_dashboard_widgets(
|
||||
self,
|
||||
db: Session,
|
||||
platform_id: int,
|
||||
context: WidgetContext | None = None,
|
||||
) -> dict[str, list[DashboardWidget]]:
|
||||
"""
|
||||
Get all widgets for a platform, grouped by category.
|
||||
|
||||
Called by the admin dashboard to display platform-wide widgets.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
platform_id: ID of the platform to get widgets for
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
Dict mapping category name to list of DashboardWidget objects
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
result: dict[str, list[DashboardWidget]] = {}
|
||||
|
||||
for module, provider in providers:
|
||||
try:
|
||||
widgets = provider.get_platform_widgets(db, platform_id, context)
|
||||
if widgets:
|
||||
result[provider.widgets_category] = widgets
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get platform widgets from module {module.code}: {e}"
|
||||
)
|
||||
# Continue with other providers - graceful degradation
|
||||
|
||||
return result
|
||||
|
||||
def get_widgets_flat(
|
||||
self,
|
||||
db: Session,
|
||||
platform_id: int,
|
||||
vendor_id: int | None = None,
|
||||
context: WidgetContext | None = None,
|
||||
) -> list[DashboardWidget]:
|
||||
"""
|
||||
Get widgets as a flat list sorted by order.
|
||||
|
||||
Convenience method that returns all widgets in a single list,
|
||||
sorted by their order field.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
platform_id: Platform ID
|
||||
vendor_id: If provided, get vendor widgets; otherwise platform widgets
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
Flat list of DashboardWidget objects sorted by order
|
||||
"""
|
||||
if vendor_id is not None:
|
||||
categorized = self.get_vendor_dashboard_widgets(
|
||||
db, vendor_id, platform_id, context
|
||||
)
|
||||
else:
|
||||
categorized = self.get_admin_dashboard_widgets(db, platform_id, context)
|
||||
|
||||
# Flatten and sort by order
|
||||
all_widgets: list[DashboardWidget] = []
|
||||
for widgets in categorized.values():
|
||||
all_widgets.extend(widgets)
|
||||
|
||||
return sorted(all_widgets, key=lambda w: w.order)
|
||||
|
||||
def get_widget_by_key(
|
||||
self,
|
||||
db: Session,
|
||||
platform_id: int,
|
||||
key: str,
|
||||
vendor_id: int | None = None,
|
||||
context: WidgetContext | None = None,
|
||||
) -> DashboardWidget | None:
|
||||
"""
|
||||
Get a specific widget by its key.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
platform_id: Platform ID
|
||||
key: Widget key (e.g., "marketplace.recent_imports")
|
||||
vendor_id: If provided, get vendor widget; otherwise platform widget
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
The DashboardWidget with the specified key, or None if not found
|
||||
"""
|
||||
widgets = self.get_widgets_flat(db, platform_id, vendor_id, context)
|
||||
for widget in widgets:
|
||||
if widget.key == key:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def get_available_categories(
|
||||
self, db: Session, platform_id: int
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of available widget categories for a platform.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
platform_id: Platform ID (for module enablement check)
|
||||
|
||||
Returns:
|
||||
List of category names from enabled providers
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
return [provider.widgets_category for _, provider in providers]
|
||||
|
||||
|
||||
# Singleton instance
|
||||
widget_aggregator = WidgetAggregatorService()
|
||||
|
||||
__all__ = ["WidgetAggregatorService", "widget_aggregator"]
|
||||
Reference in New Issue
Block a user