fix: use metrics provider pattern for merchant dashboard stats
The merchant dashboard was showing subscription count as "Total Stores". Add get_merchant_metrics() to MetricsProviderProtocol and implement it in tenancy, billing, and customer providers. Dashboard now fetches real stats from a new /merchants/core/dashboard/stats endpoint and displays 4 cards: active subscriptions, total stores, customers, team members. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -108,6 +108,45 @@ class BillingMetricsProvider:
|
|||||||
logger.warning(f"Failed to get billing platform metrics: {e}")
|
logger.warning(f"Failed to get billing platform metrics: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_merchant_metrics(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
context: MetricsContext | None = None,
|
||||||
|
) -> list[MetricValue]:
|
||||||
|
"""
|
||||||
|
Get subscription metrics for a specific merchant.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Active subscriptions (active + trial)
|
||||||
|
"""
|
||||||
|
from app.modules.billing.models import MerchantSubscription
|
||||||
|
|
||||||
|
try:
|
||||||
|
active_subs = (
|
||||||
|
db.query(func.count(MerchantSubscription.id))
|
||||||
|
.filter(
|
||||||
|
MerchantSubscription.merchant_id == merchant_id,
|
||||||
|
MerchantSubscription.status.in_(["active", "trial"]),
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
MetricValue(
|
||||||
|
key="billing.active_subscriptions",
|
||||||
|
value=active_subs,
|
||||||
|
label="Active Subscriptions",
|
||||||
|
category="billing",
|
||||||
|
icon="clipboard-list",
|
||||||
|
description="Active or trial subscriptions for this merchant",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get billing merchant metrics: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
billing_metrics_provider = BillingMetricsProvider()
|
billing_metrics_provider = BillingMetricsProvider()
|
||||||
|
|||||||
@@ -207,6 +207,28 @@ class MetricsProviderProtocol(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def get_merchant_metrics(
|
||||||
|
self,
|
||||||
|
db: "Session",
|
||||||
|
merchant_id: int,
|
||||||
|
context: MetricsContext | None = None,
|
||||||
|
) -> list[MetricValue]:
|
||||||
|
"""
|
||||||
|
Get metrics scoped to a specific merchant.
|
||||||
|
|
||||||
|
Called by the merchant dashboard to display merchant-scoped statistics.
|
||||||
|
Should only include data belonging to the specified merchant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session for queries
|
||||||
|
merchant_id: ID of the merchant to get metrics for
|
||||||
|
context: Optional filtering/scoping context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of MetricValue objects for this merchant
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"MetricValue",
|
"MetricValue",
|
||||||
|
|||||||
22
app/modules/core/routes/api/merchant.py
Normal file
22
app/modules/core/routes/api/merchant.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# app/modules/core/routes/api/merchant.py
|
||||||
|
"""
|
||||||
|
Core module merchant API routes.
|
||||||
|
|
||||||
|
Auto-discovered by the route system (merchant.py in routes/api/ triggers
|
||||||
|
registration under /api/v1/merchants/core/*).
|
||||||
|
|
||||||
|
Aggregates:
|
||||||
|
- /dashboard/* - Merchant dashboard statistics
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .merchant_dashboard import merchant_dashboard_router
|
||||||
|
|
||||||
|
ROUTE_CONFIG = {
|
||||||
|
"prefix": "/core",
|
||||||
|
}
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
router.include_router(merchant_dashboard_router, tags=["merchant-dashboard"])
|
||||||
58
app/modules/core/routes/api/merchant_dashboard.py
Normal file
58
app/modules/core/routes/api/merchant_dashboard.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# app/modules/core/routes/api/merchant_dashboard.py
|
||||||
|
"""
|
||||||
|
Merchant dashboard statistics endpoint.
|
||||||
|
|
||||||
|
Merchant Context: Uses get_merchant_for_current_user to resolve the merchant
|
||||||
|
from the JWT token. The dependency guarantees an active merchant exists.
|
||||||
|
|
||||||
|
This module uses the StatsAggregator service from core to collect metrics from all
|
||||||
|
enabled modules via the MetricsProvider protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_merchant_for_current_user
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.core.schemas.dashboard import MerchantDashboardStatsResponse
|
||||||
|
from app.modules.core.services.stats_aggregator import stats_aggregator
|
||||||
|
|
||||||
|
merchant_dashboard_router = APIRouter(prefix="/dashboard")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@merchant_dashboard_router.get("/stats", response_model=MerchantDashboardStatsResponse)
|
||||||
|
def get_merchant_dashboard_stats(
|
||||||
|
request: Request,
|
||||||
|
merchant=Depends(get_merchant_for_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get merchant-scoped dashboard statistics.
|
||||||
|
|
||||||
|
Returns aggregated statistics for the current merchant:
|
||||||
|
- Active subscriptions
|
||||||
|
- Total stores
|
||||||
|
- Total customers across all stores
|
||||||
|
- Team members across all stores
|
||||||
|
|
||||||
|
Merchant is resolved from the JWT token.
|
||||||
|
Requires Authorization header (API endpoint).
|
||||||
|
"""
|
||||||
|
platform = getattr(request.state, "platform", None)
|
||||||
|
platform_id = platform.id if platform else 1
|
||||||
|
|
||||||
|
flat = stats_aggregator.get_merchant_stats_flat(
|
||||||
|
db=db,
|
||||||
|
merchant_id=merchant.id,
|
||||||
|
platform_id=platform_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MerchantDashboardStatsResponse(
|
||||||
|
active_subscriptions=int(flat.get("billing.active_subscriptions", 0)),
|
||||||
|
total_stores=int(flat.get("tenancy.total_stores", 0)),
|
||||||
|
total_customers=int(flat.get("customers.total", 0)),
|
||||||
|
team_members=int(flat.get("tenancy.team_members", 0)),
|
||||||
|
)
|
||||||
@@ -221,6 +221,23 @@ class StoreDashboardStatsResponse(BaseModel):
|
|||||||
revenue: StoreRevenueStats
|
revenue: StoreRevenueStats
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Merchant Dashboard Statistics
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantDashboardStatsResponse(BaseModel):
|
||||||
|
"""Merchant dashboard statistics response schema.
|
||||||
|
|
||||||
|
Used by: GET /api/v1/merchants/core/dashboard/stats
|
||||||
|
"""
|
||||||
|
|
||||||
|
active_subscriptions: int = Field(0, description="Active or trial subscriptions")
|
||||||
|
total_stores: int = Field(0, description="Total stores owned by this merchant")
|
||||||
|
total_customers: int = Field(0, description="Total customers across all stores")
|
||||||
|
team_members: int = Field(0, description="Distinct active team members across stores")
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Stats responses
|
# Stats responses
|
||||||
"StatsResponse",
|
"StatsResponse",
|
||||||
@@ -240,4 +257,6 @@ __all__ = [
|
|||||||
"StoreRevenueStats",
|
"StoreRevenueStats",
|
||||||
"StoreInfo",
|
"StoreInfo",
|
||||||
"StoreDashboardStatsResponse",
|
"StoreDashboardStatsResponse",
|
||||||
|
# Merchant dashboard
|
||||||
|
"MerchantDashboardStatsResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -196,6 +196,68 @@ class StatsAggregatorService:
|
|||||||
categorized = self.get_store_dashboard_stats(db, store_id, platform_id, context)
|
categorized = self.get_store_dashboard_stats(db, store_id, platform_id, context)
|
||||||
return self._flatten_metrics(categorized)
|
return self._flatten_metrics(categorized)
|
||||||
|
|
||||||
|
def get_merchant_dashboard_stats(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
platform_id: int,
|
||||||
|
context: MetricsContext | None = None,
|
||||||
|
) -> dict[str, list[MetricValue]]:
|
||||||
|
"""
|
||||||
|
Get all metrics for a merchant, grouped by category.
|
||||||
|
|
||||||
|
Called by the merchant dashboard to display merchant-scoped statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
merchant_id: ID of the merchant to get metrics for
|
||||||
|
platform_id: Platform ID (for module enablement check)
|
||||||
|
context: Optional filtering/scoping context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping category name to list of MetricValue objects
|
||||||
|
"""
|
||||||
|
providers = self._get_enabled_providers(db, platform_id)
|
||||||
|
result: dict[str, list[MetricValue]] = {}
|
||||||
|
|
||||||
|
for module, provider in providers:
|
||||||
|
if not hasattr(provider, "get_merchant_metrics"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
metrics = provider.get_merchant_metrics(db, merchant_id, context)
|
||||||
|
if metrics:
|
||||||
|
result[provider.metrics_category] = metrics
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to get merchant metrics from module {module.code}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_merchant_stats_flat(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
platform_id: int,
|
||||||
|
context: MetricsContext | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get merchant metrics as a flat dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
merchant_id: ID of the merchant to get metrics for
|
||||||
|
platform_id: Platform ID (for module enablement check)
|
||||||
|
context: Optional filtering/scoping context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Flat dict mapping metric keys to values
|
||||||
|
"""
|
||||||
|
categorized = self.get_merchant_dashboard_stats(
|
||||||
|
db, merchant_id, platform_id, context
|
||||||
|
)
|
||||||
|
return self._flatten_metrics(categorized)
|
||||||
|
|
||||||
def get_admin_stats_flat(
|
def get_admin_stats_flat(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
<!-- Quick Stats -->
|
||||||
<div class="grid gap-6 mb-8 md:grid-cols-3">
|
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||||
<!-- Active Subscriptions -->
|
<!-- Active Subscriptions -->
|
||||||
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
||||||
@@ -36,14 +36,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Plan -->
|
<!-- Customers -->
|
||||||
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
<div class="p-3 mr-4 text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/30 rounded-full">
|
||||||
<span x-html="$icon('sparkles', 'w-6 h-6')"></span>
|
<span x-html="$icon('users', 'w-6 h-6')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Current Plan</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Customers</p>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.current_plan || '--'">--</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.total_customers">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Members -->
|
||||||
|
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-3 mr-4 text-orange-600 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/30 rounded-full">
|
||||||
|
<span x-html="$icon('user-group', 'w-6 h-6')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Team Members</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.team_members">--</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +117,8 @@ function merchantDashboard() {
|
|||||||
stats: {
|
stats: {
|
||||||
active_subscriptions: '--',
|
active_subscriptions: '--',
|
||||||
total_stores: '--',
|
total_stores: '--',
|
||||||
current_plan: '--'
|
total_customers: '--',
|
||||||
|
team_members: '--'
|
||||||
},
|
},
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
|
|
||||||
@@ -124,15 +136,19 @@ function merchantDashboard() {
|
|||||||
|
|
||||||
async loadDashboard() {
|
async loadDashboard() {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get('/merchants/billing/subscriptions');
|
const [statsData, subsData] = await Promise.all([
|
||||||
this.subscriptions = data.subscriptions || data.items || [];
|
apiClient.get('/merchants/core/dashboard/stats'),
|
||||||
|
apiClient.get('/merchants/billing/subscriptions'),
|
||||||
|
]);
|
||||||
|
|
||||||
const active = this.subscriptions.filter(s => s.status === 'active' || s.status === 'trial');
|
// Stats from the aggregated endpoint
|
||||||
this.stats.active_subscriptions = active.length;
|
this.stats.active_subscriptions = statsData.active_subscriptions ?? 0;
|
||||||
this.stats.total_stores = this.subscriptions.length;
|
this.stats.total_stores = statsData.total_stores ?? 0;
|
||||||
this.stats.current_plan = active.length > 0
|
this.stats.total_customers = statsData.total_customers ?? 0;
|
||||||
? active[0].tier.charAt(0).toUpperCase() + active[0].tier.slice(1)
|
this.stats.team_members = statsData.team_members ?? 0;
|
||||||
: 'None';
|
|
||||||
|
// Subscriptions list for the overview table
|
||||||
|
this.subscriptions = subsData.subscriptions || subsData.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard:', error);
|
console.error('Failed to load dashboard:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -196,6 +196,47 @@ class CustomerMetricsProvider:
|
|||||||
logger.warning(f"Failed to get customer platform metrics: {e}")
|
logger.warning(f"Failed to get customer platform metrics: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_merchant_metrics(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
context: MetricsContext | None = None,
|
||||||
|
) -> list[MetricValue]:
|
||||||
|
"""
|
||||||
|
Get customer metrics scoped to a merchant.
|
||||||
|
|
||||||
|
Aggregates customer counts across all stores owned by the merchant.
|
||||||
|
"""
|
||||||
|
from app.modules.customers.models import Customer
|
||||||
|
from app.modules.tenancy.models import Store
|
||||||
|
|
||||||
|
try:
|
||||||
|
merchant_store_ids = (
|
||||||
|
db.query(Store.id)
|
||||||
|
.filter(Store.merchant_id == merchant_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
total_customers = (
|
||||||
|
db.query(Customer)
|
||||||
|
.filter(Customer.store_id.in_(merchant_store_ids))
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
MetricValue(
|
||||||
|
key="customers.total",
|
||||||
|
value=total_customers,
|
||||||
|
label="Total Customers",
|
||||||
|
category="customers",
|
||||||
|
icon="users",
|
||||||
|
description="Total customers across all merchant stores",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get customer merchant metrics: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
customer_metrics_provider = CustomerMetricsProvider()
|
customer_metrics_provider = CustomerMetricsProvider()
|
||||||
|
|||||||
@@ -394,6 +394,84 @@ class TenancyMetricsProvider:
|
|||||||
logger.warning(f"Failed to get tenancy platform metrics: {e}")
|
logger.warning(f"Failed to get tenancy platform metrics: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_merchant_metrics(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
context: MetricsContext | None = None,
|
||||||
|
) -> list[MetricValue]:
|
||||||
|
"""
|
||||||
|
Get metrics scoped to a specific merchant.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Total stores owned by this merchant
|
||||||
|
- Active stores
|
||||||
|
- Distinct active team members across all merchant stores
|
||||||
|
"""
|
||||||
|
from app.modules.tenancy.models import Store, StoreUser
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_stores = (
|
||||||
|
db.query(Store)
|
||||||
|
.filter(Store.merchant_id == merchant_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
active_stores = (
|
||||||
|
db.query(Store)
|
||||||
|
.filter(
|
||||||
|
Store.merchant_id == merchant_id,
|
||||||
|
Store.is_active == True,
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Distinct active team members across all merchant stores
|
||||||
|
merchant_store_ids = (
|
||||||
|
db.query(Store.id)
|
||||||
|
.filter(Store.merchant_id == merchant_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
team_members = (
|
||||||
|
db.query(func.count(func.distinct(StoreUser.user_id)))
|
||||||
|
.filter(
|
||||||
|
StoreUser.store_id.in_(merchant_store_ids),
|
||||||
|
StoreUser.is_active == True,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
MetricValue(
|
||||||
|
key="tenancy.total_stores",
|
||||||
|
value=total_stores,
|
||||||
|
label="Total Stores",
|
||||||
|
category="tenancy",
|
||||||
|
icon="shopping-bag",
|
||||||
|
description="Total stores owned by this merchant",
|
||||||
|
),
|
||||||
|
MetricValue(
|
||||||
|
key="tenancy.active_stores",
|
||||||
|
value=active_stores,
|
||||||
|
label="Active Stores",
|
||||||
|
category="tenancy",
|
||||||
|
icon="check-circle",
|
||||||
|
description="Active stores owned by this merchant",
|
||||||
|
),
|
||||||
|
MetricValue(
|
||||||
|
key="tenancy.team_members",
|
||||||
|
value=team_members,
|
||||||
|
label="Team Members",
|
||||||
|
category="tenancy",
|
||||||
|
icon="users",
|
||||||
|
description="Distinct active team members across all stores",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get tenancy merchant metrics: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
tenancy_metrics_provider = TenancyMetricsProvider()
|
tenancy_metrics_provider = TenancyMetricsProvider()
|
||||||
|
|||||||
Reference in New Issue
Block a user