feat: implement metrics provider pattern for modular dashboard statistics
This commit introduces a protocol-based metrics architecture that allows each module to provide its own statistics for dashboards without creating cross-module dependencies. Key changes: - Add MetricsProviderProtocol and MetricValue dataclass in contracts module - Add StatsAggregatorService in core module that discovers and aggregates metrics from all enabled modules - Implement metrics providers for all modules: - tenancy: vendor/user counts, team members, domains - customers: customer counts - cms: pages, media files - catalog: products - inventory: stock levels - orders: order counts, revenue - marketplace: import jobs, staging products - Update dashboard routes to use StatsAggregator instead of direct imports - Fix VendorPlatform junction table usage (Vendor.platform_id doesn't exist) - Add comprehensive documentation for the pattern This architecture ensures: - Dashboards always work (aggregator in core) - Each module owns its metrics (no cross-module coupling) - Optional modules are truly optional (can be removed without breaking app) - Multi-platform vendors are properly supported via VendorPlatform table Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,12 +21,34 @@ Usage:
|
||||
from app.modules.cms.services import content_page_service
|
||||
self._content = content_page_service
|
||||
return self._content
|
||||
|
||||
Metrics Provider Pattern:
|
||||
from app.modules.contracts.metrics import MetricsProviderProtocol, MetricValue
|
||||
|
||||
class OrderMetricsProvider:
|
||||
@property
|
||||
def metrics_category(self) -> str:
|
||||
return "orders"
|
||||
|
||||
def get_vendor_metrics(self, db, vendor_id, context=None) -> list[MetricValue]:
|
||||
return [MetricValue(key="orders.total", value=42, label="Total", category="orders")]
|
||||
"""
|
||||
|
||||
from app.modules.contracts.base import ServiceProtocol
|
||||
from app.modules.contracts.cms import ContentServiceProtocol
|
||||
from app.modules.contracts.metrics import (
|
||||
MetricValue,
|
||||
MetricsContext,
|
||||
MetricsProviderProtocol,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base protocols
|
||||
"ServiceProtocol",
|
||||
# CMS protocols
|
||||
"ContentServiceProtocol",
|
||||
# Metrics protocols
|
||||
"MetricValue",
|
||||
"MetricsContext",
|
||||
"MetricsProviderProtocol",
|
||||
]
|
||||
|
||||
215
app/modules/contracts/metrics.py
Normal file
215
app/modules/contracts/metrics.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# app/modules/contracts/metrics.py
|
||||
"""
|
||||
Metrics provider protocol for cross-module statistics aggregation.
|
||||
|
||||
This module defines the protocol that modules implement to expose their own metrics.
|
||||
The core module's StatsAggregator discovers and aggregates all providers.
|
||||
|
||||
Benefits:
|
||||
- Each module owns its metrics (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 metrics (just implement protocol in your module)
|
||||
|
||||
Usage:
|
||||
# 1. Implement the protocol in your module
|
||||
class OrderMetricsProvider:
|
||||
@property
|
||||
def metrics_category(self) -> str:
|
||||
return "orders"
|
||||
|
||||
def get_vendor_metrics(self, db, vendor_id, **kwargs) -> list[MetricValue]:
|
||||
return [
|
||||
MetricValue(key="orders.total", value=42, label="Total Orders", category="orders")
|
||||
]
|
||||
|
||||
# 2. Register in module definition
|
||||
def _get_metrics_provider():
|
||||
from app.modules.orders.services.order_metrics import order_metrics_provider
|
||||
return order_metrics_provider
|
||||
|
||||
orders_module = ModuleDefinition(
|
||||
code="orders",
|
||||
metrics_provider=_get_metrics_provider,
|
||||
# ...
|
||||
)
|
||||
|
||||
# 3. Metrics appear automatically in dashboards when module is enabled
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetricValue:
|
||||
"""
|
||||
Standard metric value with metadata.
|
||||
|
||||
This is the unit of data returned by metrics providers.
|
||||
It contains both the value and metadata for display.
|
||||
|
||||
Attributes:
|
||||
key: Unique identifier for this metric (e.g., "orders.total_count")
|
||||
Format: "{category}.{metric_name}" for consistency
|
||||
value: The actual metric value (int, float, or str)
|
||||
label: Human-readable label for display (e.g., "Total Orders")
|
||||
category: Grouping category (should match metrics_category of provider)
|
||||
icon: Optional Lucide icon name for UI display (e.g., "shopping-cart")
|
||||
description: Optional longer description of what this metric represents
|
||||
unit: Optional unit suffix (e.g., "EUR", "%", "items")
|
||||
trend: Optional trend indicator ("up", "down", "stable")
|
||||
trend_value: Optional numeric trend value (e.g., percentage change)
|
||||
|
||||
Example:
|
||||
MetricValue(
|
||||
key="orders.total_count",
|
||||
value=1234,
|
||||
label="Total Orders",
|
||||
category="orders",
|
||||
icon="shopping-cart",
|
||||
unit="orders",
|
||||
)
|
||||
"""
|
||||
|
||||
key: str
|
||||
value: int | float | str
|
||||
label: str
|
||||
category: str
|
||||
icon: str | None = None
|
||||
description: str | None = None
|
||||
unit: str | None = None
|
||||
trend: str | None = None # "up", "down", "stable"
|
||||
trend_value: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetricsContext:
|
||||
"""
|
||||
Context for metrics collection.
|
||||
|
||||
Provides filtering and scoping options for metrics providers.
|
||||
|
||||
Attributes:
|
||||
date_from: Start of date range filter
|
||||
date_to: End of date range filter
|
||||
include_trends: Whether to calculate trends (may be expensive)
|
||||
period: Time period for analytics (e.g., "7d", "30d", "90d", "1y")
|
||||
"""
|
||||
|
||||
date_from: datetime | None = None
|
||||
date_to: datetime | None = None
|
||||
include_trends: bool = False
|
||||
period: str = "30d"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MetricsProviderProtocol(Protocol):
|
||||
"""
|
||||
Protocol for modules that provide metrics/statistics.
|
||||
|
||||
Each module implements this to expose its own metrics.
|
||||
The core module's StatsAggregator discovers and aggregates all providers.
|
||||
|
||||
Implementation Notes:
|
||||
- Providers should be stateless (all data via db session)
|
||||
- Return empty list if no metrics available (don't raise)
|
||||
- Use consistent key format: "{category}.{metric_name}"
|
||||
- Include icon hints for UI rendering
|
||||
- Be mindful of query performance (use efficient aggregations)
|
||||
|
||||
Example Implementation:
|
||||
class OrderMetricsProvider:
|
||||
@property
|
||||
def metrics_category(self) -> str:
|
||||
return "orders"
|
||||
|
||||
def get_vendor_metrics(
|
||||
self, db: Session, vendor_id: int, context: MetricsContext | None = None
|
||||
) -> list[MetricValue]:
|
||||
from app.modules.orders.models import Order
|
||||
total = db.query(Order).filter(Order.vendor_id == vendor_id).count()
|
||||
return [
|
||||
MetricValue(
|
||||
key="orders.total",
|
||||
value=total,
|
||||
label="Total Orders",
|
||||
category="orders",
|
||||
icon="shopping-cart"
|
||||
)
|
||||
]
|
||||
|
||||
def get_platform_metrics(
|
||||
self, db: Session, platform_id: int, context: MetricsContext | None = None
|
||||
) -> list[MetricValue]:
|
||||
# Aggregate across all vendors in platform
|
||||
...
|
||||
"""
|
||||
|
||||
@property
|
||||
def metrics_category(self) -> str:
|
||||
"""
|
||||
Category name for this provider's metrics.
|
||||
|
||||
Should be a short, lowercase identifier matching the module's domain.
|
||||
Examples: "orders", "inventory", "customers", "billing"
|
||||
|
||||
Returns:
|
||||
Category string used for grouping metrics
|
||||
"""
|
||||
...
|
||||
|
||||
def get_vendor_metrics(
|
||||
self,
|
||||
db: "Session",
|
||||
vendor_id: int,
|
||||
context: MetricsContext | None = None,
|
||||
) -> list[MetricValue]:
|
||||
"""
|
||||
Get metrics for a specific vendor.
|
||||
|
||||
Called by the vendor dashboard to display vendor-scoped statistics.
|
||||
Should only include data belonging to the specified vendor.
|
||||
|
||||
Args:
|
||||
db: Database session for queries
|
||||
vendor_id: ID of the vendor to get metrics for
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
List of MetricValue objects for this vendor
|
||||
"""
|
||||
...
|
||||
|
||||
def get_platform_metrics(
|
||||
self,
|
||||
db: "Session",
|
||||
platform_id: int,
|
||||
context: MetricsContext | None = None,
|
||||
) -> list[MetricValue]:
|
||||
"""
|
||||
Get metrics aggregated for a platform.
|
||||
|
||||
Called by the admin dashboard to display platform-wide statistics.
|
||||
Should aggregate data across all vendors in the platform.
|
||||
|
||||
Args:
|
||||
db: Database session for queries
|
||||
platform_id: ID of the platform to get metrics for
|
||||
context: Optional filtering/scoping context
|
||||
|
||||
Returns:
|
||||
List of MetricValue objects aggregated for the platform
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MetricValue",
|
||||
"MetricsContext",
|
||||
"MetricsProviderProtocol",
|
||||
]
|
||||
Reference in New Issue
Block a user