Compare commits
10 Commits
b9ac252a9f
...
506171503d
| Author | SHA1 | Date | |
|---|---|---|---|
| 506171503d | |||
| be248222bc | |||
| 716a4e3d15 | |||
| 467b1510f4 | |||
| 5c8fbd21c7 | |||
| 1f3042547b | |||
| d7a383f3d7 | |||
| b77952bf89 | |||
| ff852f1ab3 | |||
| 42b894094a |
@@ -165,6 +165,10 @@ billing_module = ModuleDefinition(
|
||||
"billing", # Store billing dashboard
|
||||
"invoices", # Store invoice history
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
"subscriptions", # Merchant subscriptions
|
||||
"invoices", # Merchant billing history
|
||||
],
|
||||
},
|
||||
# New module-driven menu definitions
|
||||
menus={
|
||||
@@ -199,6 +203,31 @@ billing_module = ModuleDefinition(
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
MenuSectionDefinition(
|
||||
id="billing",
|
||||
label_key="billing.menu.billing_subscriptions",
|
||||
icon="credit-card",
|
||||
order=50,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="subscriptions",
|
||||
label_key="billing.menu.subscriptions",
|
||||
icon="clipboard-list",
|
||||
route="/merchants/billing/subscriptions",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="invoices",
|
||||
label_key="billing.menu.billing_history",
|
||||
icon="currency-euro",
|
||||
route="/merchants/billing/invoices",
|
||||
order=20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.STORE: [
|
||||
MenuSectionDefinition(
|
||||
id="sales",
|
||||
|
||||
@@ -176,6 +176,19 @@ def list_merchant_subscriptions(
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/merchants/{merchant_id}")
|
||||
def get_merchant_subscriptions(
|
||||
merchant_id: int = Path(..., description="Merchant ID"),
|
||||
current_user: UserContext = Depends(get_current_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get all subscriptions for a merchant with tier info and feature usage."""
|
||||
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
|
||||
db, merchant_id
|
||||
)
|
||||
return {"subscriptions": results}
|
||||
|
||||
|
||||
@admin_router.post(
|
||||
"/merchants/{merchant_id}/platforms/{platform_id}",
|
||||
response_model=MerchantSubscriptionAdminResponse,
|
||||
@@ -273,53 +286,14 @@ def get_subscription_for_store(
|
||||
"""
|
||||
from app.modules.billing.services.feature_service import feature_service
|
||||
|
||||
# Resolve store to merchant + all platform IDs
|
||||
# Resolve store to merchant
|
||||
merchant_id, platform_ids = feature_service._get_merchant_and_platforms_for_store(db, store_id)
|
||||
if merchant_id is None or not platform_ids:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
|
||||
platforms_map = admin_subscription_service.get_platform_names_map(db)
|
||||
|
||||
results = []
|
||||
for pid in platform_ids:
|
||||
try:
|
||||
sub, merchant = admin_subscription_service.get_subscription(db, merchant_id, pid)
|
||||
except ResourceNotFoundException:
|
||||
continue
|
||||
|
||||
# Get feature summary
|
||||
features_summary = feature_service.get_merchant_features_summary(db, merchant_id, pid)
|
||||
|
||||
# Build tier info
|
||||
tier_info = None
|
||||
if sub.tier:
|
||||
tier_info = {
|
||||
"code": sub.tier.code,
|
||||
"name": sub.tier.name,
|
||||
"feature_codes": [fl.feature_code for fl in (sub.tier.feature_limits or [])],
|
||||
}
|
||||
|
||||
# Build usage metrics (quantitative features only)
|
||||
usage_metrics = []
|
||||
for fs in features_summary:
|
||||
if fs.feature_type == "quantitative" and fs.enabled:
|
||||
usage_metrics.append({
|
||||
"name": fs.name_key.replace("_", " ").title(),
|
||||
"current": fs.current or 0,
|
||||
"limit": fs.limit,
|
||||
"percentage": fs.percent_used or 0,
|
||||
"is_unlimited": fs.limit is None,
|
||||
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
|
||||
"is_approaching_limit": (fs.percent_used or 0) >= 80,
|
||||
})
|
||||
|
||||
results.append({
|
||||
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
|
||||
"tier": tier_info,
|
||||
"features": usage_metrics,
|
||||
"platform_name": platforms_map.get(pid, ""),
|
||||
})
|
||||
|
||||
results = admin_subscription_service.get_merchant_subscriptions_with_usage(
|
||||
db, merchant_id
|
||||
)
|
||||
return {"subscriptions": results}
|
||||
|
||||
|
||||
|
||||
@@ -286,6 +286,70 @@ class AdminSubscriptionService:
|
||||
p = db.query(Platform).filter(Platform.id == platform_id).first()
|
||||
return p.name if p else None
|
||||
|
||||
# =========================================================================
|
||||
# Merchant Subscriptions with Usage
|
||||
# =========================================================================
|
||||
|
||||
def get_merchant_subscriptions_with_usage(
|
||||
self, db: Session, merchant_id: int
|
||||
) -> list[dict]:
|
||||
"""Get all subscriptions for a merchant with tier info and feature usage.
|
||||
|
||||
Returns a list of dicts, each containing:
|
||||
- subscription: serialized MerchantSubscription
|
||||
- tier: tier info dict (code, name, feature_codes)
|
||||
- features: list of quantitative usage metrics
|
||||
- platform_id: int
|
||||
- platform_name: str
|
||||
"""
|
||||
from app.modules.billing.schemas import MerchantSubscriptionAdminResponse
|
||||
from app.modules.billing.services.feature_service import feature_service
|
||||
from app.modules.billing.services.subscription_service import (
|
||||
subscription_service,
|
||||
)
|
||||
|
||||
subs = subscription_service.get_merchant_subscriptions(db, merchant_id)
|
||||
platforms_map = self.get_platform_names_map(db)
|
||||
|
||||
results = []
|
||||
for sub in subs:
|
||||
features_summary = feature_service.get_merchant_features_summary(
|
||||
db, merchant_id, sub.platform_id
|
||||
)
|
||||
|
||||
tier_info = None
|
||||
if sub.tier:
|
||||
tier_info = {
|
||||
"code": sub.tier.code,
|
||||
"name": sub.tier.name,
|
||||
"feature_codes": [
|
||||
fl.feature_code for fl in (sub.tier.feature_limits or [])
|
||||
],
|
||||
}
|
||||
|
||||
usage_metrics = []
|
||||
for fs in features_summary:
|
||||
if fs.feature_type == "quantitative" and fs.enabled:
|
||||
usage_metrics.append({
|
||||
"name": fs.name_key.replace("_", " ").title(),
|
||||
"current": fs.current or 0,
|
||||
"limit": fs.limit,
|
||||
"percentage": fs.percent_used or 0,
|
||||
"is_unlimited": fs.limit is None,
|
||||
"is_at_limit": fs.remaining == 0 if fs.remaining is not None else False,
|
||||
"is_approaching_limit": (fs.percent_used or 0) >= 80,
|
||||
})
|
||||
|
||||
results.append({
|
||||
"subscription": MerchantSubscriptionAdminResponse.model_validate(sub).model_dump(),
|
||||
"tier": tier_info,
|
||||
"features": usage_metrics,
|
||||
"platform_id": sub.platform_id,
|
||||
"platform_name": platforms_map.get(sub.platform_id, ""),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
# =========================================================================
|
||||
# Statistics
|
||||
# =========================================================================
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -146,7 +146,8 @@ class SubscriptionService:
|
||||
return (
|
||||
db.query(MerchantSubscription)
|
||||
.options(
|
||||
joinedload(MerchantSubscription.tier),
|
||||
joinedload(MerchantSubscription.tier)
|
||||
.joinedload(SubscriptionTier.feature_limits),
|
||||
joinedload(MerchantSubscription.platform),
|
||||
)
|
||||
.filter(MerchantSubscription.merchant_id == merchant_id)
|
||||
|
||||
149
app/modules/billing/tests/unit/test_billing_metrics.py
Normal file
149
app/modules/billing/tests/unit/test_billing_metrics.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# app/modules/billing/tests/unit/test_billing_metrics.py
|
||||
"""Unit tests for BillingMetricsProvider.get_merchant_metrics."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.billing.models import (
|
||||
MerchantSubscription,
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
)
|
||||
from app.modules.billing.services.billing_metrics import BillingMetricsProvider
|
||||
from app.modules.tenancy.models import Merchant, Platform, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billing_platform(db):
|
||||
"""Create a platform for billing metrics tests."""
|
||||
platform = Platform(
|
||||
code=f"bm_{uuid.uuid4().hex[:8]}",
|
||||
name="Billing Metrics Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billing_merchant(db):
|
||||
"""Create a merchant for billing metrics tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"billowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"billowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
merchant = Merchant(
|
||||
name="Billing Metrics Merchant",
|
||||
owner_user_id=user.id,
|
||||
contact_email=user.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billing_tier(db, billing_platform):
|
||||
"""Create a subscription tier."""
|
||||
tier = SubscriptionTier(
|
||||
code="professional",
|
||||
name="Professional",
|
||||
description="Pro tier",
|
||||
price_monthly_cents=2900,
|
||||
price_annual_cents=29000,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
platform_id=billing_platform.id,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
db.refresh(tier)
|
||||
return tier
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billing_extra_platforms(db):
|
||||
"""Create additional platforms for multiple subscriptions (unique constraint: merchant+platform)."""
|
||||
platforms = []
|
||||
for i in range(2):
|
||||
p = Platform(
|
||||
code=f"bm_extra_{uuid.uuid4().hex[:8]}",
|
||||
name=f"Extra Platform {i}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(p)
|
||||
platforms.append(p)
|
||||
db.commit()
|
||||
for p in platforms:
|
||||
db.refresh(p)
|
||||
return platforms
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billing_subscriptions(db, billing_merchant, billing_platform, billing_tier, billing_extra_platforms):
|
||||
"""Create subscriptions: 1 active, 1 trial, 1 cancelled (each on a different platform)."""
|
||||
platforms = [billing_platform, billing_extra_platforms[0], billing_extra_platforms[1]]
|
||||
subs = []
|
||||
for status, platform in zip(
|
||||
[SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIAL, SubscriptionStatus.CANCELLED],
|
||||
platforms, strict=False,
|
||||
):
|
||||
sub = MerchantSubscription(
|
||||
merchant_id=billing_merchant.id,
|
||||
platform_id=platform.id,
|
||||
tier_id=billing_tier.id,
|
||||
status=status.value,
|
||||
is_annual=False,
|
||||
period_start=datetime.now(UTC),
|
||||
period_end=datetime.now(UTC) + timedelta(days=30),
|
||||
)
|
||||
db.add(sub)
|
||||
subs.append(sub)
|
||||
db.commit()
|
||||
for s in subs:
|
||||
db.refresh(s)
|
||||
return subs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.billing
|
||||
class TestBillingMetricsProviderMerchant:
|
||||
"""Tests for BillingMetricsProvider.get_merchant_metrics."""
|
||||
|
||||
def setup_method(self):
|
||||
self.provider = BillingMetricsProvider()
|
||||
|
||||
def test_active_subscriptions_count(self, db, billing_merchant, billing_subscriptions):
|
||||
"""Counts active + trial subscriptions, excludes cancelled."""
|
||||
metrics = self.provider.get_merchant_metrics(db, billing_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["billing.active_subscriptions"] == 2
|
||||
|
||||
def test_no_subscriptions(self, db, billing_merchant):
|
||||
"""Returns zero when merchant has no subscriptions."""
|
||||
metrics = self.provider.get_merchant_metrics(db, billing_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["billing.active_subscriptions"] == 0
|
||||
|
||||
def test_nonexistent_merchant(self, db):
|
||||
"""Returns zero for a non-existent merchant ID."""
|
||||
metrics = self.provider.get_merchant_metrics(db, 999999)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["billing.active_subscriptions"] == 0
|
||||
@@ -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",
|
||||
|
||||
@@ -74,6 +74,9 @@ core_module = ModuleDefinition(
|
||||
"settings",
|
||||
"email-templates",
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
"dashboard",
|
||||
],
|
||||
},
|
||||
# New module-driven menu definitions
|
||||
menus={
|
||||
@@ -121,6 +124,25 @@ core_module = ModuleDefinition(
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
MenuSectionDefinition(
|
||||
id="main",
|
||||
label_key=None,
|
||||
icon=None,
|
||||
order=0,
|
||||
is_collapsible=False,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="dashboard",
|
||||
label_key="core.menu.dashboard",
|
||||
icon="home",
|
||||
route="/merchants/dashboard",
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.STORE: [
|
||||
MenuSectionDefinition(
|
||||
id="main",
|
||||
|
||||
24
app/modules/core/routes/api/merchant.py
Normal file
24
app/modules/core/routes/api/merchant.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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
|
||||
from .merchant_menu import merchant_menu_router
|
||||
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "/core",
|
||||
}
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(merchant_dashboard_router, tags=["merchant-dashboard"])
|
||||
router.include_router(merchant_menu_router, tags=["merchant-menu"])
|
||||
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)),
|
||||
)
|
||||
147
app/modules/core/routes/api/merchant_menu.py
Normal file
147
app/modules/core/routes/api/merchant_menu.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# app/modules/core/routes/api/merchant_menu.py
|
||||
"""
|
||||
Merchant menu rendering endpoint.
|
||||
|
||||
Provides the dynamic sidebar menu for the merchant portal:
|
||||
- GET /menu/render/merchant - Get rendered merchant menu for current user
|
||||
|
||||
Menu sections are driven by module definitions (FrontendType.MERCHANT).
|
||||
Only modules enabled on platforms the merchant is actively subscribed to
|
||||
will appear in the sidebar.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_merchant_from_cookie_or_header, get_db
|
||||
from app.modules.core.services.menu_service import menu_service
|
||||
from app.modules.enums import FrontendType
|
||||
from app.utils.i18n import translate
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
merchant_menu_router = APIRouter(prefix="/menu")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MenuSectionResponse(BaseModel):
|
||||
"""Menu section for rendering."""
|
||||
|
||||
id: str
|
||||
label: str | None = None
|
||||
items: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RenderedMenuResponse(BaseModel):
|
||||
"""Rendered menu for frontend."""
|
||||
|
||||
frontend_type: str
|
||||
sections: list[MenuSectionResponse]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _translate_label(label_key: str | None, language: str) -> str | None:
|
||||
"""Translate a label key, falling back to a readable version of the key."""
|
||||
if not label_key:
|
||||
return None
|
||||
|
||||
translated = translate(label_key, language=language)
|
||||
|
||||
# If translation returned the key itself, create a readable fallback
|
||||
if translated == label_key:
|
||||
parts = label_key.split(".")
|
||||
last_part = parts[-1]
|
||||
return last_part.replace("_", " ").title()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@merchant_menu_router.get("/render/merchant", response_model=RenderedMenuResponse)
|
||||
async def get_rendered_merchant_menu(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Get the rendered merchant menu for the current user.
|
||||
|
||||
Returns the filtered menu structure based on modules enabled
|
||||
on platforms the merchant is subscribed to.
|
||||
|
||||
Used by the merchant frontend to render the sidebar dynamically.
|
||||
"""
|
||||
# Resolve the merchant for this user (via service layer)
|
||||
merchant = menu_service.get_merchant_for_menu(db, current_user.id)
|
||||
|
||||
if not merchant:
|
||||
# No merchant found — return empty menu
|
||||
return RenderedMenuResponse(
|
||||
frontend_type=FrontendType.MERCHANT.value,
|
||||
sections=[],
|
||||
)
|
||||
|
||||
# Get union of enabled module codes across all subscribed platforms
|
||||
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
|
||||
|
||||
# Resolve primary platform for AdminMenuConfig visibility lookup
|
||||
primary_platform_id = menu_service.get_merchant_primary_platform_id(
|
||||
db, merchant.id
|
||||
)
|
||||
|
||||
# Get filtered menu using enabled_module_codes override + platform visibility
|
||||
menu = menu_service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
platform_id=primary_platform_id,
|
||||
enabled_module_codes=enabled_codes,
|
||||
)
|
||||
|
||||
# Resolve language
|
||||
language = current_user.preferred_language or getattr(
|
||||
request.state, "language", "en"
|
||||
)
|
||||
|
||||
# Translate section and item labels
|
||||
sections = []
|
||||
for section in menu:
|
||||
translated_items = []
|
||||
for item in section.items:
|
||||
translated_items.append(
|
||||
{
|
||||
"id": item.id,
|
||||
"label": _translate_label(item.label_key, language),
|
||||
"icon": item.icon,
|
||||
"url": item.route,
|
||||
}
|
||||
)
|
||||
|
||||
sections.append(
|
||||
MenuSectionResponse(
|
||||
id=section.id,
|
||||
label=_translate_label(section.label_key, language),
|
||||
items=translated_items,
|
||||
)
|
||||
)
|
||||
|
||||
return RenderedMenuResponse(
|
||||
frontend_type=FrontendType.MERCHANT.value,
|
||||
sections=sections,
|
||||
)
|
||||
@@ -5,15 +5,18 @@ Core module store API routes.
|
||||
Aggregates:
|
||||
- /dashboard/* - Dashboard statistics
|
||||
- /settings/* - Store settings management
|
||||
- /menu/* - Store menu rendering
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .store_dashboard import store_dashboard_router
|
||||
from .store_menu import store_menu_router
|
||||
from .store_settings import store_settings_router
|
||||
|
||||
store_router = APIRouter()
|
||||
|
||||
# Aggregate sub-routers
|
||||
store_router.include_router(store_dashboard_router, tags=["store-dashboard"])
|
||||
store_router.include_router(store_menu_router, tags=["store-menu"])
|
||||
store_router.include_router(store_settings_router, tags=["store-settings"])
|
||||
|
||||
139
app/modules/core/routes/api/store_menu.py
Normal file
139
app/modules/core/routes/api/store_menu.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# app/modules/core/routes/api/store_menu.py
|
||||
"""
|
||||
Store menu rendering endpoint.
|
||||
|
||||
Provides the dynamic sidebar menu for the store portal:
|
||||
- GET /menu/render/store - Get rendered store menu for current user
|
||||
|
||||
Menu sections are driven by module definitions (FrontendType.STORE).
|
||||
Only modules enabled on the store's platform will appear in the sidebar.
|
||||
Visibility is controlled by AdminMenuConfig records for the platform.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_store_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.core.services.menu_service import menu_service
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.services.store_service import store_service
|
||||
from app.utils.i18n import translate
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
store_menu_router = APIRouter(prefix="/menu")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MenuSectionResponse(BaseModel):
|
||||
"""Menu section for rendering."""
|
||||
|
||||
id: str
|
||||
label: str | None = None
|
||||
items: list[dict[str, Any]]
|
||||
|
||||
|
||||
class RenderedMenuResponse(BaseModel):
|
||||
"""Rendered menu for frontend."""
|
||||
|
||||
frontend_type: str
|
||||
sections: list[MenuSectionResponse]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _translate_label(label_key: str | None, language: str) -> str | None:
|
||||
"""Translate a label key, falling back to a readable version of the key."""
|
||||
if not label_key:
|
||||
return None
|
||||
|
||||
translated = translate(label_key, language=language)
|
||||
|
||||
# If translation returned the key itself, create a readable fallback
|
||||
if translated == label_key:
|
||||
parts = label_key.split(".")
|
||||
last_part = parts[-1]
|
||||
return last_part.replace("_", " ").title()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@store_menu_router.get("/render/store", response_model=RenderedMenuResponse)
|
||||
async def get_rendered_store_menu(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_store_api),
|
||||
):
|
||||
"""
|
||||
Get the rendered store menu for the current user.
|
||||
|
||||
Returns the filtered menu structure based on:
|
||||
- Modules enabled on the store's platform
|
||||
- AdminMenuConfig visibility for the platform
|
||||
- Store code for URL placeholder replacement
|
||||
|
||||
Used by the store frontend to render the sidebar dynamically.
|
||||
"""
|
||||
# Get the store from the JWT token's store context
|
||||
store = store_service.get_store_by_id(db, current_user.token_store_id)
|
||||
|
||||
# Resolve the store's platform via service layer
|
||||
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
|
||||
|
||||
# Get filtered menu with platform visibility and store_code interpolation
|
||||
menu = menu_service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.STORE,
|
||||
platform_id=platform_id,
|
||||
store_code=store.subdomain,
|
||||
)
|
||||
|
||||
# Resolve language
|
||||
language = current_user.preferred_language or getattr(
|
||||
request.state, "language", "en"
|
||||
)
|
||||
|
||||
# Translate section and item labels
|
||||
sections = []
|
||||
for section in menu:
|
||||
translated_items = []
|
||||
for item in section.items:
|
||||
translated_items.append(
|
||||
{
|
||||
"id": item.id,
|
||||
"label": _translate_label(item.label_key, language),
|
||||
"icon": item.icon,
|
||||
"url": item.route,
|
||||
}
|
||||
)
|
||||
|
||||
sections.append(
|
||||
MenuSectionResponse(
|
||||
id=section.id,
|
||||
label=_translate_label(section.label_key, language),
|
||||
items=translated_items,
|
||||
)
|
||||
)
|
||||
|
||||
return RenderedMenuResponse(
|
||||
frontend_type=FrontendType.STORE.value,
|
||||
sections=sections,
|
||||
)
|
||||
@@ -3,6 +3,7 @@
|
||||
Core Store Page Routes (HTML rendering).
|
||||
|
||||
Store pages for core functionality:
|
||||
- Dashboard
|
||||
- Media library
|
||||
- Notifications
|
||||
"""
|
||||
@@ -19,6 +20,35 @@ from app.templates_config import templates
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE DASHBOARD
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def store_dashboard_page(
|
||||
request: Request,
|
||||
store_code: str = Path(..., description="Store code"),
|
||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render store dashboard.
|
||||
|
||||
JavaScript will:
|
||||
- Load store info via API
|
||||
- Load dashboard stats via API
|
||||
- Load recent orders via API
|
||||
- Handle all interactivity
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"core/store/dashboard.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MEDIA LIBRARY
|
||||
# ============================================================================
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -123,6 +123,7 @@ class MenuDiscoveryService:
|
||||
db: Session,
|
||||
frontend_type: FrontendType,
|
||||
platform_id: int | None = None,
|
||||
enabled_module_codes: set[str] | None = None,
|
||||
) -> list[DiscoveredMenuSection]:
|
||||
"""
|
||||
Get aggregated menu sections for a frontend type.
|
||||
@@ -134,6 +135,9 @@ class MenuDiscoveryService:
|
||||
db: Database session
|
||||
frontend_type: Frontend type to get menus for
|
||||
platform_id: Platform ID for module enablement filtering
|
||||
enabled_module_codes: If provided, overrides single-platform lookup.
|
||||
A module is considered enabled if its code is in this set.
|
||||
Useful for merchant portal where a merchant may span multiple platforms.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredMenuSection sorted by order
|
||||
@@ -144,12 +148,15 @@ class MenuDiscoveryService:
|
||||
sections_map: dict[str, DiscoveredMenuSection] = {}
|
||||
|
||||
for module_code, module_def in MODULES.items():
|
||||
# Check if module is enabled for this platform
|
||||
is_module_enabled = True
|
||||
if platform_id:
|
||||
# Check if module is enabled
|
||||
if enabled_module_codes is not None:
|
||||
is_module_enabled = module_code in enabled_module_codes
|
||||
elif platform_id:
|
||||
is_module_enabled = module_service.is_module_enabled(
|
||||
db, platform_id, module_code
|
||||
)
|
||||
else:
|
||||
is_module_enabled = True
|
||||
|
||||
# Get menu sections for this frontend type
|
||||
module_sections = module_def.menus.get(frontend_type, [])
|
||||
@@ -204,6 +211,7 @@ class MenuDiscoveryService:
|
||||
user_id: int | None = None,
|
||||
is_super_admin: bool = False,
|
||||
store_code: str | None = None,
|
||||
enabled_module_codes: set[str] | None = None,
|
||||
) -> list[DiscoveredMenuSection]:
|
||||
"""
|
||||
Get filtered menu structure for frontend rendering.
|
||||
@@ -221,12 +229,16 @@ class MenuDiscoveryService:
|
||||
user_id: User ID for user-specific visibility (super admins only)
|
||||
is_super_admin: Whether the user is a super admin
|
||||
store_code: Store code for route placeholder replacement
|
||||
enabled_module_codes: If provided, overrides single-platform lookup
|
||||
for module enablement. Passed through to get_menu_sections_for_frontend.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredMenuSection with filtered and sorted items
|
||||
"""
|
||||
# Get all sections with module enablement filtering
|
||||
sections = self.get_menu_sections_for_frontend(db, frontend_type, platform_id)
|
||||
sections = self.get_menu_sections_for_frontend(
|
||||
db, frontend_type, platform_id, enabled_module_codes=enabled_module_codes
|
||||
)
|
||||
|
||||
# Get visibility configuration
|
||||
visible_item_ids = self._get_visible_item_ids(
|
||||
|
||||
@@ -228,6 +228,7 @@ class MenuService:
|
||||
user_id: int | None = None,
|
||||
is_super_admin: bool = False,
|
||||
store_code: str | None = None,
|
||||
enabled_module_codes: set[str] | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Get filtered menu structure for frontend rendering.
|
||||
@@ -241,11 +242,14 @@ class MenuService:
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
frontend_type: Which frontend (admin or store)
|
||||
frontend_type: Which frontend (admin, store, or merchant)
|
||||
platform_id: Platform ID (for platform admins and stores)
|
||||
user_id: User ID (for super admins only)
|
||||
is_super_admin: Whether user is super admin (affects admin-only sections)
|
||||
store_code: Store code for URL placeholder replacement (store frontend)
|
||||
enabled_module_codes: If provided, overrides single-platform lookup
|
||||
for module enablement. Used by merchant portal where a merchant
|
||||
may have subscriptions across multiple platforms.
|
||||
|
||||
Returns:
|
||||
List of DiscoveredMenuSection ready for rendering
|
||||
@@ -257,6 +261,195 @@ class MenuService:
|
||||
user_id=user_id,
|
||||
is_super_admin=is_super_admin,
|
||||
store_code=store_code,
|
||||
enabled_module_codes=enabled_module_codes,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Merchant Menu
|
||||
# =========================================================================
|
||||
|
||||
def get_merchant_enabled_module_codes(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
) -> set[str]:
|
||||
"""
|
||||
Get the union of enabled module codes across all platforms the merchant
|
||||
has an active subscription on.
|
||||
|
||||
Core modules (those with is_core=True) are always included.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID
|
||||
|
||||
Returns:
|
||||
Set of enabled module codes
|
||||
"""
|
||||
from app.modules.billing.models.merchant_subscription import (
|
||||
MerchantSubscription,
|
||||
)
|
||||
from app.modules.billing.models.subscription import SubscriptionStatus
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
# Always include core modules
|
||||
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
|
||||
|
||||
# Find all platform IDs where merchant has active/trial subscriptions
|
||||
active_statuses = [
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.PAST_DUE.value,
|
||||
SubscriptionStatus.CANCELLED.value,
|
||||
]
|
||||
|
||||
subscriptions = (
|
||||
db.query(MerchantSubscription.platform_id)
|
||||
.filter(
|
||||
MerchantSubscription.merchant_id == merchant_id,
|
||||
MerchantSubscription.status.in_(active_statuses),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
platform_ids = {sub.platform_id for sub in subscriptions}
|
||||
|
||||
if not platform_ids:
|
||||
return core_codes
|
||||
|
||||
# Union enabled module codes across all subscribed platforms
|
||||
all_enabled = set(core_codes)
|
||||
for platform_id in platform_ids:
|
||||
platform_codes = module_service.get_enabled_module_codes(db, platform_id)
|
||||
all_enabled |= platform_codes
|
||||
|
||||
return all_enabled
|
||||
|
||||
def get_merchant_primary_platform_id(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
) -> int | None:
|
||||
"""
|
||||
Get the primary platform ID for a merchant's visibility config.
|
||||
|
||||
Resolution order:
|
||||
1. Platform from the store marked is_primary in StorePlatform
|
||||
2. First active subscription's platform (fallback)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID
|
||||
|
||||
Returns:
|
||||
Platform ID or None if no active subscriptions
|
||||
"""
|
||||
from app.modules.billing.models.merchant_subscription import (
|
||||
MerchantSubscription,
|
||||
)
|
||||
from app.modules.billing.models.subscription import SubscriptionStatus
|
||||
from app.modules.tenancy.models import Store
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
active_statuses = [
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.PAST_DUE.value,
|
||||
SubscriptionStatus.CANCELLED.value,
|
||||
]
|
||||
|
||||
# Try to find the primary store's platform
|
||||
primary_platform_id = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.join(Store, Store.id == StorePlatform.store_id)
|
||||
.join(
|
||||
MerchantSubscription,
|
||||
(MerchantSubscription.platform_id == StorePlatform.platform_id)
|
||||
& (MerchantSubscription.merchant_id == merchant_id),
|
||||
)
|
||||
.filter(
|
||||
Store.merchant_id == merchant_id,
|
||||
Store.is_active == True, # noqa: E712
|
||||
StorePlatform.is_primary == True, # noqa: E712
|
||||
StorePlatform.is_active == True, # noqa: E712
|
||||
MerchantSubscription.status.in_(active_statuses),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if primary_platform_id:
|
||||
return primary_platform_id[0]
|
||||
|
||||
# Fallback: first active subscription's platform
|
||||
first_sub = (
|
||||
db.query(MerchantSubscription.platform_id)
|
||||
.filter(
|
||||
MerchantSubscription.merchant_id == merchant_id,
|
||||
MerchantSubscription.status.in_(active_statuses),
|
||||
)
|
||||
.order_by(MerchantSubscription.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return first_sub[0] if first_sub else None
|
||||
|
||||
def get_store_primary_platform_id(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
) -> int | None:
|
||||
"""
|
||||
Get the primary platform ID for a store's menu visibility config.
|
||||
|
||||
Prefers the active StorePlatform marked is_primary, falls back to
|
||||
the first active StorePlatform by ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
|
||||
Returns:
|
||||
Platform ID or None if no active store-platform link
|
||||
"""
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
sp = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(StorePlatform.is_primary.desc(), StorePlatform.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return sp[0] if sp else None
|
||||
|
||||
def get_merchant_for_menu(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
):
|
||||
"""
|
||||
Get the active merchant owned by a user, for menu rendering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Owner user ID
|
||||
|
||||
Returns:
|
||||
Merchant ORM object or None
|
||||
"""
|
||||
from app.modules.tenancy.models import Merchant
|
||||
|
||||
return (
|
||||
db.query(Merchant)
|
||||
.filter(
|
||||
Merchant.owner_user_id == user_id,
|
||||
Merchant.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(Merchant.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Provides common data and methods for all merchant pages
|
||||
*/
|
||||
|
||||
// Use centralized logger
|
||||
const merchantLog = window.LogConfig.log;
|
||||
// Use centralized logger (guarded: log-config.js loads with defer, so it may not be ready yet)
|
||||
const merchantLog = (window.LogConfig && window.LogConfig.log) || console.log;
|
||||
|
||||
console.log('[MERCHANT INIT-ALPINE] Loading...');
|
||||
|
||||
@@ -21,11 +21,8 @@ function getMerchantSidebarSectionsFromStorage() {
|
||||
} catch (e) {
|
||||
console.warn('[MERCHANT INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
|
||||
}
|
||||
// Default: all sections open
|
||||
return {
|
||||
billing: true,
|
||||
account: true
|
||||
};
|
||||
// Default: empty — dynamic menu will initialize section state
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveMerchantSidebarSectionsToStorage(sections) {
|
||||
@@ -36,6 +33,19 @@ function saveMerchantSidebarSectionsToStorage(sections) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to find section ID for a page from menu data
|
||||
function findSectionForPage(menuData, pageId) {
|
||||
if (!menuData?.sections) return null;
|
||||
for (const section of menuData.sections) {
|
||||
for (const item of (section.items || [])) {
|
||||
if (item.id === pageId) {
|
||||
return section.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function data() {
|
||||
console.log('[MERCHANT INIT-ALPINE] data() function called');
|
||||
return {
|
||||
@@ -48,13 +58,21 @@ function data() {
|
||||
// Sidebar collapsible sections state
|
||||
openSections: getMerchantSidebarSectionsFromStorage(),
|
||||
|
||||
// Dynamic menu (loaded from API)
|
||||
menuData: null,
|
||||
menuLoading: false,
|
||||
|
||||
init() {
|
||||
// Set current page from URL
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
// For /merchants/dashboard -> 'dashboard'
|
||||
// For /merchants/billing/subscriptions -> 'subscriptions'
|
||||
this.currentPage = segments[segments.length - 1] || 'dashboard';
|
||||
// For /merchants/billing/subscriptions/3 -> 'subscriptions' (skip numeric IDs)
|
||||
const last = segments[segments.length - 1] || 'dashboard';
|
||||
this.currentPage = /^\d+$/.test(last) && segments.length > 2
|
||||
? segments[segments.length - 2]
|
||||
: last;
|
||||
|
||||
// Load merchant name from JWT token
|
||||
const token = localStorage.getItem('merchant_token');
|
||||
@@ -83,6 +101,9 @@ function data() {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Load dynamic menu
|
||||
this.loadMenuConfig();
|
||||
},
|
||||
|
||||
toggleSideMenu() {
|
||||
@@ -112,6 +133,54 @@ function data() {
|
||||
saveMerchantSidebarSectionsToStorage(this.openSections);
|
||||
},
|
||||
|
||||
// Auto-expand section containing current page
|
||||
expandSectionForCurrentPage() {
|
||||
if (!this.menuData) return;
|
||||
const section = findSectionForPage(this.menuData, this.currentPage);
|
||||
if (section && !this.openSections[section]) {
|
||||
this.openSections[section] = true;
|
||||
saveMerchantSidebarSectionsToStorage(this.openSections);
|
||||
}
|
||||
},
|
||||
|
||||
// Dynamic menu loading from API
|
||||
async loadMenuConfig() {
|
||||
if (this.menuData || this.menuLoading) return;
|
||||
|
||||
// Skip if apiClient is not available (e.g., on login page)
|
||||
if (typeof apiClient === 'undefined') {
|
||||
console.debug('Menu config: apiClient not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if not authenticated
|
||||
if (!localStorage.getItem('merchant_token')) {
|
||||
console.debug('Menu config: no merchant_token, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.menuLoading = true;
|
||||
try {
|
||||
this.menuData = await apiClient.get('/merchants/core/menu/render/merchant');
|
||||
const sections = this.menuData?.sections || [];
|
||||
for (const section of sections) {
|
||||
// Initialize openSections for new sections (default: open)
|
||||
if (this.openSections[section.id] === undefined) {
|
||||
this.openSections[section.id] = true;
|
||||
}
|
||||
}
|
||||
saveMerchantSidebarSectionsToStorage(this.openSections);
|
||||
console.debug('Menu config loaded:', sections.length, 'sections');
|
||||
|
||||
// Auto-expand section containing current page
|
||||
this.expandSectionForCurrentPage();
|
||||
} catch (e) {
|
||||
console.debug('Menu config not loaded, using fallback:', e?.message || e);
|
||||
} finally {
|
||||
this.menuLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async handleLogout() {
|
||||
console.log('Logging out merchant user...');
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Provides common data and methods for all store pages
|
||||
*/
|
||||
|
||||
// ✅ Use centralized logger
|
||||
const storeLog = window.LogConfig.log;
|
||||
// Use centralized logger (guarded: log-config.js loads with defer, so it may not be ready yet)
|
||||
const storeLog = (window.LogConfig && window.LogConfig.log) || console.log;
|
||||
|
||||
console.log('[STORE INIT-ALPINE] Loading...');
|
||||
|
||||
@@ -21,14 +21,20 @@ function getStoreSidebarSectionsFromStorage() {
|
||||
} catch (e) {
|
||||
console.warn('[STORE INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
|
||||
}
|
||||
// Default: all sections open
|
||||
return {
|
||||
products: true,
|
||||
sales: true,
|
||||
customers: true,
|
||||
shop: true,
|
||||
account: true
|
||||
};
|
||||
// Default: empty (populated dynamically from API response)
|
||||
return {};
|
||||
}
|
||||
|
||||
function findSectionForPage(menuData, pageId) {
|
||||
if (!menuData?.sections) return null;
|
||||
for (const section of menuData.sections) {
|
||||
for (const item of (section.items || [])) {
|
||||
if (item.id === pageId) {
|
||||
return section.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveStoreSidebarSectionsToStorage(sections) {
|
||||
@@ -51,6 +57,10 @@ function data() {
|
||||
store: null,
|
||||
storeCode: null,
|
||||
|
||||
// Dynamic menu state
|
||||
menuData: null,
|
||||
menuLoading: false,
|
||||
|
||||
// Sidebar collapsible sections state
|
||||
openSections: getStoreSidebarSectionsFromStorage(),
|
||||
|
||||
@@ -58,7 +68,11 @@ function data() {
|
||||
// Set current page from URL
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
this.currentPage = segments[segments.length - 1] || 'dashboard';
|
||||
// For /store/ABC/orders/123 -> 'orders' (skip numeric IDs)
|
||||
const last = segments[segments.length - 1] || 'dashboard';
|
||||
this.currentPage = /^\d+$/.test(last) && segments.length > 2
|
||||
? segments[segments.length - 2]
|
||||
: last;
|
||||
|
||||
// Get store code from URL
|
||||
if (segments[0] === 'store' && segments[1]) {
|
||||
@@ -77,8 +91,9 @@ function data() {
|
||||
this.dark = true;
|
||||
}
|
||||
|
||||
// Load store info
|
||||
// Load store info and dynamic menu
|
||||
this.loadStoreInfo();
|
||||
this.loadMenuConfig();
|
||||
|
||||
// Save last visited page (for redirect after login)
|
||||
// Exclude login, logout, onboarding, error pages
|
||||
@@ -107,6 +122,38 @@ function data() {
|
||||
}
|
||||
},
|
||||
|
||||
async loadMenuConfig() {
|
||||
if (this.menuData || this.menuLoading) return;
|
||||
if (typeof apiClient === 'undefined') return;
|
||||
if (!localStorage.getItem('store_token')) return;
|
||||
|
||||
this.menuLoading = true;
|
||||
try {
|
||||
this.menuData = await apiClient.get('/store/core/menu/render/store');
|
||||
// Initialize section open state from response
|
||||
for (const section of (this.menuData?.sections || [])) {
|
||||
if (this.openSections[section.id] === undefined) {
|
||||
this.openSections[section.id] = true;
|
||||
}
|
||||
}
|
||||
saveStoreSidebarSectionsToStorage(this.openSections);
|
||||
this.expandSectionForCurrentPage();
|
||||
} catch (e) {
|
||||
console.debug('Menu config not loaded, using fallback:', e?.message || e);
|
||||
} finally {
|
||||
this.menuLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
expandSectionForCurrentPage() {
|
||||
if (!this.menuData) return;
|
||||
const sectionId = findSectionForPage(this.menuData, this.currentPage);
|
||||
if (sectionId && !this.openSections[sectionId]) {
|
||||
this.openSections[sectionId] = true;
|
||||
saveStoreSidebarSectionsToStorage(this.openSections);
|
||||
}
|
||||
},
|
||||
|
||||
toggleSideMenu() {
|
||||
this.isSideMenuOpen = !this.isSideMenuOpen;
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
0
app/modules/core/tests/integration/__init__.py
Normal file
0
app/modules/core/tests/integration/__init__.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# app/modules/core/tests/integration/test_merchant_dashboard_routes.py
|
||||
"""
|
||||
Integration tests for merchant dashboard API routes.
|
||||
|
||||
Tests the merchant dashboard endpoint at:
|
||||
GET /api/v1/merchants/core/dashboard/stats
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
|
||||
from app.modules.billing.models import (
|
||||
MerchantSubscription,
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
)
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.tenancy.models import Merchant, Platform, Store, StoreUser, User
|
||||
from main import app
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
BASE = "/api/v1/merchants/core"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_owner(db):
|
||||
"""Create a merchant owner user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"dashowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"dashowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_platform(db):
|
||||
"""Create a platform."""
|
||||
platform = Platform(
|
||||
code=f"dp_{uuid.uuid4().hex[:8]}",
|
||||
name="Dashboard Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_merchant(db, dash_owner):
|
||||
"""Create a merchant."""
|
||||
merchant = Merchant(
|
||||
name="Dashboard Test Merchant",
|
||||
owner_user_id=dash_owner.id,
|
||||
contact_email=dash_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_stores(db, dash_merchant):
|
||||
"""Create 3 stores (2 active, 1 inactive)."""
|
||||
stores = []
|
||||
for i, active in enumerate([True, True, False]):
|
||||
uid = uuid.uuid4().hex[:8].upper()
|
||||
store = Store(
|
||||
merchant_id=dash_merchant.id,
|
||||
store_code=f"DSTORE_{uid}",
|
||||
subdomain=f"dstore{uid.lower()}",
|
||||
name=f"Dashboard Store {i}",
|
||||
is_active=active,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
stores.append(store)
|
||||
db.commit()
|
||||
for s in stores:
|
||||
db.refresh(s)
|
||||
return stores
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_team_members(db, dash_stores, dash_owner):
|
||||
"""Create team members across stores."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
users = []
|
||||
for _ in range(2):
|
||||
u = User(
|
||||
email=f"dteam_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"dteam_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_user",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(u)
|
||||
users.append(u)
|
||||
db.flush()
|
||||
|
||||
db.add(StoreUser(store_id=dash_stores[0].id, user_id=users[0].id, is_active=True))
|
||||
db.add(StoreUser(store_id=dash_stores[1].id, user_id=users[1].id, is_active=True))
|
||||
db.commit()
|
||||
return users
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_customers(db, dash_stores):
|
||||
"""Create customers in the merchant's stores."""
|
||||
for i in range(4):
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
db.add(
|
||||
Customer(
|
||||
store_id=dash_stores[0].id,
|
||||
email=f"dc_{uid}@test.com",
|
||||
hashed_password="hashed", # noqa: SEC001
|
||||
first_name=f"F{i}",
|
||||
last_name=f"L{i}",
|
||||
customer_number=f"DC{uid}",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_subscription(db, dash_merchant, dash_platform):
|
||||
"""Create an active subscription."""
|
||||
tier = SubscriptionTier(
|
||||
code=f"pro_{uuid.uuid4().hex[:8]}",
|
||||
name="Professional",
|
||||
description="Pro",
|
||||
price_monthly_cents=2900,
|
||||
price_annual_cents=29000,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
platform_id=dash_platform.id,
|
||||
)
|
||||
db.add(tier)
|
||||
db.flush()
|
||||
|
||||
sub = MerchantSubscription(
|
||||
merchant_id=dash_merchant.id,
|
||||
platform_id=dash_platform.id,
|
||||
tier_id=tier.id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_annual=False,
|
||||
period_start=datetime.now(UTC),
|
||||
period_end=datetime.now(UTC) + timedelta(days=30),
|
||||
)
|
||||
db.add(sub)
|
||||
db.commit()
|
||||
db.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_auth(dash_owner, dash_merchant):
|
||||
"""Override auth dependencies for dashboard merchant."""
|
||||
user_context = UserContext(
|
||||
id=dash_owner.id,
|
||||
email=dash_owner.email,
|
||||
username=dash_owner.username,
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def _override_merchant():
|
||||
return dash_merchant
|
||||
|
||||
def _override_user():
|
||||
return user_context
|
||||
|
||||
app.dependency_overrides[get_merchant_for_current_user] = _override_merchant
|
||||
app.dependency_overrides[get_current_merchant_api] = _override_user
|
||||
yield {"Authorization": "Bearer fake-token"}
|
||||
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
||||
app.dependency_overrides.pop(get_current_merchant_api, None)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core
|
||||
class TestMerchantDashboardStats:
|
||||
"""Tests for GET /api/v1/merchants/core/dashboard/stats."""
|
||||
|
||||
def test_returns_correct_stats(
|
||||
self, client, db, dash_auth, dash_stores, dash_team_members, dash_customers, dash_subscription
|
||||
):
|
||||
"""Endpoint returns correct aggregated stats."""
|
||||
response = client.get(f"{BASE}/dashboard/stats", headers=dash_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_stores"] == 3
|
||||
assert data["active_subscriptions"] == 1
|
||||
assert data["total_customers"] == 4
|
||||
assert data["team_members"] == 2
|
||||
|
||||
def test_returns_zeros_when_empty(self, client, db, dash_auth):
|
||||
"""Returns zero counts when merchant has no data."""
|
||||
response = client.get(f"{BASE}/dashboard/stats", headers=dash_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_stores"] == 0
|
||||
assert data["active_subscriptions"] == 0
|
||||
assert data["total_customers"] == 0
|
||||
assert data["team_members"] == 0
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
"""Returns 401 without auth."""
|
||||
# Remove any overrides
|
||||
app.dependency_overrides.pop(get_merchant_for_current_user, None)
|
||||
app.dependency_overrides.pop(get_current_merchant_api, None)
|
||||
response = client.get(f"{BASE}/dashboard/stats")
|
||||
assert response.status_code == 401
|
||||
477
app/modules/core/tests/integration/test_merchant_menu_routes.py
Normal file
477
app/modules/core/tests/integration/test_merchant_menu_routes.py
Normal file
@@ -0,0 +1,477 @@
|
||||
# app/modules/core/tests/integration/test_merchant_menu_routes.py
|
||||
"""
|
||||
Integration tests for merchant menu API routes.
|
||||
|
||||
Tests the merchant menu rendering endpoint at:
|
||||
GET /api/v1/merchants/core/menu/render/merchant
|
||||
|
||||
Verifies:
|
||||
- Dynamic menu rendering based on module enablement
|
||||
- Subscription gating (only modules from subscribed platforms)
|
||||
- Auth requirement
|
||||
- Response structure
|
||||
- Fallback when merchant has no subscriptions
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import get_current_merchant_from_cookie_or_header
|
||||
from app.modules.billing.models import (
|
||||
MerchantSubscription,
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant, Platform, User
|
||||
from app.modules.tenancy.models.platform_module import PlatformModule
|
||||
from main import app
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
BASE = "/api/v1/merchants/core/menu/render/merchant"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def menu_owner(db):
|
||||
"""Create a merchant owner user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"menuowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"menuowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def menu_admin(db):
|
||||
"""Create a super admin for enabling modules."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"menuadmin_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"menuadmin_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def menu_platform(db):
|
||||
"""Create a platform."""
|
||||
platform = Platform(
|
||||
code=f"mp_{uuid.uuid4().hex[:8]}",
|
||||
name="Menu Test Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def menu_merchant(db, menu_owner):
|
||||
"""Create a merchant owned by menu_owner."""
|
||||
merchant = Merchant(
|
||||
name="Menu Test Merchant",
|
||||
owner_user_id=menu_owner.id,
|
||||
contact_email=menu_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def menu_tier(db, menu_platform):
|
||||
"""Create a subscription tier."""
|
||||
tier = SubscriptionTier(
|
||||
code=f"pro_{uuid.uuid4().hex[:8]}",
|
||||
name="Professional",
|
||||
description="Pro tier",
|
||||
price_monthly_cents=2900,
|
||||
price_annual_cents=29000,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
platform_id=menu_platform.id,
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
db.refresh(tier)
|
||||
return tier
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def menu_subscription(db, menu_merchant, menu_platform, menu_tier):
|
||||
"""Create an active subscription for the merchant on the platform."""
|
||||
sub = MerchantSubscription(
|
||||
merchant_id=menu_merchant.id,
|
||||
platform_id=menu_platform.id,
|
||||
tier_id=menu_tier.id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_annual=False,
|
||||
period_start=datetime.now(UTC),
|
||||
period_end=datetime.now(UTC) + timedelta(days=30),
|
||||
)
|
||||
db.add(sub)
|
||||
db.commit()
|
||||
db.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def menu_loyalty_module(db, menu_platform, menu_admin):
|
||||
"""Enable loyalty module on the platform."""
|
||||
pm = PlatformModule(
|
||||
platform_id=menu_platform.id,
|
||||
module_code="loyalty",
|
||||
is_enabled=True,
|
||||
enabled_at=datetime.now(UTC),
|
||||
enabled_by_user_id=menu_admin.id,
|
||||
config={},
|
||||
)
|
||||
db.add(pm)
|
||||
db.commit()
|
||||
db.refresh(pm)
|
||||
return pm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def menu_auth(menu_owner):
|
||||
"""Override auth dependency for merchant cookie/header auth."""
|
||||
user_context = UserContext(
|
||||
id=menu_owner.id,
|
||||
email=menu_owner.email,
|
||||
username=menu_owner.username,
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def _override():
|
||||
return user_context
|
||||
|
||||
app.dependency_overrides[get_current_merchant_from_cookie_or_header] = _override
|
||||
yield {"Authorization": "Bearer fake-token"}
|
||||
app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core
|
||||
class TestMerchantMenuRendering:
|
||||
"""Tests for GET /api/v1/merchants/core/menu/render/merchant."""
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
"""Returns 401 without auth."""
|
||||
app.dependency_overrides.pop(get_current_merchant_from_cookie_or_header, None)
|
||||
response = client.get(BASE)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_returns_valid_structure(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription
|
||||
):
|
||||
"""Response has correct top-level structure."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "frontend_type" in data
|
||||
assert data["frontend_type"] == "merchant"
|
||||
assert "sections" in data
|
||||
assert isinstance(data["sections"], list)
|
||||
|
||||
def test_returns_core_sections(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription
|
||||
):
|
||||
"""Menu includes core sections: main, billing, account."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
section_ids = {s["id"] for s in data["sections"]}
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
assert "account" in section_ids
|
||||
|
||||
def test_main_section_has_dashboard(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription
|
||||
):
|
||||
"""Main section contains the dashboard item."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
data = response.json()
|
||||
main = next(s for s in data["sections"] if s["id"] == "main")
|
||||
item_ids = [i["id"] for i in main["items"]]
|
||||
assert "dashboard" in item_ids
|
||||
|
||||
def test_section_items_have_required_fields(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription
|
||||
):
|
||||
"""Each menu item has id, label, icon, and url."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
data = response.json()
|
||||
for section in data["sections"]:
|
||||
for item in section["items"]:
|
||||
assert "id" in item
|
||||
assert "label" in item
|
||||
assert "icon" in item
|
||||
assert "url" in item
|
||||
assert item["label"] is not None
|
||||
assert item["url"].startswith("/merchants/")
|
||||
|
||||
def test_sections_have_correct_labels(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription
|
||||
):
|
||||
"""Labeled sections have non-null labels, main section has null label."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
data = response.json()
|
||||
for section in data["sections"]:
|
||||
if section["id"] == "main":
|
||||
assert section["label"] is None
|
||||
else:
|
||||
assert section["label"] is not None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core
|
||||
class TestMerchantMenuModuleGating:
|
||||
"""Tests for module-based menu filtering."""
|
||||
|
||||
def test_loyalty_appears_when_module_enabled(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
menu_loyalty_module,
|
||||
):
|
||||
"""Loyalty section appears when loyalty module is enabled on subscribed platform."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
section_ids = {s["id"] for s in data["sections"]}
|
||||
assert "loyalty" in section_ids
|
||||
|
||||
def test_loyalty_hidden_when_module_not_enabled(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
):
|
||||
"""Loyalty section is hidden when loyalty module is NOT enabled."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
section_ids = {s["id"] for s in data["sections"]}
|
||||
assert "loyalty" not in section_ids
|
||||
|
||||
def test_loyalty_item_has_correct_route(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
menu_loyalty_module,
|
||||
):
|
||||
"""Loyalty overview item has the correct URL."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
data = response.json()
|
||||
loyalty = next(s for s in data["sections"] if s["id"] == "loyalty")
|
||||
overview = next(i for i in loyalty["items"] if i["id"] == "loyalty-overview")
|
||||
assert overview["url"] == "/merchants/loyalty/overview"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core
|
||||
class TestMerchantMenuNoSubscription:
|
||||
"""Tests for menu when merchant has no subscriptions."""
|
||||
|
||||
def test_empty_menu_when_no_merchant(self, client, db, menu_auth):
|
||||
"""Returns empty sections when user has no merchant."""
|
||||
# menu_auth provides a user, but no merchant fixture is loaded
|
||||
# The endpoint queries for merchant by owner_user_id — none exists for this user
|
||||
# unless menu_merchant is requested
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["frontend_type"] == "merchant"
|
||||
assert data["sections"] == []
|
||||
|
||||
def test_core_sections_with_merchant_no_subscription(
|
||||
self, client, db, menu_auth, menu_merchant,
|
||||
):
|
||||
"""With merchant but no subscription, core sections still appear."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
section_ids = {s["id"] for s in data["sections"]}
|
||||
# Core modules (core, billing, tenancy) are always included
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
assert "account" in section_ids
|
||||
# Non-core modules require subscription
|
||||
assert "loyalty" not in section_ids
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core
|
||||
class TestMerchantMenuSubscriptionStatus:
|
||||
"""Tests for subscription status filtering."""
|
||||
|
||||
def test_trial_subscription_shows_modules(
|
||||
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
|
||||
menu_loyalty_module,
|
||||
):
|
||||
"""Trial subscription shows modules from that platform."""
|
||||
sub = MerchantSubscription(
|
||||
merchant_id=menu_merchant.id,
|
||||
platform_id=menu_platform.id,
|
||||
tier_id=menu_tier.id,
|
||||
status=SubscriptionStatus.TRIAL.value,
|
||||
is_annual=False,
|
||||
period_start=datetime.now(UTC),
|
||||
period_end=datetime.now(UTC) + timedelta(days=14),
|
||||
trial_ends_at=datetime.now(UTC) + timedelta(days=14),
|
||||
)
|
||||
db.add(sub)
|
||||
db.commit()
|
||||
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
||||
assert "loyalty" in section_ids
|
||||
|
||||
def test_expired_subscription_hides_non_core_modules(
|
||||
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
|
||||
menu_loyalty_module,
|
||||
):
|
||||
"""Expired subscription does not show modules from that platform."""
|
||||
sub = MerchantSubscription(
|
||||
merchant_id=menu_merchant.id,
|
||||
platform_id=menu_platform.id,
|
||||
tier_id=menu_tier.id,
|
||||
status=SubscriptionStatus.EXPIRED.value,
|
||||
is_annual=False,
|
||||
period_start=datetime.now(UTC) - timedelta(days=60),
|
||||
period_end=datetime.now(UTC) - timedelta(days=30),
|
||||
)
|
||||
db.add(sub)
|
||||
db.commit()
|
||||
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
||||
# Loyalty should NOT appear because subscription is expired
|
||||
assert "loyalty" not in section_ids
|
||||
# Core sections always appear
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core
|
||||
class TestMerchantMenuMultiPlatform:
|
||||
"""Tests for merchants subscribed to multiple platforms."""
|
||||
|
||||
def test_union_of_modules_from_multiple_platforms(
|
||||
self, client, db, menu_auth, menu_merchant, menu_admin,
|
||||
):
|
||||
"""Modules enabled on different platforms are unioned together."""
|
||||
# Platform A with loyalty enabled
|
||||
platform_a = Platform(
|
||||
code=f"mpa_{uuid.uuid4().hex[:8]}",
|
||||
name="Platform A",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform_a)
|
||||
db.flush()
|
||||
|
||||
tier_a = SubscriptionTier(
|
||||
code=f"ta_{uuid.uuid4().hex[:8]}",
|
||||
name="Tier A",
|
||||
description="T",
|
||||
price_monthly_cents=1000,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
platform_id=platform_a.id,
|
||||
)
|
||||
db.add(tier_a)
|
||||
db.flush()
|
||||
|
||||
db.add(PlatformModule(
|
||||
platform_id=platform_a.id,
|
||||
module_code="loyalty",
|
||||
is_enabled=True,
|
||||
enabled_at=datetime.now(UTC),
|
||||
enabled_by_user_id=menu_admin.id,
|
||||
config={},
|
||||
))
|
||||
|
||||
sub_a = MerchantSubscription(
|
||||
merchant_id=menu_merchant.id,
|
||||
platform_id=platform_a.id,
|
||||
tier_id=tier_a.id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_annual=False,
|
||||
period_start=datetime.now(UTC),
|
||||
period_end=datetime.now(UTC) + timedelta(days=30),
|
||||
)
|
||||
db.add(sub_a)
|
||||
|
||||
# Platform B without loyalty
|
||||
platform_b = Platform(
|
||||
code=f"mpb_{uuid.uuid4().hex[:8]}",
|
||||
name="Platform B",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform_b)
|
||||
db.flush()
|
||||
|
||||
tier_b = SubscriptionTier(
|
||||
code=f"tb_{uuid.uuid4().hex[:8]}",
|
||||
name="Tier B",
|
||||
description="T",
|
||||
price_monthly_cents=1000,
|
||||
display_order=1,
|
||||
is_active=True,
|
||||
is_public=True,
|
||||
platform_id=platform_b.id,
|
||||
)
|
||||
db.add(tier_b)
|
||||
db.flush()
|
||||
|
||||
sub_b = MerchantSubscription(
|
||||
merchant_id=menu_merchant.id,
|
||||
platform_id=platform_b.id,
|
||||
tier_id=tier_b.id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_annual=False,
|
||||
period_start=datetime.now(UTC),
|
||||
period_end=datetime.now(UTC) + timedelta(days=30),
|
||||
)
|
||||
db.add(sub_b)
|
||||
db.commit()
|
||||
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
||||
# Loyalty enabled on Platform A should appear in the union
|
||||
assert "loyalty" in section_ids
|
||||
# Core sections always present
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
assert "account" in section_ids
|
||||
@@ -0,0 +1,200 @@
|
||||
# app/modules/core/tests/integration/test_store_dashboard_routes.py
|
||||
"""
|
||||
Integration tests for store dashboard page route.
|
||||
|
||||
Tests the store dashboard page at:
|
||||
GET /store/{store_code}/dashboard
|
||||
|
||||
Verifies:
|
||||
- Dashboard renders without any onboarding check
|
||||
- Dashboard requires authentication
|
||||
- Dashboard is served from the core module (no marketplace dependency)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import get_current_store_from_cookie_or_header
|
||||
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from main import app
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_owner(db):
|
||||
"""Create a store owner user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"sdowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"sdowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_platform(db):
|
||||
"""Create a platform without marketplace enabled."""
|
||||
platform = Platform(
|
||||
code=f"sdp_{uuid.uuid4().hex[:8]}",
|
||||
name="Loyalty Only Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_merchant(db, sd_owner):
|
||||
"""Create a merchant."""
|
||||
merchant = Merchant(
|
||||
name="Dashboard Test Merchant",
|
||||
owner_user_id=sd_owner.id,
|
||||
contact_email=sd_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_store(db, sd_merchant):
|
||||
"""Create a store."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=sd_merchant.id,
|
||||
store_code=f"SDSTORE_{uid.upper()}",
|
||||
subdomain=f"sdstore{uid.lower()}",
|
||||
name="Dashboard Test Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_store_platform(db, sd_store, sd_platform):
|
||||
"""Link store to a loyalty-only platform (no marketplace)."""
|
||||
sp = StorePlatform(
|
||||
store_id=sd_store.id,
|
||||
platform_id=sd_platform.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
db.refresh(sp)
|
||||
return sp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sd_auth(sd_owner, sd_store):
|
||||
"""Override auth dependency for store cookie/header auth."""
|
||||
user_context = UserContext(
|
||||
id=sd_owner.id,
|
||||
email=sd_owner.email,
|
||||
username=sd_owner.username,
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
token_store_id=sd_store.id,
|
||||
token_store_code=sd_store.store_code,
|
||||
)
|
||||
|
||||
def _override():
|
||||
return user_context
|
||||
|
||||
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
|
||||
yield {"Authorization": "Bearer fake-token"}
|
||||
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core
|
||||
class TestStoreDashboardPage:
|
||||
"""Tests for GET /store/{store_code}/dashboard."""
|
||||
|
||||
def test_dashboard_renders_for_authenticated_store_user(
|
||||
self, client, db, sd_auth, sd_store, sd_store_platform
|
||||
):
|
||||
"""Dashboard page returns 200 for authenticated store user."""
|
||||
response = client.get(
|
||||
f"/store/{sd_store.subdomain}/dashboard",
|
||||
headers=sd_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_dashboard_renders_without_onboarding_check(
|
||||
self, client, db, sd_auth, sd_store, sd_store_platform
|
||||
):
|
||||
"""Dashboard loads without any onboarding redirect — no marketplace dependency.
|
||||
|
||||
On a loyalty-only platform, there's no StoreOnboarding record,
|
||||
yet the dashboard should still render successfully.
|
||||
"""
|
||||
response = client.get(
|
||||
f"/store/{sd_store.subdomain}/dashboard",
|
||||
headers=sd_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
# Should NOT redirect to onboarding
|
||||
assert response.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
|
||||
def test_dashboard_requires_auth(self, client):
|
||||
"""Returns 401 without auth."""
|
||||
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
|
||||
response = client.get(
|
||||
"/store/anystore/dashboard",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.core
|
||||
class TestStoreDashboardNoMarketplace:
|
||||
"""Tests verifying dashboard has no marketplace module dependency."""
|
||||
|
||||
def test_dashboard_has_no_onboarding_import(self):
|
||||
"""Core store routes module does not import OnboardingService."""
|
||||
import app.modules.core.routes.pages.store as core_store_module
|
||||
|
||||
with open(core_store_module.__file__) as f:
|
||||
source = f.read()
|
||||
assert "OnboardingService" not in source
|
||||
assert "onboarding" not in source.lower()
|
||||
|
||||
def test_dashboard_has_no_marketplace_import(self):
|
||||
"""Core store routes module does not import from marketplace."""
|
||||
import app.modules.core.routes.pages.store as core_store_module
|
||||
|
||||
with open(core_store_module.__file__) as f:
|
||||
source = f.read()
|
||||
assert "app.modules.marketplace" not in source
|
||||
@@ -3,6 +3,7 @@
|
||||
import pytest
|
||||
|
||||
from app.modules.core.services.menu_discovery_service import MenuDiscoveryService
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +17,165 @@ class TestMenuDiscoveryService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
def test_discover_all_menus_includes_merchant(self):
|
||||
"""discover_all_menus() includes FrontendType.MERCHANT entries."""
|
||||
menus = self.service.discover_all_menus()
|
||||
assert FrontendType.MERCHANT in menus
|
||||
merchant_sections = menus[FrontendType.MERCHANT]
|
||||
assert len(merchant_sections) > 0, "Expected at least one MERCHANT menu section"
|
||||
|
||||
def test_merchant_menu_has_expected_section_ids(self):
|
||||
"""MERCHANT menus contain main, billing, account, and loyalty sections."""
|
||||
menus = self.service.discover_all_menus()
|
||||
section_ids = {s.id for s in menus[FrontendType.MERCHANT]}
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
assert "account" in section_ids
|
||||
assert "loyalty" in section_ids
|
||||
|
||||
def test_merchant_main_section_has_dashboard(self):
|
||||
"""Main section contains the dashboard item."""
|
||||
menus = self.service.discover_all_menus()
|
||||
main_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "main"]
|
||||
assert len(main_sections) == 1
|
||||
item_ids = [i.id for i in main_sections[0].items]
|
||||
assert "dashboard" in item_ids
|
||||
|
||||
def test_merchant_billing_section_items(self):
|
||||
"""Billing section contains subscriptions and invoices."""
|
||||
menus = self.service.discover_all_menus()
|
||||
billing_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "billing"]
|
||||
assert len(billing_sections) == 1
|
||||
item_ids = [i.id for i in billing_sections[0].items]
|
||||
assert "subscriptions" in item_ids
|
||||
assert "invoices" in item_ids
|
||||
|
||||
def test_merchant_account_section_items(self):
|
||||
"""Account section contains stores and profile."""
|
||||
menus = self.service.discover_all_menus()
|
||||
account_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "account"]
|
||||
assert len(account_sections) == 1
|
||||
item_ids = [i.id for i in account_sections[0].items]
|
||||
assert "stores" in item_ids
|
||||
assert "profile" in item_ids
|
||||
|
||||
def test_merchant_loyalty_section_items(self):
|
||||
"""Loyalty section contains loyalty-overview."""
|
||||
menus = self.service.discover_all_menus()
|
||||
loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"]
|
||||
assert len(loyalty_sections) == 1
|
||||
item_ids = [i.id for i in loyalty_sections[0].items]
|
||||
assert "loyalty-overview" in item_ids
|
||||
|
||||
def test_get_all_menu_items_merchant(self):
|
||||
"""get_all_menu_items returns items for MERCHANT frontend type."""
|
||||
items = self.service.get_all_menu_items(FrontendType.MERCHANT)
|
||||
assert len(items) > 0
|
||||
item_ids = {i.id for i in items}
|
||||
assert "dashboard" in item_ids
|
||||
assert "subscriptions" in item_ids
|
||||
assert "loyalty-overview" in item_ids
|
||||
|
||||
def test_get_all_menu_item_ids_merchant(self):
|
||||
"""get_all_menu_item_ids returns IDs for MERCHANT frontend type."""
|
||||
item_ids = self.service.get_all_menu_item_ids(FrontendType.MERCHANT)
|
||||
assert "dashboard" in item_ids
|
||||
assert "subscriptions" in item_ids
|
||||
assert "invoices" in item_ids
|
||||
assert "stores" in item_ids
|
||||
assert "profile" in item_ids
|
||||
assert "loyalty-overview" in item_ids
|
||||
|
||||
def test_get_mandatory_item_ids_merchant(self):
|
||||
"""Mandatory items for MERCHANT include dashboard and subscriptions."""
|
||||
mandatory = self.service.get_mandatory_item_ids(FrontendType.MERCHANT)
|
||||
assert "dashboard" in mandatory
|
||||
assert "subscriptions" in mandatory
|
||||
|
||||
def test_merchant_sections_have_expected_orders(self):
|
||||
"""MERCHANT menu sections have the expected order values."""
|
||||
menus = self.service.discover_all_menus()
|
||||
sections = menus[FrontendType.MERCHANT]
|
||||
order_map = {s.id: s.order for s in sections}
|
||||
# main is lowest, account is highest
|
||||
assert order_map["main"] < order_map["billing"]
|
||||
assert order_map["billing"] < order_map["loyalty"]
|
||||
assert order_map["loyalty"] < order_map["account"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.core
|
||||
class TestMenuDiscoveryServiceEnabledModuleCodes:
|
||||
"""Test enabled_module_codes parameter on get_menu_sections_for_frontend."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MenuDiscoveryService()
|
||||
|
||||
def test_all_modules_enabled_shows_all_sections(self, db):
|
||||
"""When all module codes are provided, all sections appear."""
|
||||
enabled = {"core", "billing", "tenancy", "loyalty"}
|
||||
sections = self.service.get_menu_sections_for_frontend(
|
||||
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
|
||||
)
|
||||
section_ids = {s.id for s in sections}
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
assert "account" in section_ids
|
||||
assert "loyalty" in section_ids
|
||||
|
||||
def test_loyalty_disabled_hides_loyalty_section(self, db):
|
||||
"""When loyalty is not in enabled_module_codes, its section still appears
|
||||
but items are marked as disabled (filtered out by get_menu_for_frontend)."""
|
||||
enabled = {"core", "billing", "tenancy"}
|
||||
sections = self.service.get_menu_sections_for_frontend(
|
||||
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
|
||||
)
|
||||
# Sections still exist, but loyalty items are marked disabled
|
||||
loyalty_sections = [s for s in sections if s.id == "loyalty"]
|
||||
if loyalty_sections:
|
||||
for item in loyalty_sections[0].items:
|
||||
assert item.is_module_enabled is False
|
||||
|
||||
def test_get_menu_for_frontend_filters_disabled_modules(self, db):
|
||||
"""get_menu_for_frontend removes items from disabled modules."""
|
||||
enabled = {"core", "billing", "tenancy"} # No loyalty
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
|
||||
)
|
||||
section_ids = {s.id for s in sections}
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
assert "account" in section_ids
|
||||
assert "loyalty" not in section_ids, "Loyalty section should be filtered out"
|
||||
|
||||
def test_get_menu_for_frontend_includes_loyalty_when_enabled(self, db):
|
||||
"""get_menu_for_frontend includes loyalty when its module is enabled."""
|
||||
enabled = {"core", "billing", "tenancy", "loyalty"}
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
|
||||
)
|
||||
section_ids = {s.id for s in sections}
|
||||
assert "loyalty" in section_ids
|
||||
|
||||
def test_core_only_shows_minimal_sections(self, db):
|
||||
"""With only core modules, only main section appears."""
|
||||
enabled = {"core"}
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.MERCHANT, enabled_module_codes=enabled,
|
||||
)
|
||||
section_ids = {s.id for s in sections}
|
||||
assert "main" in section_ids
|
||||
assert "billing" not in section_ids
|
||||
assert "loyalty" not in section_ids
|
||||
|
||||
def test_enabled_module_codes_none_shows_all(self, db):
|
||||
"""When enabled_module_codes is None and no platform_id, all modules shown."""
|
||||
sections = self.service.get_menu_for_frontend(
|
||||
db, FrontendType.MERCHANT, enabled_module_codes=None,
|
||||
)
|
||||
section_ids = {s.id for s in sections}
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
assert "loyalty" in section_ids
|
||||
assert "account" in section_ids
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import pytest
|
||||
|
||||
from app.modules.core.services.menu_service import MenuService
|
||||
from app.modules.enums import FrontendType
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +17,96 @@ class TestMenuService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.core
|
||||
class TestMenuServiceMerchantRendering:
|
||||
"""Test get_menu_for_rendering with FrontendType.MERCHANT and enabled_module_codes."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MenuService()
|
||||
|
||||
def test_render_merchant_menu_all_modules(self, db):
|
||||
"""Rendering with all modules shows all sections."""
|
||||
enabled = {"core", "billing", "tenancy", "loyalty"}
|
||||
sections = self.service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
enabled_module_codes=enabled,
|
||||
)
|
||||
section_ids = {s.id for s in sections}
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
assert "account" in section_ids
|
||||
assert "loyalty" in section_ids
|
||||
|
||||
def test_render_merchant_menu_without_loyalty(self, db):
|
||||
"""Rendering without loyalty module hides loyalty section."""
|
||||
enabled = {"core", "billing", "tenancy"}
|
||||
sections = self.service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
enabled_module_codes=enabled,
|
||||
)
|
||||
section_ids = {s.id for s in sections}
|
||||
assert "loyalty" not in section_ids
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
|
||||
def test_render_merchant_menu_section_item_structure(self, db):
|
||||
"""Each section has items with expected attributes."""
|
||||
enabled = {"core", "billing", "tenancy", "loyalty"}
|
||||
sections = self.service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
enabled_module_codes=enabled,
|
||||
)
|
||||
for section in sections:
|
||||
assert section.id
|
||||
assert len(section.items) > 0
|
||||
for item in section.items:
|
||||
assert item.id
|
||||
assert item.label_key
|
||||
assert item.icon
|
||||
assert item.route
|
||||
assert item.route.startswith("/merchants/")
|
||||
|
||||
def test_render_merchant_menu_section_ordering(self, db):
|
||||
"""Sections are returned in correct order."""
|
||||
enabled = {"core", "billing", "tenancy", "loyalty"}
|
||||
sections = self.service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
enabled_module_codes=enabled,
|
||||
)
|
||||
orders = [s.order for s in sections]
|
||||
assert orders == sorted(orders)
|
||||
|
||||
def test_render_merchant_menu_no_enabled_modules_returns_nothing(self, db):
|
||||
"""With empty enabled set, no sections are returned."""
|
||||
sections = self.service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
enabled_module_codes=set(),
|
||||
)
|
||||
assert len(sections) == 0
|
||||
|
||||
def test_enabled_module_codes_propagated(self, db):
|
||||
"""enabled_module_codes parameter is properly propagated to discovery service."""
|
||||
# With loyalty
|
||||
with_loyalty = self.service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
enabled_module_codes={"core", "billing", "tenancy", "loyalty"},
|
||||
)
|
||||
# Without loyalty
|
||||
without_loyalty = self.service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
enabled_module_codes={"core", "billing", "tenancy"},
|
||||
)
|
||||
with_ids = {s.id for s in with_loyalty}
|
||||
without_ids = {s.id for s in without_loyalty}
|
||||
assert "loyalty" in with_ids
|
||||
assert "loyalty" not in without_ids
|
||||
|
||||
106
app/modules/core/tests/unit/test_stats_aggregator.py
Normal file
106
app/modules/core/tests/unit/test_stats_aggregator.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# app/modules/core/tests/unit/test_stats_aggregator.py
|
||||
"""Unit tests for StatsAggregatorService merchant methods."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.contracts.metrics import MetricValue
|
||||
from app.modules.core.services.stats_aggregator import StatsAggregatorService
|
||||
|
||||
|
||||
def _make_metric(key: str, value: int, category: str = "test") -> MetricValue:
|
||||
return MetricValue(key=key, value=value, label=key, category=category)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.core
|
||||
class TestStatsAggregatorMerchant:
|
||||
"""Tests for StatsAggregatorService merchant aggregation methods."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StatsAggregatorService()
|
||||
|
||||
def test_get_merchant_dashboard_stats_aggregates_providers(self, db):
|
||||
"""Aggregates metrics from providers that implement get_merchant_metrics."""
|
||||
provider_a = MagicMock()
|
||||
provider_a.metrics_category = "billing"
|
||||
provider_a.get_merchant_metrics.return_value = [
|
||||
_make_metric("billing.active_subscriptions", 2, "billing"),
|
||||
]
|
||||
|
||||
provider_b = MagicMock()
|
||||
provider_b.metrics_category = "tenancy"
|
||||
provider_b.get_merchant_metrics.return_value = [
|
||||
_make_metric("tenancy.total_stores", 3, "tenancy"),
|
||||
]
|
||||
|
||||
module_a = MagicMock()
|
||||
module_a.code = "billing"
|
||||
module_b = MagicMock()
|
||||
module_b.code = "tenancy"
|
||||
|
||||
with patch.object(
|
||||
self.service, "_get_enabled_providers", return_value=[(module_a, provider_a), (module_b, provider_b)]
|
||||
):
|
||||
result = self.service.get_merchant_dashboard_stats(db, merchant_id=1, platform_id=1)
|
||||
|
||||
assert "billing" in result
|
||||
assert "tenancy" in result
|
||||
assert result["billing"][0].value == 2
|
||||
assert result["tenancy"][0].value == 3
|
||||
|
||||
def test_get_merchant_dashboard_stats_skips_providers_without_method(self, db):
|
||||
"""Skips providers that don't have get_merchant_metrics."""
|
||||
provider = MagicMock(spec=[]) # No attributes at all
|
||||
provider.metrics_category = "legacy"
|
||||
module = MagicMock()
|
||||
module.code = "legacy"
|
||||
|
||||
with patch.object(
|
||||
self.service, "_get_enabled_providers", return_value=[(module, provider)]
|
||||
):
|
||||
result = self.service.get_merchant_dashboard_stats(db, merchant_id=1, platform_id=1)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_get_merchant_dashboard_stats_handles_provider_error(self, db):
|
||||
"""Gracefully handles a provider raising an exception."""
|
||||
provider = MagicMock()
|
||||
provider.metrics_category = "broken"
|
||||
provider.get_merchant_metrics.side_effect = RuntimeError("DB error")
|
||||
module = MagicMock()
|
||||
module.code = "broken"
|
||||
|
||||
with patch.object(
|
||||
self.service, "_get_enabled_providers", return_value=[(module, provider)]
|
||||
):
|
||||
result = self.service.get_merchant_dashboard_stats(db, merchant_id=1, platform_id=1)
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_get_merchant_stats_flat(self, db):
|
||||
"""Flattens categorized metrics into a single dict."""
|
||||
provider = MagicMock()
|
||||
provider.metrics_category = "tenancy"
|
||||
provider.get_merchant_metrics.return_value = [
|
||||
_make_metric("tenancy.total_stores", 5, "tenancy"),
|
||||
_make_metric("tenancy.team_members", 12, "tenancy"),
|
||||
]
|
||||
module = MagicMock()
|
||||
module.code = "tenancy"
|
||||
|
||||
with patch.object(
|
||||
self.service, "_get_enabled_providers", return_value=[(module, provider)]
|
||||
):
|
||||
flat = self.service.get_merchant_stats_flat(db, merchant_id=1, platform_id=1)
|
||||
|
||||
assert flat["tenancy.total_stores"] == 5
|
||||
assert flat["tenancy.team_members"] == 12
|
||||
|
||||
def test_get_merchant_stats_flat_empty(self, db):
|
||||
"""Returns empty dict when no providers have merchant metrics."""
|
||||
with patch.object(self.service, "_get_enabled_providers", return_value=[]):
|
||||
flat = self.service.get_merchant_stats_flat(db, merchant_id=1, platform_id=1)
|
||||
|
||||
assert flat == {}
|
||||
@@ -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()
|
||||
|
||||
130
app/modules/customers/tests/unit/test_customer_metrics.py
Normal file
130
app/modules/customers/tests/unit/test_customer_metrics.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# app/modules/customers/tests/unit/test_customer_metrics.py
|
||||
"""Unit tests for CustomerMetricsProvider.get_merchant_metrics."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.customers.services.customer_metrics import CustomerMetricsProvider
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cust_merchant(db):
|
||||
"""Create a merchant for customer metrics tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"custowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"custowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
merchant = Merchant(
|
||||
name="Customer Metrics Merchant",
|
||||
owner_user_id=user.id,
|
||||
contact_email=user.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cust_stores(db, cust_merchant):
|
||||
"""Create 2 stores for the merchant."""
|
||||
stores = []
|
||||
for i in range(2):
|
||||
uid = uuid.uuid4().hex[:8].upper()
|
||||
store = Store(
|
||||
merchant_id=cust_merchant.id,
|
||||
store_code=f"CSTORE_{uid}",
|
||||
subdomain=f"cstore{uid.lower()}",
|
||||
name=f"Cust Store {i}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
stores.append(store)
|
||||
db.commit()
|
||||
for s in stores:
|
||||
db.refresh(s)
|
||||
return stores
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cust_customers(db, cust_stores):
|
||||
"""Create customers across the merchant's stores."""
|
||||
customers = []
|
||||
# 3 customers in store 0
|
||||
for i in range(3):
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
c = Customer(
|
||||
store_id=cust_stores[0].id,
|
||||
email=f"cust_{uid}@test.com",
|
||||
hashed_password="hashed", # noqa: SEC001
|
||||
first_name=f"First{i}",
|
||||
last_name=f"Last{i}",
|
||||
customer_number=f"C{uid}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(c)
|
||||
customers.append(c)
|
||||
# 2 customers in store 1
|
||||
for i in range(2):
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
c = Customer(
|
||||
store_id=cust_stores[1].id,
|
||||
email=f"cust_{uid}@test.com",
|
||||
hashed_password="hashed", # noqa: SEC001
|
||||
first_name=f"First{i}",
|
||||
last_name=f"Last{i}",
|
||||
customer_number=f"C{uid}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(c)
|
||||
customers.append(c)
|
||||
db.commit()
|
||||
return customers
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.customers
|
||||
class TestCustomerMetricsProviderMerchant:
|
||||
"""Tests for CustomerMetricsProvider.get_merchant_metrics."""
|
||||
|
||||
def setup_method(self):
|
||||
self.provider = CustomerMetricsProvider()
|
||||
|
||||
def test_total_customers_across_stores(self, db, cust_merchant, cust_stores, cust_customers):
|
||||
"""Aggregates customers across all merchant stores."""
|
||||
metrics = self.provider.get_merchant_metrics(db, cust_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["customers.total"] == 5
|
||||
|
||||
def test_no_customers(self, db, cust_merchant, cust_stores):
|
||||
"""Returns zero when stores have no customers."""
|
||||
metrics = self.provider.get_merchant_metrics(db, cust_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["customers.total"] == 0
|
||||
|
||||
def test_no_stores(self, db, cust_merchant):
|
||||
"""Returns zero when merchant has no stores."""
|
||||
metrics = self.provider.get_merchant_metrics(db, cust_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["customers.total"] == 0
|
||||
|
||||
def test_nonexistent_merchant(self, db):
|
||||
"""Returns zero for a non-existent merchant ID."""
|
||||
metrics = self.provider.get_merchant_metrics(db, 999999)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["customers.total"] == 0
|
||||
@@ -117,6 +117,9 @@ loyalty_module = ModuleDefinition(
|
||||
"loyalty-cards", # Customer cards
|
||||
"loyalty-stats", # Store stats
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
"loyalty-overview", # Merchant loyalty overview
|
||||
],
|
||||
},
|
||||
# New module-driven menu definitions
|
||||
menus={
|
||||
@@ -175,6 +178,23 @@ loyalty_module = ModuleDefinition(
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
MenuSectionDefinition(
|
||||
id="loyalty",
|
||||
label_key="loyalty.menu.loyalty",
|
||||
icon="gift",
|
||||
order=60,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="loyalty-overview",
|
||||
label_key="loyalty.menu.overview",
|
||||
icon="gift",
|
||||
route="/merchants/loyalty/overview",
|
||||
order=10,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.STOREFRONT: [
|
||||
MenuSectionDefinition(
|
||||
id="account",
|
||||
|
||||
@@ -4,7 +4,6 @@ Marketplace Store Page Routes (HTML rendering).
|
||||
|
||||
Store pages for marketplace management:
|
||||
- Onboarding wizard
|
||||
- Dashboard
|
||||
- Marketplace imports
|
||||
- Letzshop integration
|
||||
"""
|
||||
@@ -16,7 +15,9 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_store_from_cookie_or_header, get_db
|
||||
from app.modules.core.utils.page_context import get_store_context
|
||||
from app.modules.marketplace.services.onboarding_service import OnboardingService
|
||||
from app.modules.service import module_service
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from app.templates_config import templates
|
||||
|
||||
router = APIRouter()
|
||||
@@ -39,14 +40,27 @@ async def store_onboarding_page(
|
||||
"""
|
||||
Render store onboarding wizard.
|
||||
|
||||
Mandatory 4-step wizard that must be completed before accessing dashboard:
|
||||
4-step wizard for marketplace setup:
|
||||
1. Merchant Profile Setup
|
||||
2. Letzshop API Configuration
|
||||
3. Product & Order Import Configuration
|
||||
4. Order Sync (historical import)
|
||||
|
||||
If onboarding is already completed, redirects to dashboard.
|
||||
If marketplace module is not enabled for this store's platform,
|
||||
redirects to dashboard. If onboarding is already completed,
|
||||
redirects to dashboard.
|
||||
"""
|
||||
sp = (
|
||||
db.query(StorePlatform)
|
||||
.filter(StorePlatform.store_id == current_user.token_store_id)
|
||||
.first()
|
||||
)
|
||||
if not sp or not module_service.is_module_enabled(db, sp.platform_id, "marketplace"):
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/dashboard",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
onboarding_service = OnboardingService(db)
|
||||
if onboarding_service.is_completed(current_user.token_store_id):
|
||||
return RedirectResponse(
|
||||
@@ -60,44 +74,6 @@ async def store_onboarding_page(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STORE DASHBOARD
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{store_code}/dashboard", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def store_dashboard_page(
|
||||
request: Request,
|
||||
store_code: str = Path(..., description="Store code"),
|
||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render store dashboard.
|
||||
|
||||
Redirects to onboarding if not completed.
|
||||
|
||||
JavaScript will:
|
||||
- Load store info via API
|
||||
- Load dashboard stats via API
|
||||
- Load recent orders via API
|
||||
- Handle all interactivity
|
||||
"""
|
||||
onboarding_service = OnboardingService(db)
|
||||
if not onboarding_service.is_completed(current_user.token_store_id):
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/onboarding",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"core/store/dashboard.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MARKETPLACE IMPORTS
|
||||
# ============================================================================
|
||||
@@ -115,7 +91,15 @@ async def store_marketplace_page(
|
||||
"""
|
||||
Render marketplace import page.
|
||||
JavaScript loads import jobs and products via API.
|
||||
Redirects to onboarding if not completed.
|
||||
"""
|
||||
onboarding_service = OnboardingService(db)
|
||||
if not onboarding_service.is_completed(current_user.token_store_id):
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/onboarding",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"marketplace/store/marketplace.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
@@ -139,7 +123,15 @@ async def store_letzshop_page(
|
||||
"""
|
||||
Render Letzshop integration page.
|
||||
JavaScript loads orders, credentials status, and handles fulfillment operations.
|
||||
Redirects to onboarding if not completed.
|
||||
"""
|
||||
onboarding_service = OnboardingService(db)
|
||||
if not onboarding_service.is_completed(current_user.token_store_id):
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/onboarding",
|
||||
status_code=302,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"marketplace/store/letzshop.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
# app/modules/marketplace/tests/integration/test_store_page_routes.py
|
||||
"""
|
||||
Integration tests for marketplace store page routes.
|
||||
|
||||
Tests the onboarding, marketplace, and letzshop page routes at:
|
||||
GET /store/{store_code}/onboarding
|
||||
GET /store/{store_code}/marketplace
|
||||
GET /store/{store_code}/letzshop
|
||||
|
||||
Verifies:
|
||||
- Onboarding page redirects to dashboard on non-marketplace platforms
|
||||
- Onboarding page redirects to dashboard when already completed
|
||||
- Onboarding page renders when marketplace enabled and not completed
|
||||
- Marketplace/Letzshop pages redirect to onboarding when not completed
|
||||
- Marketplace/Letzshop pages render when onboarding is completed
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import get_current_store_from_cookie_or_header
|
||||
from app.modules.marketplace.models import OnboardingStatus, StoreOnboarding
|
||||
from app.modules.tenancy.models import Merchant, Platform, Store, User
|
||||
from app.modules.tenancy.models.platform_module import PlatformModule
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from main import app
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_admin(db):
|
||||
"""Create an admin user for enabling modules."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"mpadmin_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"mpadmin_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_owner(db):
|
||||
"""Create a store owner user."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"mpowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"mpowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_platform_no_marketplace(db):
|
||||
"""Create a platform without marketplace module enabled."""
|
||||
platform = Platform(
|
||||
code=f"mpnm_{uuid.uuid4().hex[:8]}",
|
||||
name="No Marketplace Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_platform_with_marketplace(db, mp_admin):
|
||||
"""Create a platform with marketplace module enabled."""
|
||||
platform = Platform(
|
||||
code=f"mpmk_{uuid.uuid4().hex[:8]}",
|
||||
name="Marketplace Platform",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.flush()
|
||||
|
||||
pm = PlatformModule(
|
||||
platform_id=platform.id,
|
||||
module_code="marketplace",
|
||||
is_enabled=True,
|
||||
enabled_at=datetime.now(UTC),
|
||||
enabled_by_user_id=mp_admin.id,
|
||||
config={},
|
||||
)
|
||||
db.add(pm)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_merchant(db, mp_owner):
|
||||
"""Create a merchant."""
|
||||
merchant = Merchant(
|
||||
name="MP Test Merchant",
|
||||
owner_user_id=mp_owner.id,
|
||||
contact_email=mp_owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_store(db, mp_merchant):
|
||||
"""Create a store."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=mp_merchant.id,
|
||||
store_code=f"MPSTORE_{uid.upper()}",
|
||||
subdomain=f"mpstore{uid.lower()}",
|
||||
name="MP Test Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_store_no_marketplace(db, mp_store, mp_platform_no_marketplace):
|
||||
"""Link store to a platform without marketplace."""
|
||||
sp = StorePlatform(
|
||||
store_id=mp_store.id,
|
||||
platform_id=mp_platform_no_marketplace.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
db.refresh(sp)
|
||||
return sp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_store_with_marketplace(db, mp_store, mp_platform_with_marketplace):
|
||||
"""Link store to a platform with marketplace enabled."""
|
||||
sp = StorePlatform(
|
||||
store_id=mp_store.id,
|
||||
platform_id=mp_platform_with_marketplace.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
db.refresh(sp)
|
||||
return sp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_onboarding_not_completed(db, mp_store):
|
||||
"""Create an incomplete onboarding record for the store."""
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=mp_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
db.refresh(onboarding)
|
||||
return onboarding
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_onboarding_completed(db, mp_store):
|
||||
"""Create a completed onboarding record for the store."""
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=mp_store.id,
|
||||
status=OnboardingStatus.COMPLETED.value,
|
||||
step_merchant_profile_completed=True,
|
||||
step_letzshop_api_completed=True,
|
||||
step_product_import_completed=True,
|
||||
step_order_sync_completed=True,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
db.refresh(onboarding)
|
||||
return onboarding
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mp_auth(mp_owner, mp_store):
|
||||
"""Override auth dependency for store cookie/header auth."""
|
||||
user_context = UserContext(
|
||||
id=mp_owner.id,
|
||||
email=mp_owner.email,
|
||||
username=mp_owner.username,
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
token_store_id=mp_store.id,
|
||||
token_store_code=mp_store.store_code,
|
||||
)
|
||||
|
||||
def _override():
|
||||
return user_context
|
||||
|
||||
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
|
||||
yield {"Authorization": "Bearer fake-token"}
|
||||
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Onboarding page tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.marketplace
|
||||
class TestOnboardingPageRoutes:
|
||||
"""Tests for GET /store/{store_code}/onboarding."""
|
||||
|
||||
def test_redirects_to_dashboard_on_non_marketplace_platform(
|
||||
self, client, db, mp_auth, mp_store, mp_store_no_marketplace
|
||||
):
|
||||
"""Onboarding page redirects to dashboard on platform without marketplace."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/onboarding",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert f"/store/{mp_store.subdomain}/dashboard" in response.headers["location"]
|
||||
|
||||
def test_redirects_to_dashboard_when_onboarding_completed(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
mp_onboarding_completed,
|
||||
):
|
||||
"""Onboarding page redirects to dashboard when already completed."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/onboarding",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert f"/store/{mp_store.subdomain}/dashboard" in response.headers["location"]
|
||||
|
||||
def test_renders_onboarding_when_not_completed(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
mp_onboarding_not_completed,
|
||||
):
|
||||
"""Onboarding page renders wizard when marketplace enabled and not completed."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/onboarding",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_renders_onboarding_when_no_onboarding_record(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
):
|
||||
"""Onboarding page renders wizard when marketplace enabled and no record exists."""
|
||||
# No mp_onboarding_* fixture — is_completed() returns False for missing record
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/onboarding",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
"""Returns 401 without auth."""
|
||||
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
|
||||
response = client.get(
|
||||
"/store/anystore/onboarding",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Marketplace page tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.marketplace
|
||||
class TestMarketplacePageRoutes:
|
||||
"""Tests for GET /store/{store_code}/marketplace."""
|
||||
|
||||
def test_redirects_to_onboarding_when_not_completed(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
mp_onboarding_not_completed,
|
||||
):
|
||||
"""Marketplace page redirects to onboarding when not completed."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/marketplace",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"]
|
||||
|
||||
def test_renders_when_onboarding_completed(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
mp_onboarding_completed,
|
||||
):
|
||||
"""Marketplace page renders when onboarding is completed."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/marketplace",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_redirects_when_no_onboarding_record(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
):
|
||||
"""Marketplace page redirects when no onboarding record (not completed)."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/marketplace",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Letzshop page tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.marketplace
|
||||
class TestLetzshopPageRoutes:
|
||||
"""Tests for GET /store/{store_code}/letzshop."""
|
||||
|
||||
def test_redirects_to_onboarding_when_not_completed(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
mp_onboarding_not_completed,
|
||||
):
|
||||
"""Letzshop page redirects to onboarding when not completed."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/letzshop",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"]
|
||||
|
||||
def test_renders_when_onboarding_completed(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
mp_onboarding_completed,
|
||||
):
|
||||
"""Letzshop page renders when onboarding is completed."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/letzshop",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_redirects_when_no_onboarding_record(
|
||||
self, client, db, mp_auth, mp_store, mp_store_with_marketplace,
|
||||
):
|
||||
"""Letzshop page redirects when no onboarding record (not completed)."""
|
||||
response = client.get(
|
||||
f"/store/{mp_store.subdomain}/letzshop",
|
||||
headers=mp_auth,
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert f"/store/{mp_store.subdomain}/onboarding" in response.headers["location"]
|
||||
298
app/modules/marketplace/tests/unit/test_store_page_routes.py
Normal file
298
app/modules/marketplace/tests/unit/test_store_page_routes.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# app/modules/marketplace/tests/unit/test_store_page_routes.py
|
||||
"""
|
||||
Unit tests for marketplace store page routes.
|
||||
|
||||
Tests the onboarding, marketplace, and letzshop page route logic:
|
||||
- Onboarding page guards (marketplace module check, completion redirect)
|
||||
- Marketplace page onboarding gate
|
||||
- Letzshop page onboarding gate
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.modules.marketplace.routes.pages.store import (
|
||||
store_letzshop_page,
|
||||
store_marketplace_page,
|
||||
store_onboarding_page,
|
||||
)
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
|
||||
def _make_user_context(store_id: int = 1, store_code: str = "teststore") -> UserContext:
|
||||
"""Create a UserContext for testing."""
|
||||
return UserContext(
|
||||
id=1,
|
||||
email="test@test.com",
|
||||
username="testuser",
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
token_store_id=store_id,
|
||||
token_store_code=store_code,
|
||||
)
|
||||
|
||||
|
||||
def _make_request() -> MagicMock:
|
||||
"""Create a mock Request."""
|
||||
return MagicMock(spec=Request)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ONBOARDING PAGE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.marketplace
|
||||
@pytest.mark.asyncio
|
||||
class TestOnboardingPageGuard:
|
||||
"""Test that onboarding page redirects to dashboard when marketplace is not enabled."""
|
||||
|
||||
@patch("app.modules.marketplace.routes.pages.store.module_service")
|
||||
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
|
||||
@patch("app.modules.marketplace.routes.pages.store.templates")
|
||||
async def test_redirects_to_dashboard_when_no_store_platform(
|
||||
self, mock_templates, mock_ctx, mock_module_service, db
|
||||
):
|
||||
"""Redirect to dashboard if store has no StorePlatform record."""
|
||||
user = _make_user_context(store_id=99999)
|
||||
response = await store_onboarding_page(
|
||||
request=_make_request(),
|
||||
store_code="teststore",
|
||||
current_user=user,
|
||||
db=db,
|
||||
)
|
||||
assert isinstance(response, RedirectResponse)
|
||||
assert response.status_code == 302
|
||||
assert "/store/teststore/dashboard" in response.headers["location"]
|
||||
|
||||
@patch("app.modules.marketplace.routes.pages.store.module_service")
|
||||
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
|
||||
@patch("app.modules.marketplace.routes.pages.store.templates")
|
||||
async def test_redirects_to_dashboard_when_marketplace_not_enabled(
|
||||
self, mock_templates, mock_ctx, mock_module_service, db, test_store
|
||||
):
|
||||
"""Redirect to dashboard if marketplace module is not enabled on platform."""
|
||||
from app.modules.tenancy.models import Platform
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
platform = Platform(code="nomarket", name="No Marketplace", is_active=True)
|
||||
db.add(platform)
|
||||
db.flush()
|
||||
|
||||
sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
|
||||
mock_module_service.is_module_enabled.return_value = False
|
||||
|
||||
user = _make_user_context(store_id=test_store.id)
|
||||
response = await store_onboarding_page(
|
||||
request=_make_request(),
|
||||
store_code="teststore",
|
||||
current_user=user,
|
||||
db=db,
|
||||
)
|
||||
assert isinstance(response, RedirectResponse)
|
||||
assert response.status_code == 302
|
||||
assert "/store/teststore/dashboard" in response.headers["location"]
|
||||
mock_module_service.is_module_enabled.assert_called_once_with(
|
||||
db, platform.id, "marketplace"
|
||||
)
|
||||
|
||||
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
|
||||
@patch("app.modules.marketplace.routes.pages.store.module_service")
|
||||
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
|
||||
@patch("app.modules.marketplace.routes.pages.store.templates")
|
||||
async def test_redirects_to_dashboard_when_onboarding_completed(
|
||||
self, mock_templates, mock_ctx, mock_module_service, mock_onboarding_cls, db, test_store
|
||||
):
|
||||
"""Redirect to dashboard if onboarding is already completed."""
|
||||
from app.modules.tenancy.models import Platform
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
platform = Platform(code="mktplace", name="Marketplace", is_active=True)
|
||||
db.add(platform)
|
||||
db.flush()
|
||||
|
||||
sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
|
||||
mock_module_service.is_module_enabled.return_value = True
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.is_completed.return_value = True
|
||||
mock_onboarding_cls.return_value = mock_svc
|
||||
|
||||
user = _make_user_context(store_id=test_store.id)
|
||||
response = await store_onboarding_page(
|
||||
request=_make_request(),
|
||||
store_code="teststore",
|
||||
current_user=user,
|
||||
db=db,
|
||||
)
|
||||
assert isinstance(response, RedirectResponse)
|
||||
assert response.status_code == 302
|
||||
assert "/store/teststore/dashboard" in response.headers["location"]
|
||||
|
||||
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
|
||||
@patch("app.modules.marketplace.routes.pages.store.module_service")
|
||||
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
|
||||
@patch("app.modules.marketplace.routes.pages.store.templates")
|
||||
async def test_renders_onboarding_when_marketplace_enabled_and_not_completed(
|
||||
self, mock_templates, mock_ctx, mock_module_service, mock_onboarding_cls, db, test_store
|
||||
):
|
||||
"""Render onboarding wizard when marketplace enabled and onboarding not completed."""
|
||||
from app.modules.tenancy.models import Platform
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
platform = Platform(code="mktplace2", name="Marketplace 2", is_active=True)
|
||||
db.add(platform)
|
||||
db.flush()
|
||||
|
||||
sp = StorePlatform(store_id=test_store.id, platform_id=platform.id, is_active=True)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
|
||||
mock_module_service.is_module_enabled.return_value = True
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.is_completed.return_value = False
|
||||
mock_onboarding_cls.return_value = mock_svc
|
||||
|
||||
mock_ctx.return_value = {"request": _make_request()}
|
||||
mock_templates.TemplateResponse.return_value = "rendered"
|
||||
|
||||
user = _make_user_context(store_id=test_store.id)
|
||||
response = await store_onboarding_page(
|
||||
request=_make_request(),
|
||||
store_code="teststore",
|
||||
current_user=user,
|
||||
db=db,
|
||||
)
|
||||
assert response == "rendered"
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
template_name = mock_templates.TemplateResponse.call_args[0][0]
|
||||
assert template_name == "marketplace/store/onboarding.html"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MARKETPLACE PAGE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.marketplace
|
||||
@pytest.mark.asyncio
|
||||
class TestMarketplacePageOnboardingGate:
|
||||
"""Test that marketplace page redirects to onboarding when not completed."""
|
||||
|
||||
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
|
||||
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
|
||||
@patch("app.modules.marketplace.routes.pages.store.templates")
|
||||
async def test_redirects_to_onboarding_when_not_completed(
|
||||
self, mock_templates, mock_ctx, mock_onboarding_cls, db
|
||||
):
|
||||
"""Redirect to onboarding if onboarding not completed."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.is_completed.return_value = False
|
||||
mock_onboarding_cls.return_value = mock_svc
|
||||
|
||||
user = _make_user_context()
|
||||
response = await store_marketplace_page(
|
||||
request=_make_request(),
|
||||
store_code="teststore",
|
||||
current_user=user,
|
||||
db=db,
|
||||
)
|
||||
assert isinstance(response, RedirectResponse)
|
||||
assert response.status_code == 302
|
||||
assert "/store/teststore/onboarding" in response.headers["location"]
|
||||
|
||||
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
|
||||
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
|
||||
@patch("app.modules.marketplace.routes.pages.store.templates")
|
||||
async def test_renders_marketplace_when_onboarding_completed(
|
||||
self, mock_templates, mock_ctx, mock_onboarding_cls, db
|
||||
):
|
||||
"""Render marketplace page if onboarding is completed."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.is_completed.return_value = True
|
||||
mock_onboarding_cls.return_value = mock_svc
|
||||
|
||||
mock_ctx.return_value = {"request": _make_request()}
|
||||
mock_templates.TemplateResponse.return_value = "rendered"
|
||||
|
||||
user = _make_user_context()
|
||||
response = await store_marketplace_page(
|
||||
request=_make_request(),
|
||||
store_code="teststore",
|
||||
current_user=user,
|
||||
db=db,
|
||||
)
|
||||
assert response == "rendered"
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
template_name = mock_templates.TemplateResponse.call_args[0][0]
|
||||
assert template_name == "marketplace/store/marketplace.html"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LETZSHOP PAGE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.marketplace
|
||||
@pytest.mark.asyncio
|
||||
class TestLetzshopPageOnboardingGate:
|
||||
"""Test that letzshop page redirects to onboarding when not completed."""
|
||||
|
||||
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
|
||||
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
|
||||
@patch("app.modules.marketplace.routes.pages.store.templates")
|
||||
async def test_redirects_to_onboarding_when_not_completed(
|
||||
self, mock_templates, mock_ctx, mock_onboarding_cls, db
|
||||
):
|
||||
"""Redirect to onboarding if onboarding not completed."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.is_completed.return_value = False
|
||||
mock_onboarding_cls.return_value = mock_svc
|
||||
|
||||
user = _make_user_context()
|
||||
response = await store_letzshop_page(
|
||||
request=_make_request(),
|
||||
store_code="teststore",
|
||||
current_user=user,
|
||||
db=db,
|
||||
)
|
||||
assert isinstance(response, RedirectResponse)
|
||||
assert response.status_code == 302
|
||||
assert "/store/teststore/onboarding" in response.headers["location"]
|
||||
|
||||
@patch("app.modules.marketplace.routes.pages.store.OnboardingService")
|
||||
@patch("app.modules.marketplace.routes.pages.store.get_store_context")
|
||||
@patch("app.modules.marketplace.routes.pages.store.templates")
|
||||
async def test_renders_letzshop_when_onboarding_completed(
|
||||
self, mock_templates, mock_ctx, mock_onboarding_cls, db
|
||||
):
|
||||
"""Render letzshop page if onboarding is completed."""
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.is_completed.return_value = True
|
||||
mock_onboarding_cls.return_value = mock_svc
|
||||
|
||||
mock_ctx.return_value = {"request": _make_request()}
|
||||
mock_templates.TemplateResponse.return_value = "rendered"
|
||||
|
||||
user = _make_user_context()
|
||||
response = await store_letzshop_page(
|
||||
request=_make_request(),
|
||||
store_code="teststore",
|
||||
current_user=user,
|
||||
db=db,
|
||||
)
|
||||
assert response == "rendered"
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
template_name = mock_templates.TemplateResponse.call_args[0][0]
|
||||
assert template_name == "marketplace/store/letzshop.html"
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Unit tests for InvoicePDFService."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.orders.services.invoice_pdf_service import InvoicePDFService
|
||||
from app.modules.orders.services.invoice_pdf_service import (
|
||||
TEMPLATE_DIR,
|
||||
InvoicePDFService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +21,25 @@ class TestInvoicePDFService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
def test_template_directory_exists(self):
|
||||
"""Template directory must exist at the expected path."""
|
||||
assert TEMPLATE_DIR.exists(), f"Template directory missing: {TEMPLATE_DIR}"
|
||||
assert TEMPLATE_DIR.is_dir()
|
||||
|
||||
def test_invoice_template_exists(self):
|
||||
"""invoice.html template must exist in the template directory."""
|
||||
template_path = TEMPLATE_DIR / "invoice.html"
|
||||
assert template_path.exists(), f"Invoice template missing: {template_path}"
|
||||
|
||||
def test_template_can_be_loaded(self):
|
||||
"""Jinja2 environment can load the invoice template."""
|
||||
template = self.service.env.get_template("invoice.html")
|
||||
assert template is not None
|
||||
|
||||
def test_template_dir_is_inside_orders_module(self):
|
||||
"""Template directory should be inside the orders module, not the global templates."""
|
||||
orders_module_dir = Path(__file__).parent.parent.parent
|
||||
assert str(TEMPLATE_DIR).startswith(str(orders_module_dir)), (
|
||||
f"TEMPLATE_DIR ({TEMPLATE_DIR}) should be inside orders module ({orders_module_dir})"
|
||||
)
|
||||
|
||||
@@ -91,6 +91,10 @@ tenancy_module = ModuleDefinition(
|
||||
FrontendType.STORE: [
|
||||
"team",
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
"stores",
|
||||
"profile",
|
||||
],
|
||||
},
|
||||
# New module-driven menu definitions
|
||||
menus={
|
||||
@@ -160,6 +164,30 @@ tenancy_module = ModuleDefinition(
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
MenuSectionDefinition(
|
||||
id="account",
|
||||
label_key="tenancy.menu.account",
|
||||
icon="cog",
|
||||
order=900,
|
||||
items=[
|
||||
MenuItemDefinition(
|
||||
id="stores",
|
||||
label_key="tenancy.menu.stores",
|
||||
icon="shopping-bag",
|
||||
route="/merchants/account/stores",
|
||||
order=10,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="profile",
|
||||
label_key="tenancy.menu.profile",
|
||||
icon="user",
|
||||
route="/merchants/account/profile",
|
||||
order=20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
FrontendType.STORE: [
|
||||
MenuSectionDefinition(
|
||||
id="account",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -51,8 +51,7 @@ function adminMerchantDetail() {
|
||||
this.merchantId = match[1];
|
||||
merchantDetailLog.info('Viewing merchant:', this.merchantId);
|
||||
await this.loadMerchant();
|
||||
await this.loadPlatforms();
|
||||
await this.loadSubscriptions();
|
||||
await Promise.all([this.loadPlatforms(), this.loadSubscriptions()]);
|
||||
} else {
|
||||
merchantDetailLog.error('No merchant ID in URL');
|
||||
this.error = 'Invalid merchant URL';
|
||||
@@ -110,31 +109,19 @@ function adminMerchantDetail() {
|
||||
}
|
||||
},
|
||||
|
||||
// Load subscriptions for all platforms
|
||||
// Load all subscriptions for this merchant in a single call
|
||||
async loadSubscriptions() {
|
||||
if (!this.merchantId || this.platforms.length === 0) return;
|
||||
if (!this.merchantId) return;
|
||||
|
||||
merchantDetailLog.info('Loading subscriptions for merchant:', this.merchantId);
|
||||
this.subscriptions = [];
|
||||
|
||||
for (const platform of this.platforms) {
|
||||
try {
|
||||
const url = `/admin/subscriptions/merchants/${this.merchantId}/platforms/${platform.id}`;
|
||||
const url = `/admin/subscriptions/merchants/${this.merchantId}`;
|
||||
const response = await apiClient.get(url);
|
||||
const sub = response.subscription || response;
|
||||
|
||||
this.subscriptions.push({
|
||||
subscription: sub,
|
||||
tier: response.tier || null,
|
||||
features: response.features || [],
|
||||
platform_id: platform.id,
|
||||
platform_name: platform.name,
|
||||
});
|
||||
this.subscriptions = response.subscriptions || [];
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
merchantDetailLog.warn(`Failed to load subscription for platform ${platform.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
merchantDetailLog.warn('Failed to load subscriptions:', error.message);
|
||||
}
|
||||
|
||||
merchantDetailLog.info('Subscriptions loaded:', {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="platform?.name || 'Loading...'"></h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure which menu items are visible for admins and stores on this platform.
|
||||
Configure which menu items are visible for admins, stores, and merchants on this platform.
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" x-text="platform?.code?.toUpperCase()"></span>
|
||||
@@ -52,6 +52,17 @@
|
||||
<span x-html="$icon('shopping-bag', 'w-4 h-4 inline mr-2')"></span>
|
||||
Store Frontend
|
||||
</button>
|
||||
<button
|
||||
@click="frontendType = 'merchant'; loadPlatformMenuConfig()"
|
||||
:class="{
|
||||
'bg-white dark:bg-gray-800 shadow': frontendType === 'merchant',
|
||||
'text-gray-600 dark:text-gray-400': frontendType !== 'merchant'
|
||||
}"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md transition-all"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-4 h-4 inline mr-2')"></span>
|
||||
Merchant Frontend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -151,28 +151,9 @@ function merchantProfile() {
|
||||
this.loadProfile();
|
||||
},
|
||||
|
||||
getToken() {
|
||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
|
||||
async loadProfile() {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
window.location.href = '/merchants/login';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/merchants/account/profile', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/merchants/login';
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to load profile');
|
||||
const data = await resp.json();
|
||||
const data = await apiClient.get('/merchants/account/profile');
|
||||
|
||||
this.form.name = data.name || '';
|
||||
this.form.contact_email = data.contact_email || data.email || '';
|
||||
@@ -193,22 +174,9 @@ function merchantProfile() {
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
const token = this.getToken();
|
||||
try {
|
||||
const resp = await fetch('/api/v1/merchants/account/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
throw new Error(data.detail || 'Failed to save profile');
|
||||
}
|
||||
await apiClient.put('/merchants/account/profile', this.form);
|
||||
this.successMessage = 'Profile updated successfully.';
|
||||
// Auto-hide success message after 3 seconds
|
||||
setTimeout(() => { this.successMessage = null; }, 3000);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
|
||||
@@ -92,28 +92,9 @@ function merchantStores() {
|
||||
this.loadStores();
|
||||
},
|
||||
|
||||
getToken() {
|
||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
|
||||
async loadStores() {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
window.location.href = '/merchants/login';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/merchants/account/stores', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/merchants/login';
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to load stores');
|
||||
const data = await resp.json();
|
||||
const data = await apiClient.get('/merchants/account/stores');
|
||||
this.stores = data.stores || data.items || [];
|
||||
} catch (err) {
|
||||
console.error('Error loading stores:', err);
|
||||
|
||||
@@ -181,10 +181,24 @@
|
||||
<!-- 4. API Client -->
|
||||
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||
|
||||
<!-- 5. Alpine.js v3 -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
<!-- 5. Alpine.js v3 with CDN fallback -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js';
|
||||
script.onerror = function() {
|
||||
console.warn('Alpine.js CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.defer = true;
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/alpine.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 6. Login Logic -->
|
||||
<!-- 6. Store Login Logic -->
|
||||
<script defer src="{{ url_for('tenancy_static', path='store/js/login.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
132
app/modules/tenancy/tests/unit/test_tenancy_metrics.py
Normal file
132
app/modules/tenancy/tests/unit/test_tenancy_metrics.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# app/modules/tenancy/tests/unit/test_tenancy_metrics.py
|
||||
"""Unit tests for TenancyMetricsProvider.get_merchant_metrics."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store, StoreUser, User
|
||||
from app.modules.tenancy.services.tenancy_metrics import TenancyMetricsProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics_merchant(db):
|
||||
"""Create a merchant owner and merchant for metrics tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
user = User(
|
||||
email=f"metricsowner_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"metricsowner_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
merchant = Merchant(
|
||||
name="Metrics Test Merchant",
|
||||
owner_user_id=user.id,
|
||||
contact_email=user.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics_stores(db, metrics_merchant):
|
||||
"""Create 3 stores (2 active, 1 inactive) for the merchant."""
|
||||
stores = []
|
||||
for i, active in enumerate([True, True, False]):
|
||||
uid = uuid.uuid4().hex[:8].upper()
|
||||
store = Store(
|
||||
merchant_id=metrics_merchant.id,
|
||||
store_code=f"MSTORE_{uid}",
|
||||
subdomain=f"mstore{uid.lower()}",
|
||||
name=f"Metrics Store {i}",
|
||||
is_active=active,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
stores.append(store)
|
||||
db.commit()
|
||||
for s in stores:
|
||||
db.refresh(s)
|
||||
return stores
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metrics_team_members(db, metrics_stores):
|
||||
"""Create team members across merchant stores."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
users = []
|
||||
for i in range(3):
|
||||
u = User(
|
||||
email=f"team_{uuid.uuid4().hex[:8]}@test.com",
|
||||
username=f"team_{uuid.uuid4().hex[:8]}",
|
||||
hashed_password=auth.hash_password("pass123"),
|
||||
role="store_user",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(u)
|
||||
users.append(u)
|
||||
db.flush()
|
||||
|
||||
# User 0 on store 0 and store 1 (should be counted once)
|
||||
db.add(StoreUser(store_id=metrics_stores[0].id, user_id=users[0].id, is_active=True))
|
||||
db.add(StoreUser(store_id=metrics_stores[1].id, user_id=users[0].id, is_active=True))
|
||||
# User 1 on store 0 only
|
||||
db.add(StoreUser(store_id=metrics_stores[0].id, user_id=users[1].id, is_active=True))
|
||||
# User 2 on store 0 but inactive
|
||||
db.add(StoreUser(store_id=metrics_stores[0].id, user_id=users[2].id, is_active=False))
|
||||
db.commit()
|
||||
return users
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tenancy
|
||||
class TestTenancyMetricsProviderMerchant:
|
||||
"""Tests for TenancyMetricsProvider.get_merchant_metrics."""
|
||||
|
||||
def setup_method(self):
|
||||
self.provider = TenancyMetricsProvider()
|
||||
|
||||
def test_total_stores(self, db, metrics_merchant, metrics_stores):
|
||||
"""Returns correct total store count for merchant."""
|
||||
metrics = self.provider.get_merchant_metrics(db, metrics_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["tenancy.total_stores"] == 3
|
||||
|
||||
def test_active_stores(self, db, metrics_merchant, metrics_stores):
|
||||
"""Returns correct active store count (excludes inactive)."""
|
||||
metrics = self.provider.get_merchant_metrics(db, metrics_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["tenancy.active_stores"] == 2
|
||||
|
||||
def test_team_members_distinct(self, db, metrics_merchant, metrics_stores, metrics_team_members):
|
||||
"""Counts distinct active team members across stores."""
|
||||
metrics = self.provider.get_merchant_metrics(db, metrics_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
# 2 active distinct users (user 0 on 2 stores counted once, user 1, user 2 inactive)
|
||||
assert by_key["tenancy.team_members"] == 2
|
||||
|
||||
def test_no_stores(self, db, metrics_merchant):
|
||||
"""Returns zero counts when merchant has no stores."""
|
||||
metrics = self.provider.get_merchant_metrics(db, metrics_merchant.id)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["tenancy.total_stores"] == 0
|
||||
assert by_key["tenancy.active_stores"] == 0
|
||||
assert by_key["tenancy.team_members"] == 0
|
||||
|
||||
def test_nonexistent_merchant(self, db):
|
||||
"""Returns zero counts for a non-existent merchant ID."""
|
||||
metrics = self.provider.get_merchant_metrics(db, 999999)
|
||||
by_key = {m.key: m.value for m in metrics}
|
||||
assert by_key["tenancy.total_stores"] == 0
|
||||
@@ -1,60 +1,5 @@
|
||||
{# app/templates/merchant/partials/sidebar.html #}
|
||||
{# Collapsible sidebar sections with localStorage persistence - matching store pattern #}
|
||||
|
||||
{# ============================================================================
|
||||
REUSABLE MACROS FOR SIDEBAR ITEMS
|
||||
============================================================================ #}
|
||||
|
||||
{# Macro for collapsible section header #}
|
||||
{% macro section_header(title, section_key, icon=none) %}
|
||||
<div class="px-6 my-4">
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
<button
|
||||
@click="toggleSection('{{ section_key }}')"
|
||||
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{% if icon %}
|
||||
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2 text-gray-400')"></span>
|
||||
{% endif %}
|
||||
{{ title }}
|
||||
</span>
|
||||
<span
|
||||
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||
:class="{ 'rotate-180': openSections.{{ section_key }} }"
|
||||
></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro for collapsible section content wrapper #}
|
||||
{% macro section_content(section_key) %}
|
||||
<ul
|
||||
x-show="openSections.{{ section_key }}"
|
||||
x-transition:enter="transition-all duration-200 ease-out"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition-all duration-150 ease-in"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="mt-1 overflow-hidden"
|
||||
>
|
||||
{{ caller() }}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro for menu item - uses static href (no storeCode needed) #}
|
||||
{% macro menu_item(page_id, path, icon, label) %}
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="{{ path }}">
|
||||
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
|
||||
<span class="ml-4">{{ label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
{# Dynamic sidebar driven by menu discovery API - matching admin pattern #}
|
||||
|
||||
{# ============================================================================
|
||||
SIDEBAR CONTENT (shared between desktop and mobile)
|
||||
@@ -69,24 +14,92 @@
|
||||
<span>Merchant Portal</span>
|
||||
</a>
|
||||
|
||||
<!-- Dashboard (always visible) -->
|
||||
<!-- Loading skeleton -->
|
||||
<div x-show="menuLoading" class="mt-6 px-6 space-y-4 animate-pulse">
|
||||
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
|
||||
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic menu from API -->
|
||||
<div x-show="!menuLoading && menuData" x-cloak>
|
||||
<template x-for="section in (menuData?.sections || [])" :key="section.id">
|
||||
<div>
|
||||
{# Unlabeled sections (e.g. "main" with dashboard) render items directly #}
|
||||
<template x-if="!section.label">
|
||||
<ul class="mt-6">
|
||||
{{ menu_item('dashboard', '/merchants/dashboard', 'home', 'Dashboard') }}
|
||||
<template x-for="item in section.items" :key="item.id">
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === item.id" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
:href="item.url">
|
||||
<span x-html="$icon(item.icon, 'w-5 h-5')"></span>
|
||||
<span class="ml-4" x-text="item.label"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Billing Section -->
|
||||
{{ section_header('Billing', 'billing', 'credit-card') }}
|
||||
{% call section_content('billing') %}
|
||||
{{ menu_item('subscriptions', '/merchants/billing/subscriptions', 'clipboard-list', 'Subscriptions') }}
|
||||
{{ menu_item('billing', '/merchants/billing/billing', 'currency-euro', 'Billing History') }}
|
||||
{% endcall %}
|
||||
{# Labeled sections are collapsible #}
|
||||
<template x-if="section.label">
|
||||
<div>
|
||||
<div class="px-6 my-4">
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
<button
|
||||
@click="toggleSection(section.id)"
|
||||
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span class="flex items-center" x-text="section.label"></span>
|
||||
<span
|
||||
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||
:class="{ 'rotate-180': openSections[section.id] }"
|
||||
></span>
|
||||
</button>
|
||||
<ul
|
||||
x-show="openSections[section.id]"
|
||||
x-transition:enter="transition-all duration-200 ease-out"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition-all duration-150 ease-in"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="mt-1 overflow-hidden"
|
||||
>
|
||||
<template x-for="item in section.items" :key="item.id">
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === item.id" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
:href="item.url">
|
||||
<span x-html="$icon(item.icon, 'w-5 h-5')"></span>
|
||||
<span class="ml-4" x-text="item.label"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
{{ section_header('Account', 'account', 'cog') }}
|
||||
{% call section_content('account') %}
|
||||
{{ menu_item('stores', '/merchants/account/stores', 'shopping-bag', 'Stores') }}
|
||||
{{ menu_item('profile', '/merchants/account/profile', 'user', 'Profile') }}
|
||||
{% endcall %}
|
||||
<!-- Fallback: static dashboard link (if menu API fails) -->
|
||||
<div x-show="!menuLoading && !menuData" x-cloak>
|
||||
<ul class="mt-6">
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/merchants/dashboard">
|
||||
<span x-html="$icon('home', 'w-5 h-5')"></span>
|
||||
<span class="ml-4">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
{# app/templates/store/partials/sidebar.html #}
|
||||
{# Collapsible sidebar sections with localStorage persistence - matching admin pattern #}
|
||||
|
||||
{# ============================================================================
|
||||
REUSABLE MACROS FOR SIDEBAR ITEMS
|
||||
============================================================================ #}
|
||||
|
||||
{# Macro for collapsible section header #}
|
||||
{% macro section_header(title, section_key, icon=none) %}
|
||||
<div class="px-6 my-4">
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
<button
|
||||
@click="toggleSection('{{ section_key }}')"
|
||||
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{% if icon %}
|
||||
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2 text-gray-400')"></span>
|
||||
{% endif %}
|
||||
{{ title }}
|
||||
</span>
|
||||
<span
|
||||
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||
:class="{ 'rotate-180': openSections.{{ section_key }} }"
|
||||
></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro for collapsible section content wrapper #}
|
||||
{% macro section_content(section_key) %}
|
||||
<ul
|
||||
x-show="openSections.{{ section_key }}"
|
||||
x-transition:enter="transition-all duration-200 ease-out"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition-all duration-150 ease-in"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="mt-1 overflow-hidden"
|
||||
>
|
||||
{{ caller() }}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro for menu item - uses storeCode for dynamic URLs #}
|
||||
{% macro menu_item(page_id, path, icon, label) %}
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
:href="`/store/${storeCode}/{{ path }}`">
|
||||
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
|
||||
<span class="ml-4">{{ label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
{# Dynamic sidebar driven by menu discovery API - matching merchant/admin pattern #}
|
||||
|
||||
{# ============================================================================
|
||||
SIDEBAR CONTENT (shared between desktop and mobile)
|
||||
@@ -69,57 +14,94 @@
|
||||
<span x-text="store?.name || 'Store Portal'"></span>
|
||||
</a>
|
||||
|
||||
<!-- Dashboard (always visible) -->
|
||||
<!-- Loading skeleton -->
|
||||
<div x-show="menuLoading" class="mt-6 px-6 space-y-4 animate-pulse">
|
||||
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
|
||||
<div class="h-8 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic menu from API -->
|
||||
<div x-show="!menuLoading && menuData" x-cloak>
|
||||
<template x-for="section in (menuData?.sections || [])" :key="section.id">
|
||||
<div>
|
||||
{# Unlabeled sections (e.g. "main" with dashboard) render items directly #}
|
||||
<template x-if="!section.label">
|
||||
<ul class="mt-6">
|
||||
{{ menu_item('dashboard', 'dashboard', 'home', 'Dashboard') }}
|
||||
{{ menu_item('analytics', 'analytics', 'chart-bar', 'Analytics') }}
|
||||
<template x-for="item in section.items" :key="item.id">
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === item.id" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
:href="item.url">
|
||||
<span x-html="$icon(item.icon, 'w-5 h-5')"></span>
|
||||
<span class="ml-4" x-text="item.label"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Products & Inventory Section -->
|
||||
{{ section_header('Products & Inventory', 'products', 'cube') }}
|
||||
{% call section_content('products') %}
|
||||
{{ menu_item('products', 'products', 'shopping-bag', 'All Products') }}
|
||||
{{ menu_item('inventory', 'inventory', 'clipboard-list', 'Inventory') }}
|
||||
{{ menu_item('marketplace', 'marketplace', 'download', 'Marketplace Import') }}
|
||||
{% endcall %}
|
||||
{# Labeled sections are collapsible #}
|
||||
<template x-if="section.label">
|
||||
<div>
|
||||
<div class="px-6 my-4">
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
<button
|
||||
@click="toggleSection(section.id)"
|
||||
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span class="flex items-center" x-text="section.label"></span>
|
||||
<span
|
||||
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||
:class="{ 'rotate-180': openSections[section.id] }"
|
||||
></span>
|
||||
</button>
|
||||
<ul
|
||||
x-show="openSections[section.id]"
|
||||
x-transition:enter="transition-all duration-200 ease-out"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition-all duration-150 ease-in"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="mt-1 overflow-hidden"
|
||||
>
|
||||
<template x-for="item in section.items" :key="item.id">
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === item.id" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
:href="item.url">
|
||||
<span x-html="$icon(item.icon, 'w-5 h-5')"></span>
|
||||
<span class="ml-4" x-text="item.label"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Sales & Orders Section -->
|
||||
{{ section_header('Sales & Orders', 'sales', 'shopping-cart') }}
|
||||
{% call section_content('sales') %}
|
||||
{{ menu_item('orders', 'orders', 'document-text', 'Orders') }}
|
||||
{{ menu_item('letzshop', 'letzshop', 'external-link', 'Letzshop Orders') }}
|
||||
{{ menu_item('invoices', 'invoices', 'currency-euro', 'Invoices') }}
|
||||
{% endcall %}
|
||||
<!-- Fallback: static dashboard link (if menu API fails) -->
|
||||
<div x-show="!menuLoading && !menuData" x-cloak>
|
||||
<ul class="mt-6">
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
:href="`/store/${storeCode}/dashboard`">
|
||||
<span x-html="$icon('home', 'w-5 h-5')"></span>
|
||||
<span class="ml-4">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Customers & Communication Section -->
|
||||
{{ section_header('Customers', 'customers', 'users') }}
|
||||
{% call section_content('customers') %}
|
||||
{{ menu_item('customers', 'customers', 'user-group', 'All Customers') }}
|
||||
{{ menu_item('messages', 'messages', 'chat-bubble-left-right', 'Messages') }}
|
||||
{{ menu_item('notifications', 'notifications', 'bell', 'Notifications') }}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Shop & Content Section -->
|
||||
{{ section_header('Shop & Content', 'shop', 'color-swatch') }}
|
||||
{% call section_content('shop') %}
|
||||
{{ menu_item('content-pages', 'content-pages', 'document-text', 'Content Pages') }}
|
||||
{{ menu_item('media', 'media', 'photograph', 'Media Library') }}
|
||||
{# Future: Theme customization, if enabled for store tier
|
||||
{{ menu_item('theme', 'theme', 'paint-brush', 'Theme') }}
|
||||
#}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Account & Settings Section -->
|
||||
{{ section_header('Account & Settings', 'account', 'cog') }}
|
||||
{% call section_content('account') %}
|
||||
{{ menu_item('team', 'team', 'user-group', 'Team') }}
|
||||
{{ menu_item('profile', 'profile', 'user', 'Profile') }}
|
||||
{{ menu_item('billing', 'billing', 'credit-card', 'Billing') }}
|
||||
{{ menu_item('email-templates', 'email-templates', 'mail', 'Email Templates') }}
|
||||
{{ menu_item('settings', 'settings', 'adjustments', 'Settings') }}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<!-- Quick Actions (static, outside dynamic menu) -->
|
||||
<div class="px-6 my-6">
|
||||
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
@click="$dispatch('open-add-product-modal')">
|
||||
|
||||
Reference in New Issue
Block a user