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}")
|
||||
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
|
||||
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__ = [
|
||||
"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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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__ = [
|
||||
# Stats responses
|
||||
"StatsResponse",
|
||||
@@ -240,4 +257,6 @@ __all__ = [
|
||||
"StoreRevenueStats",
|
||||
"StoreInfo",
|
||||
"StoreDashboardStatsResponse",
|
||||
# Merchant dashboard
|
||||
"MerchantDashboardStatsResponse",
|
||||
]
|
||||
|
||||
@@ -196,6 +196,68 @@ class StatsAggregatorService:
|
||||
categorized = self.get_store_dashboard_stats(db, store_id, platform_id, context)
|
||||
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(
|
||||
self,
|
||||
db: Session,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<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">
|
||||
@@ -36,14 +36,25 @@
|
||||
</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="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
||||
<span x-html="$icon('sparkles', 'w-6 h-6')"></span>
|
||||
<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('users', 'w-6 h-6')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Current Plan</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.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.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>
|
||||
@@ -106,7 +117,8 @@ function merchantDashboard() {
|
||||
stats: {
|
||||
active_subscriptions: '--',
|
||||
total_stores: '--',
|
||||
current_plan: '--'
|
||||
total_customers: '--',
|
||||
team_members: '--'
|
||||
},
|
||||
subscriptions: [],
|
||||
|
||||
@@ -124,15 +136,19 @@ function merchantDashboard() {
|
||||
|
||||
async loadDashboard() {
|
||||
try {
|
||||
const data = await apiClient.get('/merchants/billing/subscriptions');
|
||||
this.subscriptions = data.subscriptions || data.items || [];
|
||||
const [statsData, subsData] = await Promise.all([
|
||||
apiClient.get('/merchants/core/dashboard/stats'),
|
||||
apiClient.get('/merchants/billing/subscriptions'),
|
||||
]);
|
||||
|
||||
const active = this.subscriptions.filter(s => s.status === 'active' || s.status === 'trial');
|
||||
this.stats.active_subscriptions = active.length;
|
||||
this.stats.total_stores = this.subscriptions.length;
|
||||
this.stats.current_plan = active.length > 0
|
||||
? active[0].tier.charAt(0).toUpperCase() + active[0].tier.slice(1)
|
||||
: 'None';
|
||||
// Stats from the aggregated endpoint
|
||||
this.stats.active_subscriptions = statsData.active_subscriptions ?? 0;
|
||||
this.stats.total_stores = statsData.total_stores ?? 0;
|
||||
this.stats.total_customers = statsData.total_customers ?? 0;
|
||||
this.stats.team_members = statsData.team_members ?? 0;
|
||||
|
||||
// Subscriptions list for the overview table
|
||||
this.subscriptions = subsData.subscriptions || subsData.items || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error);
|
||||
} finally {
|
||||
|
||||
@@ -196,6 +196,47 @@ class CustomerMetricsProvider:
|
||||
logger.warning(f"Failed to get customer platform metrics: {e}")
|
||||
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
|
||||
customer_metrics_provider = CustomerMetricsProvider()
|
||||
|
||||
@@ -394,6 +394,84 @@ class TenancyMetricsProvider:
|
||||
logger.warning(f"Failed to get tenancy platform metrics: {e}")
|
||||
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
|
||||
tenancy_metrics_provider = TenancyMetricsProvider()
|
||||
|
||||
Reference in New Issue
Block a user