feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
Some checks failed
- Add admin SQL query tool with saved queries, schema explorer presets, and collapsible category sections (dev_tools module) - Add platform debug tool for admin diagnostics - Add loyalty settings page with owner-only access control - Fix loyalty settings owner check (use currentUser instead of window.__userData) - Replace HTTPException with AuthorizationException in loyalty routes - Expand loyalty module with PIN service, Apple Wallet, program management - Improve store login with platform detection and multi-platform support - Update billing feature gates and subscription services - Add store platform sync improvements and remove is_primary column - Add unit tests for loyalty (PIN, points, stamps, program services) - Update i18n translations across dev_tools locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -103,9 +103,12 @@ class RequireFeature:
|
||||
) -> None:
|
||||
"""Check if store's merchant has access to any of the required features."""
|
||||
store_id = current_user.token_store_id
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
for feature_code in self.feature_codes:
|
||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
||||
if feature_service.has_feature_for_store(
|
||||
db, store_id, feature_code, platform_id=platform_id
|
||||
):
|
||||
return
|
||||
|
||||
# None of the features are available
|
||||
@@ -136,7 +139,8 @@ class RequireWithinLimit:
|
||||
store_id = current_user.token_store_id
|
||||
|
||||
allowed, message = feature_service.check_resource_limit(
|
||||
db, self.feature_code, store_id=store_id
|
||||
db, self.feature_code, store_id=store_id,
|
||||
platform_id=current_user.token_platform_id,
|
||||
)
|
||||
|
||||
if not allowed:
|
||||
@@ -176,9 +180,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
||||
)
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
for feature_code in feature_codes:
|
||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
||||
if feature_service.has_feature_for_store(
|
||||
db, store_id, feature_code, platform_id=platform_id
|
||||
):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||
@@ -195,9 +202,12 @@ def require_feature(*feature_codes: str) -> Callable:
|
||||
)
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
platform_id = current_user.token_platform_id
|
||||
|
||||
for feature_code in feature_codes:
|
||||
if feature_service.has_feature_for_store(db, store_id, feature_code):
|
||||
if feature_service.has_feature_for_store(
|
||||
db, store_id, feature_code, platform_id=platform_id
|
||||
):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
raise FeatureNotAvailableError(feature_code=feature_codes[0])
|
||||
|
||||
@@ -44,7 +44,7 @@ def upgrade() -> None:
|
||||
sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"),
|
||||
sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"), # Removed in migration remove_is_primary_001
|
||||
sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"),
|
||||
sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"),
|
||||
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"),
|
||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
||||
sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"),
|
||||
)
|
||||
op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"])
|
||||
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"])
|
||||
op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]) # Removed in migration remove_is_primary_001
|
||||
|
||||
# --- tier_feature_limits ---
|
||||
op.create_table(
|
||||
|
||||
@@ -46,7 +46,7 @@ def get_subscription_status(
|
||||
):
|
||||
"""Get current subscription status."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
subscription, tier = billing_service.get_subscription_with_tier(db, merchant_id, platform_id)
|
||||
|
||||
@@ -83,7 +83,7 @@ def get_available_tiers(
|
||||
):
|
||||
"""Get available subscription tiers for upgrade/downgrade."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
subscription = subscription_service.get_or_create_subscription(db, merchant_id, platform_id)
|
||||
current_tier_id = subscription.tier_id
|
||||
@@ -105,7 +105,7 @@ def get_invoices(
|
||||
):
|
||||
"""Get invoice history."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
invoices, total = billing_service.get_invoices(db, merchant_id, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def create_checkout_session(
|
||||
):
|
||||
"""Create a Stripe checkout session for subscription."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
store_code = subscription_service.get_store_code(db, store_id)
|
||||
|
||||
@@ -84,7 +84,7 @@ def create_portal_session(
|
||||
):
|
||||
"""Create a Stripe customer portal session."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
store_code = subscription_service.get_store_code(db, store_id)
|
||||
return_url = f"https://{settings.platform_domain}/store/{store_code}/billing"
|
||||
@@ -102,7 +102,7 @@ def cancel_subscription(
|
||||
):
|
||||
"""Cancel subscription."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
result = billing_service.cancel_subscription(
|
||||
db=db,
|
||||
@@ -126,7 +126,7 @@ def reactivate_subscription(
|
||||
):
|
||||
"""Reactivate a cancelled subscription."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
result = billing_service.reactivate_subscription(db, merchant_id, platform_id)
|
||||
db.commit()
|
||||
@@ -141,7 +141,7 @@ def get_upcoming_invoice(
|
||||
):
|
||||
"""Preview the upcoming invoice."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
result = billing_service.get_upcoming_invoice(db, merchant_id, platform_id)
|
||||
|
||||
@@ -161,7 +161,7 @@ def change_tier(
|
||||
):
|
||||
"""Change subscription tier (upgrade/downgrade)."""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
result = billing_service.change_tier(
|
||||
db=db,
|
||||
|
||||
@@ -95,7 +95,7 @@ def get_available_features(
|
||||
List of feature codes the store has access to
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
# Get available feature codes
|
||||
feature_codes = feature_service.get_merchant_feature_codes(db, merchant_id, platform_id)
|
||||
@@ -134,7 +134,7 @@ def get_features(
|
||||
List of features with metadata and availability
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
# Get all declarations and available codes
|
||||
all_declarations = feature_aggregator.get_all_declarations()
|
||||
@@ -197,7 +197,7 @@ def get_features_grouped(
|
||||
Useful for rendering feature comparison tables or settings pages.
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
# Get declarations grouped by category and available codes
|
||||
by_category = feature_aggregator.get_declarations_by_category()
|
||||
@@ -246,7 +246,9 @@ def check_feature(
|
||||
has_feature and feature_code
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
has = feature_service.has_feature_for_store(db, store_id, feature_code)
|
||||
has = feature_service.has_feature_for_store(
|
||||
db, store_id, feature_code, platform_id=current_user.token_platform_id
|
||||
)
|
||||
|
||||
return StoreFeatureCheckResponse(has_feature=has, feature_code=feature_code)
|
||||
|
||||
@@ -270,7 +272,7 @@ def get_feature_detail(
|
||||
Feature details with upgrade info if locked
|
||||
"""
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id)
|
||||
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
|
||||
|
||||
# Get feature declaration
|
||||
decl = feature_aggregator.get_declaration(feature_code)
|
||||
|
||||
@@ -108,10 +108,17 @@ class FeatureService:
|
||||
# Store -> Merchant Resolution
|
||||
# =========================================================================
|
||||
|
||||
def _get_merchant_for_store(self, db: Session, store_id: int) -> tuple[int | None, int | None]:
|
||||
def _get_merchant_for_store(
|
||||
self, db: Session, store_id: int, platform_id: int | None = None
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""
|
||||
Resolve store_id to (merchant_id, platform_id).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||
|
||||
Returns:
|
||||
Tuple of (merchant_id, platform_id), either may be None
|
||||
"""
|
||||
@@ -123,7 +130,8 @@ class FeatureService:
|
||||
return None, None
|
||||
|
||||
merchant_id = store.merchant_id
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
|
||||
return merchant_id, platform_id
|
||||
|
||||
@@ -204,28 +212,29 @@ class FeatureService:
|
||||
return subscription.tier.has_feature(feature_code)
|
||||
|
||||
def has_feature_for_store(
|
||||
self, db: Session, store_id: int, feature_code: str
|
||||
self, db: Session, store_id: int, feature_code: str,
|
||||
platform_id: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Convenience method that resolves the store -> merchant -> platform
|
||||
hierarchy and checks whether the merchant has access to a feature.
|
||||
|
||||
Looks up the store's merchant_id and platform_id, then delegates
|
||||
to has_feature().
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
store_id: The store ID to resolve.
|
||||
feature_code: The feature code to check.
|
||||
platform_id: Platform ID from JWT. When provided, skips DB lookup.
|
||||
|
||||
Returns:
|
||||
True if the resolved merchant has access to the feature,
|
||||
False if the store/merchant cannot be resolved or lacks access.
|
||||
"""
|
||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
||||
if merchant_id is None or platform_id is None:
|
||||
merchant_id, resolved_platform_id = self._get_merchant_for_store(
|
||||
db, store_id, platform_id=platform_id
|
||||
)
|
||||
if merchant_id is None or resolved_platform_id is None:
|
||||
return False
|
||||
return self.has_feature(db, merchant_id, platform_id, feature_code)
|
||||
return self.has_feature(db, merchant_id, resolved_platform_id, feature_code)
|
||||
|
||||
def get_merchant_feature_codes(
|
||||
self, db: Session, merchant_id: int, platform_id: int
|
||||
@@ -317,7 +326,7 @@ class FeatureService:
|
||||
feature_code: Feature code (e.g., "products_limit")
|
||||
store_id: Store ID (if checking per-store)
|
||||
merchant_id: Merchant ID (if already known)
|
||||
platform_id: Platform ID (if already known)
|
||||
platform_id: Platform ID (if already known, e.g. from JWT)
|
||||
|
||||
Returns:
|
||||
(allowed, error_message) tuple
|
||||
@@ -326,7 +335,9 @@ class FeatureService:
|
||||
|
||||
# Resolve store -> merchant if needed
|
||||
if merchant_id is None and store_id is not None:
|
||||
merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
|
||||
merchant_id, platform_id = self._get_merchant_for_store(
|
||||
db, store_id, platform_id=platform_id
|
||||
)
|
||||
|
||||
if merchant_id is None or platform_id is None:
|
||||
return False, "No subscription found"
|
||||
|
||||
@@ -32,7 +32,7 @@ class StorePlatformSync:
|
||||
Upsert StorePlatform for every store belonging to a merchant.
|
||||
|
||||
- Existing entry → update is_active (and tier_id if provided)
|
||||
- Missing + is_active=True → create (set is_primary if store has none)
|
||||
- Missing + is_active=True → create
|
||||
- Missing + is_active=False → no-op
|
||||
"""
|
||||
stores = store_service.get_stores_by_merchant_id(db, merchant_id)
|
||||
|
||||
@@ -311,6 +311,7 @@ class StripeService:
|
||||
trial_days: int | None = None,
|
||||
quantity: int = 1,
|
||||
metadata: dict | None = None,
|
||||
platform_id: int | None = None,
|
||||
) -> stripe.checkout.Session:
|
||||
"""
|
||||
Create a Stripe Checkout session for subscription signup.
|
||||
@@ -324,6 +325,7 @@ class StripeService:
|
||||
trial_days: Optional trial period
|
||||
quantity: Number of items (default 1)
|
||||
metadata: Additional metadata to store
|
||||
platform_id: Platform ID (from JWT or caller). Falls back to DB lookup.
|
||||
|
||||
Returns:
|
||||
Stripe Checkout Session object
|
||||
@@ -334,7 +336,8 @@ class StripeService:
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.tenancy.services.team_service import team_service
|
||||
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store.id)
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||
subscription = None
|
||||
if store.merchant_id and platform_id:
|
||||
subscription = (
|
||||
|
||||
@@ -47,9 +47,16 @@ class SubscriptionService:
|
||||
# Store Resolution
|
||||
# =========================================================================
|
||||
|
||||
def resolve_store_to_merchant(self, db: Session, store_id: int) -> tuple[int, int]:
|
||||
def resolve_store_to_merchant(
|
||||
self, db: Session, store_id: int, platform_id: int | None = None
|
||||
) -> tuple[int, int]:
|
||||
"""Resolve store_id to (merchant_id, platform_id).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
platform_id: Platform ID from JWT token. When provided, skips DB lookup.
|
||||
|
||||
Raises:
|
||||
ResourceNotFoundException: If store not found or has no platform
|
||||
"""
|
||||
@@ -59,7 +66,8 @@ class SubscriptionService:
|
||||
store = store_service.get_store_by_id_optional(db, store_id)
|
||||
if not store or not store.merchant_id:
|
||||
raise ResourceNotFoundException("Store", str(store_id))
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
if platform_id is None:
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
if not platform_id:
|
||||
raise ResourceNotFoundException("StorePlatform", f"store_id={store_id}")
|
||||
return store.merchant_id, platform_id
|
||||
@@ -185,7 +193,7 @@ class SubscriptionService:
|
||||
if merchant_id is None:
|
||||
return None
|
||||
|
||||
platform_id = platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
if platform_id is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -39,52 +39,6 @@ class TestStorePlatformSyncCreate:
|
||||
assert sp is not None
|
||||
assert sp.is_active is True
|
||||
|
||||
def test_sync_sets_primary_when_none(self, db, test_store, test_platform):
|
||||
"""First platform synced for a store gets is_primary=True."""
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
||||
)
|
||||
|
||||
sp = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == test_store.id,
|
||||
StorePlatform.platform_id == test_platform.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert sp.is_primary is True
|
||||
|
||||
def test_sync_no_primary_override(self, db, test_store, test_platform, another_platform):
|
||||
"""Second platform synced does not override existing primary."""
|
||||
# First platform becomes primary
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
db, test_store.merchant_id, test_platform.id, is_active=True
|
||||
)
|
||||
# Second platform should not be primary
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
db, test_store.merchant_id, another_platform.id, is_active=True
|
||||
)
|
||||
|
||||
sp1 = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == test_store.id,
|
||||
StorePlatform.platform_id == test_platform.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
sp2 = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == test_store.id,
|
||||
StorePlatform.platform_id == another_platform.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert sp1.is_primary is True
|
||||
assert sp2.is_primary is False
|
||||
|
||||
def test_sync_sets_tier_id(self, db, test_store, test_platform, sync_tier):
|
||||
"""Sync passes tier_id to newly created StorePlatform."""
|
||||
self.service.sync_store_platforms_for_merchant(
|
||||
@@ -118,7 +72,6 @@ class TestStorePlatformSyncUpdate:
|
||||
store_id=test_store.id,
|
||||
platform_id=test_platform.id,
|
||||
is_active=True,
|
||||
is_primary=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.flush()
|
||||
@@ -137,7 +90,6 @@ class TestStorePlatformSyncUpdate:
|
||||
store_id=test_store.id,
|
||||
platform_id=test_platform.id,
|
||||
is_active=True,
|
||||
is_primary=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.flush()
|
||||
|
||||
@@ -52,7 +52,7 @@ def list_store_pages(
|
||||
|
||||
Returns store-specific overrides + platform defaults (store overrides take precedence).
|
||||
"""
|
||||
platform_id = content_page_service.resolve_platform_id(db, current_user.token_store_id)
|
||||
platform_id = current_user.token_platform_id or content_page_service.resolve_platform_id(db, current_user.token_store_id)
|
||||
pages = content_page_service.list_pages_for_store(
|
||||
db, platform_id=platform_id, store_id=current_user.token_store_id, include_unpublished=include_unpublished
|
||||
)
|
||||
@@ -176,7 +176,7 @@ def get_page(
|
||||
|
||||
Returns store override if exists, otherwise platform default.
|
||||
"""
|
||||
platform_id = content_page_service.resolve_platform_id(db, current_user.token_store_id)
|
||||
platform_id = current_user.token_platform_id or content_page_service.resolve_platform_id(db, current_user.token_store_id)
|
||||
page = content_page_service.get_page_for_store_or_raise(
|
||||
db,
|
||||
platform_id=platform_id,
|
||||
|
||||
@@ -47,7 +47,7 @@ class ContentPageService:
|
||||
@staticmethod
|
||||
def resolve_platform_id(db: Session, store_id: int) -> int | None:
|
||||
"""
|
||||
Resolve platform_id from store's primary StorePlatform.
|
||||
Resolve platform_id from store's first active StorePlatform.
|
||||
|
||||
Resolution order:
|
||||
1. Primary StorePlatform for the store
|
||||
@@ -62,7 +62,7 @@ class ContentPageService:
|
||||
"""
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
return platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
return platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
|
||||
@staticmethod
|
||||
def resolve_platform_id_or_raise(db: Session, store_id: int) -> int:
|
||||
|
||||
@@ -143,9 +143,11 @@ def get_onboarding_status(
|
||||
store_id = current_user.token_store_id
|
||||
store = store_service.get_store_by_id(db, store_id)
|
||||
|
||||
return onboarding_aggregator.get_onboarding_summary(
|
||||
summary = onboarding_aggregator.get_onboarding_summary(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
platform_id=platform.id,
|
||||
store_code=store.store_code,
|
||||
)
|
||||
summary["is_owner"] = current_user.role == "merchant_owner"
|
||||
return summary
|
||||
|
||||
@@ -100,7 +100,7 @@ async def get_rendered_store_menu(
|
||||
# Platform from JWT (set at login from URL context), fall back to DB for old tokens
|
||||
platform_id = current_user.token_platform_id
|
||||
if platform_id is None:
|
||||
platform_id = menu_service.get_store_primary_platform_id(db, store.id)
|
||||
platform_id = menu_service.get_store_fallback_platform_id(db, store.id)
|
||||
|
||||
# Get filtered menu with platform visibility, store_code, and permission filtering
|
||||
menu = menu_service.get_menu_for_rendering(
|
||||
|
||||
@@ -422,7 +422,7 @@ class MenuService:
|
||||
Get the primary platform ID for a merchant's visibility config.
|
||||
|
||||
Resolution order:
|
||||
1. Platform from the store marked is_primary in StorePlatform
|
||||
1. First active StorePlatform for the merchant's stores (by joined_at)
|
||||
2. First active subscription's platform (fallback)
|
||||
|
||||
Args:
|
||||
@@ -445,7 +445,7 @@ class MenuService:
|
||||
|
||||
# Try primary store platform first
|
||||
for store in stores:
|
||||
pid = platform_service.get_primary_platform_id_for_store(db, store.id)
|
||||
pid = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||
if pid is not None:
|
||||
# Verify merchant has active subscription on this platform
|
||||
active_pids = subscription_service.get_active_subscription_platform_ids(
|
||||
@@ -460,16 +460,16 @@ class MenuService:
|
||||
)
|
||||
return active_pids[0] if active_pids else None
|
||||
|
||||
def get_store_primary_platform_id(
|
||||
def get_store_fallback_platform_id(
|
||||
self,
|
||||
db: Session,
|
||||
store_id: int,
|
||||
) -> int | None:
|
||||
"""
|
||||
Get the primary platform ID for a store's menu visibility config.
|
||||
Get the fallback 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.
|
||||
Returns the first active StorePlatform ordered by joined_at.
|
||||
Used only when platform_id is not available from JWT context.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -480,7 +480,7 @@ class MenuService:
|
||||
"""
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
return platform_service.get_primary_platform_id_for_store(db, store_id)
|
||||
return platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
|
||||
def get_merchant_for_menu(
|
||||
self,
|
||||
|
||||
@@ -39,20 +39,6 @@ class OnboardingAggregatorService:
|
||||
a unified interface for the dashboard onboarding banner.
|
||||
"""
|
||||
|
||||
def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]:
|
||||
"""Get platform IDs the store is actively subscribed to."""
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
rows = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {r.platform_id for r in rows}
|
||||
|
||||
def _get_enabled_providers(
|
||||
self, db: Session, store_id: int, platform_id: int
|
||||
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
|
||||
@@ -68,10 +54,10 @@ class OnboardingAggregatorService:
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
|
||||
store_platform_ids = self._get_store_platform_ids(db, store_id)
|
||||
if not store_platform_ids:
|
||||
# Fallback to the passed platform_id if no subscriptions found
|
||||
store_platform_ids = {platform_id}
|
||||
# Only check the current platform, not all subscribed platforms.
|
||||
# This prevents cross-platform content leakage (e.g. showing OMS steps
|
||||
# when logged in on the loyalty platform).
|
||||
store_platform_ids = {platform_id}
|
||||
|
||||
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ function onboardingBanner() {
|
||||
return {
|
||||
t,
|
||||
visible: false,
|
||||
isOwner: true,
|
||||
steps: [],
|
||||
totalSteps: 0,
|
||||
completedSteps: 0,
|
||||
@@ -33,6 +34,7 @@ function onboardingBanner() {
|
||||
try {
|
||||
const response = await apiClient.get('/store/dashboard/onboarding');
|
||||
const steps = response.steps || [];
|
||||
this.isOwner = response.is_owner !== false;
|
||||
|
||||
// Load module translations BEFORE setting reactive data
|
||||
// Keys are like "tenancy.onboarding...." — first segment is the module
|
||||
|
||||
@@ -35,6 +35,7 @@ dev_tools_module = ModuleDefinition(
|
||||
"performance_validation", # Performance validator
|
||||
"test_runner", # Test execution
|
||||
"violation_management", # Violation tracking and assignment
|
||||
"sql_query", # Ad-hoc SQL query tool
|
||||
],
|
||||
menu_items={
|
||||
FrontendType.ADMIN: [
|
||||
@@ -42,6 +43,7 @@ dev_tools_module = ModuleDefinition(
|
||||
"icons", # Icon browser page
|
||||
"code-quality", # Code quality dashboard
|
||||
"tests", # Test runner dashboard
|
||||
"sql-query", # SQL query tool
|
||||
],
|
||||
FrontendType.STORE: [], # No store menu items - internal module
|
||||
},
|
||||
@@ -69,6 +71,20 @@ dev_tools_module = ModuleDefinition(
|
||||
route="/admin/icons",
|
||||
order=20,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="sql-query",
|
||||
label_key="dev_tools.menu.sql_query",
|
||||
icon="database",
|
||||
route="/admin/sql-query",
|
||||
order=30,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="platform-debug",
|
||||
label_key="dev_tools.menu.platform_debug",
|
||||
icon="search",
|
||||
route="/admin/platform-debug",
|
||||
order=40,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -94,11 +110,8 @@ def get_dev_tools_module_with_routers() -> ModuleDefinition:
|
||||
"""
|
||||
Get dev-tools module definition.
|
||||
|
||||
Note: API routes have been moved to monitoring module.
|
||||
This module has no routers to attach.
|
||||
Note: Admin API routes are auto-discovered from routes/api/admin.py.
|
||||
"""
|
||||
# No routers - API routes are now in monitoring module
|
||||
dev_tools_module.router = None
|
||||
dev_tools_module.router = None
|
||||
return dev_tools_module
|
||||
|
||||
|
||||
@@ -12,6 +12,23 @@
|
||||
"menu": {
|
||||
"developer_tools": "Entwicklerwerkzeuge",
|
||||
"components": "Komponenten",
|
||||
"icons": "Icons"
|
||||
"icons": "Icons",
|
||||
"sql_query": "SQL Abfrage",
|
||||
"platform_debug": "Plattform Debug"
|
||||
},
|
||||
"sql_query": {
|
||||
"title": "SQL Abfrage-Werkzeug",
|
||||
"execute": "Abfrage ausführen",
|
||||
"save": "Abfrage speichern",
|
||||
"export_csv": "CSV exportieren",
|
||||
"saved_queries": "Gespeicherte Abfragen",
|
||||
"no_saved_queries": "Noch keine gespeicherten Abfragen.",
|
||||
"query_name": "Abfragename",
|
||||
"description": "Beschreibung",
|
||||
"forbidden_keyword": "Verbotenes SQL-Schlüsselwort. Nur SELECT-Abfragen sind erlaubt.",
|
||||
"query_empty": "Abfrage darf nicht leer sein.",
|
||||
"rows_returned": "Zeilen zurückgegeben",
|
||||
"results_truncated": "Ergebnisse auf 1000 Zeilen beschränkt",
|
||||
"execution_time": "Ausführungszeit"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,23 @@
|
||||
"menu": {
|
||||
"developer_tools": "Developer Tools",
|
||||
"components": "Components",
|
||||
"icons": "Icons"
|
||||
"icons": "Icons",
|
||||
"sql_query": "SQL Query",
|
||||
"platform_debug": "Platform Debug"
|
||||
},
|
||||
"sql_query": {
|
||||
"title": "SQL Query Tool",
|
||||
"execute": "Run Query",
|
||||
"save": "Save Query",
|
||||
"export_csv": "Export CSV",
|
||||
"saved_queries": "Saved Queries",
|
||||
"no_saved_queries": "No saved queries yet.",
|
||||
"query_name": "Query Name",
|
||||
"description": "Description",
|
||||
"forbidden_keyword": "Forbidden SQL keyword. Only SELECT queries are allowed.",
|
||||
"query_empty": "Query cannot be empty.",
|
||||
"rows_returned": "rows returned",
|
||||
"results_truncated": "Results truncated to 1000 rows",
|
||||
"execution_time": "Execution time"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,23 @@
|
||||
"menu": {
|
||||
"developer_tools": "Outils de développement",
|
||||
"components": "Composants",
|
||||
"icons": "Icônes"
|
||||
"icons": "Icônes",
|
||||
"sql_query": "Requête SQL",
|
||||
"platform_debug": "Debug Plateforme"
|
||||
},
|
||||
"sql_query": {
|
||||
"title": "Outil de requête SQL",
|
||||
"execute": "Exécuter la requête",
|
||||
"save": "Enregistrer la requête",
|
||||
"export_csv": "Exporter en CSV",
|
||||
"saved_queries": "Requêtes enregistrées",
|
||||
"no_saved_queries": "Aucune requête enregistrée.",
|
||||
"query_name": "Nom de la requête",
|
||||
"description": "Description",
|
||||
"forbidden_keyword": "Mot-clé SQL interdit. Seules les requêtes SELECT sont autorisées.",
|
||||
"query_empty": "La requête ne peut pas être vide.",
|
||||
"rows_returned": "lignes retournées",
|
||||
"results_truncated": "Résultats tronqués à 1000 lignes",
|
||||
"execution_time": "Temps d'exécution"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,23 @@
|
||||
"menu": {
|
||||
"developer_tools": "Entwécklerwerkzäicher",
|
||||
"components": "Komponenten",
|
||||
"icons": "Icons"
|
||||
"icons": "Icons",
|
||||
"sql_query": "SQL Ufro",
|
||||
"platform_debug": "Plattform Debug"
|
||||
},
|
||||
"sql_query": {
|
||||
"title": "SQL Ufro-Werkzeug",
|
||||
"execute": "Ufro ausféieren",
|
||||
"save": "Ufro späicheren",
|
||||
"export_csv": "CSV exportéieren",
|
||||
"saved_queries": "Gespäichert Ufroen",
|
||||
"no_saved_queries": "Nach keng gespäichert Ufroen.",
|
||||
"query_name": "Ufroennumm",
|
||||
"description": "Beschreiwung",
|
||||
"forbidden_keyword": "Verbuedent SQL-Schlësselwuert. Nëmmen SELECT-Ufroen sinn erlaabt.",
|
||||
"query_empty": "Ufro däerf net eidel sinn.",
|
||||
"rows_returned": "Zeilen zréckginn",
|
||||
"results_truncated": "Resultater op 1000 Zeilen beschränkt",
|
||||
"execution_time": "Ausféierungszäit"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""dev_tools saved queries
|
||||
|
||||
Revision ID: dev_tools_002
|
||||
Revises: dev_tools_001
|
||||
Create Date: 2026-03-10
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "dev_tools_002"
|
||||
down_revision = "dev_tools_001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"dev_tools_saved_queries",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False, index=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("sql_text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
|
||||
),
|
||||
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"run_count", sa.Integer(), nullable=False, server_default="0"
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("dev_tools_saved_queries")
|
||||
@@ -25,6 +25,7 @@ from app.modules.dev_tools.models.architecture_scan import (
|
||||
ViolationAssignment,
|
||||
ViolationComment,
|
||||
)
|
||||
from app.modules.dev_tools.models.saved_query import SavedQuery
|
||||
from app.modules.dev_tools.models.test_run import (
|
||||
TestCollection,
|
||||
TestResult,
|
||||
@@ -42,4 +43,6 @@ __all__ = [
|
||||
"TestRun",
|
||||
"TestResult",
|
||||
"TestCollection",
|
||||
# Saved query models
|
||||
"SavedQuery",
|
||||
]
|
||||
|
||||
34
app/modules/dev_tools/models/saved_query.py
Normal file
34
app/modules/dev_tools/models/saved_query.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# app/modules/dev_tools/models/saved_query.py
|
||||
"""
|
||||
Saved SQL Query Model
|
||||
|
||||
Database model for storing frequently-used SQL queries.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SavedQuery(Base):
|
||||
"""A saved SQL query for quick re-running from the admin UI."""
|
||||
|
||||
__tablename__ = "dev_tools_saved_queries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
sql_text = Column(Text, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
last_run_at = Column(DateTime(timezone=True), nullable=True)
|
||||
run_count = Column(Integer, default=0, server_default="0")
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -6,5 +6,9 @@ Note: Code quality and test running routes have been moved to the monitoring mod
|
||||
The dev_tools module keeps models but routes are now in monitoring.
|
||||
"""
|
||||
|
||||
# No routes exported - code quality and tests are in monitoring module
|
||||
__all__ = []
|
||||
from app.modules.dev_tools.routes.api.admin_platform_debug import (
|
||||
router as platform_debug_router,
|
||||
)
|
||||
from app.modules.dev_tools.routes.api.admin_sql_query import router as sql_query_router
|
||||
|
||||
__all__ = ["sql_query_router", "platform_debug_router"]
|
||||
|
||||
17
app/modules/dev_tools/routes/api/admin.py
Normal file
17
app/modules/dev_tools/routes/api/admin.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# app/modules/dev_tools/routes/api/admin.py
|
||||
"""
|
||||
Dev-Tools module admin API routes.
|
||||
|
||||
Aggregates all admin API routers for the dev-tools module.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.modules.dev_tools.routes.api.admin_platform_debug import (
|
||||
router as platform_debug_router,
|
||||
)
|
||||
from app.modules.dev_tools.routes.api.admin_sql_query import router as sql_query_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(sql_query_router, tags=["sql-query"])
|
||||
router.include_router(platform_debug_router, tags=["platform-debug"])
|
||||
344
app/modules/dev_tools/routes/api/admin_platform_debug.py
Normal file
344
app/modules/dev_tools/routes/api/admin_platform_debug.py
Normal file
@@ -0,0 +1,344 @@
|
||||
# app/modules/dev_tools/routes/api/admin_platform_debug.py
|
||||
"""
|
||||
Platform resolution debug endpoint.
|
||||
|
||||
Simulates the middleware pipeline for arbitrary host/path combos
|
||||
to diagnose platform context issues across all URL patterns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_super_admin_api, get_db
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
|
||||
router = APIRouter(prefix="/debug")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlatformTraceStep(BaseModel):
|
||||
step: str
|
||||
result: dict | None = None
|
||||
note: str = ""
|
||||
|
||||
|
||||
class PlatformTraceResponse(BaseModel):
|
||||
input_host: str
|
||||
input_path: str
|
||||
input_platform_code_body: str | None = None
|
||||
steps: list[PlatformTraceStep]
|
||||
resolved_platform_code: str | None = None
|
||||
resolved_platform_id: int | None = None
|
||||
resolved_store_code: str | None = None
|
||||
resolved_store_id: int | None = None
|
||||
login_platform_source: str | None = None
|
||||
login_platform_code: str | None = None
|
||||
|
||||
|
||||
@router.get("/platform-trace", response_model=PlatformTraceResponse)
|
||||
def trace_platform_resolution(
|
||||
host: str = Query(..., description="Host header to simulate (e.g. localhost:8000, www.omsflow.lu)"),
|
||||
path: str = Query(..., description="URL path to simulate (e.g. /platforms/loyalty/store/WIZATECH/login)"),
|
||||
platform_code_body: str | None = Query(None, description="platform_code sent in login request body (Source 2)"),
|
||||
store_code_body: str | None = Query(None, description="store_code sent in login request body"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin_api),
|
||||
):
|
||||
"""
|
||||
Simulate the full middleware + login platform resolution pipeline.
|
||||
|
||||
Traces each step:
|
||||
1. PlatformContextMiddleware detection
|
||||
2. Platform DB lookup
|
||||
3. Path rewrite
|
||||
4. StoreContextMiddleware detection
|
||||
5. Store DB lookup
|
||||
6. Login handler Source 1/2/3 resolution
|
||||
"""
|
||||
from middleware.platform_context import (
|
||||
_LOCAL_HOSTS,
|
||||
MAIN_PLATFORM_CODE,
|
||||
PlatformContextMiddleware,
|
||||
)
|
||||
from middleware.store_context import StoreContextManager
|
||||
|
||||
steps = []
|
||||
mw = PlatformContextMiddleware(None)
|
||||
|
||||
# ── Step 1: Platform detection ──
|
||||
platform_context = mw._detect_platform_context(path, host)
|
||||
steps.append(PlatformTraceStep(
|
||||
step="1. PlatformContextMiddleware._detect_platform_context()",
|
||||
result=_sanitize(platform_context),
|
||||
note="Returns None for admin routes or /store/ on localhost without /platforms/ prefix",
|
||||
))
|
||||
|
||||
# ── Step 1b: Referer fallback (storefront API on localhost) ──
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
if (
|
||||
host_without_port in _LOCAL_HOSTS
|
||||
and path.startswith("/api/v1/storefront/")
|
||||
and platform_context
|
||||
and platform_context.get("detection_method") == "default"
|
||||
):
|
||||
steps.append(PlatformTraceStep(
|
||||
step="1b. Referer fallback check",
|
||||
note="Would check Referer header for /platforms/{code}/ — not simulated here",
|
||||
))
|
||||
|
||||
# ── Step 2: Platform DB lookup ──
|
||||
from middleware.platform_context import PlatformContextManager
|
||||
|
||||
platform = None
|
||||
if platform_context:
|
||||
platform = PlatformContextManager.get_platform_from_context(db, platform_context)
|
||||
|
||||
platform_dict = None
|
||||
if platform:
|
||||
platform_dict = {
|
||||
"id": platform.id,
|
||||
"code": platform.code,
|
||||
"name": platform.name,
|
||||
"domain": platform.domain,
|
||||
"path_prefix": platform.path_prefix,
|
||||
}
|
||||
steps.append(PlatformTraceStep(
|
||||
step="2. PlatformContextManager.get_platform_from_context()",
|
||||
result=platform_dict,
|
||||
note="DB lookup using detection context" if platform_context else "Skipped — no platform_context",
|
||||
))
|
||||
|
||||
# ── Step 3: Path rewrite ──
|
||||
clean_path = path
|
||||
if platform_context and platform_context.get("detection_method") == "path":
|
||||
clean_path = platform_context.get("clean_path", path)
|
||||
|
||||
steps.append(PlatformTraceStep(
|
||||
step="3. Path rewrite (scope['path'])",
|
||||
result={"original_path": path, "rewritten_path": clean_path},
|
||||
note="Dev mode strips /platforms/{code}/ prefix for routing"
|
||||
if clean_path != path
|
||||
else "No rewrite — path unchanged",
|
||||
))
|
||||
|
||||
# ── Step 4: StoreContextMiddleware detection ──
|
||||
# Simulate what StoreContextManager.detect_store_context() would see
|
||||
# It reads request.state.platform_clean_path which is set to clean_path
|
||||
store_context = _simulate_store_detection(clean_path, host)
|
||||
if store_context and platform:
|
||||
store_context["_platform"] = platform
|
||||
|
||||
steps.append(PlatformTraceStep(
|
||||
step="4. StoreContextManager.detect_store_context()",
|
||||
result=_sanitize(store_context),
|
||||
note="Uses rewritten path (platform_clean_path) for detection",
|
||||
))
|
||||
|
||||
# ── Step 5: Store DB lookup ──
|
||||
store = None
|
||||
if store_context:
|
||||
store = StoreContextManager.get_store_from_context(db, store_context)
|
||||
|
||||
store_dict = None
|
||||
if store:
|
||||
store_dict = {
|
||||
"id": store.id,
|
||||
"store_code": store.store_code,
|
||||
"subdomain": store.subdomain,
|
||||
"name": store.name,
|
||||
}
|
||||
steps.append(PlatformTraceStep(
|
||||
step="5. StoreContextManager.get_store_from_context()",
|
||||
result=store_dict,
|
||||
note="DB lookup using store context" if store_context else "Skipped — no store_context",
|
||||
))
|
||||
|
||||
# ── Step 6: Is this an API path? ──
|
||||
# The actual API path after rewrite determines middleware behavior
|
||||
is_api = clean_path.startswith("/api/")
|
||||
is_store_api = clean_path.startswith(("/store/", "/api/v1/store/"))
|
||||
steps.append(PlatformTraceStep(
|
||||
step="6. Route classification",
|
||||
result={
|
||||
"is_api_path": is_api,
|
||||
"is_store_path": is_store_api,
|
||||
"clean_path": clean_path,
|
||||
"middleware_skips_store_detection_for_api": is_api and not clean_path.startswith("/api/v1/storefront/"),
|
||||
},
|
||||
note="StoreContextMiddleware SKIPS store detection for non-storefront API routes",
|
||||
))
|
||||
|
||||
# ── Step 7: Login handler — Source 1/2/3 ──
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
login_source = None
|
||||
login_platform = None
|
||||
login_platform_code = None
|
||||
|
||||
# Source 1: middleware platform (only if not "main")
|
||||
src1_note = ""
|
||||
if platform and platform.code != MAIN_PLATFORM_CODE:
|
||||
login_platform = platform
|
||||
login_source = "Source 1: middleware (request.state.platform)"
|
||||
src1_note = f"platform.code={platform.code!r} != 'main' → used"
|
||||
elif platform:
|
||||
src1_note = f"platform.code={platform.code!r} == 'main' → skipped"
|
||||
else:
|
||||
src1_note = "No platform in middleware → skipped"
|
||||
|
||||
steps.append(PlatformTraceStep(
|
||||
step="7a. Login Source 1: middleware platform",
|
||||
result={"platform_code": platform.code if platform else None, "used": login_source is not None},
|
||||
note=src1_note,
|
||||
))
|
||||
|
||||
# Source 2: platform_code from body
|
||||
src2_note = ""
|
||||
if login_platform is None and platform_code_body:
|
||||
body_platform = platform_service.get_platform_by_code_optional(db, platform_code_body)
|
||||
if body_platform:
|
||||
login_platform = body_platform
|
||||
login_source = "Source 2: request body (platform_code)"
|
||||
src2_note = f"Found platform with code={platform_code_body!r} → used"
|
||||
else:
|
||||
src2_note = f"No platform found for code={platform_code_body!r} → would raise error"
|
||||
elif login_platform is not None:
|
||||
src2_note = "Skipped — Source 1 already resolved"
|
||||
else:
|
||||
src2_note = "No platform_code in body → skipped"
|
||||
|
||||
steps.append(PlatformTraceStep(
|
||||
step="7b. Login Source 2: request body platform_code",
|
||||
result={"platform_code_body": platform_code_body, "used": login_source == "Source 2: request body (platform_code)"},
|
||||
note=src2_note,
|
||||
))
|
||||
|
||||
# Source 3: fallback to store's first active platform
|
||||
src3_note = ""
|
||||
if login_platform is None and store:
|
||||
primary_pid = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||
if primary_pid:
|
||||
login_platform = platform_service.get_platform_by_id(db, primary_pid)
|
||||
login_source = "Source 3: get_first_active_platform_id_for_store()"
|
||||
src3_note = f"Fallback → first active platform for store {store.store_code}: platform_id={primary_pid}"
|
||||
else:
|
||||
src3_note = "No active platform for store"
|
||||
elif login_platform is not None:
|
||||
src3_note = "Skipped — earlier source already resolved"
|
||||
# Still show what Source 3 WOULD return for comparison
|
||||
if store:
|
||||
primary_pid = platform_service.get_first_active_platform_id_for_store(db, store.id)
|
||||
if primary_pid:
|
||||
fallback = platform_service.get_platform_by_id(db, primary_pid)
|
||||
src3_note += f" (would have been: {fallback.code!r})"
|
||||
else:
|
||||
src3_note = "No store resolved — cannot determine fallback"
|
||||
|
||||
steps.append(PlatformTraceStep(
|
||||
step="7c. Login Source 3: store's first active platform (fallback)",
|
||||
result={
|
||||
"store_id": store.id if store else None,
|
||||
"used": login_source == "Source 3: get_first_active_platform_id_for_store()",
|
||||
},
|
||||
note=src3_note,
|
||||
))
|
||||
|
||||
if login_platform:
|
||||
login_platform_code = login_platform.code
|
||||
|
||||
# ── Step 8: Final result ──
|
||||
steps.append(PlatformTraceStep(
|
||||
step="8. FINAL LOGIN PLATFORM",
|
||||
result={
|
||||
"source": login_source,
|
||||
"platform_code": login_platform_code,
|
||||
"platform_id": login_platform.id if login_platform else None,
|
||||
},
|
||||
note=f"JWT will be minted with platform_code={login_platform_code!r}",
|
||||
))
|
||||
|
||||
return PlatformTraceResponse(
|
||||
input_host=host,
|
||||
input_path=path,
|
||||
input_platform_code_body=platform_code_body,
|
||||
steps=steps,
|
||||
resolved_platform_code=platform.code if platform else None,
|
||||
resolved_platform_id=platform.id if platform else None,
|
||||
resolved_store_code=store.store_code if store else None,
|
||||
resolved_store_id=store.id if store else None,
|
||||
login_platform_source=login_source,
|
||||
login_platform_code=login_platform_code,
|
||||
)
|
||||
|
||||
|
||||
def _simulate_store_detection(clean_path: str, host: str) -> dict | None:
|
||||
"""
|
||||
Simulate StoreContextManager.detect_store_context() for a given path/host.
|
||||
|
||||
Reproduces the same logic without needing a real Request object.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.modules.tenancy.models import StoreDomain
|
||||
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
|
||||
# Method 1: Custom domain
|
||||
platform_domain = getattr(settings, "platform_domain", "platform.com")
|
||||
is_custom_domain = (
|
||||
host_without_port
|
||||
and not host_without_port.endswith(f".{platform_domain}")
|
||||
and host_without_port != platform_domain
|
||||
and host_without_port not in ["localhost", "127.0.0.1", "admin.localhost", "admin.127.0.0.1"]
|
||||
and not host_without_port.startswith("admin.")
|
||||
)
|
||||
if is_custom_domain:
|
||||
normalized_domain = StoreDomain.normalize_domain(host_without_port)
|
||||
return {
|
||||
"domain": normalized_domain,
|
||||
"detection_method": "custom_domain",
|
||||
"host": host_without_port,
|
||||
"original_host": host,
|
||||
}
|
||||
|
||||
# Method 2: Subdomain
|
||||
if "." in host_without_port:
|
||||
parts = host_without_port.split(".")
|
||||
if len(parts) >= 2 and parts[0] not in ["www", "admin", "api"]:
|
||||
subdomain = parts[0]
|
||||
return {
|
||||
"subdomain": subdomain,
|
||||
"detection_method": "subdomain",
|
||||
"host": host_without_port,
|
||||
}
|
||||
|
||||
# Method 3: Path-based
|
||||
if clean_path.startswith(("/store/", "/stores/", "/storefront/")):
|
||||
if clean_path.startswith("/storefront/"):
|
||||
prefix_len = len("/storefront/")
|
||||
elif clean_path.startswith("/stores/"):
|
||||
prefix_len = len("/stores/")
|
||||
else:
|
||||
prefix_len = len("/store/")
|
||||
|
||||
path_parts = clean_path[prefix_len:].split("/")
|
||||
if len(path_parts) >= 1 and path_parts[0]:
|
||||
store_code = path_parts[0]
|
||||
return {
|
||||
"subdomain": store_code,
|
||||
"detection_method": "path",
|
||||
"path_prefix": clean_path[:prefix_len + len(store_code)],
|
||||
"full_prefix": clean_path[:prefix_len],
|
||||
"host": host_without_port,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize(d: dict | None) -> dict | None:
|
||||
"""Remove non-serializable objects from dict."""
|
||||
if d is None:
|
||||
return None
|
||||
return {k: v for k, v in d.items() if not k.startswith("_")}
|
||||
158
app/modules/dev_tools/routes/api/admin_sql_query.py
Normal file
158
app/modules/dev_tools/routes/api/admin_sql_query.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# app/modules/dev_tools/routes/api/admin_sql_query.py
|
||||
"""
|
||||
SQL Query API endpoints.
|
||||
|
||||
All endpoints require super-admin authentication via Bearer token.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import UserContext, get_current_super_admin_api, get_db
|
||||
from app.modules.dev_tools.services.sql_query_service import (
|
||||
create_saved_query,
|
||||
delete_saved_query,
|
||||
execute_query,
|
||||
list_saved_queries,
|
||||
record_query_run,
|
||||
update_saved_query,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/sql-query", tags=["sql-query"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExecuteRequest(BaseModel):
|
||||
sql: str = Field(..., min_length=1, max_length=10000)
|
||||
saved_query_id: int | None = None
|
||||
|
||||
|
||||
class SavedQueryCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
sql_text: str = Field(..., min_length=1, max_length=10000)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SavedQueryUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=200)
|
||||
sql_text: str | None = Field(None, min_length=1, max_length=10000)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/execute") # noqa: API001
|
||||
async def execute_sql(
|
||||
body: ExecuteRequest,
|
||||
ctx: UserContext = Depends(get_current_super_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Execute a read-only SQL query."""
|
||||
result = execute_query(db, body.sql)
|
||||
|
||||
# Track run if this was a saved query
|
||||
if body.saved_query_id:
|
||||
try:
|
||||
record_query_run(db, body.saved_query_id)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/saved") # noqa: API001
|
||||
async def get_saved_queries(
|
||||
ctx: UserContext = Depends(get_current_super_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all saved queries."""
|
||||
queries = list_saved_queries(db)
|
||||
return [
|
||||
{
|
||||
"id": q.id,
|
||||
"name": q.name,
|
||||
"description": q.description,
|
||||
"sql_text": q.sql_text,
|
||||
"created_by": q.created_by,
|
||||
"last_run_at": q.last_run_at.isoformat() if q.last_run_at else None,
|
||||
"run_count": q.run_count,
|
||||
"created_at": q.created_at.isoformat() if q.created_at else None,
|
||||
}
|
||||
for q in queries
|
||||
]
|
||||
|
||||
|
||||
@router.post("/saved") # noqa: API001
|
||||
async def create_saved(
|
||||
body: SavedQueryCreate,
|
||||
ctx: UserContext = Depends(get_current_super_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new saved query."""
|
||||
q = create_saved_query(
|
||||
db,
|
||||
name=body.name,
|
||||
sql_text=body.sql_text,
|
||||
description=body.description,
|
||||
created_by=ctx.id,
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"id": q.id,
|
||||
"name": q.name,
|
||||
"description": q.description,
|
||||
"sql_text": q.sql_text,
|
||||
"created_by": q.created_by,
|
||||
"run_count": q.run_count,
|
||||
"created_at": q.created_at.isoformat() if q.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/saved/{query_id}") # noqa: API001
|
||||
async def update_saved(
|
||||
query_id: int,
|
||||
body: SavedQueryUpdate,
|
||||
ctx: UserContext = Depends(get_current_super_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update a saved query."""
|
||||
q = update_saved_query(
|
||||
db,
|
||||
query_id,
|
||||
name=body.name,
|
||||
sql_text=body.sql_text,
|
||||
description=body.description,
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"id": q.id,
|
||||
"name": q.name,
|
||||
"description": q.description,
|
||||
"sql_text": q.sql_text,
|
||||
"run_count": q.run_count,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/saved/{query_id}") # noqa: API001
|
||||
async def delete_saved(
|
||||
query_id: int,
|
||||
ctx: UserContext = Depends(get_current_super_admin_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a saved query."""
|
||||
delete_saved_query(db, query_id)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -129,3 +129,39 @@ async def admin_test_stores_users_migration(
|
||||
"dev_tools/admin/test-stores-users-migration.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/platform-debug", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_platform_debug_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(
|
||||
require_menu_access("testing", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render platform resolution debug page.
|
||||
Traces middleware pipeline for all URL patterns.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"dev_tools/admin/platform-debug.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sql-query", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_sql_query_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(
|
||||
require_menu_access("sql-query", FrontendType.ADMIN)
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render SQL query tool page.
|
||||
Ad-hoc SQL query execution with saved query management.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"dev_tools/admin/sql-query.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
197
app/modules/dev_tools/services/sql_query_service.py
Normal file
197
app/modules/dev_tools/services/sql_query_service.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# app/modules/dev_tools/services/sql_query_service.py
|
||||
"""
|
||||
SQL Query Service
|
||||
|
||||
Provides safe, read-only SQL query execution and saved query CRUD operations.
|
||||
Security layers:
|
||||
1. Regex-based DML/DDL rejection
|
||||
2. SET TRANSACTION READ ONLY on PostgreSQL
|
||||
3. Statement timeout (30s)
|
||||
4. Automatic rollback after every execution
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions.base import ResourceNotFoundException, ValidationException
|
||||
from app.modules.dev_tools.models.saved_query import SavedQuery
|
||||
|
||||
# Forbidden SQL keywords — matches whole words, case-insensitive
|
||||
_FORBIDDEN_PATTERN = re.compile(
|
||||
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|COPY|VACUUM|REINDEX)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class QueryValidationError(ValidationException):
|
||||
"""Raised when a query contains forbidden SQL statements."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message=message, field="sql")
|
||||
|
||||
|
||||
def _strip_sql_comments(sql: str) -> str:
|
||||
"""Remove SQL comments (-- line comments and /* block comments */)."""
|
||||
# Remove block comments
|
||||
result = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
|
||||
# Remove line comments
|
||||
result = re.sub(r"--[^\n]*", " ", result)
|
||||
return result
|
||||
|
||||
|
||||
def validate_query(sql: str) -> None:
|
||||
"""Validate that the SQL query is SELECT-only (no DML/DDL)."""
|
||||
stripped = sql.strip().rstrip(";")
|
||||
if not stripped:
|
||||
raise QueryValidationError("Query cannot be empty.")
|
||||
|
||||
# Strip comments before checking for forbidden keywords
|
||||
code_only = _strip_sql_comments(stripped)
|
||||
match = _FORBIDDEN_PATTERN.search(code_only)
|
||||
if match:
|
||||
raise QueryValidationError(
|
||||
f"Forbidden SQL keyword: {match.group().upper()}. Only SELECT queries are allowed."
|
||||
)
|
||||
|
||||
|
||||
def _make_json_safe(value: Any) -> Any:
|
||||
"""Convert a database value to a JSON-serializable representation."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int | float | bool):
|
||||
return value
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
if isinstance(value, bytes):
|
||||
return f"<bytes({len(value)})>"
|
||||
if isinstance(value, list | dict):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def execute_query(db: Session, sql: str) -> dict:
|
||||
"""
|
||||
Execute a read-only SQL query and return results.
|
||||
|
||||
Returns:
|
||||
dict with columns, rows, row_count, truncated, execution_time_ms
|
||||
"""
|
||||
validate_query(sql)
|
||||
|
||||
max_rows = 1000
|
||||
connection = db.connection()
|
||||
|
||||
try:
|
||||
# Set read-only transaction and statement timeout
|
||||
connection.execute(text("SET TRANSACTION READ ONLY"))
|
||||
connection.execute(text("SET statement_timeout = '30s'"))
|
||||
|
||||
start = time.perf_counter()
|
||||
result = connection.execute(text(sql))
|
||||
rows_raw = result.fetchmany(max_rows + 1)
|
||||
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
|
||||
columns = list(result.keys()) if result.returns_rows else []
|
||||
truncated = len(rows_raw) > max_rows
|
||||
rows_raw = rows_raw[:max_rows]
|
||||
|
||||
rows = [
|
||||
[_make_json_safe(cell) for cell in row] for row in rows_raw
|
||||
]
|
||||
|
||||
return {
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"row_count": len(rows),
|
||||
"truncated": truncated,
|
||||
"execution_time_ms": elapsed_ms,
|
||||
}
|
||||
except (QueryValidationError, ValidationException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise QueryValidationError(str(e)) from e
|
||||
finally:
|
||||
db.rollback()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saved Query CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_saved_queries(db: Session) -> list[SavedQuery]:
|
||||
"""List all saved queries ordered by name."""
|
||||
return db.query(SavedQuery).order_by(SavedQuery.name).all()
|
||||
|
||||
|
||||
def create_saved_query(
|
||||
db: Session,
|
||||
*,
|
||||
name: str,
|
||||
sql_text: str,
|
||||
description: str | None,
|
||||
created_by: int,
|
||||
) -> SavedQuery:
|
||||
"""Create a new saved query."""
|
||||
query = SavedQuery(
|
||||
name=name,
|
||||
sql_text=sql_text,
|
||||
description=description,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(query)
|
||||
db.flush()
|
||||
db.refresh(query)
|
||||
return query
|
||||
|
||||
|
||||
def update_saved_query(
|
||||
db: Session,
|
||||
query_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
sql_text: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> SavedQuery:
|
||||
"""Update an existing saved query. Raises ResourceNotFoundException if not found."""
|
||||
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
|
||||
if not query:
|
||||
raise ResourceNotFoundException("SavedQuery", str(query_id))
|
||||
if name is not None:
|
||||
query.name = name
|
||||
if sql_text is not None:
|
||||
query.sql_text = sql_text
|
||||
if description is not None:
|
||||
query.description = description
|
||||
db.flush()
|
||||
db.refresh(query)
|
||||
return query
|
||||
|
||||
|
||||
def delete_saved_query(db: Session, query_id: int) -> None:
|
||||
"""Delete a saved query. Raises ResourceNotFoundException if not found."""
|
||||
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
|
||||
if not query:
|
||||
raise ResourceNotFoundException("SavedQuery", str(query_id))
|
||||
db.delete(query)
|
||||
db.flush()
|
||||
|
||||
|
||||
def record_query_run(db: Session, query_id: int) -> None:
|
||||
"""Increment run_count and update last_run_at for a saved query."""
|
||||
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
|
||||
if query:
|
||||
query.run_count = (query.run_count or 0) + 1
|
||||
query.last_run_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
249
app/modules/dev_tools/static/admin/js/sql-query.js
Normal file
249
app/modules/dev_tools/static/admin/js/sql-query.js
Normal file
@@ -0,0 +1,249 @@
|
||||
// app/modules/dev_tools/static/admin/js/sql-query.js
|
||||
|
||||
const sqlLog = window.LogConfig.createLogger('SQL_QUERY');
|
||||
|
||||
/**
|
||||
* SQL Query Tool Alpine.js Component
|
||||
* Execute ad-hoc SQL queries and manage saved queries.
|
||||
*/
|
||||
function sqlQueryTool() {
|
||||
return {
|
||||
// Inherit base layout functionality
|
||||
...data(),
|
||||
|
||||
// Page identifier
|
||||
currentPage: 'sql-query',
|
||||
|
||||
// Editor state
|
||||
sql: '',
|
||||
running: false,
|
||||
error: null,
|
||||
|
||||
// Results
|
||||
columns: [],
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
truncated: false,
|
||||
executionTimeMs: null,
|
||||
|
||||
// Saved queries
|
||||
savedQueries: [],
|
||||
loadingSaved: false,
|
||||
activeSavedId: null,
|
||||
|
||||
// Save modal
|
||||
showSaveModal: false,
|
||||
saveName: '',
|
||||
saveDescription: '',
|
||||
saving: false,
|
||||
|
||||
// Schema explorer
|
||||
showPresets: true,
|
||||
expandedCategories: {},
|
||||
presetQueries: [
|
||||
{
|
||||
category: 'Schema',
|
||||
items: [
|
||||
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
|
||||
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
|
||||
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
|
||||
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Statistics',
|
||||
items: [
|
||||
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
|
||||
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
|
||||
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Tenancy',
|
||||
items: [
|
||||
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
|
||||
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
|
||||
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
|
||||
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Permissions',
|
||||
items: [
|
||||
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
|
||||
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
|
||||
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
|
||||
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
|
||||
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'System',
|
||||
items: [
|
||||
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
|
||||
]
|
||||
},
|
||||
],
|
||||
|
||||
toggleCategory(category) {
|
||||
this.expandedCategories[category] = !this.expandedCategories[category];
|
||||
},
|
||||
|
||||
isCategoryExpanded(category) {
|
||||
return this.expandedCategories[category] || false;
|
||||
},
|
||||
|
||||
async init() {
|
||||
if (window._sqlQueryInitialized) return;
|
||||
window._sqlQueryInitialized = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (typeof this.initBase === 'function') this.initBase();
|
||||
});
|
||||
|
||||
try {
|
||||
await this.loadSavedQueries();
|
||||
} catch (e) {
|
||||
sqlLog.error('Failed to initialize:', e);
|
||||
}
|
||||
|
||||
// Ctrl+Enter shortcut
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.executeQuery();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async executeQuery() {
|
||||
if (!this.sql.trim() || this.running) return;
|
||||
this.running = true;
|
||||
this.error = null;
|
||||
this.columns = [];
|
||||
this.rows = [];
|
||||
this.rowCount = 0;
|
||||
this.truncated = false;
|
||||
this.executionTimeMs = null;
|
||||
|
||||
try {
|
||||
const payload = { sql: this.sql };
|
||||
if (this.activeSavedId) {
|
||||
payload.saved_query_id = this.activeSavedId;
|
||||
}
|
||||
const data = await apiClient.post('/admin/sql-query/execute', payload);
|
||||
this.columns = data.columns;
|
||||
this.rows = data.rows;
|
||||
this.rowCount = data.row_count;
|
||||
this.truncated = data.truncated;
|
||||
this.executionTimeMs = data.execution_time_ms;
|
||||
|
||||
// Refresh saved queries to update run_count
|
||||
if (this.activeSavedId) {
|
||||
await this.loadSavedQueries();
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadSavedQueries() {
|
||||
this.loadingSaved = true;
|
||||
try {
|
||||
this.savedQueries = await apiClient.get('/admin/sql-query/saved');
|
||||
} catch (e) {
|
||||
sqlLog.error('Failed to load saved queries:', e);
|
||||
} finally {
|
||||
this.loadingSaved = false;
|
||||
}
|
||||
},
|
||||
|
||||
loadQuery(q) {
|
||||
this.sql = q.sql_text;
|
||||
this.activeSavedId = q.id;
|
||||
this.error = null;
|
||||
},
|
||||
|
||||
loadPreset(preset) {
|
||||
this.sql = preset.sql;
|
||||
this.activeSavedId = null;
|
||||
this.error = null;
|
||||
},
|
||||
|
||||
openSaveModal() {
|
||||
this.saveName = '';
|
||||
this.saveDescription = '';
|
||||
this.showSaveModal = true;
|
||||
},
|
||||
|
||||
async saveQuery() {
|
||||
if (!this.saveName.trim() || !this.sql.trim()) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
const saved = await apiClient.post('/admin/sql-query/saved', {
|
||||
name: this.saveName,
|
||||
sql_text: this.sql,
|
||||
description: this.saveDescription || null,
|
||||
});
|
||||
this.activeSavedId = saved.id;
|
||||
this.showSaveModal = false;
|
||||
await this.loadSavedQueries();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSavedQuery(id) {
|
||||
if (!confirm('Delete this saved query?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/admin/sql-query/saved/${id}`);
|
||||
if (this.activeSavedId === id) {
|
||||
this.activeSavedId = null;
|
||||
}
|
||||
await this.loadSavedQueries();
|
||||
} catch (e) {
|
||||
sqlLog.error('Failed to delete:', e);
|
||||
}
|
||||
},
|
||||
|
||||
exportCsv() {
|
||||
if (!this.columns.length || !this.rows.length) return;
|
||||
|
||||
const escape = (val) => {
|
||||
if (val === null || val === undefined) return '';
|
||||
const s = String(val);
|
||||
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||
return '"' + s.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const lines = [this.columns.map(escape).join(',')];
|
||||
for (const row of this.rows) {
|
||||
lines.push(row.map(escape).join(','));
|
||||
}
|
||||
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'query-results.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
formatCell(val) {
|
||||
if (val === null || val === undefined) return 'NULL';
|
||||
return String(val);
|
||||
},
|
||||
|
||||
isNull(val) {
|
||||
return val === null || val === undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
{# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Platform Resolution Debug{% endblock %}
|
||||
|
||||
{% block alpine_data %}platformDebug(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Simulates the middleware pipeline for each URL pattern to trace how platform & store context are resolved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<button @click="runAllTests()" :disabled="running"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
<span x-show="!running">Run All Tests</span>
|
||||
<span x-show="running">Running...</span>
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button @click="filterGroup = 'all'" :class="filterGroup === 'all' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">All</button>
|
||||
<button @click="filterGroup = 'dev'" :class="filterGroup === 'dev' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Dev Path-Based</button>
|
||||
<button @click="filterGroup = 'prod-domain'" :class="filterGroup === 'prod-domain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Domain</button>
|
||||
<button @click="filterGroup = 'prod-subdomain'" :class="filterGroup === 'prod-subdomain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Subdomain</button>
|
||||
<button @click="filterGroup = 'prod-custom'" :class="filterGroup === 'prod-custom' ? 'bg-gray-200 dark:bg-gray-600' : ''"
|
||||
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Custom Domain</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom test -->
|
||||
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Custom Test</h3>
|
||||
<div class="flex gap-3 items-end flex-wrap">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Host</label>
|
||||
<input x-model="customHost" type="text" placeholder="localhost:8000"
|
||||
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-56">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Path</label>
|
||||
<input x-model="customPath" type="text" placeholder="/platforms/loyalty/store/WIZATECH/login"
|
||||
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-80">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Body platform_code</label>
|
||||
<input x-model="customPlatformCode" type="text" placeholder="(optional)"
|
||||
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
|
||||
</div>
|
||||
<button @click="runCustomTest()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm">
|
||||
Trace
|
||||
</button>
|
||||
</div>
|
||||
<!-- Custom result -->
|
||||
<template x-if="customResult">
|
||||
<div class="mt-4">
|
||||
<div x-html="renderTrace(customResult)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Test Results -->
|
||||
<div class="space-y-4">
|
||||
<template x-for="test in filteredTests" :key="test.id">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center justify-between"
|
||||
@click="test.expanded = !test.expanded">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Status indicator -->
|
||||
<div class="w-3 h-3 rounded-full"
|
||||
:class="{
|
||||
'bg-gray-300 dark:bg-gray-600': !test.result,
|
||||
'bg-green-500': test.result && test.pass,
|
||||
'bg-red-500': test.result && !test.pass,
|
||||
'animate-pulse bg-yellow-400': test.running
|
||||
}"></div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': test.group === 'dev',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': test.group === 'prod-domain',
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': test.group === 'prod-subdomain',
|
||||
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': test.group === 'prod-custom'
|
||||
}" x-text="test.groupLabel"></span>
|
||||
<div>
|
||||
<span class="font-mono text-sm text-gray-900 dark:text-white" x-text="test.label"></span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" x-text="test.description"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<template x-if="test.result">
|
||||
<span class="text-xs font-mono px-2 py-1 rounded"
|
||||
:class="test.pass ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
|
||||
x-text="'platform=' + (test.result.login_platform_code || 'null')"></span>
|
||||
</template>
|
||||
<template x-if="test.result">
|
||||
<button @click.stop="copyTrace(test)" title="Copy full trace"
|
||||
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
Copy
|
||||
</button>
|
||||
</template>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Detail -->
|
||||
<div x-show="test.expanded" x-cloak class="px-4 py-3">
|
||||
<template x-if="test.result">
|
||||
<div x-html="renderTrace(test.result)"></div>
|
||||
</template>
|
||||
<template x-if="!test.result && !test.running">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Run All Tests" to trace this case.</p>
|
||||
</template>
|
||||
<template x-if="test.running">
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400">Running...</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function platformDebug() {
|
||||
return {
|
||||
running: false,
|
||||
filterGroup: 'all',
|
||||
customHost: 'localhost:8000',
|
||||
customPath: '/platforms/loyalty/store/WIZATECH/login',
|
||||
customPlatformCode: '',
|
||||
customResult: null,
|
||||
tests: [
|
||||
// ── Dev path-based ──
|
||||
{
|
||||
id: 'dev-1', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/platforms/oms/store/ACME/login',
|
||||
description: 'Store login, OMS platform context',
|
||||
host: 'localhost:8000',
|
||||
path: '/platforms/oms/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-2', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/platforms/loyalty/store/ACME/login',
|
||||
description: 'Store login, Loyalty platform context',
|
||||
host: 'localhost:8000',
|
||||
path: '/platforms/loyalty/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-3', group: 'dev', groupLabel: 'Dev',
|
||||
label: 'API: /api/v1/store/auth/login (no body platform)',
|
||||
description: 'What middleware sees for the login API call — no platform_code in body',
|
||||
host: 'localhost:8000',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: null,
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-4', group: 'dev', groupLabel: 'Dev',
|
||||
label: 'API: /api/v1/store/auth/login + body platform_code=loyalty',
|
||||
description: 'Login API with loyalty in body (Source 2)',
|
||||
host: 'localhost:8000',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: 'loyalty',
|
||||
store_code_body: 'WIZATECH',
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-5', group: 'dev', groupLabel: 'Dev',
|
||||
label: 'API: /api/v1/store/auth/login + body platform_code=oms',
|
||||
description: 'Login API with oms in body (Source 2)',
|
||||
host: 'localhost:8000',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: 'oms',
|
||||
store_code_body: 'WIZATECH',
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-6', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/store/ACME/login (no /platforms/ prefix)',
|
||||
description: 'Store login without platform prefix on localhost',
|
||||
host: 'localhost:8000',
|
||||
path: '/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: null,
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-7', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/platforms/oms/storefront/ACME/account/login',
|
||||
description: 'Customer login, OMS platform context',
|
||||
host: 'localhost:8000',
|
||||
path: '/platforms/oms/storefront/ACME/account/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'dev-8', group: 'dev', groupLabel: 'Dev',
|
||||
label: '/platforms/loyalty/storefront/ACME/account/login',
|
||||
description: 'Customer login, Loyalty platform context',
|
||||
host: 'localhost:8000',
|
||||
path: '/platforms/loyalty/storefront/ACME/account/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
|
||||
// ── Prod domain-based (path-based demo/trial) ──
|
||||
{
|
||||
id: 'prod-d-1', group: 'prod-domain', groupLabel: 'Prod Domain',
|
||||
label: 'omsflow.lu /store/ACME/login',
|
||||
description: 'OMS platform domain, store login',
|
||||
host: 'omsflow.lu',
|
||||
path: '/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-d-2', group: 'prod-domain', groupLabel: 'Prod Domain',
|
||||
label: 'rewardflow.lu /store/ACME/login',
|
||||
description: 'Loyalty platform domain, store login',
|
||||
host: 'rewardflow.lu',
|
||||
path: '/store/ACME/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-d-3', group: 'prod-domain', groupLabel: 'Prod Domain',
|
||||
label: 'omsflow.lu /api/v1/store/auth/login (API)',
|
||||
description: 'API call on OMS domain — middleware should detect platform',
|
||||
host: 'omsflow.lu',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-d-4', group: 'prod-domain', groupLabel: 'Prod Domain',
|
||||
label: 'omsflow.lu /storefront/ACME/account/login',
|
||||
description: 'Customer login on OMS domain',
|
||||
host: 'omsflow.lu',
|
||||
path: '/storefront/ACME/account/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
|
||||
// ── Prod subdomain ──
|
||||
{
|
||||
id: 'prod-s-1', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
|
||||
label: 'acme.omsflow.lu /store/login',
|
||||
description: 'OMS subdomain, store login',
|
||||
host: 'acme.omsflow.lu',
|
||||
path: '/store/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-s-2', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
|
||||
label: 'acme.omsflow.lu /account/login',
|
||||
description: 'OMS subdomain, customer login',
|
||||
host: 'acme.omsflow.lu',
|
||||
path: '/account/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-s-3', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
|
||||
label: 'acme-rewards.rewardflow.lu /store/login',
|
||||
description: 'Loyalty subdomain (custom_subdomain), store login',
|
||||
host: 'acme-rewards.rewardflow.lu',
|
||||
path: '/store/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-s-4', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
|
||||
label: 'acme.omsflow.lu /api/v1/store/auth/login (API)',
|
||||
description: 'API call on OMS subdomain',
|
||||
host: 'acme.omsflow.lu',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
|
||||
// ── Prod custom domain ──
|
||||
// Uses real StoreDomain records:
|
||||
// wizatech.shop → store_id=1 (WIZATECH), platform_id=1 (oms)
|
||||
// fashionhub.store → store_id=4 (FASHIONHUB), platform_id=3 (loyalty)
|
||||
{
|
||||
id: 'prod-c-1', group: 'prod-custom', groupLabel: 'Prod Custom',
|
||||
label: 'wizatech.shop /store/login',
|
||||
description: 'Custom domain → WIZATECH, OMS platform (StoreDomain.platform_id=1)',
|
||||
host: 'wizatech.shop',
|
||||
path: '/store/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-c-2', group: 'prod-custom', groupLabel: 'Prod Custom',
|
||||
label: 'fashionhub.store /store/login',
|
||||
description: 'Custom domain → FASHIONHUB, Loyalty platform (StoreDomain.platform_id=3)',
|
||||
host: 'fashionhub.store',
|
||||
path: '/store/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-c-3', group: 'prod-custom', groupLabel: 'Prod Custom',
|
||||
label: 'wizatech.shop /api/v1/store/auth/login (API)',
|
||||
description: 'API call on custom domain — middleware should detect platform',
|
||||
host: 'wizatech.shop',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'oms',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
{
|
||||
id: 'prod-c-4', group: 'prod-custom', groupLabel: 'Prod Custom',
|
||||
label: 'fashionhub.store /api/v1/store/auth/login (API)',
|
||||
description: 'API call on custom domain — middleware should detect platform',
|
||||
host: 'fashionhub.store',
|
||||
path: '/api/v1/store/auth/login',
|
||||
platform_code_body: null,
|
||||
expect_platform: 'loyalty',
|
||||
expanded: false, result: null, pass: null, running: false,
|
||||
},
|
||||
],
|
||||
|
||||
get filteredTests() {
|
||||
if (this.filterGroup === 'all') return this.tests;
|
||||
return this.tests.filter(t => t.group === this.filterGroup);
|
||||
},
|
||||
|
||||
async runAllTests() {
|
||||
this.running = true;
|
||||
for (const test of this.tests) {
|
||||
test.running = true;
|
||||
try {
|
||||
test.result = await this.traceRequest(test.host, test.path, test.platform_code_body, test.store_code_body);
|
||||
test.pass = test.expect_platform === null
|
||||
? true // No expectation
|
||||
: test.result.login_platform_code === test.expect_platform;
|
||||
} catch (e) {
|
||||
test.result = { error: e.message };
|
||||
test.pass = false;
|
||||
}
|
||||
test.running = false;
|
||||
}
|
||||
this.running = false;
|
||||
},
|
||||
|
||||
async runCustomTest() {
|
||||
try {
|
||||
this.customResult = await this.traceRequest(
|
||||
this.customHost, this.customPath, this.customPlatformCode || null
|
||||
);
|
||||
} catch (e) {
|
||||
this.customResult = { error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
async traceRequest(host, path, platformCodeBody, storeCodeBody) {
|
||||
let url = `/api/v1/admin/debug/platform-trace?host=${encodeURIComponent(host)}&path=${encodeURIComponent(path)}`;
|
||||
if (platformCodeBody) url += `&platform_code_body=${encodeURIComponent(platformCodeBody)}`;
|
||||
if (storeCodeBody) url += `&store_code_body=${encodeURIComponent(storeCodeBody)}`;
|
||||
const resp = await apiClient.get(url.replace('/api/v1', ''));
|
||||
return resp;
|
||||
},
|
||||
|
||||
copyTrace(test) {
|
||||
const r = test.result;
|
||||
if (!r || r.error) return;
|
||||
let text = `${test.label}\n${test.description}\n`;
|
||||
text += `Host: ${r.input_host} Path: ${r.input_path}`;
|
||||
if (r.input_platform_code_body) text += ` Body platform_code: ${r.input_platform_code_body}`;
|
||||
text += '\n\n';
|
||||
for (const step of r.steps) {
|
||||
text += `${step.step}\n`;
|
||||
if (step.note) text += `${step.note}\n`;
|
||||
if (step.result) text += JSON.stringify(step.result, null, 2) + '\n';
|
||||
text += '\n';
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
|
||||
copyStepText(step) {
|
||||
let text = `${step.step}\n`;
|
||||
if (step.note) text += `${step.note}\n`;
|
||||
if (step.result) text += JSON.stringify(step.result, null, 2);
|
||||
navigator.clipboard.writeText(text);
|
||||
},
|
||||
|
||||
renderTrace(result) {
|
||||
if (result.error) {
|
||||
return `<div class="text-red-600 dark:text-red-400 text-sm font-mono">${result.error}</div>`;
|
||||
}
|
||||
|
||||
let html = `<div class="text-xs font-mono space-y-1">`;
|
||||
html += `<div class="mb-2 text-gray-500 dark:text-gray-400">`;
|
||||
html += `Host: <span class="text-gray-900 dark:text-white">${result.input_host}</span> `;
|
||||
html += `Path: <span class="text-gray-900 dark:text-white">${result.input_path}</span>`;
|
||||
if (result.input_platform_code_body) {
|
||||
html += ` Body platform_code: <span class="text-gray-900 dark:text-white">${result.input_platform_code_body}</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
for (let i = 0; i < result.steps.length; i++) {
|
||||
const step = result.steps[i];
|
||||
const isLast = step.step.startsWith('8.');
|
||||
const bgClass = isLast
|
||||
? 'bg-indigo-50 dark:bg-indigo-900/30 border-indigo-200 dark:border-indigo-700'
|
||||
: 'bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700';
|
||||
|
||||
html += `<div class="p-2 rounded border ${bgClass}">`;
|
||||
html += `<div class="flex items-start justify-between">`;
|
||||
html += `<div class="font-semibold text-gray-700 dark:text-gray-300">${step.step}</div>`;
|
||||
const stepData = btoa(unescape(encodeURIComponent(
|
||||
step.step + '\n' + (step.note || '') + '\n' + (step.result ? JSON.stringify(step.result, null, 2) : '')
|
||||
)));
|
||||
html += `<button onclick="navigator.clipboard.writeText(decodeURIComponent(escape(atob(this.dataset.text))))" data-text="${stepData}" class="ml-2 shrink-0 px-1.5 py-0.5 text-[10px] bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Copy</button>`;
|
||||
html += `</div>`;
|
||||
if (step.note) {
|
||||
html += `<div class="text-gray-500 dark:text-gray-400 mt-0.5">${step.note}</div>`;
|
||||
}
|
||||
if (step.result) {
|
||||
const jsonStr = JSON.stringify(step.result, null, 2);
|
||||
html += `<pre class="mt-1 text-green-700 dark:text-green-400 whitespace-pre-wrap">${jsonStr}</pre>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
205
app/modules/dev_tools/templates/dev_tools/admin/sql-query.html
Normal file
205
app/modules/dev_tools/templates/dev_tools/admin/sql-query.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{# app/modules/dev_tools/templates/dev_tools/admin/sql-query.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/modals.html' import modal %}
|
||||
|
||||
{% block title %}SQL Query Tool{% endblock %}
|
||||
|
||||
{% block alpine_data %}sqlQueryTool(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('SQL Query Tool', back_url='/admin/dashboard', back_label='Back to Dashboard') }}
|
||||
|
||||
<div class="flex gap-6">
|
||||
<!-- Left sidebar -->
|
||||
<div class="w-72 flex-shrink-0 space-y-4">
|
||||
<!-- Schema Explorer (preset queries) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<button @click="showPresets = !showPresets"
|
||||
class="flex items-center justify-between w-full text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span x-html="$icon('database', 'w-4 h-4')"></span>
|
||||
Schema Explorer
|
||||
</span>
|
||||
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<div x-show="showPresets" x-collapse class="mt-3">
|
||||
<template x-for="group in presetQueries" :key="group.category">
|
||||
<div class="mb-1">
|
||||
<button @click="toggleCategory(group.category)"
|
||||
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
<span x-text="group.category"></span>
|
||||
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '−' : '+'"></span>
|
||||
</button>
|
||||
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
|
||||
<template x-for="preset in group.items" :key="preset.name">
|
||||
<li @click="loadPreset(preset)"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
|
||||
<span class="truncate" x-text="preset.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Queries -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<span x-html="$icon('collection', 'w-4 h-4')"></span>
|
||||
Saved Queries
|
||||
</h3>
|
||||
<div x-show="loadingSaved" class="text-sm text-gray-500">Loading...</div>
|
||||
<div x-show="!loadingSaved && savedQueries.length === 0" class="text-sm text-gray-400">
|
||||
No saved queries yet.
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="q in savedQueries" :key="q.id">
|
||||
<li class="group flex items-center justify-between rounded-md px-2 py-1.5 text-sm cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
:class="activeSavedId === q.id ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'"
|
||||
@click="loadQuery(q)">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate font-medium" x-text="q.name"></div>
|
||||
<div class="text-xs text-gray-400" x-show="q.run_count > 0">
|
||||
Run <span x-text="q.run_count"></span> time<span x-show="q.run_count !== 1">s</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click.stop="deleteSavedQuery(q.id)"
|
||||
class="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500 transition-opacity"
|
||||
title="Delete">
|
||||
<span x-html="$icon('trash', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main area -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- SQL Editor -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-4">
|
||||
<div class="p-4">
|
||||
<textarea
|
||||
x-model="sql"
|
||||
rows="8"
|
||||
class="w-full bg-gray-900 text-green-400 font-mono text-sm rounded-lg p-4 border border-gray-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 resize-y"
|
||||
placeholder="Enter your SQL query here... (SELECT only)"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Action bar -->
|
||||
<div class="flex items-center gap-3 px-4 pb-4">
|
||||
<button @click="executeQuery()"
|
||||
:disabled="running || !sql.trim()"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-show="!running" x-html="$icon('play', 'w-4 h-4 mr-1.5')"></span>
|
||||
<span x-show="running" x-html="$icon('spinner', 'w-4 h-4 mr-1.5')"></span>
|
||||
<span x-text="running ? 'Running...' : 'Run Query'"></span>
|
||||
<span class="ml-1.5 text-xs opacity-70">(Ctrl+Enter)</span>
|
||||
</button>
|
||||
|
||||
<button @click="openSaveModal()"
|
||||
:disabled="!sql.trim()"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span x-html="$icon('save', 'w-4 h-4 mr-1.5')"></span>
|
||||
Save Query
|
||||
</button>
|
||||
|
||||
<button @click="exportCsv()"
|
||||
x-show="rows.length > 0"
|
||||
class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
|
||||
<span x-html="$icon('download', 'w-4 h-4 mr-1.5')"></span>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Execution info -->
|
||||
<div x-show="executionTimeMs !== null" class="mb-4 text-sm text-gray-600 dark:text-gray-400 flex items-center gap-4">
|
||||
<span>
|
||||
<span x-text="rowCount"></span> row<span x-show="rowCount !== 1">s</span> returned
|
||||
</span>
|
||||
<span x-show="truncated" class="text-amber-600 dark:text-amber-400 font-medium">
|
||||
(results truncated to 1000 rows)
|
||||
</span>
|
||||
<span>
|
||||
<span x-text="executionTimeMs"></span> ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div x-show="error" class="mb-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('exclamation-circle', 'w-5 h-5 text-red-500 mr-2 flex-shrink-0')"></span>
|
||||
<pre class="text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words" x-text="error"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results table -->
|
||||
<div x-show="columns.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<template x-for="col in columns" :key="col">
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider whitespace-nowrap"
|
||||
x-text="col"></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<template x-for="(row, rowIdx) in rows" :key="rowIdx">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750">
|
||||
<template x-for="(cell, cellIdx) in row" :key="cellIdx">
|
||||
<td class="px-4 py-2 text-sm font-mono whitespace-nowrap max-w-xs truncate"
|
||||
:class="isNull(cell) ? 'text-gray-400 italic' : 'text-gray-900 dark:text-gray-100'"
|
||||
:title="formatCell(cell)"
|
||||
x-text="formatCell(cell)"></td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Query Modal -->
|
||||
{% call modal('saveQueryModal', 'Save Query', show_var='showSaveModal', size='sm', show_footer=false) %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input type="text" x-model="saveName"
|
||||
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="e.g. Active users count"
|
||||
@keydown.enter="saveQuery()">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description (optional)</label>
|
||||
<input type="text" x-model="saveDescription"
|
||||
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Brief description of what this query does">
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button @click="showSaveModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="saveQuery()"
|
||||
:disabled="!saveName.trim() || saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||
<span x-text="saving ? 'Saving...' : 'Save'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('dev_tools_static', path='admin/js/sql-query.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -129,9 +129,9 @@ loyalty_module = ModuleDefinition(
|
||||
"loyalty-analytics", # Platform-wide stats
|
||||
],
|
||||
FrontendType.STORE: [
|
||||
"loyalty", # Loyalty dashboard
|
||||
"loyalty-cards", # Customer cards
|
||||
"loyalty-stats", # Store stats
|
||||
"terminal", # Loyalty terminal
|
||||
"cards", # Customer cards
|
||||
"stats", # Store stats
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
"loyalty-overview", # Merchant loyalty overview
|
||||
|
||||
@@ -6,6 +6,8 @@ Admin-controlled settings that apply to a merchant's loyalty program.
|
||||
These settings are managed by platform administrators, not stores.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
@@ -20,7 +22,7 @@ from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class StaffPinPolicy(str):
|
||||
class StaffPinPolicy(str, enum.Enum):
|
||||
"""Staff PIN policy options."""
|
||||
|
||||
REQUIRED = "required" # Staff PIN always required
|
||||
|
||||
@@ -11,6 +11,7 @@ Platform endpoints for:
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Path, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -92,8 +93,14 @@ def download_apple_pass(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AppleRegisterDeviceRequest(BaseModel):
|
||||
"""Request body for Apple device registration."""
|
||||
push_token: str = Field(..., alias="pushToken")
|
||||
|
||||
|
||||
@platform_router.post("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
|
||||
def register_device(
|
||||
body: AppleRegisterDeviceRequest,
|
||||
device_id: str = Path(...),
|
||||
pass_type_id: str = Path(...),
|
||||
serial_number: str = Path(...),
|
||||
@@ -111,10 +118,7 @@ def register_device(
|
||||
# Verify auth token (raises InvalidAppleAuthTokenException if invalid)
|
||||
apple_wallet_service.verify_auth_token(card, authorization)
|
||||
|
||||
# Get push token from request body
|
||||
# Note: In real implementation, parse the JSON body for pushToken
|
||||
# For now, use device_id as a placeholder
|
||||
apple_wallet_service.register_device_safe(db, card, device_id, device_id)
|
||||
apple_wallet_service.register_device_safe(db, card, device_id, body.pushToken)
|
||||
return Response(status_code=201)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_store_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.exceptions.base import AuthorizationException
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.loyalty.schemas import (
|
||||
CardDetailResponse,
|
||||
@@ -40,8 +41,10 @@ from app.modules.loyalty.schemas import (
|
||||
PointsRedeemResponse,
|
||||
PointsVoidRequest,
|
||||
PointsVoidResponse,
|
||||
ProgramCreate,
|
||||
ProgramResponse,
|
||||
ProgramStatsResponse,
|
||||
ProgramUpdate,
|
||||
StampRedeemRequest,
|
||||
StampRedeemResponse,
|
||||
StampRequest,
|
||||
@@ -104,6 +107,52 @@ def get_program(
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/program", response_model=ProgramResponse, status_code=201)
|
||||
def create_program(
|
||||
data: ProgramCreate,
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a loyalty program (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can create programs")
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id = get_store_merchant_id(db, store_id)
|
||||
|
||||
program = program_service.create_program(db, merchant_id, data)
|
||||
|
||||
response = ProgramResponse.model_validate(program)
|
||||
response.is_stamps_enabled = program.is_stamps_enabled
|
||||
response.is_points_enabled = program.is_points_enabled
|
||||
response.display_name = program.display_name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/program", response_model=ProgramResponse)
|
||||
def update_program(
|
||||
data: ProgramUpdate,
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update the merchant's loyalty program (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can update programs")
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
|
||||
program = program_service.require_program_by_store(db, store_id)
|
||||
program = program_service.update_program(db, program.id, data)
|
||||
|
||||
response = ProgramResponse.model_validate(program)
|
||||
response.is_stamps_enabled = program.is_stamps_enabled
|
||||
response.is_points_enabled = program.is_points_enabled
|
||||
response.display_name = program.display_name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ProgramStatsResponse)
|
||||
def get_stats(
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
|
||||
@@ -208,6 +208,32 @@ async def store_loyalty_stats(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SETTINGS (Merchant Owner)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/loyalty/settings",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def store_loyalty_settings(
|
||||
request: Request,
|
||||
store_code: str = Depends(get_resolved_store_code),
|
||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render loyalty program settings page.
|
||||
Allows merchant owners to create or edit their loyalty program.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"loyalty/store/settings.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENROLLMENT
|
||||
# ============================================================================
|
||||
|
||||
@@ -240,6 +240,7 @@ class ProgramStatsResponse(BaseModel):
|
||||
# Cards
|
||||
total_cards: int = 0
|
||||
active_cards: int = 0
|
||||
new_this_month: int = 0
|
||||
|
||||
# Stamps (if enabled)
|
||||
total_stamps_issued: int = 0
|
||||
@@ -250,6 +251,7 @@ class ProgramStatsResponse(BaseModel):
|
||||
# Points (if enabled)
|
||||
total_points_issued: int = 0
|
||||
total_points_redeemed: int = 0
|
||||
total_points_balance: int = 0
|
||||
points_this_month: int = 0
|
||||
points_redeemed_this_month: int = 0
|
||||
|
||||
@@ -257,6 +259,12 @@ class ProgramStatsResponse(BaseModel):
|
||||
cards_with_activity_30d: int = 0
|
||||
average_stamps_per_card: float = 0.0
|
||||
average_points_per_card: float = 0.0
|
||||
avg_points_per_member: float = 0.0
|
||||
|
||||
# 30-day metrics
|
||||
transactions_30d: int = 0
|
||||
points_issued_30d: int = 0
|
||||
points_redeemed_30d: int = 0
|
||||
|
||||
# Value
|
||||
estimated_liability_cents: int = 0 # Unredeemed stamps/points value
|
||||
|
||||
@@ -234,12 +234,11 @@ class AppleWalletService:
|
||||
"pass.json": json.dumps(pass_data).encode("utf-8"),
|
||||
}
|
||||
|
||||
# Add placeholder images (in production, these would be actual images)
|
||||
# For now, we'll skip images and use the pass.json only
|
||||
# pass_files["icon.png"] = self._get_icon_bytes(program)
|
||||
# pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
|
||||
# pass_files["logo.png"] = self._get_logo_bytes(program)
|
||||
# pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
|
||||
# Add pass images (icon and logo)
|
||||
pass_files["icon.png"] = self._get_icon_bytes(program)
|
||||
pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
|
||||
pass_files["logo.png"] = self._get_logo_bytes(program)
|
||||
pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
|
||||
|
||||
# Create manifest
|
||||
manifest = {}
|
||||
@@ -377,6 +376,105 @@ class AppleWalletService:
|
||||
|
||||
return pass_data
|
||||
|
||||
def _get_icon_bytes(self, program: LoyaltyProgram, scale: int = 1) -> bytes:
|
||||
"""
|
||||
Generate icon image for Apple Wallet pass.
|
||||
|
||||
Apple icon dimensions: 29x29 (@1x), 58x58 (@2x).
|
||||
Uses program logo if available, otherwise generates a colored square
|
||||
with the program initial.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
size = 29 * scale
|
||||
|
||||
if program.logo_url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
img = img.convert("RGBA")
|
||||
img = img.resize((size, size), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch logo for icon, using fallback")
|
||||
|
||||
# Fallback: colored square with initial
|
||||
hex_color = (program.card_color or "#4F46E5").lstrip("#")
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
|
||||
img = Image.new("RGBA", (size, size), (r, g, b, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
initial = (program.display_name or "L")[0].upper()
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size // 2)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), initial, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((size - tw) / 2, (size - th) / 2 - bbox[1]), initial, fill=(255, 255, 255, 255), font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def _get_logo_bytes(self, program: LoyaltyProgram, scale: int = 1) -> bytes:
|
||||
"""
|
||||
Generate logo image for Apple Wallet pass.
|
||||
|
||||
Apple logo dimensions: 160x50 (@1x), 320x100 (@2x).
|
||||
Uses program logo if available, otherwise generates a colored rectangle
|
||||
with the program initial.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
width, height = 160 * scale, 50 * scale
|
||||
|
||||
if program.logo_url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
img = img.convert("RGBA")
|
||||
# Fit within dimensions preserving aspect ratio
|
||||
img.thumbnail((width, height), Image.LANCZOS)
|
||||
# Center on transparent canvas
|
||||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
x = (width - img.width) // 2
|
||||
y = (height - img.height) // 2
|
||||
canvas.paste(img, (x, y))
|
||||
buf = io.BytesIO()
|
||||
canvas.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch logo for pass logo, using fallback")
|
||||
|
||||
# Fallback: colored rectangle with initial
|
||||
hex_color = (program.card_color or "#4F46E5").lstrip("#")
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
|
||||
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
initial = (program.display_name or "L")[0].upper()
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", height // 2)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), initial, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
# Draw initial centered
|
||||
draw.text(((width - tw) / 2, (height - th) / 2 - bbox[1]), initial, fill=(r, g, b, 255), font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def _hex_to_rgb(self, hex_color: str) -> str:
|
||||
"""Convert hex color to RGB format for Apple Wallet."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
|
||||
@@ -164,7 +164,34 @@ class LoyaltyFeatureProvider:
|
||||
db: Session,
|
||||
store_id: int,
|
||||
) -> list[FeatureUsage]:
|
||||
return []
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard, StaffPin
|
||||
|
||||
active_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.enrolled_at_store_id == store_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
active_pins = (
|
||||
db.query(func.count(StaffPin.id))
|
||||
.filter(
|
||||
StaffPin.store_id == store_id,
|
||||
StaffPin.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return [
|
||||
FeatureUsage(feature_code="loyalty_cards", current_usage=active_cards),
|
||||
FeatureUsage(feature_code="loyalty_staff_pins", current_usage=active_pins),
|
||||
]
|
||||
|
||||
def get_merchant_usage(
|
||||
self,
|
||||
@@ -172,7 +199,58 @@ class LoyaltyFeatureProvider:
|
||||
merchant_id: int,
|
||||
platform_id: int,
|
||||
) -> list[FeatureUsage]:
|
||||
return []
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import (
|
||||
AppleDeviceRegistration,
|
||||
LoyaltyCard,
|
||||
StaffPin,
|
||||
)
|
||||
|
||||
active_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
active_pins = (
|
||||
db.query(func.count(StaffPin.id))
|
||||
.filter(
|
||||
StaffPin.merchant_id == merchant_id,
|
||||
StaffPin.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
apple_registrations = (
|
||||
db.query(func.count(AppleDeviceRegistration.id))
|
||||
.join(LoyaltyCard)
|
||||
.filter(LoyaltyCard.merchant_id == merchant_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
google_wallets = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.google_object_id.isnot(None),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return [
|
||||
FeatureUsage(feature_code="loyalty_cards", current_usage=active_cards),
|
||||
FeatureUsage(feature_code="loyalty_staff_pins", current_usage=active_pins),
|
||||
FeatureUsage(feature_code="loyalty_apple_wallet", current_usage=apple_registrations),
|
||||
FeatureUsage(feature_code="loyalty_google_wallet", current_usage=google_wallets),
|
||||
]
|
||||
|
||||
|
||||
# Singleton instance for module registration
|
||||
|
||||
@@ -29,7 +29,7 @@ class LoyaltyOnboardingProvider:
|
||||
title_key="loyalty.onboarding.create_program.title",
|
||||
description_key="loyalty.onboarding.create_program.description",
|
||||
icon="gift",
|
||||
route_template="/store/{store_code}/loyalty/programs",
|
||||
route_template="/store/{store_code}/loyalty/settings",
|
||||
order=300,
|
||||
category="loyalty",
|
||||
),
|
||||
@@ -49,13 +49,13 @@ class LoyaltyOnboardingProvider:
|
||||
if not store:
|
||||
return False
|
||||
|
||||
count = (
|
||||
exists = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.merchant_id == store.merchant_id)
|
||||
.limit(1)
|
||||
.count()
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
return count > 0
|
||||
return exists
|
||||
|
||||
|
||||
loyalty_onboarding_provider = LoyaltyOnboardingProvider()
|
||||
|
||||
@@ -291,9 +291,8 @@ class PinService:
|
||||
|
||||
return pin
|
||||
|
||||
# No match found - record failed attempt on all unlocked PINs
|
||||
# This is a simplified approach; in production you might want to
|
||||
# track which PIN was attempted based on additional context
|
||||
# No match found - record failed attempt on the first unlocked PIN only
|
||||
# This limits blast radius to 1 lockout instead of N
|
||||
locked_pin = None
|
||||
remaining = None
|
||||
|
||||
@@ -306,7 +305,8 @@ class PinService:
|
||||
if is_now_locked:
|
||||
locked_pin = pin
|
||||
else:
|
||||
remaining = pin.remaining_attempts
|
||||
remaining = max(0, config.pin_max_failed_attempts - pin.failed_attempts)
|
||||
break # Only record on the first unlocked PIN
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -663,6 +663,78 @@ class ProgramService:
|
||||
avg_stamps = total_stamps_issued / total_cards if total_cards > 0 else 0
|
||||
avg_points = total_points_issued / total_cards if total_cards > 0 else 0
|
||||
|
||||
# New this month (cards created since month start)
|
||||
new_this_month = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyCard.created_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Points activity this month
|
||||
points_this_month = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
LoyaltyTransaction.transaction_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_redeemed_this_month = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
LoyaltyTransaction.transaction_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# 30-day transaction metrics
|
||||
transactions_30d = (
|
||||
db.query(func.count(LoyaltyTransaction.id))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_issued_30d = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_redeemed_30d = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Estimated liability (unredeemed value)
|
||||
current_stamps = (
|
||||
db.query(func.sum(LoyaltyCard.stamp_count))
|
||||
@@ -677,6 +749,7 @@ class ProgramService:
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
total_points_balance = current_points
|
||||
# Rough estimate: assume 100 points = €1
|
||||
points_value_cents = current_points // 100 * 100
|
||||
|
||||
@@ -684,18 +757,28 @@ class ProgramService:
|
||||
(current_stamps * stamp_value // program.stamps_target) + points_value_cents
|
||||
)
|
||||
|
||||
avg_points_per_member = round(current_points / active_cards, 2) if active_cards > 0 else 0
|
||||
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"active_cards": active_cards,
|
||||
"new_this_month": new_this_month,
|
||||
"total_stamps_issued": total_stamps_issued,
|
||||
"total_stamps_redeemed": total_stamps_redeemed,
|
||||
"stamps_this_month": stamps_this_month,
|
||||
"redemptions_this_month": redemptions_this_month,
|
||||
"total_points_issued": total_points_issued,
|
||||
"total_points_redeemed": total_points_redeemed,
|
||||
"total_points_balance": total_points_balance,
|
||||
"points_this_month": points_this_month,
|
||||
"points_redeemed_this_month": points_redeemed_this_month,
|
||||
"cards_with_activity_30d": cards_with_activity_30d,
|
||||
"average_stamps_per_card": round(avg_stamps, 2),
|
||||
"average_points_per_card": round(avg_points, 2),
|
||||
"avg_points_per_member": avg_points_per_member,
|
||||
"transactions_30d": transactions_30d,
|
||||
"points_issued_30d": points_issued_30d,
|
||||
"points_redeemed_30d": points_redeemed_30d,
|
||||
"estimated_liability_cents": estimated_liability,
|
||||
}
|
||||
|
||||
|
||||
@@ -59,11 +59,10 @@ function storeLoyaltyCards() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadProgram(),
|
||||
this.loadCards(),
|
||||
this.loadStats()
|
||||
]);
|
||||
await this.loadProgram();
|
||||
if (this.program) {
|
||||
await Promise.all([this.loadCards(), this.loadStats()]);
|
||||
}
|
||||
} catch (error) {
|
||||
loyaltyCardsLog.error('Failed to load data:', error);
|
||||
this.error = error.message;
|
||||
|
||||
182
app/modules/loyalty/static/store/js/loyalty-settings.js
Normal file
182
app/modules/loyalty/static/store/js/loyalty-settings.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// app/modules/loyalty/static/store/js/loyalty-settings.js
|
||||
// noqa: js-006 - async init pattern is safe, loadData has try/catch
|
||||
|
||||
const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings');
|
||||
|
||||
// ============================================
|
||||
// STORE LOYALTY SETTINGS FUNCTION
|
||||
// ============================================
|
||||
function loyaltySettings() {
|
||||
return {
|
||||
// Inherit base layout functionality
|
||||
...data(),
|
||||
|
||||
// Page identifier
|
||||
currentPage: 'loyalty-settings',
|
||||
|
||||
// State
|
||||
program: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
isOwner: false,
|
||||
|
||||
// Form data
|
||||
form: {
|
||||
loyalty_type: 'points',
|
||||
stamps_target: 10,
|
||||
stamps_reward_description: 'Free item',
|
||||
stamps_reward_value_cents: null,
|
||||
points_per_euro: 10,
|
||||
welcome_bonus_points: 0,
|
||||
minimum_redemption_points: 100,
|
||||
minimum_purchase_cents: 0,
|
||||
points_expiration_days: null,
|
||||
points_rewards: [],
|
||||
cooldown_minutes: 15,
|
||||
max_daily_stamps: 5,
|
||||
require_staff_pin: true,
|
||||
card_name: '',
|
||||
card_color: '#4F46E5',
|
||||
logo_url: '',
|
||||
terms_text: '',
|
||||
},
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZING ===');
|
||||
|
||||
if (window._loyaltySettingsInitialized) {
|
||||
loyaltySettingsLog.warn('Already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._loyaltySettingsInitialized = true;
|
||||
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
// Check if user is merchant_owner
|
||||
this.isOwner = this.currentUser?.role === 'merchant_owner';
|
||||
|
||||
await this.loadData();
|
||||
|
||||
loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await this.loadProgram();
|
||||
} catch (error) {
|
||||
loyaltySettingsLog.error('Failed to load data:', error);
|
||||
this.error = error.message || 'Failed to load settings';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadProgram() {
|
||||
try {
|
||||
loyaltySettingsLog.info('Loading program...');
|
||||
const response = await apiClient.get('/store/loyalty/program');
|
||||
|
||||
if (response) {
|
||||
this.program = response;
|
||||
this.populateForm(response);
|
||||
loyaltySettingsLog.info('Program loaded:', response.display_name);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
loyaltySettingsLog.info('No program configured — showing create form');
|
||||
this.program = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
populateForm(program) {
|
||||
this.form.loyalty_type = program.loyalty_type || 'points';
|
||||
this.form.stamps_target = program.stamps_target || 10;
|
||||
this.form.stamps_reward_description = program.stamps_reward_description || 'Free item';
|
||||
this.form.stamps_reward_value_cents = program.stamps_reward_value_cents || null;
|
||||
this.form.points_per_euro = program.points_per_euro || 10;
|
||||
this.form.welcome_bonus_points = program.welcome_bonus_points || 0;
|
||||
this.form.minimum_redemption_points = program.minimum_redemption_points || 100;
|
||||
this.form.minimum_purchase_cents = program.minimum_purchase_cents || 0;
|
||||
this.form.points_expiration_days = program.points_expiration_days || null;
|
||||
this.form.points_rewards = (program.points_rewards || []).map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
points_required: r.points_required,
|
||||
description: r.description || '',
|
||||
is_active: r.is_active !== false,
|
||||
}));
|
||||
this.form.cooldown_minutes = program.cooldown_minutes ?? 15;
|
||||
this.form.max_daily_stamps = program.max_daily_stamps || 5;
|
||||
this.form.require_staff_pin = program.require_staff_pin !== false;
|
||||
this.form.card_name = program.card_name || '';
|
||||
this.form.card_color = program.card_color || '#4F46E5';
|
||||
this.form.logo_url = program.logo_url || '';
|
||||
this.form.terms_text = program.terms_text || '';
|
||||
},
|
||||
|
||||
addReward() {
|
||||
const id = 'reward_' + Date.now();
|
||||
this.form.points_rewards.push({
|
||||
id: id,
|
||||
name: '',
|
||||
points_required: 100,
|
||||
description: '',
|
||||
is_active: true,
|
||||
});
|
||||
},
|
||||
|
||||
async saveProgram() {
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const payload = { ...this.form };
|
||||
|
||||
// Clean up empty optional fields
|
||||
if (!payload.stamps_reward_value_cents) payload.stamps_reward_value_cents = null;
|
||||
if (!payload.points_expiration_days) payload.points_expiration_days = null;
|
||||
if (!payload.card_name) payload.card_name = null;
|
||||
if (!payload.logo_url) payload.logo_url = null;
|
||||
if (!payload.terms_text) payload.terms_text = null;
|
||||
|
||||
let response;
|
||||
if (this.program) {
|
||||
// Update existing
|
||||
response = await apiClient.put('/store/loyalty/program', payload);
|
||||
Utils.showToast('Program updated successfully', 'success');
|
||||
} else {
|
||||
// Create new
|
||||
response = await apiClient.post('/store/loyalty/program', payload);
|
||||
Utils.showToast('Program created successfully', 'success');
|
||||
}
|
||||
|
||||
this.program = response;
|
||||
this.populateForm(response);
|
||||
|
||||
loyaltySettingsLog.info('Program saved:', response.display_name);
|
||||
} catch (error) {
|
||||
Utils.showToast(`Failed to save: ${error.message}`, 'error');
|
||||
loyaltySettingsLog.error('Save failed:', error);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Register logger
|
||||
if (!window.LogConfig.loggers.loyaltySettings) {
|
||||
window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings');
|
||||
}
|
||||
|
||||
loyaltySettingsLog.info('Loyalty settings module loaded');
|
||||
@@ -8,6 +8,8 @@ function storeLoyaltyStats() {
|
||||
...data(),
|
||||
currentPage: 'loyalty-stats',
|
||||
|
||||
program: null,
|
||||
|
||||
stats: {
|
||||
total_cards: 0,
|
||||
active_cards: 0,
|
||||
@@ -35,10 +37,22 @@ function storeLoyaltyStats() {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
await this.loadStats();
|
||||
await this.loadProgram();
|
||||
if (this.program) {
|
||||
await this.loadStats();
|
||||
}
|
||||
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
async loadProgram() {
|
||||
try {
|
||||
const response = await apiClient.get('/store/loyalty/program');
|
||||
if (response) this.program = response;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -191,7 +191,11 @@ function storeLoyaltyTerminal() {
|
||||
this.processing = true;
|
||||
|
||||
try {
|
||||
if (this.pendingAction === 'earn') {
|
||||
if (this.pendingAction === 'stamp') {
|
||||
await this.addStamp();
|
||||
} else if (this.pendingAction === 'redeemStamps') {
|
||||
await this.redeemStamps();
|
||||
} else if (this.pendingAction === 'earn') {
|
||||
await this.earnPoints();
|
||||
} else if (this.pendingAction === 'redeem') {
|
||||
await this.redeemReward();
|
||||
@@ -216,6 +220,30 @@ function storeLoyaltyTerminal() {
|
||||
}
|
||||
},
|
||||
|
||||
// Add stamp
|
||||
async addStamp() {
|
||||
loyaltyTerminalLog.info('Adding stamp...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp', {
|
||||
card_id: this.selectedCard.id,
|
||||
staff_pin: this.pinDigits
|
||||
});
|
||||
|
||||
Utils.showToast('Stamp added!', 'success');
|
||||
},
|
||||
|
||||
// Redeem stamps
|
||||
async redeemStamps() {
|
||||
loyaltyTerminalLog.info('Redeeming stamps...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp/redeem', {
|
||||
card_id: this.selectedCard.id,
|
||||
staff_pin: this.pinDigits
|
||||
});
|
||||
|
||||
Utils.showToast('Stamps redeemed! Reward earned.', 'success');
|
||||
},
|
||||
|
||||
// Earn points
|
||||
async earnPoints() {
|
||||
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
|
||||
|
||||
@@ -26,8 +26,28 @@
|
||||
|
||||
{{ error_state('Error loading members') }}
|
||||
|
||||
<!-- No Program Setup Notice -->
|
||||
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
|
||||
{% if user.role == 'merchant_owner' %}
|
||||
<a href="/store/{{ store_code }}/loyalty/settings"
|
||||
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||
Set Up Loyalty Program
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<div x-show="!loading && program" class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('users', 'w-5 h-5')"></span>
|
||||
@@ -67,7 +87,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div x-show="!loading && program" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<div class="relative">
|
||||
@@ -91,7 +111,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Cards Table -->
|
||||
<div x-show="!loading">
|
||||
<div x-show="!loading && program">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Member', 'Card Number', 'Points Balance', 'Last Activity', 'Status', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
|
||||
246
app/modules/loyalty/templates/loyalty/store/settings.html
Normal file
246
app/modules/loyalty/templates/loyalty/store/settings.html
Normal file
@@ -0,0 +1,246 @@
|
||||
{# app/modules/loyalty/templates/loyalty/store/settings.html #}
|
||||
{% extends "store/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
|
||||
{% block title %}Loyalty Settings{% endblock %}
|
||||
|
||||
{% block alpine_data %}loyaltySettings(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='Loyalty Settings', subtitle='Configure your loyalty program') %}
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/store/{{ store_code }}/loyalty/terminal"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
|
||||
<span x-html="$icon('terminal', 'w-4 h-4 mr-2')"></span>
|
||||
Terminal
|
||||
</a>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading settings...') }}
|
||||
|
||||
{{ error_state('Error loading settings') }}
|
||||
|
||||
<!-- Access Denied (non-owner) -->
|
||||
<div x-show="!loading && !isOwner" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('shield', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Access Restricted</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Only the merchant owner can manage loyalty program settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<div x-show="!loading && isOwner" class="space-y-6">
|
||||
|
||||
<!-- Program Type (create only) -->
|
||||
<div x-show="!program" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('layers', 'inline w-5 h-5 mr-2')"></span>
|
||||
Program Type
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||
:class="form.loyalty_type === 'stamps' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
|
||||
<input type="radio" x-model="form.loyalty_type" value="stamps" class="sr-only">
|
||||
<div>
|
||||
<span x-html="$icon('stamp', 'w-8 h-8 text-purple-500 mb-2')"></span>
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200">Stamps</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Collect stamps for rewards</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||
:class="form.loyalty_type === 'points' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
|
||||
<input type="radio" x-model="form.loyalty_type" value="points" class="sr-only">
|
||||
<div>
|
||||
<span x-html="$icon('coins', 'w-8 h-8 text-purple-500 mb-2')"></span>
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200">Points</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Earn points per purchase</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||
:class="form.loyalty_type === 'hybrid' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
|
||||
<input type="radio" x-model="form.loyalty_type" value="hybrid" class="sr-only">
|
||||
<div>
|
||||
<span x-html="$icon('layers', 'w-8 h-8 text-purple-500 mb-2')"></span>
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200">Hybrid</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Both stamps and points</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stamps Configuration -->
|
||||
<div x-show="form.loyalty_type === 'stamps' || form.loyalty_type === 'hybrid'"
|
||||
class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Stamps Configuration</h3>
|
||||
</div>
|
||||
<div class="p-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stamps for Reward</label>
|
||||
<input type="number" x-model.number="form.stamps_target" min="1" max="50"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reward Description</label>
|
||||
<input type="text" x-model="form.stamps_reward_description" maxlength="255"
|
||||
placeholder="e.g., Free coffee"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reward Value (cents)</label>
|
||||
<input type="number" x-model.number="form.stamps_reward_value_cents" min="0"
|
||||
placeholder="e.g., 500 for 5 EUR"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Points Configuration -->
|
||||
<div x-show="form.loyalty_type === 'points' || form.loyalty_type === 'hybrid'"
|
||||
class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Points Configuration</h3>
|
||||
</div>
|
||||
<div class="p-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points per EUR</label>
|
||||
<input type="number" x-model.number="form.points_per_euro" min="1" max="100"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Welcome Bonus Points</label>
|
||||
<input type="number" x-model.number="form.welcome_bonus_points" min="0"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Min Redemption Points</label>
|
||||
<input type="number" x-model.number="form.minimum_redemption_points" min="1"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Min Purchase (cents)</label>
|
||||
<input type="number" x-model.number="form.minimum_purchase_cents" min="0"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points Expiry (days)</label>
|
||||
<input type="number" x-model.number="form.points_expiration_days" min="30"
|
||||
placeholder="Leave empty for no expiry"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rewards List -->
|
||||
<div class="px-4 pb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rewards</label>
|
||||
<div class="space-y-2">
|
||||
<template x-for="(reward, index) in form.points_rewards" :key="index">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" x-model="reward.name" placeholder="Reward name"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
<input type="number" x-model.number="reward.points_required" placeholder="Points" min="1"
|
||||
class="w-24 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
<button @click="form.points_rewards.splice(index, 1)" type="button"
|
||||
class="p-2 text-red-500 hover:text-red-700">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button @click="addReward()" type="button"
|
||||
class="mt-2 text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400">
|
||||
+ Add Reward
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anti-Fraud -->
|
||||
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Anti-Fraud Settings</h3>
|
||||
</div>
|
||||
<div class="p-4 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Cooldown (minutes)</label>
|
||||
<input type="number" x-model.number="form.cooldown_minutes" min="0" max="1440"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Max Daily Stamps</label>
|
||||
<input type="number" x-model.number="form.max_daily_stamps" min="1" max="50"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.require_staff_pin"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Require Staff PIN</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branding -->
|
||||
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Branding</h3>
|
||||
</div>
|
||||
<div class="p-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Name</label>
|
||||
<input type="text" x-model="form.card_name" maxlength="100"
|
||||
placeholder="e.g., My Rewards Card"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Color</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="color" x-model="form.card_color"
|
||||
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer">
|
||||
<input type="text" x-model="form.card_color" maxlength="7" placeholder="#4F46E5"
|
||||
class="flex-1 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Logo URL</label>
|
||||
<input type="url" x-model="form.logo_url" maxlength="500"
|
||||
placeholder="https://..."
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Terms & Conditions</label>
|
||||
<textarea x-model="form.terms_text" rows="3"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/store/{{ store_code }}/loyalty/terminal"
|
||||
class="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||
Cancel
|
||||
</a>
|
||||
<button @click="saveProgram()"
|
||||
:disabled="saving"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
|
||||
<span x-text="saving ? 'Saving...' : (program ? 'Save Changes' : 'Create Program')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -17,7 +17,27 @@
|
||||
{{ loading_state('Loading statistics...') }}
|
||||
{{ error_state('Error loading statistics') }}
|
||||
|
||||
<div x-show="!loading">
|
||||
<!-- No Program Setup Notice -->
|
||||
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
|
||||
{% if user.role == 'merchant_owner' %}
|
||||
<a href="/store/{{ store_code }}/loyalty/settings"
|
||||
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||
Set Up Loyalty Program
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && program">
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
|
||||
@@ -36,11 +36,15 @@
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
|
||||
{% if user.role == 'merchant_owner' %}
|
||||
<a href="/store/{{ store_code }}/loyalty/settings"
|
||||
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||
Set Up Loyalty Program
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,70 +129,127 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Points Balance -->
|
||||
<!-- Balance Area -->
|
||||
<div class="mb-6 p-4 rounded-lg text-center"
|
||||
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '10'">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
|
||||
<p class="text-3xl font-bold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
|
||||
<!-- Points balance (for points and hybrid) -->
|
||||
<template x-if="program?.is_points_enabled">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
|
||||
<p class="text-3xl font-bold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Stamps progress (for stamps and hybrid) -->
|
||||
<template x-if="program?.is_stamps_enabled">
|
||||
<div :class="program?.is_points_enabled ? 'mt-3 pt-3 border-t border-gray-200 dark:border-gray-700' : ''">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Stamps</p>
|
||||
<p class="text-3xl font-bold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="(selectedCard?.stamp_count || 0) + ' / ' + (program?.stamps_target || 10)"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"
|
||||
x-text="selectedCard?.stamps_until_reward > 0 ? (selectedCard.stamps_until_reward + ' more for reward') : 'Ready to redeem!'"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<!-- Action Panels -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Earn Points -->
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
Earn Points
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Purchase Amount</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500">EUR</span>
|
||||
<input type="number" step="0.01" min="0" {# noqa: FE-008 #}
|
||||
x-model.number="earnAmount"
|
||||
class="w-full pl-12 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-green-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Points to award: <span class="font-semibold text-green-600" x-text="Math.floor((earnAmount || 0) * (program?.points_per_euro || 1))"></span>
|
||||
</p>
|
||||
<button @click="showPinModal('earn')"
|
||||
:disabled="!earnAmount || earnAmount <= 0"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
Award Points
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Redeem Points -->
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
|
||||
Redeem Reward
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Select Reward</label>
|
||||
<select x-model="selectedReward"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-orange-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Select reward...</option>
|
||||
<template x-for="reward in availableRewards" :key="reward.id">
|
||||
<option :value="reward.id" :disabled="(selectedCard?.points_balance || 0) < reward.points_required"
|
||||
x-text="reward.name + ' (' + reward.points_required + ' pts)'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<template x-if="selectedReward">
|
||||
<!-- Stamp Panels (for stamps and hybrid) -->
|
||||
<template x-if="program?.is_stamps_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
Add Stamp
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Points after: <span class="font-semibold text-orange-600" x-text="formatNumber((selectedCard?.points_balance || 0) - (getSelectedRewardPoints() || 0))"></span>
|
||||
Current: <span class="font-semibold" x-text="(selectedCard?.stamp_count || 0) + '/' + (program?.stamps_target || 10)"></span>
|
||||
</p>
|
||||
</template>
|
||||
<button @click="showPinModal('redeem')"
|
||||
:disabled="!selectedReward"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
Redeem Reward
|
||||
</button>
|
||||
</div>
|
||||
<button @click="showPinModal('stamp')"
|
||||
:disabled="!selectedCard?.can_stamp"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
Add Stamp
|
||||
</button>
|
||||
<template x-if="!selectedCard?.can_stamp && selectedCard?.cooldown_ends_at">
|
||||
<p class="text-xs text-red-500 mt-2">Cooldown active</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="program?.is_stamps_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
|
||||
Redeem Stamps
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"
|
||||
x-text="selectedCard?.can_redeem_stamps ? 'Reward: ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || 'Free item') : 'Not enough stamps yet'"></p>
|
||||
<button @click="showPinModal('redeemStamps')"
|
||||
:disabled="!selectedCard?.can_redeem_stamps"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
Redeem Stamps
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Point Panels (for points and hybrid) -->
|
||||
<template x-if="program?.is_points_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
Earn Points
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Purchase Amount</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500">EUR</span>
|
||||
<input type="number" step="0.01" min="0" {# noqa: FE-008 #}
|
||||
x-model.number="earnAmount"
|
||||
class="w-full pl-12 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-green-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Points to award: <span class="font-semibold text-green-600" x-text="Math.floor((earnAmount || 0) * (program?.points_per_euro || 1))"></span>
|
||||
</p>
|
||||
<button @click="showPinModal('earn')"
|
||||
:disabled="!earnAmount || earnAmount <= 0"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
Award Points
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="program?.is_points_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
|
||||
Redeem Reward
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Select Reward</label>
|
||||
<select x-model="selectedReward"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-orange-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Select reward...</option>
|
||||
<template x-for="reward in availableRewards" :key="reward.id">
|
||||
<option :value="reward.id" :disabled="(selectedCard?.points_balance || 0) < reward.points_required"
|
||||
x-text="reward.name + ' (' + reward.points_required + ' pts)'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<template x-if="selectedReward">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Points after: <span class="font-semibold text-orange-600" x-text="formatNumber((selectedCard?.points_balance || 0) - (getSelectedRewardPoints() || 0))"></span>
|
||||
</p>
|
||||
</template>
|
||||
<button @click="showPinModal('redeem')"
|
||||
:disabled="!selectedReward"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
Redeem Reward
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,15 @@ Tests:
|
||||
Authentication: Uses real JWT tokens via store login endpoint.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyTransaction
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
BASE = "/api/v1/store/loyalty"
|
||||
|
||||
@@ -217,42 +220,20 @@ class TestEarnPoints:
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Store Program CRUD Removed
|
||||
# Store Program CRUD
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.loyalty
|
||||
class TestStoreProgramCrudRemoved:
|
||||
"""Verify POST/PATCH /program endpoints are removed from store API."""
|
||||
class TestStoreProgramCrud:
|
||||
"""Tests for store program CRUD endpoints (merchant_owner only)."""
|
||||
|
||||
def test_create_program_removed(
|
||||
self, client, loyalty_store_headers
|
||||
):
|
||||
"""POST /program no longer exists on store API."""
|
||||
response = client.post(
|
||||
f"{BASE}/program",
|
||||
json={"loyalty_type": "points"},
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 405 # Method Not Allowed
|
||||
|
||||
def test_update_program_removed(
|
||||
self, client, loyalty_store_headers
|
||||
):
|
||||
"""PATCH /program no longer exists on store API."""
|
||||
response = client.patch(
|
||||
f"{BASE}/program",
|
||||
json={"card_name": "Should Not Work"},
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 405 # Method Not Allowed
|
||||
|
||||
def test_get_program_still_works(
|
||||
def test_get_program_works(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
"""GET /program still works (read-only)."""
|
||||
"""GET /program returns program."""
|
||||
response = client.get(
|
||||
f"{BASE}/program",
|
||||
headers=loyalty_store_headers,
|
||||
@@ -261,6 +242,19 @@ class TestStoreProgramCrudRemoved:
|
||||
data = response.json()
|
||||
assert data["id"] == loyalty_store_setup["program"].id
|
||||
|
||||
def test_update_program_via_put(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
"""PUT /program updates the program (merchant_owner)."""
|
||||
response = client.put(
|
||||
f"{BASE}/program",
|
||||
json={"card_name": "Updated Card"},
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["card_name"] == "Updated Card"
|
||||
|
||||
def test_stats_still_works(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
@@ -270,3 +264,201 @@ class TestStoreProgramCrudRemoved:
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_stats_has_new_fields(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
"""GET /stats returns all new fields."""
|
||||
response = client.get(
|
||||
f"{BASE}/stats",
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "new_this_month" in data
|
||||
assert "total_points_balance" in data
|
||||
assert "avg_points_per_member" in data
|
||||
assert "transactions_30d" in data
|
||||
assert "points_issued_30d" in data
|
||||
assert "points_redeemed_30d" in data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Stamp Earn/Redeem Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stamp_store_setup(db, loyalty_platform):
|
||||
"""Setup with a stamps-type program for integration tests."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"stampint_{uid}@test.com",
|
||||
username=f"stampint_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Stamp Int Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"STINT_{uid.upper()}",
|
||||
subdomain=f"stint{uid}",
|
||||
name=f"Stamp Int Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
sp = StorePlatform(store_id=store.id, platform_id=loyalty_platform.id)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
|
||||
customer = Customer(
|
||||
email=f"stampcust_{uid}@test.com",
|
||||
first_name="Stamp",
|
||||
last_name="IntCustomer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"SIC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.HYBRID.value,
|
||||
stamps_target=3,
|
||||
stamps_reward_description="Free item",
|
||||
stamps_reward_value_cents=500,
|
||||
points_per_euro=10,
|
||||
welcome_bonus_points=0,
|
||||
minimum_redemption_points=100,
|
||||
minimum_purchase_cents=0,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=50,
|
||||
require_staff_pin=False,
|
||||
card_name="Int Stamp Card",
|
||||
card_color="#FF0000",
|
||||
is_active=True,
|
||||
points_rewards=[
|
||||
{"id": "r1", "name": "5 EUR off", "points_required": 100, "is_active": True},
|
||||
],
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store.id,
|
||||
card_number=f"SINTCARD-{uid.upper()}",
|
||||
stamp_count=0,
|
||||
total_stamps_earned=0,
|
||||
stamps_redeemed=0,
|
||||
points_balance=0,
|
||||
total_points_earned=0,
|
||||
points_redeemed=0,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"owner": owner,
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"platform": loyalty_platform,
|
||||
"customer": customer,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stamp_store_headers(client, stamp_store_setup):
|
||||
"""JWT auth headers for stamp store setup."""
|
||||
owner = stamp_store_setup["owner"]
|
||||
response = client.post(
|
||||
"/api/v1/store/auth/login",
|
||||
json={"email_or_username": owner.username, "password": "storepass123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.loyalty
|
||||
class TestStampEarnRedeem:
|
||||
"""Integration tests for stamp earn/redeem via store API."""
|
||||
|
||||
def test_stamp_earn(self, client, stamp_store_headers, stamp_store_setup):
|
||||
"""POST /stamp adds a stamp."""
|
||||
card = stamp_store_setup["card"]
|
||||
response = client.post(
|
||||
f"{BASE}/stamp",
|
||||
json={"card_id": card.id},
|
||||
headers=stamp_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["stamp_count"] == 1
|
||||
|
||||
def test_stamp_redeem(self, client, stamp_store_headers, stamp_store_setup, db):
|
||||
"""POST /stamp/redeem redeems stamps."""
|
||||
card = stamp_store_setup["card"]
|
||||
program = stamp_store_setup["program"]
|
||||
|
||||
# Give enough stamps
|
||||
card.stamp_count = program.stamps_target
|
||||
card.total_stamps_earned = program.stamps_target
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/stamp/redeem",
|
||||
json={"card_id": card.id},
|
||||
headers=stamp_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["stamp_count"] == 0
|
||||
|
||||
0
app/modules/loyalty/tests/unit/__init__.py
Normal file
0
app/modules/loyalty/tests/unit/__init__.py
Normal file
@@ -1,8 +1,20 @@
|
||||
"""Unit tests for PinService."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
InvalidStaffPinException,
|
||||
StaffPinLockedException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyProgram, StaffPin
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.schemas.pin import PinCreate
|
||||
from app.modules.loyalty.services.pin_service import PinService
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +28,208 @@ class TestPinService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pin_setup(db):
|
||||
"""Create a full setup for PIN tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"pinowner_{uid}@test.com",
|
||||
username=f"pinowner_{uid}",
|
||||
hashed_password=auth.hash_password("testpass"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"PIN Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"PIN_{uid.upper()}",
|
||||
subdomain=f"pin{uid}",
|
||||
name=f"PIN Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=True,
|
||||
card_name="PIN Card",
|
||||
card_color="#00FF00",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
return {
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"program": program,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Create / Unlock Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestCreatePin:
|
||||
"""Tests for create_pin."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PinService()
|
||||
|
||||
def test_create_pin(self, db, pin_setup):
|
||||
"""Create a staff PIN."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
data = PinCreate(name="Alice", staff_id="EMP001", pin="1234")
|
||||
pin = self.service.create_pin(db, program.id, store.id, data)
|
||||
|
||||
assert pin.id is not None
|
||||
assert pin.name == "Alice"
|
||||
assert pin.staff_id == "EMP001"
|
||||
assert pin.verify_pin("1234")
|
||||
|
||||
def test_unlock_pin(self, db, pin_setup):
|
||||
"""Unlock a locked PIN."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
data = PinCreate(name="Bob", staff_id="EMP002", pin="5678")
|
||||
pin = self.service.create_pin(db, program.id, store.id, data)
|
||||
|
||||
# Lock it
|
||||
pin.failed_attempts = 5
|
||||
from datetime import timedelta
|
||||
pin.locked_until = datetime.now(UTC) + timedelta(minutes=30)
|
||||
db.commit()
|
||||
|
||||
assert pin.is_locked
|
||||
|
||||
# Unlock
|
||||
unlocked = self.service.unlock_pin(db, pin.id)
|
||||
assert unlocked.failed_attempts == 0
|
||||
assert unlocked.locked_until is None
|
||||
assert not unlocked.is_locked
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Verify PIN Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestVerifyPin:
|
||||
"""Tests for verify_pin."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PinService()
|
||||
|
||||
def test_verify_pin_success(self, db, pin_setup):
|
||||
"""Correct PIN verifies successfully."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
data = PinCreate(name="Charlie", staff_id="EMP003", pin="1111")
|
||||
self.service.create_pin(db, program.id, store.id, data)
|
||||
|
||||
result = self.service.verify_pin(db, program.id, "1111", store_id=store.id)
|
||||
assert result.name == "Charlie"
|
||||
|
||||
def test_verify_pin_wrong_single_failure(self, db, pin_setup):
|
||||
"""Wrong PIN records failure on ONE pin only, not all."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
# Create two PINs
|
||||
self.service.create_pin(db, program.id, store.id, PinCreate(name="A", pin="1111"))
|
||||
self.service.create_pin(db, program.id, store.id, PinCreate(name="B", pin="2222"))
|
||||
|
||||
# Wrong PIN
|
||||
with pytest.raises(InvalidStaffPinException):
|
||||
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
||||
|
||||
# Only one PIN should have failed_attempts incremented
|
||||
pins = self.service.list_pins(db, program.id, store_id=store.id, is_active=True)
|
||||
failed_counts = [p.failed_attempts for p in pins]
|
||||
assert sum(failed_counts) == 1 # Only 1 PIN got the failure, not both
|
||||
|
||||
def test_verify_pin_lockout(self, db, pin_setup):
|
||||
"""After max failures, PIN gets locked."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
self.service.create_pin(db, program.id, store.id, PinCreate(name="Lock", pin="3333"))
|
||||
|
||||
# Fail 5 times (default max)
|
||||
for _ in range(5):
|
||||
try:
|
||||
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
||||
except (InvalidStaffPinException, StaffPinLockedException):
|
||||
pass
|
||||
|
||||
# Next attempt should be locked
|
||||
with pytest.raises((InvalidStaffPinException, StaffPinLockedException)):
|
||||
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
||||
|
||||
def test_verify_skips_locked_pins(self, db, pin_setup):
|
||||
"""Locked PINs are skipped during verification."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
# Create a locked PIN and an unlocked one
|
||||
data1 = PinCreate(name="Locked", pin="1111")
|
||||
pin1 = self.service.create_pin(db, program.id, store.id, data1)
|
||||
pin1.locked_until = datetime.now(UTC) + timedelta(minutes=30)
|
||||
pin1.failed_attempts = 5
|
||||
db.commit()
|
||||
|
||||
data2 = PinCreate(name="Active", pin="2222")
|
||||
self.service.create_pin(db, program.id, store.id, data2)
|
||||
|
||||
# Should find the active PIN
|
||||
result = self.service.verify_pin(db, program.id, "2222", store_id=store.id)
|
||||
assert result.name == "Active"
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
"""Unit tests for PointsService."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
InsufficientPointsException,
|
||||
InvalidRewardException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
from app.modules.loyalty.services.points_service import PointsService
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +28,347 @@ class TestPointsService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def points_setup(db):
|
||||
"""Create a full setup for points tests."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"ptsowner_{uid}@test.com",
|
||||
username=f"ptsowner_{uid}",
|
||||
hashed_password=auth.hash_password("testpass"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Points Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"PTS_{uid.upper()}",
|
||||
subdomain=f"pts{uid}",
|
||||
name=f"Points Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
customer = Customer(
|
||||
email=f"ptscust_{uid}@test.com",
|
||||
first_name="Points",
|
||||
last_name="Customer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"PC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
welcome_bonus_points=0,
|
||||
minimum_redemption_points=50,
|
||||
minimum_purchase_cents=100,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=False,
|
||||
card_name="Points Card",
|
||||
card_color="#0000FF",
|
||||
is_active=True,
|
||||
points_rewards=[
|
||||
{"id": "r1", "name": "5 EUR off", "points_required": 100, "is_active": True},
|
||||
{"id": "r2", "name": "10 EUR off", "points_required": 200, "is_active": True},
|
||||
{"id": "r3", "name": "Inactive Reward", "points_required": 50, "is_active": False},
|
||||
],
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store.id,
|
||||
card_number=f"PTSCARD-{uid.upper()}",
|
||||
stamp_count=0,
|
||||
total_stamps_earned=0,
|
||||
stamps_redeemed=0,
|
||||
points_balance=500,
|
||||
total_points_earned=500,
|
||||
points_redeemed=0,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"customer": customer,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Earn Points Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestEarnPoints:
|
||||
"""Tests for earn_points."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_earn_points_calculation(self, db, points_setup):
|
||||
"""Points calculated correctly from purchase amount."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=2000, # 20 EUR
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_earned"] == 200 # 20 EUR * 10 pts/EUR
|
||||
assert result["points_balance"] == 700 # 500 + 200
|
||||
|
||||
def test_earn_points_minimum_purchase(self, db, points_setup):
|
||||
"""Below minimum purchase returns 0 points."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=50, # Below min of 100
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_earned"] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Redeem Points Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestRedeemPoints:
|
||||
"""Tests for redeem_points."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_redeem_points_success(self, db, points_setup):
|
||||
"""Successfully redeem points for a reward."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result = self.service.redeem_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
reward_id="r1",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_spent"] == 100
|
||||
assert result["points_balance"] == 400 # 500 - 100
|
||||
|
||||
def test_redeem_points_insufficient(self, db, points_setup):
|
||||
"""Redeeming without enough points raises exception."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
# Set balance low
|
||||
card.points_balance = 50
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(InsufficientPointsException):
|
||||
self.service.redeem_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
reward_id="r1", # needs 100
|
||||
)
|
||||
|
||||
def test_redeem_inactive_reward(self, db, points_setup):
|
||||
"""Redeeming an inactive reward raises exception."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
with pytest.raises(InvalidRewardException):
|
||||
self.service.redeem_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
reward_id="r3", # inactive
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Void Points Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestVoidPoints:
|
||||
"""Tests for void_points."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_void_by_transaction(self, db, points_setup):
|
||||
"""Void points by original transaction ID."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
# Earn some points
|
||||
earn_result = self.service.earn_points(
|
||||
db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000,
|
||||
)
|
||||
assert earn_result["points_earned"] == 100
|
||||
|
||||
# Find the earn transaction
|
||||
tx = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
void_result = self.service.void_points(
|
||||
db, store_id=store.id, card_id=card.id, original_transaction_id=tx.id,
|
||||
)
|
||||
|
||||
assert void_result["success"] is True
|
||||
assert void_result["points_voided"] == 100
|
||||
|
||||
def test_void_by_order_reference(self, db, points_setup):
|
||||
"""Void points by order reference."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
# Earn with order reference
|
||||
self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=2000,
|
||||
order_reference="ORDER-VOID-TEST",
|
||||
)
|
||||
|
||||
void_result = self.service.void_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
order_reference="ORDER-VOID-TEST",
|
||||
)
|
||||
|
||||
assert void_result["success"] is True
|
||||
assert void_result["points_voided"] == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Adjust Points Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestAdjustPoints:
|
||||
"""Tests for adjust_points."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_adjust_positive(self, db, points_setup):
|
||||
"""Add points via adjustment."""
|
||||
card = points_setup["card"]
|
||||
|
||||
result = self.service.adjust_points(
|
||||
db,
|
||||
card_id=card.id,
|
||||
points_delta=50,
|
||||
reason="Goodwill bonus",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_balance"] == 550 # 500 + 50
|
||||
|
||||
def test_adjust_negative(self, db, points_setup):
|
||||
"""Remove points via adjustment."""
|
||||
card = points_setup["card"]
|
||||
|
||||
result = self.service.adjust_points(
|
||||
db,
|
||||
card_id=card.id,
|
||||
points_delta=-100,
|
||||
reason="Correction",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_balance"] == 400 # 500 - 100
|
||||
|
||||
def test_adjust_floor_at_zero(self, db, points_setup):
|
||||
"""Negative adjustment doesn't go below zero."""
|
||||
card = points_setup["card"]
|
||||
|
||||
result = self.service.adjust_points(
|
||||
db,
|
||||
card_id=card.id,
|
||||
points_delta=-9999,
|
||||
reason="Full correction",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_balance"] == 0
|
||||
|
||||
@@ -352,3 +352,98 @@ class TestDeleteProgram:
|
||||
"""Deleting non-existent program raises exception."""
|
||||
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||
self.service.delete_program(db, 999999)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Stats
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestGetProgramStats:
|
||||
"""Tests for get_program_stats."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProgramService()
|
||||
|
||||
def test_stats_returns_all_fields(self, db, ps_program):
|
||||
"""Stats response includes all required fields."""
|
||||
stats = self.service.get_program_stats(db, ps_program.id)
|
||||
|
||||
assert "total_cards" in stats
|
||||
assert "active_cards" in stats
|
||||
assert "new_this_month" in stats
|
||||
assert "total_points_balance" in stats
|
||||
assert "avg_points_per_member" in stats
|
||||
assert "transactions_30d" in stats
|
||||
assert "points_issued_30d" in stats
|
||||
assert "points_redeemed_30d" in stats
|
||||
assert "points_this_month" in stats
|
||||
assert "points_redeemed_this_month" in stats
|
||||
assert "estimated_liability_cents" in stats
|
||||
|
||||
def test_stats_empty_program(self, db, ps_program):
|
||||
"""Stats for program with no cards."""
|
||||
stats = self.service.get_program_stats(db, ps_program.id)
|
||||
|
||||
assert stats["total_cards"] == 0
|
||||
assert stats["active_cards"] == 0
|
||||
assert stats["new_this_month"] == 0
|
||||
assert stats["total_points_balance"] == 0
|
||||
assert stats["avg_points_per_member"] == 0
|
||||
|
||||
def test_stats_with_cards(self, db, ps_program, ps_merchant):
|
||||
"""Stats reflect actual card data."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.loyalty.models import LoyaltyCard
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
uid_store = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=ps_merchant.id,
|
||||
store_code=f"STAT_{uid_store.upper()}",
|
||||
subdomain=f"stat{uid_store}",
|
||||
name=f"Stats Store {uid_store}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.flush()
|
||||
|
||||
# Create cards with customers
|
||||
for i in range(3):
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
customer = Customer(
|
||||
email=f"stat_{uid}@test.com",
|
||||
first_name="Stat",
|
||||
last_name=f"Customer{i}",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"SC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.flush()
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=ps_merchant.id,
|
||||
program_id=ps_program.id,
|
||||
customer_id=customer.id,
|
||||
card_number=f"STAT-{i}-{uuid.uuid4().hex[:6]}",
|
||||
points_balance=100 * (i + 1),
|
||||
total_points_earned=100 * (i + 1),
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
|
||||
stats = self.service.get_program_stats(db, ps_program.id)
|
||||
|
||||
assert stats["total_cards"] == 3
|
||||
assert stats["active_cards"] == 3
|
||||
assert stats["total_points_balance"] == 600 # 100+200+300
|
||||
assert stats["avg_points_per_member"] == 200.0 # 600/3
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
"""Unit tests for StampService."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
DailyStampLimitException,
|
||||
InsufficientStampsException,
|
||||
StampCooldownException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
from app.modules.loyalty.services.stamp_service import StampService
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +29,259 @@ class TestStampService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stamp_setup(db):
|
||||
"""Create a full setup for stamp tests with stamps-type program."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"stampowner_{uid}@test.com",
|
||||
username=f"stampowner_{uid}",
|
||||
hashed_password=auth.hash_password("testpass"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Stamp Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"STAMP_{uid.upper()}",
|
||||
subdomain=f"stamp{uid}",
|
||||
name=f"Stamp Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
customer = Customer(
|
||||
email=f"stampcust_{uid}@test.com",
|
||||
first_name="Stamp",
|
||||
last_name="Customer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"SC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.STAMPS.value,
|
||||
stamps_target=5,
|
||||
stamps_reward_description="Free coffee",
|
||||
stamps_reward_value_cents=500,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=False,
|
||||
card_name="Stamp Card",
|
||||
card_color="#FF0000",
|
||||
is_active=True,
|
||||
points_per_euro=1,
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store.id,
|
||||
card_number=f"STAMPCARD-{uid.upper()}",
|
||||
stamp_count=0,
|
||||
total_stamps_earned=0,
|
||||
stamps_redeemed=0,
|
||||
points_balance=0,
|
||||
total_points_earned=0,
|
||||
points_redeemed=0,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"customer": customer,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Add Stamp Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestAddStamp:
|
||||
"""Tests for add_stamp."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StampService()
|
||||
|
||||
def test_add_stamp_success(self, db, stamp_setup):
|
||||
"""Successfully add a stamp to a card."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
|
||||
result = self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stamp_count"] == 1
|
||||
assert result["stamps_target"] == 5
|
||||
assert result["stamps_until_reward"] == 4
|
||||
|
||||
def test_add_stamp_cooldown_violation(self, db, stamp_setup):
|
||||
"""Stamp within cooldown period raises exception."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
# Set cooldown
|
||||
program.cooldown_minutes = 15
|
||||
db.commit()
|
||||
|
||||
# Add first stamp
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
# Second stamp should fail (cooldown)
|
||||
with pytest.raises(StampCooldownException):
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
def test_add_stamp_daily_limit(self, db, stamp_setup):
|
||||
"""Exceeding daily stamp limit raises exception."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
# Set max 2 daily stamps
|
||||
program.max_daily_stamps = 2
|
||||
db.commit()
|
||||
|
||||
# Add 2 stamps
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
# Third should fail
|
||||
with pytest.raises(DailyStampLimitException):
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Redeem Stamps Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestRedeemStamps:
|
||||
"""Tests for redeem_stamps."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StampService()
|
||||
|
||||
def test_redeem_stamps_success(self, db, stamp_setup):
|
||||
"""Successfully redeem stamps for a reward."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
# Give enough stamps
|
||||
card.stamp_count = program.stamps_target
|
||||
card.total_stamps_earned = program.stamps_target
|
||||
db.commit()
|
||||
|
||||
result = self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stamp_count"] == 0
|
||||
assert result["reward_description"] == "Free coffee"
|
||||
|
||||
def test_redeem_stamps_insufficient(self, db, stamp_setup):
|
||||
"""Redeeming without enough stamps raises exception."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
|
||||
# Card has 0 stamps, needs 5
|
||||
with pytest.raises(InsufficientStampsException):
|
||||
self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Void Stamps Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestVoidStamps:
|
||||
"""Tests for void_stamps."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StampService()
|
||||
|
||||
def test_void_stamps_by_transaction(self, db, stamp_setup):
|
||||
"""Void stamps by original transaction ID."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
|
||||
# Add a stamp first
|
||||
result = self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
assert result["stamp_count"] == 1
|
||||
|
||||
# Find the transaction
|
||||
tx = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
void_result = self.service.void_stamps(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
original_transaction_id=tx.id,
|
||||
)
|
||||
|
||||
assert void_result["success"] is True
|
||||
assert void_result["stamp_count"] == 0
|
||||
|
||||
@@ -87,13 +87,6 @@ class StorePlatform(Base, TimestampMixin):
|
||||
comment="Whether the store is active on this platform",
|
||||
)
|
||||
|
||||
is_primary = Column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
comment="Whether this is the store's primary platform",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Platform-Specific Configuration
|
||||
# ========================================================================
|
||||
@@ -165,11 +158,6 @@ class StorePlatform(Base, TimestampMixin):
|
||||
"platform_id",
|
||||
"is_active",
|
||||
),
|
||||
Index(
|
||||
"idx_store_platform_primary",
|
||||
"store_id",
|
||||
"is_primary",
|
||||
),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -48,6 +48,7 @@ class StoreLoginResponse(BaseModel):
|
||||
user: dict
|
||||
store: dict
|
||||
store_role: str
|
||||
platform_code: str | None = None
|
||||
|
||||
|
||||
@store_auth_router.post("/login", response_model=StoreLoginResponse)
|
||||
@@ -116,27 +117,48 @@ def store_login(
|
||||
f"for store {store.store_code} as {store_role}"
|
||||
)
|
||||
|
||||
# Resolve platform from the store's primary platform link.
|
||||
# Middleware-detected platform is unreliable for API paths on localhost
|
||||
# (e.g., /api/v1/store/auth/login defaults to "main" instead of the store's platform).
|
||||
platform_id = None
|
||||
platform_code = None
|
||||
if store:
|
||||
from app.modules.core.services.menu_service import menu_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
# Resolve platform — prefer explicit sources, fall back to store's primary platform
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
primary_pid = menu_service.get_store_primary_platform_id(db, store.id)
|
||||
platform = None
|
||||
|
||||
# Source 1: middleware-detected platform (production domain-based)
|
||||
mw_platform = get_current_platform(request)
|
||||
if mw_platform and mw_platform.code != "main":
|
||||
platform = mw_platform
|
||||
|
||||
# Source 2: platform_code from login body (dev mode — JS sends platform from page context)
|
||||
if platform is None and user_credentials.platform_code:
|
||||
platform = platform_service.get_platform_by_code_optional(
|
||||
db, user_credentials.platform_code
|
||||
)
|
||||
if not platform:
|
||||
raise InvalidCredentialsException(
|
||||
f"Unknown platform: {user_credentials.platform_code}"
|
||||
)
|
||||
|
||||
# Source 3: fall back to store's primary platform
|
||||
if platform is None:
|
||||
primary_pid = platform_service.get_first_active_platform_id_for_store(
|
||||
db, store.id
|
||||
)
|
||||
if primary_pid:
|
||||
plat = platform_service.get_platform_by_id(db, primary_pid)
|
||||
if plat:
|
||||
platform_id = plat.id
|
||||
platform_code = plat.code
|
||||
platform = platform_service.get_platform_by_id(db, primary_pid)
|
||||
|
||||
if platform_id is None:
|
||||
# Fallback to middleware-detected platform
|
||||
platform = get_current_platform(request)
|
||||
platform_id = platform.id if platform else None
|
||||
platform_code = platform.code if platform else None
|
||||
# Verify store-platform link if platform was resolved explicitly (source 1 or 2)
|
||||
if platform is not None and (
|
||||
mw_platform or user_credentials.platform_code
|
||||
):
|
||||
link = platform_service.get_store_platform_entry(
|
||||
db, store.id, platform.id
|
||||
)
|
||||
if not link or not link.is_active:
|
||||
raise InvalidCredentialsException(
|
||||
f"Store {store.store_code} is not available on platform {platform.code}"
|
||||
)
|
||||
|
||||
platform_id = platform.id if platform else None
|
||||
platform_code = platform.code if platform else None
|
||||
|
||||
# Create store-scoped access token with store information
|
||||
token_data = auth_service.auth_manager.create_access_token(
|
||||
@@ -186,6 +208,7 @@ def store_login(
|
||||
"is_verified": store.is_verified,
|
||||
},
|
||||
store_role=store_role,
|
||||
platform_code=platform_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -226,6 +249,7 @@ def get_current_store_user(
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
platform_code=user.token_platform_code,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -64,20 +64,36 @@ async def store_login_page(
|
||||
"""
|
||||
Render store login page.
|
||||
|
||||
If user is already authenticated as store, redirect to dashboard.
|
||||
Otherwise, show login form.
|
||||
If user is already authenticated on the SAME platform, redirect to dashboard.
|
||||
If authenticated on a DIFFERENT platform, show login form so the user can
|
||||
re-login with the correct platform context.
|
||||
"""
|
||||
if current_user:
|
||||
return RedirectResponse(
|
||||
url=f"/store/{store_code}/dashboard", status_code=302
|
||||
)
|
||||
|
||||
language = getattr(request.state, "language", "fr")
|
||||
platform = getattr(request.state, "platform", None)
|
||||
platform_code = platform.code if platform else None
|
||||
|
||||
if current_user:
|
||||
# If the URL's platform matches the JWT's platform (or no platform in URL),
|
||||
# redirect to dashboard. Otherwise, show login form for platform switch.
|
||||
jwt_platform = current_user.token_platform_code
|
||||
same_platform = (
|
||||
platform_code is None
|
||||
or jwt_platform is None
|
||||
or platform_code == jwt_platform
|
||||
)
|
||||
if same_platform:
|
||||
if platform_code:
|
||||
redirect_url = f"/platforms/{platform_code}/store/{store_code}/dashboard"
|
||||
else:
|
||||
redirect_url = f"/store/{store_code}/dashboard"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/store/login.html",
|
||||
{
|
||||
"request": request,
|
||||
"store_code": store_code,
|
||||
"platform_code": platform_code,
|
||||
**get_jinja2_globals(language),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,6 +20,9 @@ class UserLogin(BaseModel):
|
||||
store_code: str | None = Field(
|
||||
None, description="Optional store code for context"
|
||||
)
|
||||
platform_code: str | None = Field(
|
||||
None, description="Platform code from login context"
|
||||
)
|
||||
|
||||
@field_validator("email_or_username")
|
||||
@classmethod
|
||||
@@ -200,6 +203,7 @@ class StoreUserResponse(BaseModel):
|
||||
email: str
|
||||
role: str
|
||||
is_active: bool
|
||||
platform_code: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -115,8 +115,9 @@ class MerchantDomainService:
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id.in_(store_ids),
|
||||
StorePlatform.is_primary.is_(True),
|
||||
StorePlatform.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(StorePlatform.joined_at)
|
||||
.first()
|
||||
)
|
||||
platform_id = primary_sp.platform_id if primary_sp else None
|
||||
|
||||
@@ -324,9 +324,12 @@ class PlatformService:
|
||||
# ========================================================================
|
||||
|
||||
@staticmethod
|
||||
def get_primary_platform_id_for_store(db: Session, store_id: int) -> int | None:
|
||||
def get_first_active_platform_id_for_store(db: Session, store_id: int) -> int | None:
|
||||
"""
|
||||
Get the primary platform ID for a store.
|
||||
Get the first active platform ID for a store (ordered by joined_at).
|
||||
|
||||
Used as a fallback when platform_id is not available from JWT context
|
||||
(e.g. background tasks, old tokens).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -341,7 +344,7 @@ class PlatformService:
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(StorePlatform.is_primary.desc())
|
||||
.order_by(StorePlatform.joined_at)
|
||||
.first()
|
||||
)
|
||||
return result[0] if result else None
|
||||
@@ -364,7 +367,7 @@ class PlatformService:
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active == True, # noqa: E712
|
||||
)
|
||||
.order_by(StorePlatform.is_primary.desc())
|
||||
.order_by(StorePlatform.joined_at)
|
||||
.all()
|
||||
)
|
||||
return [r[0] for r in results]
|
||||
@@ -393,29 +396,6 @@ class PlatformService:
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_primary_store_platform_entry(
|
||||
db: Session, store_id: int
|
||||
) -> StorePlatform | None:
|
||||
"""
|
||||
Get the primary StorePlatform entry for a store.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
store_id: Store ID
|
||||
|
||||
Returns:
|
||||
StorePlatform object or None
|
||||
"""
|
||||
return (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_primary.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_store_ids_for_platform(
|
||||
db: Session, platform_id: int, active_only: bool = True
|
||||
@@ -450,7 +430,7 @@ class PlatformService:
|
||||
Upsert a StorePlatform entry.
|
||||
|
||||
If the entry exists, update is_active (and tier_id if provided).
|
||||
If missing and is_active=True, create it (set is_primary if store has none).
|
||||
If missing and is_active=True, create it.
|
||||
If missing and is_active=False, no-op.
|
||||
|
||||
Args:
|
||||
@@ -479,20 +459,10 @@ class PlatformService:
|
||||
return existing
|
||||
|
||||
if is_active:
|
||||
has_primary = (
|
||||
db.query(StorePlatform)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_primary.is_(True),
|
||||
)
|
||||
.first()
|
||||
) is not None
|
||||
|
||||
sp = StorePlatform(
|
||||
store_id=store_id,
|
||||
platform_id=platform_id,
|
||||
is_active=True,
|
||||
is_primary=not has_primary,
|
||||
tier_id=tier_id,
|
||||
)
|
||||
db.add(sp)
|
||||
|
||||
@@ -105,16 +105,13 @@ class StoreDomainService:
|
||||
if domain_data.is_primary:
|
||||
self._unset_primary_domains(db, store_id)
|
||||
|
||||
# Resolve platform_id: use provided value, or auto-resolve from primary StorePlatform
|
||||
# Resolve platform_id: use provided value, or auto-resolve from first active StorePlatform
|
||||
platform_id = domain_data.platform_id
|
||||
if not platform_id:
|
||||
from app.modules.tenancy.models import StorePlatform
|
||||
primary_sp = (
|
||||
db.query(StorePlatform)
|
||||
.filter(StorePlatform.store_id == store_id, StorePlatform.is_primary.is_(True))
|
||||
.first()
|
||||
from app.modules.tenancy.services.platform_service import (
|
||||
platform_service,
|
||||
)
|
||||
platform_id = primary_sp.platform_id if primary_sp else None
|
||||
platform_id = platform_service.get_first_active_platform_id_for_store(db, store_id)
|
||||
|
||||
# Create domain record
|
||||
new_domain = StoreDomain(
|
||||
|
||||
@@ -126,7 +126,8 @@ function storeLogin() {
|
||||
const response = await apiClient.post('/store/auth/login', {
|
||||
email_or_username: this.credentials.username,
|
||||
password: this.credentials.password,
|
||||
store_code: this.storeCode
|
||||
store_code: this.storeCode,
|
||||
platform_code: window.STORE_PLATFORM_CODE || localStorage.getItem('store_platform') || null
|
||||
});
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
@@ -143,17 +144,30 @@ function storeLogin() {
|
||||
localStorage.setItem('store_token', response.access_token);
|
||||
localStorage.setItem('currentUser', JSON.stringify(response.user));
|
||||
localStorage.setItem('storeCode', this.storeCode);
|
||||
if (response.platform_code) {
|
||||
localStorage.setItem('store_platform', response.platform_code);
|
||||
}
|
||||
storeLoginLog.debug('Token stored as store_token in localStorage');
|
||||
|
||||
this.success = 'Login successful! Redirecting...';
|
||||
|
||||
// Build platform-aware base path
|
||||
const platformCode = window.STORE_PLATFORM_CODE;
|
||||
const basePath = platformCode
|
||||
? `/platforms/${platformCode}/store/${this.storeCode}`
|
||||
: `/store/${this.storeCode}`;
|
||||
|
||||
// Check for last visited page (saved before logout)
|
||||
const lastPage = localStorage.getItem('store_last_visited_page');
|
||||
const validLastPage = lastPage &&
|
||||
lastPage.startsWith(`/store/${this.storeCode}/`) &&
|
||||
!lastPage.includes('/login') &&
|
||||
!lastPage.includes('/onboarding');
|
||||
const redirectTo = validLastPage ? lastPage : `/store/${this.storeCode}/dashboard`;
|
||||
let redirectTo = `${basePath}/dashboard`;
|
||||
|
||||
if (lastPage && !lastPage.includes('/login') && !lastPage.includes('/onboarding')) {
|
||||
// Extract the store-relative path (strip any existing prefix)
|
||||
const storePathMatch = lastPage.match(/\/store\/[^/]+(\/.*)/);
|
||||
if (storePathMatch) {
|
||||
redirectTo = `${basePath}${storePathMatch[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
storeLoginLog.info('Last visited page:', lastPage);
|
||||
storeLoginLog.info('Redirecting to:', redirectTo);
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
|
||||
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
@@ -234,7 +237,13 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 6. Store Login Logic -->
|
||||
<!-- 6. Platform context for login -->
|
||||
<script>
|
||||
window.STORE_PLATFORM_CODE = {{ platform_code|tojson }};
|
||||
</script>
|
||||
|
||||
<!-- 7. Store Login Logic -->
|
||||
<script defer src="{{ url_for('tenancy_static', path='store/js/login.js') }}"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -374,44 +374,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Step checklist #}
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<template x-for="step in steps" :key="step.key">
|
||||
<a :href="step.route"
|
||||
class="flex items-center px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
|
||||
{# Completion indicator #}
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
<template x-if="step.completed">
|
||||
<div class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
{# Non-owner message #}
|
||||
<template x-if="!isOwner">
|
||||
<div class="px-6 py-4 text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span x-html="$icon('information-circle', 'inline w-5 h-5 mr-1 text-gray-400')"></span>
|
||||
Contact your administrator to complete platform setup.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Step checklist (owners only) #}
|
||||
<template x-if="isOwner">
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<template x-for="step in steps" :key="step.key">
|
||||
<a :href="step.route"
|
||||
class="flex items-center px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
|
||||
{# Completion indicator #}
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
<template x-if="step.completed">
|
||||
<div class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!step.completed">
|
||||
<div class="w-6 h-6 rounded-full border-2 border-gray-300 dark:border-gray-600 group-hover:border-purple-400 transition-colors"></div>
|
||||
</template>
|
||||
</div>
|
||||
{# Step icon + text #}
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<span x-html="$icon(step.icon, 'w-5 h-5 text-gray-400 dark:text-gray-500')" class="block"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
:class="{ 'line-through text-gray-400 dark:text-gray-500': step.completed }"
|
||||
x-text="t(step.title_key)"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"
|
||||
x-text="t(step.description_key)"></p>
|
||||
</div>
|
||||
{# Arrow #}
|
||||
<template x-if="!step.completed">
|
||||
<div class="w-6 h-6 rounded-full border-2 border-gray-300 dark:border-gray-600 group-hover:border-purple-400 transition-colors"></div>
|
||||
<svg class="w-4 h-4 text-gray-400 group-hover:text-purple-500 transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
</div>
|
||||
{# Step icon + text #}
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<span x-html="$icon(step.icon, 'w-5 h-5 text-gray-400 dark:text-gray-500')" class="block"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
:class="{ 'line-through text-gray-400 dark:text-gray-500': step.completed }"
|
||||
x-text="t(step.title_key)"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"
|
||||
x-text="t(step.description_key)"></p>
|
||||
</div>
|
||||
{# Arrow #}
|
||||
<template x-if="!step.completed">
|
||||
<svg class="w-4 h-4 text-gray-400 group-hover:text-purple-500 transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
|
||||
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body x-cloak>
|
||||
|
||||
Reference in New Issue
Block a user