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

@@ -45,6 +45,7 @@ if TYPE_CHECKING:
from pydantic import BaseModel
from app.modules.contracts.metrics import MetricsProviderProtocol
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
from app.modules.enums import FrontendType
@@ -413,6 +414,26 @@ class ModuleDefinition:
# The provider will be discovered by core's StatsAggregator service.
metrics_provider: "Callable[[], MetricsProviderProtocol] | None" = None
# =========================================================================
# Widget Provider (Module-Driven Dashboard Widgets)
# =========================================================================
# Callable that returns a DashboardWidgetProviderProtocol implementation.
# Use a callable (factory function) to enable lazy loading and avoid
# circular imports. Each module can provide its own widgets for dashboards.
#
# Example:
# 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,
# )
#
# The provider will be discovered by core's WidgetAggregator service.
widget_provider: "Callable[[], DashboardWidgetProviderProtocol] | None" = None
# =========================================================================
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
# =========================================================================
@@ -799,6 +820,28 @@ class ModuleDefinition:
return None
return self.metrics_provider()
# =========================================================================
# Widget Provider Methods
# =========================================================================
def has_widget_provider(self) -> bool:
"""Check if this module has a widget provider."""
return self.widget_provider is not None
def get_widget_provider_instance(self) -> "DashboardWidgetProviderProtocol | None":
"""
Get the widget provider instance for this module.
Calls the widget_provider factory function to get the provider.
Returns None if no provider is configured.
Returns:
DashboardWidgetProviderProtocol instance, or None
"""
if self.widget_provider is None:
return None
return self.widget_provider()
# =========================================================================
# Magic Methods
# =========================================================================

View File

