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,
)