feat: loyalty feature provider, admin data fixes, storefront mobile menu
- Add LoyaltyFeatureProvider with 11 BINARY/MERCHANT features for billing feature gating, wired into loyalty module definition - Fix subscription-tiers admin page showing 0 features by populating feature_codes from tier relationship in all admin tier endpoints - Fix merchants admin page showing 0 stores and N/A owner by adding store_count and owner_email to MerchantResponse and eager-loading owner - Add "no tiers" warning with link in subscription creation modal when platform has no configured tiers - Add missing mobile menu panel to storefront base template so hamburger toggle actually shows navigation links Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,7 @@ def list_subscription_tiers(
|
||||
for t in tiers:
|
||||
resp = SubscriptionTierResponse.model_validate(t)
|
||||
resp.platform_name = platforms_map.get(t.platform_id) if t.platform_id else None
|
||||
resp.feature_codes = sorted(t.get_feature_codes())
|
||||
tiers_response.append(resp)
|
||||
|
||||
return SubscriptionTierListResponse(
|
||||
@@ -83,7 +84,9 @@ def get_subscription_tier(
|
||||
):
|
||||
"""Get a specific subscription tier by code."""
|
||||
tier = admin_subscription_service.get_tier_by_code(db, tier_code)
|
||||
return SubscriptionTierResponse.model_validate(tier)
|
||||
resp = SubscriptionTierResponse.model_validate(tier)
|
||||
resp.feature_codes = sorted(tier.get_feature_codes())
|
||||
return resp
|
||||
|
||||
|
||||
@admin_router.post("/tiers", response_model=SubscriptionTierResponse, status_code=201)
|
||||
@@ -96,7 +99,9 @@ def create_subscription_tier(
|
||||
tier = admin_subscription_service.create_tier(db, tier_data.model_dump())
|
||||
db.commit()
|
||||
db.refresh(tier)
|
||||
return SubscriptionTierResponse.model_validate(tier)
|
||||
resp = SubscriptionTierResponse.model_validate(tier)
|
||||
resp.feature_codes = sorted(tier.get_feature_codes())
|
||||
return resp
|
||||
|
||||
|
||||
@admin_router.patch("/tiers/{tier_code}", response_model=SubscriptionTierResponse)
|
||||
@@ -111,7 +116,9 @@ def update_subscription_tier(
|
||||
tier = admin_subscription_service.update_tier(db, tier_code, update_data)
|
||||
db.commit()
|
||||
db.refresh(tier)
|
||||
return SubscriptionTierResponse.model_validate(tier)
|
||||
resp = SubscriptionTierResponse.model_validate(tier)
|
||||
resp.feature_codes = sorted(tier.get_feature_codes())
|
||||
return resp
|
||||
|
||||
|
||||
@admin_router.delete("/tiers/{tier_code}", status_code=204)
|
||||
|
||||
@@ -44,6 +44,13 @@ def _get_storefront_router():
|
||||
return storefront_router
|
||||
|
||||
|
||||
def _get_feature_provider():
|
||||
"""Lazy import of feature provider to avoid circular imports."""
|
||||
from app.modules.loyalty.services.loyalty_features import loyalty_feature_provider
|
||||
|
||||
return loyalty_feature_provider
|
||||
|
||||
|
||||
# Loyalty module definition
|
||||
loyalty_module = ModuleDefinition(
|
||||
code="loyalty",
|
||||
@@ -213,6 +220,8 @@ loyalty_module = ModuleDefinition(
|
||||
options={"queue": "scheduled"},
|
||||
),
|
||||
],
|
||||
# Feature provider for billing feature gating
|
||||
feature_provider=_get_feature_provider,
|
||||
)
|
||||
|
||||
|
||||
|
||||
184
app/modules/loyalty/services/loyalty_features.py
Normal file
184
app/modules/loyalty/services/loyalty_features.py
Normal file
@@ -0,0 +1,184 @@
|
||||
# app/modules/loyalty/services/loyalty_features.py
|
||||
"""
|
||||
Loyalty feature provider for the billing feature system.
|
||||
|
||||
Declares loyalty-related billable features (stamps, points, cards, wallets, etc.)
|
||||
for feature gating.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.modules.contracts.features import (
|
||||
FeatureDeclaration,
|
||||
FeatureScope,
|
||||
FeatureType,
|
||||
FeatureUsage,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoyaltyFeatureProvider:
|
||||
"""Feature provider for the loyalty module.
|
||||
|
||||
Declares:
|
||||
- loyalty_stamps: stamp-based loyalty programs
|
||||
- loyalty_points: points-based loyalty programs
|
||||
- loyalty_hybrid: combined stamps and points
|
||||
- loyalty_cards: customer card management
|
||||
- loyalty_enrollment: customer enrollment
|
||||
- loyalty_staff_pins: staff PIN management
|
||||
- loyalty_anti_fraud: cooldown and daily limits
|
||||
- loyalty_google_wallet: Google Wallet passes
|
||||
- loyalty_apple_wallet: Apple Wallet passes
|
||||
- loyalty_stats: dashboard statistics
|
||||
- loyalty_reports: transaction reports
|
||||
"""
|
||||
|
||||
@property
|
||||
def feature_category(self) -> str:
|
||||
return "loyalty"
|
||||
|
||||
def get_feature_declarations(self) -> list[FeatureDeclaration]:
|
||||
return [
|
||||
FeatureDeclaration(
|
||||
code="loyalty_stamps",
|
||||
name_key="loyalty.features.loyalty_stamps.name",
|
||||
description_key="loyalty.features.loyalty_stamps.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="stamp",
|
||||
display_order=10,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_points",
|
||||
name_key="loyalty.features.loyalty_points.name",
|
||||
description_key="loyalty.features.loyalty_points.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="coins",
|
||||
display_order=20,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_hybrid",
|
||||
name_key="loyalty.features.loyalty_hybrid.name",
|
||||
description_key="loyalty.features.loyalty_hybrid.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="layers",
|
||||
display_order=30,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_cards",
|
||||
name_key="loyalty.features.loyalty_cards.name",
|
||||
description_key="loyalty.features.loyalty_cards.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="credit-card",
|
||||
display_order=40,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_enrollment",
|
||||
name_key="loyalty.features.loyalty_enrollment.name",
|
||||
description_key="loyalty.features.loyalty_enrollment.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="user-plus",
|
||||
display_order=50,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_staff_pins",
|
||||
name_key="loyalty.features.loyalty_staff_pins.name",
|
||||
description_key="loyalty.features.loyalty_staff_pins.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="shield",
|
||||
display_order=60,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_anti_fraud",
|
||||
name_key="loyalty.features.loyalty_anti_fraud.name",
|
||||
description_key="loyalty.features.loyalty_anti_fraud.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="lock",
|
||||
display_order=70,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_google_wallet",
|
||||
name_key="loyalty.features.loyalty_google_wallet.name",
|
||||
description_key="loyalty.features.loyalty_google_wallet.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="smartphone",
|
||||
display_order=80,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_apple_wallet",
|
||||
name_key="loyalty.features.loyalty_apple_wallet.name",
|
||||
description_key="loyalty.features.loyalty_apple_wallet.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="smartphone",
|
||||
display_order=85,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_stats",
|
||||
name_key="loyalty.features.loyalty_stats.name",
|
||||
description_key="loyalty.features.loyalty_stats.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="bar-chart",
|
||||
display_order=90,
|
||||
),
|
||||
FeatureDeclaration(
|
||||
code="loyalty_reports",
|
||||
name_key="loyalty.features.loyalty_reports.name",
|
||||
description_key="loyalty.features.loyalty_reports.description",
|
||||
category="loyalty",
|
||||
feature_type=FeatureType.BINARY,
|
||||
scope=FeatureScope.MERCHANT,
|
||||
ui_icon="file-text",
|
||||
display_order=100,
|
||||
),
|
||||
]
|
||||
|
||||
def get_store_usage(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
) -> list[FeatureUsage]:
|
||||
return []
|
||||
|
||||
def get_merchant_usage(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
platform_id: int,
|
||||
) -> list[FeatureUsage]:
|
||||
return []
|
||||
|
||||
|
||||
# Singleton instance for module registration
|
||||
loyalty_feature_provider = LoyaltyFeatureProvider()
|
||||
|
||||
__all__ = [
|
||||
"LoyaltyFeatureProvider",
|
||||
"loyalty_feature_provider",
|
||||
]
|
||||
@@ -109,6 +109,7 @@ def get_all_merchants(
|
||||
name=c.name,
|
||||
description=c.description,
|
||||
owner_user_id=c.owner_user_id,
|
||||
owner_email=c.owner.email if c.owner else None,
|
||||
contact_email=c.contact_email,
|
||||
contact_phone=c.contact_phone,
|
||||
website=c.website,
|
||||
@@ -118,6 +119,7 @@ def get_all_merchants(
|
||||
is_verified=c.is_verified,
|
||||
created_at=c.created_at.isoformat(),
|
||||
updated_at=c.updated_at.isoformat(),
|
||||
store_count=c.store_count,
|
||||
)
|
||||
for c in merchants
|
||||
],
|
||||
|
||||
@@ -90,6 +90,7 @@ class MerchantResponse(BaseModel):
|
||||
|
||||
# Owner information
|
||||
owner_user_id: int
|
||||
owner_email: str | None = Field(None, description="Owner's email address")
|
||||
|
||||
# Contact Information
|
||||
contact_email: str
|
||||
@@ -108,6 +109,9 @@ class MerchantResponse(BaseModel):
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
# Store statistics
|
||||
store_count: int = Field(0, description="Number of stores under this merchant")
|
||||
|
||||
|
||||
class MerchantDetailResponse(MerchantResponse):
|
||||
"""
|
||||
@@ -117,11 +121,9 @@ class MerchantDetailResponse(MerchantResponse):
|
||||
"""
|
||||
|
||||
# Owner details (from related User)
|
||||
owner_email: str | None = Field(None, description="Owner's email address")
|
||||
owner_username: str | None = Field(None, description="Owner's username")
|
||||
|
||||
# Store statistics
|
||||
store_count: int = Field(0, description="Number of stores under this merchant")
|
||||
active_store_count: int = Field(
|
||||
0, description="Number of active stores under this merchant"
|
||||
)
|
||||
|
||||
@@ -148,7 +148,10 @@ class MerchantService:
|
||||
Returns:
|
||||
Tuple of (merchants list, total count)
|
||||
"""
|
||||
query = select(Merchant).options(joinedload(Merchant.stores))
|
||||
query = select(Merchant).options(
|
||||
joinedload(Merchant.stores),
|
||||
joinedload(Merchant.owner),
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if search:
|
||||
|
||||
@@ -331,12 +331,20 @@
|
||||
<!-- Tier Selector -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subscription Tier</label>
|
||||
<template x-if="tiers.length > 0">
|
||||
<select x-model="createForm.tier_code"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<template x-for="tier in tiers" :key="tier.code">
|
||||
<option :value="tier.code" x-text="tier.name + ' — ' + formatTierPrice(tier)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
<template x-if="tiers.length === 0">
|
||||
<div class="p-3 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg dark:text-amber-300 dark:bg-amber-900/20 dark:border-amber-800">
|
||||
No tiers configured for this platform.
|
||||
<a href="/admin/subscription-tiers" class="underline font-medium hover:text-amber-900 dark:hover:text-amber-100">Create a tier first</a>.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
@@ -372,7 +380,7 @@
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="createSubscription()"
|
||||
:disabled="creatingSubscription"
|
||||
:disabled="creatingSubscription || tiers.length === 0"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!creatingSubscription">Create</span>
|
||||
<span x-show="creatingSubscription">Creating...</span>
|
||||
|
||||
@@ -206,6 +206,36 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# Mobile menu panel #}
|
||||
<div x-show="mobileMenuOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="md:hidden bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 shadow-lg">
|
||||
<nav class="max-w-7xl mx-auto px-4 py-4 space-y-1">
|
||||
<a href="{{ base_url }}" @click="closeMobileMenu()"
|
||||
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 font-medium">
|
||||
Home
|
||||
</a>
|
||||
{% for item in storefront_nav.get('nav', []) %}
|
||||
<a href="{{ base_url }}{{ item.route }}" @click="closeMobileMenu()"
|
||||
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
{{ _(item.label_key) }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for page in header_pages|default([]) %}
|
||||
<a href="{{ base_url }}storefront/{{ page.slug }}" @click="closeMobileMenu()"
|
||||
class="block px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
{{ page.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{# Main Content Area #}
|
||||
<main class="min-h-screen">
|
||||
{% block content %}
|
||||
|
||||
0
tests/unit/modules/loyalty/__init__.py
Normal file
0
tests/unit/modules/loyalty/__init__.py
Normal file
92
tests/unit/modules/loyalty/test_loyalty_features.py
Normal file
92
tests/unit/modules/loyalty/test_loyalty_features.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# tests/unit/modules/loyalty/test_loyalty_features.py
|
||||
"""
|
||||
Unit tests for the loyalty feature provider.
|
||||
|
||||
Tests cover:
|
||||
- Feature category
|
||||
- Feature declarations (count, codes, types, scopes)
|
||||
- Usage methods return empty lists
|
||||
- Protocol compliance
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.contracts.features import (
|
||||
FeatureProviderProtocol,
|
||||
FeatureScope,
|
||||
FeatureType,
|
||||
)
|
||||
from app.modules.loyalty.definition import loyalty_module
|
||||
from app.modules.loyalty.services.loyalty_features import (
|
||||
LoyaltyFeatureProvider,
|
||||
loyalty_feature_provider,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLoyaltyFeatureProvider:
|
||||
"""Tests for LoyaltyFeatureProvider."""
|
||||
|
||||
def test_feature_category(self):
|
||||
"""Feature category should be 'loyalty'."""
|
||||
assert loyalty_feature_provider.feature_category == "loyalty"
|
||||
|
||||
def test_get_feature_declarations_count(self):
|
||||
"""Should return exactly 11 feature declarations."""
|
||||
declarations = loyalty_feature_provider.get_feature_declarations()
|
||||
assert len(declarations) == 11
|
||||
|
||||
def test_feature_codes_match_module_definition(self):
|
||||
"""All declared feature codes should match loyalty_module.features."""
|
||||
declarations = loyalty_feature_provider.get_feature_declarations()
|
||||
declared_codes = {d.code for d in declarations}
|
||||
module_features = set(loyalty_module.features)
|
||||
assert declared_codes == module_features
|
||||
|
||||
def test_all_features_are_binary(self):
|
||||
"""All loyalty features should be BINARY type."""
|
||||
declarations = loyalty_feature_provider.get_feature_declarations()
|
||||
for decl in declarations:
|
||||
assert decl.feature_type == FeatureType.BINARY, (
|
||||
f"Feature {decl.code} should be BINARY, got {decl.feature_type}"
|
||||
)
|
||||
|
||||
def test_all_features_are_merchant_scoped(self):
|
||||
"""All loyalty features should be MERCHANT scoped."""
|
||||
declarations = loyalty_feature_provider.get_feature_declarations()
|
||||
for decl in declarations:
|
||||
assert decl.scope == FeatureScope.MERCHANT, (
|
||||
f"Feature {decl.code} should be MERCHANT scoped, got {decl.scope}"
|
||||
)
|
||||
|
||||
def test_all_features_have_name_key(self):
|
||||
"""All features should have a non-empty name_key."""
|
||||
declarations = loyalty_feature_provider.get_feature_declarations()
|
||||
for decl in declarations:
|
||||
assert decl.name_key, f"Feature {decl.code} missing name_key"
|
||||
|
||||
def test_all_features_have_description_key(self):
|
||||
"""All features should have a non-empty description_key."""
|
||||
declarations = loyalty_feature_provider.get_feature_declarations()
|
||||
for decl in declarations:
|
||||
assert decl.description_key, f"Feature {decl.code} missing description_key"
|
||||
|
||||
def test_get_store_usage_returns_empty(self):
|
||||
"""get_store_usage should return an empty list."""
|
||||
result = loyalty_feature_provider.get_store_usage(db=None, store_id=1)
|
||||
assert result == []
|
||||
|
||||
def test_get_merchant_usage_returns_empty(self):
|
||||
"""get_merchant_usage should return an empty list."""
|
||||
result = loyalty_feature_provider.get_merchant_usage(
|
||||
db=None, merchant_id=1, platform_id=1
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_satisfies_feature_provider_protocol(self):
|
||||
"""Provider should satisfy FeatureProviderProtocol."""
|
||||
assert isinstance(loyalty_feature_provider, FeatureProviderProtocol)
|
||||
|
||||
def test_singleton_is_correct_type(self):
|
||||
"""Singleton instance should be a LoyaltyFeatureProvider."""
|
||||
assert isinstance(loyalty_feature_provider, LoyaltyFeatureProvider)
|
||||
Reference in New Issue
Block a user