@@ -32,6 +32,17 @@ Metrics Provider Pattern:
def get_vendor_metrics(self, db, vendor_id, context=None) -> list[MetricValue]:
return [MetricValue(key="orders.total", value=42, label="Total", category="orders")]
Widget Provider Pattern:
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol, DashboardWidget
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", ...)]
"""
from app.modules.contracts.base import ServiceProtocol
@@ -41,6 +52,15 @@ from app.modules.contracts.metrics import (
MetricsContext,
MetricsProviderProtocol,
)
from app.modules.contracts.widgets import (
BreakdownWidget,
DashboardWidget,
DashboardWidgetProviderProtocol,
ListWidget,
WidgetBreakdownItem,
WidgetContext,
WidgetListItem,
)
__all__ = [
# Base protocols
@@ -51,4 +71,12 @@ __all__ = [
"MetricValue",
"MetricsContext",
"MetricsProviderProtocol",
# Widget protocols
"WidgetContext",
"WidgetListItem",
"WidgetBreakdownItem",
"ListWidget",
"BreakdownWidget",
"DashboardWidget",
"DashboardWidgetProviderProtocol",
]

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

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

View File

@@ -33,6 +33,13 @@ def _get_metrics_provider():
return marketplace_metrics_provider
def _get_widget_provider():
"""Lazy import of widget provider to avoid circular imports."""
from app.modules.marketplace.services.marketplace_widgets import marketplace_widget_provider
return marketplace_widget_provider
# Marketplace module definition
marketplace_module = ModuleDefinition(
code="marketplace",
@@ -155,6 +162,8 @@ marketplace_module = ModuleDefinition(
],
# Metrics provider for dashboard statistics
metrics_provider=_get_metrics_provider,
# Widget provider for dashboard widgets
widget_provider=_get_widget_provider,
)

View File

@@ -0,0 +1,211 @@
# app/modules/marketplace/services/marketplace_widgets.py
"""
Marketplace dashboard widget provider.
Provides widgets for marketplace-related data on vendor and admin dashboards.
Implements the DashboardWidgetProviderProtocol.
Widgets provided:
- recent_imports: List of recent import jobs with status
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
DashboardWidgetProviderProtocol,
ListWidget,
WidgetContext,
WidgetListItem,
)
logger = logging.getLogger(__name__)
class MarketplaceWidgetProvider:
"""
Widget provider for marketplace module.
Provides dashboard widgets for import jobs and other marketplace data.
"""
@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.
Args:
db: Database session
vendor_id: ID of the vendor
context: Optional filtering/scoping context
Returns:
List of DashboardWidget objects for the vendor
"""
from app.modules.marketplace.models import MarketplaceImportJob
limit = context.limit if context else 5
# Get recent imports for this vendor
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,
"status": job.status,
},
)
for job in jobs
]
# Get total count for "view all" indicator
total_count = (
db.query(MarketplaceImportJob)
.filter(MarketplaceImportJob.vendor_id == vendor_id)
.count()
)
return [
DashboardWidget(
key="marketplace.recent_imports",
widget_type="list",
title="Recent Imports",
category="marketplace",
data=ListWidget(
items=items,
total_count=total_count,
view_all_url="/vendor/marketplace/imports",
),
icon="download",
description="Latest product import jobs",
order=20,
)
]
def get_platform_widgets(
self,
db: Session,
platform_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
"""
Get marketplace widgets for the admin/platform dashboard.
Args:
db: Database session
platform_id: ID of the platform
context: Optional filtering/scoping context
Returns:
List of DashboardWidget objects for the platform
"""
from sqlalchemy.orm import joinedload
from app.modules.marketplace.models import MarketplaceImportJob
from app.modules.tenancy.models import Vendor, VendorPlatform
limit = context.limit if context else 5
# Get vendor IDs for this platform
vendor_ids_subquery = (
db.query(VendorPlatform.vendor_id)
.filter(VendorPlatform.platform_id == platform_id)
.subquery()
)
# Get recent imports across all vendors in the platform
jobs = (
db.query(MarketplaceImportJob)
.options(joinedload(MarketplaceImportJob.vendor))
.filter(MarketplaceImportJob.vendor_id.in_(vendor_ids_subquery))
.order_by(MarketplaceImportJob.created_at.desc())
.limit(limit)
.all()
)
items = [
WidgetListItem(
id=job.id,
title=f"Import #{job.id}",
subtitle=job.vendor.name if job.vendor else "Unknown Vendor",
status=self._map_status_to_display(job.status),
timestamp=job.created_at,
url=f"/admin/marketplace/imports/{job.id}",
metadata={
"vendor_id": job.vendor_id,
"vendor_code": job.vendor.vendor_code if job.vendor else None,
"marketplace": job.marketplace,
"total_processed": job.total_processed or 0,
"imported_count": job.imported_count or 0,
"error_count": job.error_count or 0,
"status": job.status,
},
)
for job in jobs
]
# Get total count for platform
total_count = (
db.query(MarketplaceImportJob)
.filter(MarketplaceImportJob.vendor_id.in_(vendor_ids_subquery))
.count()
)
return [
DashboardWidget(
key="marketplace.recent_imports",
widget_type="list",
title="Recent Imports",
category="marketplace",
data=ListWidget(
items=items,
total_count=total_count,
view_all_url="/admin/marketplace/letzshop",
),
icon="download",
description="Latest product import jobs across all vendors",
order=20,
)
]
# Singleton instance
marketplace_widget_provider = MarketplaceWidgetProvider()
__all__ = ["MarketplaceWidgetProvider", "marketplace_widget_provider"]

View File

@@ -22,6 +22,13 @@ def _get_metrics_provider():
return tenancy_metrics_provider
def _get_widget_provider():
"""Lazy import of widget provider to avoid circular imports."""
from app.modules.tenancy.services.tenancy_widgets import tenancy_widget_provider
return tenancy_widget_provider
tenancy_module = ModuleDefinition(
code="tenancy",
name="Tenancy Management",
@@ -161,6 +168,8 @@ tenancy_module = ModuleDefinition(
exceptions_path="app.modules.tenancy.exceptions",
# Metrics provider for dashboard statistics
metrics_provider=_get_metrics_provider,
# Widget provider for dashboard widgets
widget_provider=_get_widget_provider,
)
__all__ = ["tenancy_module"]

View File

@@ -0,0 +1,156 @@
# app/modules/tenancy/services/tenancy_widgets.py
"""
Tenancy dashboard widget provider.
Provides widgets for tenancy-related data on admin dashboards.
Implements the DashboardWidgetProviderProtocol.
Widgets provided:
- recent_vendors: List of recently created vendors with status
"""
import logging
from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
DashboardWidgetProviderProtocol,
ListWidget,
WidgetContext,
WidgetListItem,
)
logger = logging.getLogger(__name__)
class TenancyWidgetProvider:
"""
Widget provider for tenancy module.
Provides dashboard widgets for vendors, users, and other tenancy data.
"""
@property
def widgets_category(self) -> str:
return "tenancy"
def _get_vendor_status(self, vendor) -> str:
"""Determine widget status indicator for a vendor."""
if not vendor.is_active:
return "neutral"
if not vendor.is_verified:
return "warning"
return "success"
def get_vendor_widgets(
self,
db: Session,
vendor_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
"""
Get tenancy widgets for a vendor dashboard.
Tenancy module doesn't provide vendor-scoped widgets
(vendors don't see other vendors).
Args:
db: Database session
vendor_id: ID of the vendor
context: Optional filtering/scoping context
Returns:
Empty list (no vendor-scoped tenancy widgets)
"""
# Tenancy widgets are platform/admin-only
return []
def get_platform_widgets(
self,
db: Session,
platform_id: int,
context: WidgetContext | None = None,
) -> list[DashboardWidget]:
"""
Get tenancy widgets for the admin/platform dashboard.
Args:
db: Database session
platform_id: ID of the platform
context: Optional filtering/scoping context
Returns:
List of DashboardWidget objects for the platform
"""
from sqlalchemy.orm import joinedload
from app.modules.tenancy.models import Vendor, VendorPlatform
limit = context.limit if context else 5
# Get vendor IDs for this platform
vendor_ids_subquery = (
db.query(VendorPlatform.vendor_id)
.filter(VendorPlatform.platform_id == platform_id)
.subquery()
)
# Get recent vendors for this platform
vendors = (
db.query(Vendor)
.options(joinedload(Vendor.company))
.filter(Vendor.id.in_(vendor_ids_subquery))
.order_by(Vendor.created_at.desc())
.limit(limit)
.all()
)
items = [
WidgetListItem(
id=vendor.id,
title=vendor.name,
subtitle=vendor.vendor_code,
status=self._get_vendor_status(vendor),
timestamp=vendor.created_at,
url=f"/admin/vendors/{vendor.id}",
metadata={
"vendor_code": vendor.vendor_code,
"subdomain": vendor.subdomain,
"is_active": vendor.is_active,
"is_verified": vendor.is_verified,
"company_name": vendor.company.name if vendor.company else None,
},
)
for vendor in vendors
]
# Get total vendor count for platform
total_count = (
db.query(Vendor)
.filter(Vendor.id.in_(vendor_ids_subquery))
.count()
)
return [
DashboardWidget(
key="tenancy.recent_vendors",
widget_type="list",
title="Recent Vendors",
category="tenancy",
data=ListWidget(
items=items,
total_count=total_count,
view_all_url="/admin/vendors",
),
icon="shopping-bag",
description="Recently created vendor accounts",
order=10,
)
]
# Singleton instance
tenancy_widget_provider = TenancyWidgetProvider()
__all__ = ["TenancyWidgetProvider", "tenancy_widget_provider"]

View File

@@ -0,0 +1,310 @@
# Cross-Module Import Rules
This document defines the strict import rules that ensure the module system remains decoupled, testable, and resilient. These rules are critical for maintaining a truly modular architecture.
## Core Principle
**Core modules NEVER import from optional modules.**
This is the fundamental rule that enables optional modules to be truly optional. When a core module imports from an optional module:
- The app crashes if that module is disabled
- You can't test core functionality in isolation
- You create a hidden dependency that violates the architecture
## Module Classification
### Core Modules (Always Enabled)
- `contracts` - Protocols and interfaces
- `core` - Dashboard, settings, profile
- `tenancy` - Platform, company, vendor, admin user management
- `cms` - Content pages, media library
- `customers` - Customer database
- `billing` - Subscriptions, tier limits
- `payments` - Payment gateway integrations
- `messaging` - Email, notifications
### Optional Modules (Per-Platform)
- `analytics` - Reports, dashboards
- `cart` - Shopping cart
- `catalog` - Product browsing
- `checkout` - Cart-to-order conversion
- `inventory` - Stock management
- `loyalty` - Loyalty programs
- `marketplace` - Letzshop integration
- `orders` - Order management
## Import Rules Matrix
| From \ To | Core | Optional | Contracts |
|-----------|------|----------|-----------|
| **Core** | :white_check_mark: | :x: FORBIDDEN | :white_check_mark: |
| **Optional** | :white_check_mark: | :warning: With care | :white_check_mark: |
| **Contracts** | :x: | :x: | :white_check_mark: |
### Explanation
1. **Core → Core**: Allowed. Core modules can import from each other (e.g., billing imports from tenancy)
2. **Core → Optional**: **FORBIDDEN**. This is the most important rule. Core modules must never have direct imports from optional modules.
3. **Core → Contracts**: Allowed. Contracts define shared protocols and data structures.
4. **Optional → Core**: Allowed. Optional modules can use core functionality.
5. **Optional → Optional**: Allowed with care. Check dependencies in `definition.py` to ensure proper ordering.
6. **Optional → Contracts**: Allowed. This is how optional modules implement protocols.
7. **Contracts → Anything**: Contracts should only depend on stdlib/typing/Protocol. No module imports.
## Anti-Patterns (DO NOT DO)
### Direct Import from Optional Module
```python
# app/modules/core/routes/api/admin_dashboard.py
# BAD: Core importing from optional module
from app.modules.marketplace.services import marketplace_service
from app.modules.analytics.services import stats_service
@router.get("/dashboard")
def get_dashboard():
# This crashes if marketplace is disabled!
imports = marketplace_service.get_recent_imports()
```
### Conditional Import with ismodule_enabled Check
```python
# BAD: Still creates import-time dependency
from app.modules.service import module_service
if module_service.is_module_enabled(db, platform_id, "marketplace"):
from app.modules.marketplace.services import marketplace_service # Still loaded!
imports = marketplace_service.get_recent_imports()
```
### Type Hints from Optional Modules
```python
# BAD: Type hint forces import
from app.modules.marketplace.models import MarketplaceImportJob
def process_import(job: MarketplaceImportJob) -> None: # Crashes if disabled
...
```
## Approved Patterns
### 1. Provider Protocol Pattern (Metrics & Widgets)
Use the provider protocol pattern for cross-module data:
```python
# app/modules/core/services/stats_aggregator.py
# GOOD: Import only protocols and contracts
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue
class StatsAggregatorService:
def _get_enabled_providers(self, db, platform_id):
# Discover providers from MODULES registry
from app.modules.registry import MODULES
for module in MODULES.values():
if module.has_metrics_provider():
# Provider is called through protocol, not direct import
provider = module.get_metrics_provider_instance()
yield provider
```
### 2. Context Provider Pattern
Modules contribute context without direct imports:
```python
# app/modules/core/utils.py
def get_context_for_frontend(frontend_type, request, db, platform):
# GOOD: Discover and call providers through registry
for module in get_enabled_modules(db, platform.id):
if module.has_context_provider(frontend_type):
context.update(module.get_context_contribution(...))
```
### 3. Lazy Factory Functions
Use factory functions in module definitions:
```python
# app/modules/marketplace/definition.py
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",
widget_provider=_get_widget_provider, # Called lazily when needed
)
```
### 4. Type Checking Only Imports
Use `TYPE_CHECKING` for type hints without runtime dependency:
```python
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.modules.marketplace.models import MarketplaceImportJob
def process_import(job: "MarketplaceImportJob") -> None:
# Works at runtime even if marketplace is disabled
...
```
### 5. Registry-Based Discovery
Discover modules through the registry, not imports:
```python
# GOOD: Discovery through registry
from app.modules.registry import MODULES
def get_module_if_enabled(db, platform_id, module_code):
module = MODULES.get(module_code)
if module and module_service.is_module_enabled(db, platform_id, module_code):
return module
return None
```
## Architecture Validation
The architecture validator (`scripts/validate_architecture.py`) includes rules to detect violations:
| Rule | Severity | Description |
|------|----------|-------------|
| IMPORT-001 | ERROR | Core module imports from optional module |
| IMPORT-002 | WARNING | Optional module imports from unrelated optional module |
| IMPORT-003 | INFO | Consider using protocol pattern instead of direct import |
Run validation:
```bash
python scripts/validate_architecture.py
```
## Provider Pattern Summary
### When to Use Each Pattern
| Need | Pattern | Location |
|------|---------|----------|
| Numeric statistics | MetricsProvider | `contracts/metrics.py` |
| Dashboard widgets | WidgetProvider | `contracts/widgets.py` |
| Page template context | Context Provider | `definition.py` |
| Service access | Protocol + DI | `contracts/{module}.py` |
### Provider Flow
```
┌─────────────────────────────────────────────────────────────────────┐
│ CONTRACTS LAYER │
│ (Shared protocol definitions) │
│ │
│ MetricsProviderProtocol DashboardWidgetProviderProtocol │
│ ContentServiceProtocol ServiceProtocol │
└─────────────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ CORE MODULE │ │ OPTIONAL MODULE 1│ │ OPTIONAL MODULE 2│
│ │ │ │ │ │
│ StatsAggregator │ │ OrderMetrics │ │ CatalogMetrics │
│ WidgetAggregator │ │ (implements) │ │ (implements) │
│ (discovers) │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ MODULE REGISTRY │
│ │
│ MODULES = { │
│ "orders": ModuleDefinition(metrics_provider=...), │
│ "catalog": ModuleDefinition(metrics_provider=...), │
│ } │
└──────────────────────────────────────────────────────────────┘
```
## Testing Without Dependencies
Because core doesn't depend on optional modules, you can test core in isolation:
```python
# tests/unit/core/test_stats_aggregator.py
from unittest.mock import MagicMock
from app.modules.contracts.metrics import MetricValue, MetricsProviderProtocol
class MockMetricsProvider:
"""Mock that implements the protocol without needing real modules."""
@property
def metrics_category(self) -> str:
return "test"
def get_vendor_metrics(self, db, vendor_id, context=None):
return [MetricValue(key="test.value", value=42, label="Test", category="test")]
def test_stats_aggregator_with_mock_provider():
# Test core without any optional modules
mock_provider = MockMetricsProvider()
...
```
## Consequences of Violations
### If Core Imports Optional Module
1. **Import Error**: App fails to start if module code has syntax errors
2. **Runtime Crash**: App crashes when disabled module is accessed
3. **Hidden Dependency**: Module appears optional but isn't
4. **Testing Difficulty**: Can't test core without all modules
5. **Deployment Issues**: Can't deploy minimal configurations
### Detection
Add these checks to CI:
```bash
# Check for forbidden imports
grep -r "from app.modules.marketplace" app/modules/core/ && exit 1
grep -r "from app.modules.analytics" app/modules/core/ && exit 1
grep -r "from app.modules.orders" app/modules/core/ && exit 1
```
## Summary
| Rule | Enforcement |
|------|-------------|
| Core → Optional = FORBIDDEN | Architecture validation, CI checks |
| Use Protocol pattern | Code review, documentation |
| Lazy factory functions | Required for definition.py |
| TYPE_CHECKING imports | Required for type hints across modules |
| Registry-based discovery | Required for all cross-module access |
Following these rules ensures:
- Modules can be truly enabled/disabled per platform
- Testing can be done in isolation
- New modules can be added without modifying core
- The app remains stable when modules fail
## Related Documentation
- [Module System Architecture](module-system.md) - Module structure and auto-discovery
- [Metrics Provider Pattern](metrics-provider-pattern.md) - Numeric statistics architecture
- [Widget Provider Pattern](widget-provider-pattern.md) - Dashboard widgets architecture
- [Architecture Violations Status](architecture-violations-status.md) - Current violation tracking

View File

@@ -25,12 +25,13 @@ The Wizamart platform uses a **plug-and-play modular architecture** where module
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CORE MODULES (Always Enabled) │ │
│ │ core tenancy cms customers │ │
│ │ contracts │ core tenancy cms customers │ billing │ │ │
│ │ payments │ messaging │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ OPTIONAL MODULES (Per-Platform) │ │
│ │ payments │ billing │ inventory │ ordersmarketplace │ ...│ │
│ │ analytics │ inventory │ catalogcart │ checkout │ ... │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
@@ -72,7 +73,7 @@ touch app/modules/mymodule/exceptions.py
## Three-Tier Classification
### Core Modules (5)
### Core Modules (8)
Core modules are **always enabled** and cannot be disabled. They provide fundamental platform functionality.
@@ -83,24 +84,29 @@ Core modules are **always enabled** and cannot be disabled. They provide fundame
| `cms` | Content pages, media library, themes | Content management | 5 |
| `customers` | Customer database, profiles, segmentation | Customer data management | 4 |
| `tenancy` | Platform, company, vendor, admin user management | Multi-tenant infrastructure | 4 |
| `billing` | Platform subscriptions, tier limits, vendor invoices | Subscription management, tier-based feature gating | 5 |
| `payments` | Payment gateway integrations (Stripe, PayPal, etc.) | Payment processing, required for billing | 3 |
| `messaging` | Messages, notifications, email templates | Email for registration, password reset, notifications | 3 |
### Optional Modules (11)
**Why these are core:**
- **billing**: Tier limits affect many features (team size, product limits, email providers). Subscription management is fundamental.
- **payments**: Required by billing for subscription payment processing.
- **messaging**: Email is required for user registration, password reset, and team invitations.
### Optional Modules (8)
Optional modules can be **enabled or disabled per platform**. They provide additional functionality that may not be needed by all platforms.
| Module | Dependencies | Description | Permissions |
|--------|--------------|-------------|-------------|
| `analytics` | - | Reports, dashboards | 3 |
| `billing` | `payments` | Platform subscriptions, vendor invoices | 5 |
| `analytics` | - | Reports, dashboards, advanced statistics | 3 |
| `cart` | `inventory` | Shopping cart management, session-based carts | 2 |
| `catalog` | `inventory` | Customer-facing product browsing | 6 |
| `checkout` | `cart`, `orders`, `payments`, `customers` | Cart-to-order conversion, checkout flow | 2 |
| `checkout` | `cart`, `orders`, `customers` | Cart-to-order conversion, checkout flow | 2 |
| `inventory` | - | Stock management, locations | 3 |
| `loyalty` | `customers` | Stamp/points loyalty programs, wallet integration | 4 |
| `marketplace` | `inventory` | Letzshop integration | 3 |
| `messaging` | - | Messages, notifications | 3 |
| `orders` | `payments` | Order management, customer checkout | 4 |
| `payments` | - | Payment gateway integrations (Stripe, PayPal, etc.) | 3 |
| `marketplace` | `inventory` | Letzshop integration, product import/export | 3 |
| `orders` | - | Order management, customer checkout | 4 |
### Internal Modules (2)
@@ -243,6 +249,7 @@ analytics_module = ModuleDefinition(
| `is_internal` | `bool` | Admin-only if True |
| `is_self_contained` | `bool` | Uses self-contained structure |
| `metrics_provider` | `Callable` | Factory function returning MetricsProviderProtocol (see [Metrics Provider Pattern](metrics-provider-pattern.md)) |
| `widget_provider` | `Callable` | Factory function returning DashboardWidgetProviderProtocol (see [Widget Provider Pattern](widget-provider-pattern.md)) |
## Route Auto-Discovery
@@ -308,22 +315,27 @@ The Framework Layer provides infrastructure that modules depend on. These are **
Modules can depend on other modules. When enabling a module, its dependencies are automatically enabled.
```
payments
↙ ↘
billing orders ←──┐
inventory │ │
↓ cart │
marketplace ↘ │
checkout
CORE MODULES (always enabled):
┌─────────────────────────────────────────────────────────┐
contracts core tenancy cms customers │
billing ← payments messaging
└─────────────────────────────────────────────────────────┘
OPTIONAL MODULES (dependencies shown):
inventory
↙ ↓ ↘
catalog cart marketplace
checkout ← orders
```
**Dependency Rules:**
1. Core modules cannot depend on optional modules
1. **Core modules NEVER import from optional modules** (see [Cross-Module Import Rules](cross-module-import-rules.md))
2. Enabling a module auto-enables its dependencies
3. Disabling a module auto-disables modules that depend on it
4. Circular dependencies are not allowed
5. Use protocol patterns (Metrics/Widget Provider) for cross-module data
## Module Registry
@@ -347,7 +359,7 @@ billing = get_module("billing")
tier = get_module_tier("billing") # Returns "optional"
# Get all core module codes
core_codes = get_core_module_codes() # {"core", "tenancy", "cms", "customers"}
core_codes = get_core_module_codes() # {"contracts", "core", "tenancy", "cms", "customers", "billing", "payments", "messaging"}
```
## Module Service
@@ -1252,16 +1264,20 @@ python scripts/validate_architecture.py
### Don't
- Create circular dependencies
- Make core modules depend on optional modules
- **Make core modules import from optional modules** (use provider patterns instead)
- Put framework-level code in modules
- Skip migration naming conventions
- Forget `__init__.py` in tasks directory
- Manually register modules in registry.py (use auto-discovery)
- Import optional modules at the top of core module files
- Use direct imports when a protocol pattern exists
## Related Documentation
- [Creating Modules](../development/creating-modules.md) - Step-by-step guide
- [Cross-Module Import Rules](cross-module-import-rules.md) - Import constraints and patterns
- [Metrics Provider Pattern](metrics-provider-pattern.md) - Dashboard statistics architecture
- [Widget Provider Pattern](widget-provider-pattern.md) - Dashboard widgets architecture
- [Menu Management](menu-management.md) - Sidebar configuration
- [Observability](observability.md) - Health checks integration
- [Feature Gating](../implementation/feature-gating-system.md) - Tier-based access

View File

@@ -0,0 +1,449 @@
# 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