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:
2026-02-04 19:01:23 +01:00
parent a8fae0fbc7
commit 3e38db79aa
13 changed files with 1906 additions and 25 deletions

View File

@@ -5,17 +5,22 @@ Admin dashboard and statistics endpoints.
This module uses the StatsAggregator service from core to collect metrics from all
enabled modules. Each module provides its own metrics via the MetricsProvider protocol.
Dashboard widgets are collected via the WidgetAggregator service, which discovers
DashboardWidgetProvider implementations from all enabled modules.
For backward compatibility, this also falls back to the analytics stats_service
for comprehensive statistics that haven't been migrated to the provider pattern yet.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.contracts.widgets import ListWidget
from app.modules.core.schemas.dashboard import (
AdminDashboardResponse,
ImportStatsResponse,
@@ -28,7 +33,7 @@ from app.modules.core.schemas.dashboard import (
VendorStatsResponse,
)
from app.modules.core.services.stats_aggregator import stats_aggregator
from app.modules.tenancy.services.admin_service import admin_service
from app.modules.core.services.widget_aggregator import widget_aggregator
from models.schema.auth import UserContext
admin_dashboard_router = APIRouter(prefix="/dashboard")
@@ -77,6 +82,31 @@ def _extract_metric_value(
return default
def _widget_list_item_to_dict(item) -> dict[str, Any]:
"""Convert a WidgetListItem to a dictionary for API response."""
return {
"id": item.id,
"title": item.title,
"subtitle": item.subtitle,
"status": item.status,
"timestamp": item.timestamp,
"url": item.url,
**item.metadata,
}
def _extract_widget_items(
widgets: dict[str, list], category: str, key: str
) -> list[dict[str, Any]]:
"""Extract items from a list widget for backward compatibility."""
if category not in widgets:
return []
for widget in widgets[category]:
if widget.key == key and isinstance(widget.data, ListWidget):
return [_widget_list_item_to_dict(item) for item in widget.data.items]
return []
@admin_dashboard_router.get("", response_model=AdminDashboardResponse)
def get_admin_dashboard(
request: Request,
@@ -89,6 +119,9 @@ def get_admin_dashboard(
# Get aggregated metrics from all enabled modules
metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id)
# Get aggregated widgets from all enabled modules
widgets = widget_aggregator.get_admin_dashboard_widgets(db=db, platform_id=platform_id)
# Extract user stats from tenancy module
total_users = _extract_metric_value(metrics, "tenancy", "tenancy.total_users", 0)
active_users = _extract_metric_value(metrics, "tenancy", "tenancy.active_users", 0)
@@ -110,6 +143,12 @@ def get_admin_dashboard(
metrics, "tenancy", "tenancy.inactive_vendors", 0
)
# Extract recent_vendors from tenancy widget (backward compatibility)
recent_vendors = _extract_widget_items(widgets, "tenancy", "tenancy.recent_vendors")
# Extract recent_imports from marketplace widget (backward compatibility)
recent_imports = _extract_widget_items(widgets, "marketplace", "marketplace.recent_imports")
return AdminDashboardResponse(
platform={
"name": "Multi-Tenant Ecommerce Platform",
@@ -128,8 +167,8 @@ def get_admin_dashboard(
pending=int(pending_vendors),
inactive=int(inactive_vendors),
),
recent_vendors=admin_service.get_recent_vendors(db, limit=5),
recent_imports=admin_service.get_recent_import_jobs(db, limit=10),
recent_vendors=recent_vendors,
recent_imports=recent_imports,
)

View File

@@ -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",
]

View 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"]