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:
@@ -45,6 +45,7 @@ if TYPE_CHECKING:
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.modules.contracts.metrics import MetricsProviderProtocol
|
from app.modules.contracts.metrics import MetricsProviderProtocol
|
||||||
|
from app.modules.contracts.widgets import DashboardWidgetProviderProtocol
|
||||||
|
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
|
|
||||||
@@ -413,6 +414,26 @@ class ModuleDefinition:
|
|||||||
# The provider will be discovered by core's StatsAggregator service.
|
# The provider will be discovered by core's StatsAggregator service.
|
||||||
metrics_provider: "Callable[[], MetricsProviderProtocol] | None" = None
|
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)
|
# Menu Item Methods (Legacy - uses menu_items dict of IDs)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -799,6 +820,28 @@ class ModuleDefinition:
|
|||||||
return None
|
return None
|
||||||
return self.metrics_provider()
|
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
|
# Magic Methods
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ Metrics Provider Pattern:
|
|||||||
|
|
||||||
def get_vendor_metrics(self, db, vendor_id, context=None) -> list[MetricValue]:
|
def get_vendor_metrics(self, db, vendor_id, context=None) -> list[MetricValue]:
|
||||||
return [MetricValue(key="orders.total", value=42, label="Total", category="orders")]
|
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
|
from app.modules.contracts.base import ServiceProtocol
|
||||||
@@ -41,6 +52,15 @@ from app.modules.contracts.metrics import (
|
|||||||
MetricsContext,
|
MetricsContext,
|
||||||
MetricsProviderProtocol,
|
MetricsProviderProtocol,
|
||||||
)
|
)
|
||||||
|
from app.modules.contracts.widgets import (
|
||||||
|
BreakdownWidget,
|
||||||
|
DashboardWidget,
|
||||||
|
DashboardWidgetProviderProtocol,
|
||||||
|
ListWidget,
|
||||||
|
WidgetBreakdownItem,
|
||||||
|
WidgetContext,
|
||||||
|
WidgetListItem,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Base protocols
|
# Base protocols
|
||||||
@@ -51,4 +71,12 @@ __all__ = [
|
|||||||
"MetricValue",
|
"MetricValue",
|
||||||
"MetricsContext",
|
"MetricsContext",
|
||||||
"MetricsProviderProtocol",
|
"MetricsProviderProtocol",
|
||||||
|
# Widget protocols
|
||||||
|
"WidgetContext",
|
||||||
|
"WidgetListItem",
|
||||||
|
"WidgetBreakdownItem",
|
||||||
|
"ListWidget",
|
||||||
|
"BreakdownWidget",
|
||||||
|
"DashboardWidget",
|
||||||
|
"DashboardWidgetProviderProtocol",
|
||||||
]
|
]
|
||||||
|
|||||||
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",
|
||||||
|
]
|
||||||
@@ -5,17 +5,22 @@ Admin dashboard and statistics endpoints.
|
|||||||
This module uses the StatsAggregator service from core to collect metrics from all
|
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.
|
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 backward compatibility, this also falls back to the analytics stats_service
|
||||||
for comprehensive statistics that haven't been migrated to the provider pattern yet.
|
for comprehensive statistics that haven't been migrated to the provider pattern yet.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_admin_api
|
from app.api.deps import get_current_admin_api
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.modules.contracts.widgets import ListWidget
|
||||||
from app.modules.core.schemas.dashboard import (
|
from app.modules.core.schemas.dashboard import (
|
||||||
AdminDashboardResponse,
|
AdminDashboardResponse,
|
||||||
ImportStatsResponse,
|
ImportStatsResponse,
|
||||||
@@ -28,7 +33,7 @@ from app.modules.core.schemas.dashboard import (
|
|||||||
VendorStatsResponse,
|
VendorStatsResponse,
|
||||||
)
|
)
|
||||||
from app.modules.core.services.stats_aggregator import stats_aggregator
|
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
|
from models.schema.auth import UserContext
|
||||||
|
|
||||||
admin_dashboard_router = APIRouter(prefix="/dashboard")
|
admin_dashboard_router = APIRouter(prefix="/dashboard")
|
||||||
@@ -77,6 +82,31 @@ def _extract_metric_value(
|
|||||||
return default
|
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)
|
@admin_dashboard_router.get("", response_model=AdminDashboardResponse)
|
||||||
def get_admin_dashboard(
|
def get_admin_dashboard(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -89,6 +119,9 @@ def get_admin_dashboard(
|
|||||||
# Get aggregated metrics from all enabled modules
|
# Get aggregated metrics from all enabled modules
|
||||||
metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id)
|
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
|
# Extract user stats from tenancy module
|
||||||
total_users = _extract_metric_value(metrics, "tenancy", "tenancy.total_users", 0)
|
total_users = _extract_metric_value(metrics, "tenancy", "tenancy.total_users", 0)
|
||||||
active_users = _extract_metric_value(metrics, "tenancy", "tenancy.active_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
|
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(
|
return AdminDashboardResponse(
|
||||||
platform={
|
platform={
|
||||||
"name": "Multi-Tenant Ecommerce Platform",
|
"name": "Multi-Tenant Ecommerce Platform",
|
||||||
@@ -128,8 +167,8 @@ def get_admin_dashboard(
|
|||||||
pending=int(pending_vendors),
|
pending=int(pending_vendors),
|
||||||
inactive=int(inactive_vendors),
|
inactive=int(inactive_vendors),
|
||||||
),
|
),
|
||||||
recent_vendors=admin_service.get_recent_vendors(db, limit=5),
|
recent_vendors=recent_vendors,
|
||||||
recent_imports=admin_service.get_recent_import_jobs(db, limit=10),
|
recent_imports=recent_imports,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ from app.modules.core.services.storage_service import (
|
|||||||
get_storage_backend,
|
get_storage_backend,
|
||||||
reset_storage_backend,
|
reset_storage_backend,
|
||||||
)
|
)
|
||||||
|
from app.modules.core.services.widget_aggregator import (
|
||||||
|
WidgetAggregatorService,
|
||||||
|
widget_aggregator,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Auth
|
# Auth
|
||||||
@@ -71,4 +75,7 @@ __all__ = [
|
|||||||
# Stats aggregator
|
# Stats aggregator
|
||||||
"StatsAggregatorService",
|
"StatsAggregatorService",
|
||||||
"stats_aggregator",
|
"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"]
|
||||||
@@ -33,6 +33,13 @@ def _get_metrics_provider():
|
|||||||
return marketplace_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 definition
|
||||||
marketplace_module = ModuleDefinition(
|
marketplace_module = ModuleDefinition(
|
||||||
code="marketplace",
|
code="marketplace",
|
||||||
@@ -155,6 +162,8 @@ marketplace_module = ModuleDefinition(
|
|||||||
],
|
],
|
||||||
# Metrics provider for dashboard statistics
|
# Metrics provider for dashboard statistics
|
||||||
metrics_provider=_get_metrics_provider,
|
metrics_provider=_get_metrics_provider,
|
||||||
|
# Widget provider for dashboard widgets
|
||||||
|
widget_provider=_get_widget_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
211
app/modules/marketplace/services/marketplace_widgets.py
Normal file
211
app/modules/marketplace/services/marketplace_widgets.py
Normal 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"]
|
||||||
@@ -22,6 +22,13 @@ def _get_metrics_provider():
|
|||||||
return tenancy_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(
|
tenancy_module = ModuleDefinition(
|
||||||
code="tenancy",
|
code="tenancy",
|
||||||
name="Tenancy Management",
|
name="Tenancy Management",
|
||||||
@@ -161,6 +168,8 @@ tenancy_module = ModuleDefinition(
|
|||||||
exceptions_path="app.modules.tenancy.exceptions",
|
exceptions_path="app.modules.tenancy.exceptions",
|
||||||
# Metrics provider for dashboard statistics
|
# Metrics provider for dashboard statistics
|
||||||
metrics_provider=_get_metrics_provider,
|
metrics_provider=_get_metrics_provider,
|
||||||
|
# Widget provider for dashboard widgets
|
||||||
|
widget_provider=_get_widget_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["tenancy_module"]
|
__all__ = ["tenancy_module"]
|
||||||
|
|||||||
156
app/modules/tenancy/services/tenancy_widgets.py
Normal file
156
app/modules/tenancy/services/tenancy_widgets.py
Normal 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"]
|
||||||
310
docs/architecture/cross-module-import-rules.md
Normal file
310
docs/architecture/cross-module-import-rules.md
Normal 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
|
||||||
@@ -25,12 +25,13 @@ The Wizamart platform uses a **plug-and-play modular architecture** where module
|
|||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ CORE MODULES (Always Enabled) │ │
|
│ │ CORE MODULES (Always Enabled) │ │
|
||||||
│ │ core │ tenancy │ cms │ customers │ │
|
│ │ contracts │ core │ tenancy │ cms │ customers │ billing │ │ │
|
||||||
|
│ │ payments │ messaging │ │
|
||||||
│ └─────────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
│ │ OPTIONAL MODULES (Per-Platform) │ │
|
│ │ OPTIONAL MODULES (Per-Platform) │ │
|
||||||
│ │ payments │ billing │ inventory │ orders │ marketplace │ ...│ │
|
│ │ analytics │ inventory │ catalog │ cart │ checkout │ ... │ │
|
||||||
│ └─────────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
@@ -72,7 +73,7 @@ touch app/modules/mymodule/exceptions.py
|
|||||||
|
|
||||||
## Three-Tier Classification
|
## Three-Tier Classification
|
||||||
|
|
||||||
### Core Modules (5)
|
### Core Modules (8)
|
||||||
|
|
||||||
Core modules are **always enabled** and cannot be disabled. They provide fundamental platform functionality.
|
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 |
|
| `cms` | Content pages, media library, themes | Content management | 5 |
|
||||||
| `customers` | Customer database, profiles, segmentation | Customer data management | 4 |
|
| `customers` | Customer database, profiles, segmentation | Customer data management | 4 |
|
||||||
| `tenancy` | Platform, company, vendor, admin user management | Multi-tenant infrastructure | 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.
|
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 |
|
| Module | Dependencies | Description | Permissions |
|
||||||
|--------|--------------|-------------|-------------|
|
|--------|--------------|-------------|-------------|
|
||||||
| `analytics` | - | Reports, dashboards | 3 |
|
| `analytics` | - | Reports, dashboards, advanced statistics | 3 |
|
||||||
| `billing` | `payments` | Platform subscriptions, vendor invoices | 5 |
|
|
||||||
| `cart` | `inventory` | Shopping cart management, session-based carts | 2 |
|
| `cart` | `inventory` | Shopping cart management, session-based carts | 2 |
|
||||||
| `catalog` | `inventory` | Customer-facing product browsing | 6 |
|
| `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 |
|
| `inventory` | - | Stock management, locations | 3 |
|
||||||
| `loyalty` | `customers` | Stamp/points loyalty programs, wallet integration | 4 |
|
| `loyalty` | `customers` | Stamp/points loyalty programs, wallet integration | 4 |
|
||||||
| `marketplace` | `inventory` | Letzshop integration | 3 |
|
| `marketplace` | `inventory` | Letzshop integration, product import/export | 3 |
|
||||||
| `messaging` | - | Messages, notifications | 3 |
|
| `orders` | - | Order management, customer checkout | 4 |
|
||||||
| `orders` | `payments` | Order management, customer checkout | 4 |
|
|
||||||
| `payments` | - | Payment gateway integrations (Stripe, PayPal, etc.) | 3 |
|
|
||||||
|
|
||||||
### Internal Modules (2)
|
### Internal Modules (2)
|
||||||
|
|
||||||
@@ -243,6 +249,7 @@ analytics_module = ModuleDefinition(
|
|||||||
| `is_internal` | `bool` | Admin-only if True |
|
| `is_internal` | `bool` | Admin-only if True |
|
||||||
| `is_self_contained` | `bool` | Uses self-contained structure |
|
| `is_self_contained` | `bool` | Uses self-contained structure |
|
||||||
| `metrics_provider` | `Callable` | Factory function returning MetricsProviderProtocol (see [Metrics Provider Pattern](metrics-provider-pattern.md)) |
|
| `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
|
## 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.
|
Modules can depend on other modules. When enabling a module, its dependencies are automatically enabled.
|
||||||
|
|
||||||
```
|
```
|
||||||
payments
|
CORE MODULES (always enabled):
|
||||||
↙ ↘
|
┌─────────────────────────────────────────────────────────┐
|
||||||
billing orders ←──┐
|
│ contracts core tenancy cms customers │
|
||||||
↑ │
|
│ billing ← payments messaging │
|
||||||
inventory │ │
|
└─────────────────────────────────────────────────────────┘
|
||||||
↓ cart │
|
|
||||||
marketplace ↘ │
|
OPTIONAL MODULES (dependencies shown):
|
||||||
checkout
|
inventory
|
||||||
|
↙ ↓ ↘
|
||||||
|
catalog cart marketplace
|
||||||
|
↓
|
||||||
|
checkout ← orders
|
||||||
```
|
```
|
||||||
|
|
||||||
**Dependency Rules:**
|
**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
|
2. Enabling a module auto-enables its dependencies
|
||||||
3. Disabling a module auto-disables modules that depend on it
|
3. Disabling a module auto-disables modules that depend on it
|
||||||
4. Circular dependencies are not allowed
|
4. Circular dependencies are not allowed
|
||||||
|
5. Use protocol patterns (Metrics/Widget Provider) for cross-module data
|
||||||
|
|
||||||
## Module Registry
|
## Module Registry
|
||||||
|
|
||||||
@@ -347,7 +359,7 @@ billing = get_module("billing")
|
|||||||
tier = get_module_tier("billing") # Returns "optional"
|
tier = get_module_tier("billing") # Returns "optional"
|
||||||
|
|
||||||
# Get all core module codes
|
# 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
|
## Module Service
|
||||||
@@ -1252,16 +1264,20 @@ python scripts/validate_architecture.py
|
|||||||
### Don't
|
### Don't
|
||||||
|
|
||||||
- Create circular dependencies
|
- 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
|
- Put framework-level code in modules
|
||||||
- Skip migration naming conventions
|
- Skip migration naming conventions
|
||||||
- Forget `__init__.py` in tasks directory
|
- Forget `__init__.py` in tasks directory
|
||||||
- Manually register modules in registry.py (use auto-discovery)
|
- 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
|
## Related Documentation
|
||||||
|
|
||||||
- [Creating Modules](../development/creating-modules.md) - Step-by-step guide
|
- [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
|
- [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
|
- [Menu Management](menu-management.md) - Sidebar configuration
|
||||||
- [Observability](observability.md) - Health checks integration
|
- [Observability](observability.md) - Health checks integration
|
||||||
- [Feature Gating](../implementation/feature-gating-system.md) - Tier-based access
|
- [Feature Gating](../implementation/feature-gating-system.md) - Tier-based access
|
||||||
|
|||||||
449
docs/architecture/widget-provider-pattern.md
Normal file
449
docs/architecture/widget-provider-pattern.md
Normal 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
|
||||||
Reference in New Issue
Block a user