refactor: remove all backward compatibility code across 70 files
Some checks failed
CI / ruff (push) Successful in 11s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running

Clean up 28 backward compatibility instances identified in the codebase.
The app is not live, so all shims are replaced with the target architecture:

- Remove legacy Inventory.location column (use bin_location exclusively)
- Remove dashboard _extract_metric_value helper (use flat metrics dict)
- Remove legacy stat field duplicates (total_stores, total_imports, etc.)
- Remove 13 re-export shims and class aliases across modules
- Remove module-enabling JSON fallback (use PlatformModule junction table)
- Remove menu_to_legacy_format() conversion (return dataclasses directly)
- Remove title/description from MarketplaceProductBase schema
- Clean billing convenience method docstrings
- Clean test fixtures and backward-compat comments
- Add PlatformModule seeding to init_production.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 13:20:29 +01:00
parent b0db8133a0
commit aad18c27ab
70 changed files with 501 additions and 841 deletions

View File

@@ -21,7 +21,7 @@ def upgrade() -> None:
sa.Column("code", sa.String(50), unique=True, nullable=False, index=True, comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')"), sa.Column("code", sa.String(50), unique=True, nullable=False, index=True, comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')"),
sa.Column("name", sa.String(100), nullable=False, comment="Display name (e.g., 'Orion OMS')"), sa.Column("name", sa.String(100), nullable=False, comment="Display name (e.g., 'Orion OMS')"),
sa.Column("description", sa.Text(), nullable=True, comment="Platform description for admin/marketing purposes"), sa.Column("description", sa.Text(), nullable=True, comment="Platform description for admin/marketing purposes"),
sa.Column("domain", sa.String(255), unique=True, nullable=True, index=True, comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')"), sa.Column("domain", sa.String(255), unique=True, nullable=True, index=True, comment="Production domain (e.g., 'omsflow.lu', 'rewardflow.lu')"),
sa.Column("path_prefix", sa.String(50), unique=True, nullable=True, index=True, comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)"), sa.Column("path_prefix", sa.String(50), unique=True, nullable=True, index=True, comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)"),
sa.Column("logo", sa.String(500), nullable=True, comment="Logo URL for light mode"), sa.Column("logo", sa.String(500), nullable=True, comment="Logo URL for light mode"),
sa.Column("logo_dark", sa.String(500), nullable=True, comment="Logo URL for dark mode"), sa.Column("logo_dark", sa.String(500), nullable=True, comment="Logo URL for dark mode"),

View File

@@ -248,7 +248,7 @@ def upgrade() -> None:
existing_nullable=True) existing_nullable=True)
op.alter_column("platforms", "domain", op.alter_column("platforms", "domain",
existing_type=sa.VARCHAR(length=255), existing_type=sa.VARCHAR(length=255),
comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')", comment="Production domain (e.g., 'omsflow.lu', 'rewardflow.lu')",
existing_nullable=True) existing_nullable=True)
op.alter_column("platforms", "path_prefix", op.alter_column("platforms", "path_prefix",
existing_type=sa.VARCHAR(length=50), existing_type=sa.VARCHAR(length=50),
@@ -518,7 +518,7 @@ def downgrade() -> None:
op.alter_column("platforms", "domain", op.alter_column("platforms", "domain",
existing_type=sa.VARCHAR(length=255), existing_type=sa.VARCHAR(length=255),
comment=None, comment=None,
existing_comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')", existing_comment="Production domain (e.g., 'omsflow.lu', 'rewardflow.lu')",
existing_nullable=True) existing_nullable=True)
op.alter_column("platforms", "description", op.alter_column("platforms", "description",
existing_type=sa.TEXT(), existing_type=sa.TEXT(),

View File

@@ -174,7 +174,7 @@ def upgrade() -> None:
supported_languages, is_active, is_public, theme_config, settings, supported_languages, is_active, is_public, theme_config, settings,
created_at, updated_at) created_at, updated_at)
VALUES ('oms', 'Wizamart OMS', 'Order Management System for Luxembourg merchants', VALUES ('oms', 'Wizamart OMS', 'Order Management System for Luxembourg merchants',
'oms.lu', 'oms', 'fr', '["fr", "de", "en"]', true, true, '{}', '{}', 'omsflow.lu', 'oms', 'fr', '["fr", "de", "en"]', true, true, '{}', '{}',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""") """)
) )

View File

@@ -35,7 +35,7 @@ def upgrade() -> None:
supported_languages, is_active, is_public, theme_config, settings, supported_languages, is_active, is_public, theme_config, settings,
created_at, updated_at) created_at, updated_at)
VALUES ('loyalty', 'Loyalty+', 'Customer loyalty program platform for Luxembourg businesses', VALUES ('loyalty', 'Loyalty+', 'Customer loyalty program platform for Luxembourg businesses',
'loyalty.lu', 'loyalty', 'fr', '["fr", "de", "en"]', true, true, 'rewardflow.lu', 'loyalty', 'fr', '["fr", "de", "en"]', true, true,
'{"primary_color": "#8B5CF6", "secondary_color": "#A78BFA"}', '{"primary_color": "#8B5CF6", "secondary_color": "#A78BFA"}',
'{"features": ["points", "rewards", "tiers", "analytics"]}', '{"features": ["points", "rewards", "tiers", "analytics"]}',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)

View File

@@ -6,10 +6,10 @@ Single source of truth for detecting which frontend type a request targets.
Handles both development (path-based) and production (domain-based) routing. Handles both development (path-based) and production (domain-based) routing.
Detection priority: Detection priority:
1. Admin subdomain (admin.oms.lu) 1. Admin subdomain (admin.omsflow.lu)
2. Path-based admin/store (/admin/*, /store/*, /api/v1/admin/*) 2. Path-based admin/store (/admin/*, /store/*, /api/v1/admin/*)
3. Custom domain lookup (mybakery.lu -> STOREFRONT) 3. Custom domain lookup (mybakery.lu -> STOREFRONT)
4. Store subdomain (orion.oms.lu -> STOREFRONT) 4. Store subdomain (orion.omsflow.lu -> STOREFRONT)
5. Storefront paths (/storefront/*, /api/v1/storefront/*) 5. Storefront paths (/storefront/*, /api/v1/storefront/*)
6. Default to PLATFORM (marketing pages) 6. Default to PLATFORM (marketing pages)
@@ -62,7 +62,7 @@ class FrontendDetector:
Detect frontend type from request. Detect frontend type from request.
Args: Args:
host: Request host header (e.g., "oms.lu", "orion.oms.lu", "localhost:8000") host: Request host header (e.g., "omsflow.lu", "orion.omsflow.lu", "localhost:8000")
path: Request path (e.g., "/admin/stores", "/storefront/products") path: Request path (e.g., "/admin/stores", "/storefront/products")
has_store_context: True if request.state.store is set (from middleware) has_store_context: True if request.state.store is set (from middleware)
@@ -82,7 +82,7 @@ class FrontendDetector:
}, },
) )
# 1. Admin subdomain (admin.oms.lu) # 1. Admin subdomain (admin.omsflow.lu)
if subdomain == "admin": if subdomain == "admin":
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from subdomain") logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from subdomain")
return FrontendType.ADMIN return FrontendType.ADMIN
@@ -110,7 +110,7 @@ class FrontendDetector:
logger.debug("[FRONTEND_DETECTOR] Detected PLATFORM from path") logger.debug("[FRONTEND_DETECTOR] Detected PLATFORM from path")
return FrontendType.PLATFORM return FrontendType.PLATFORM
# 3. Store subdomain detection (orion.oms.lu) # 3. Store subdomain detection (orion.omsflow.lu)
# If subdomain exists and is not reserved -> it's a store storefront # If subdomain exists and is not reserved -> it's a store storefront
if subdomain and subdomain not in cls.RESERVED_SUBDOMAINS: if subdomain and subdomain not in cls.RESERVED_SUBDOMAINS:
logger.debug( logger.debug(
@@ -138,7 +138,7 @@ class FrontendDetector:
@classmethod @classmethod
def _get_subdomain(cls, host: str) -> str | None: def _get_subdomain(cls, host: str) -> str | None:
""" """
Extract subdomain from host (e.g., 'orion' from 'orion.oms.lu'). Extract subdomain from host (e.g., 'orion' from 'orion.omsflow.lu').
Returns None for localhost, IP addresses, or root domains. Returns None for localhost, IP addresses, or root domains.
Handles special case of admin.localhost for development. Handles special case of admin.localhost for development.
@@ -195,13 +195,3 @@ class FrontendDetector:
def is_api_request(cls, path: str) -> bool: def is_api_request(cls, path: str) -> bool:
"""Check if request is for API endpoints (any frontend's API).""" """Check if request is for API endpoints (any frontend's API)."""
return path.startswith("/api/") return path.startswith("/api/")
# Convenience function for backwards compatibility
def get_frontend_type(host: str, path: str, has_store_context: bool = False) -> FrontendType:
"""
Convenience function to detect frontend type.
Wrapper around FrontendDetector.detect() for simpler imports.
"""
return FrontendDetector.detect(host, path, has_store_context)

View File

@@ -73,7 +73,7 @@ from app.modules.registry import (
is_internal_module, is_internal_module,
) )
from app.modules.service import ModuleService, module_service from app.modules.service import ModuleService, module_service
from app.modules.task_base import DatabaseTask, ModuleTask from app.modules.task_base import ModuleTask
from app.modules.tasks import ( from app.modules.tasks import (
build_beat_schedule, build_beat_schedule,
discover_module_tasks, discover_module_tasks,
@@ -87,7 +87,6 @@ __all__ = [
"ScheduledTask", "ScheduledTask",
# Task support # Task support
"ModuleTask", "ModuleTask",
"DatabaseTask",
"discover_module_tasks", "discover_module_tasks",
"build_beat_schedule", "build_beat_schedule",
"parse_schedule", "parse_schedule",

View File

@@ -2,51 +2,22 @@
""" """
Analytics module Pydantic schemas. Analytics module Pydantic schemas.
This is the canonical location for analytics schemas. This is the canonical location for analytics-specific schemas.
For core dashboard schemas, import from app.modules.core.schemas.dashboard.
""" """
from app.modules.analytics.schemas.stats import ( from app.modules.analytics.schemas.stats import (
AdminDashboardResponse,
CodeQualityDashboardStatsResponse, CodeQualityDashboardStatsResponse,
CustomerStatsResponse, CustomerStatsResponse,
ImportStatsResponse,
MarketplaceStatsResponse,
OrderStatsBasicResponse,
OrderStatsResponse, OrderStatsResponse,
PlatformStatsResponse,
ProductStatsResponse,
StatsResponse,
StoreAnalyticsCatalog, StoreAnalyticsCatalog,
StoreAnalyticsImports, StoreAnalyticsImports,
StoreAnalyticsInventory, StoreAnalyticsInventory,
StoreAnalyticsResponse, StoreAnalyticsResponse,
StoreCustomerStats,
StoreDashboardStatsResponse,
StoreInfo,
StoreOrderStats,
StoreProductStats,
StoreRevenueStats,
StoreStatsResponse,
UserStatsResponse,
ValidatorStats, ValidatorStats,
) )
__all__ = [ __all__ = [
"StatsResponse",
"MarketplaceStatsResponse",
"ImportStatsResponse",
"UserStatsResponse",
"StoreStatsResponse",
"ProductStatsResponse",
"PlatformStatsResponse",
"OrderStatsBasicResponse",
"AdminDashboardResponse",
"StoreProductStats",
"StoreOrderStats",
"StoreCustomerStats",
"StoreRevenueStats",
"StoreInfo",
"StoreDashboardStatsResponse",
"StoreAnalyticsImports", "StoreAnalyticsImports",
"StoreAnalyticsCatalog", "StoreAnalyticsCatalog",
"StoreAnalyticsInventory", "StoreAnalyticsInventory",

View File

@@ -2,8 +2,8 @@
""" """
Analytics module schemas for statistics and reporting. Analytics module schemas for statistics and reporting.
Base dashboard schemas are defined in core.schemas.dashboard. Base dashboard schemas are defined in app.modules.core.schemas.dashboard.
This module re-exports them for backward compatibility and adds Import them from there directly. This module contains only
analytics-specific schemas (trends, reports, etc.). analytics-specific schemas (trends, reports, etc.).
""" """
@@ -13,26 +13,6 @@ from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# Re-export base dashboard schemas from core for backward compatibility
# These are the canonical definitions in core module
from app.modules.core.schemas.dashboard import (
AdminDashboardResponse,
ImportStatsResponse,
MarketplaceStatsResponse,
OrderStatsBasicResponse,
PlatformStatsResponse,
ProductStatsResponse,
StatsResponse,
StoreCustomerStats,
StoreDashboardStatsResponse,
StoreInfo,
StoreOrderStats,
StoreProductStats,
StoreRevenueStats,
StoreStatsResponse,
UserStatsResponse,
)
# ============================================================================ # ============================================================================
# Store Analytics (Analytics-specific, not in core) # Store Analytics (Analytics-specific, not in core)
# ============================================================================ # ============================================================================
@@ -151,22 +131,6 @@ class OrderStatsResponse(BaseModel):
__all__ = [ __all__ = [
# Re-exported from core.schemas.dashboard (for backward compatibility)
"StatsResponse",
"MarketplaceStatsResponse",
"ImportStatsResponse",
"UserStatsResponse",
"StoreStatsResponse",
"ProductStatsResponse",
"PlatformStatsResponse",
"OrderStatsBasicResponse",
"AdminDashboardResponse",
"StoreProductStats",
"StoreOrderStats",
"StoreCustomerStats",
"StoreRevenueStats",
"StoreInfo",
"StoreDashboardStatsResponse",
# Analytics-specific schemas # Analytics-specific schemas
"StoreAnalyticsImports", "StoreAnalyticsImports",
"StoreAnalyticsCatalog", "StoreAnalyticsCatalog",

View File

@@ -107,7 +107,7 @@ class StatsService:
) )
inventory_locations = ( inventory_locations = (
db.query(func.count(func.distinct(Inventory.location))) db.query(func.count(func.distinct(Inventory.bin_location)))
.filter(Inventory.store_id == store_id) .filter(Inventory.store_id == store_id)
.scalar() .scalar()
or 0 or 0
@@ -286,17 +286,10 @@ class StatsService:
) )
return { return {
# Schema-compatible fields (StoreStatsResponse)
"total": total_stores, "total": total_stores,
"verified": verified_stores, "verified": verified_stores,
"pending": pending_stores, "pending": pending_stores,
"inactive": inactive_stores, "inactive": inactive_stores,
# Legacy fields for backward compatibility
"total_stores": total_stores,
"active_stores": active_stores,
"inactive_stores": inactive_stores,
"verified_stores": verified_stores,
"pending_stores": pending_stores,
"verification_rate": ( "verification_rate": (
(verified_stores / total_stores * 100) if total_stores > 0 else 0 (verified_stores / total_stores * 100) if total_stores > 0 else 0
), ),
@@ -485,16 +478,11 @@ class StatsService:
) )
return { return {
# Frontend-expected fields
"total": total, "total": total,
"pending": pending, "pending": pending,
"processing": processing, "processing": processing,
"completed": completed, "completed": completed,
"failed": failed, "failed": failed,
# Legacy fields for backward compatibility
"total_imports": total,
"completed_imports": completed,
"failed_imports": failed,
"success_rate": (completed / total * 100) if total > 0 else 0, "success_rate": (completed / total * 100) if total > 0 else 0,
} }
except SQLAlchemyError as e: except SQLAlchemyError as e:
@@ -505,9 +493,6 @@ class StatsService:
"processing": 0, "processing": 0,
"completed": 0, "completed": 0,
"failed": 0, "failed": 0,
"total_imports": 0,
"completed_imports": 0,
"failed_imports": 0,
"success_rate": 0, "success_rate": 0,
} }

View File

@@ -218,9 +218,20 @@ class FeatureService:
self, db: Session, store_id: int, feature_code: str self, db: Session, store_id: int, feature_code: str
) -> bool: ) -> bool:
""" """
Check if a store has access to a feature (resolves store -> merchant). Convenience method that resolves the store -> merchant -> platform
hierarchy and checks whether the merchant has access to a feature.
Convenience method for backwards compatibility. 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.
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) merchant_id, platform_id = self._get_merchant_for_store(db, store_id)
if merchant_id is None or platform_id is None: if merchant_id is None or platform_id is None:

View File

@@ -29,9 +29,7 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from app.exceptions import ResourceNotFoundException from app.exceptions import ResourceNotFoundException
from app.modules.billing.exceptions import ( from app.modules.billing.exceptions import SubscriptionNotFoundException
SubscriptionNotFoundException, # Re-exported for backward compatibility
)
from app.modules.billing.models import ( from app.modules.billing.models import (
MerchantSubscription, MerchantSubscription,
SubscriptionStatus, SubscriptionStatus,
@@ -159,9 +157,19 @@ class SubscriptionService:
self, db: Session, store_id: int self, db: Session, store_id: int
) -> MerchantSubscription | None: ) -> MerchantSubscription | None:
""" """
Resolve store merchant → subscription. Convenience method that resolves the store -> merchant -> platform
hierarchy and returns the associated merchant subscription.
Convenience method for backwards compatibility with store-level code. Looks up the store's merchant_id and platform_id, then delegates
to get_merchant_subscription().
Args:
db: Database session.
store_id: The store ID to resolve.
Returns:
The merchant subscription, or None if the store, merchant,
or platform cannot be resolved.
""" """
from app.modules.tenancy.models import Store from app.modules.tenancy.models import Store

View File

@@ -121,7 +121,7 @@ def _get_admin_router():
def _get_store_router(): def _get_store_router():
"""Lazy import of store router to avoid circular imports.""" """Lazy import of store router to avoid circular imports."""
from app.modules.cms.routes.store import store_router from app.modules.cms.routes.api.store import store_router
return store_router return store_router

View File

@@ -6,9 +6,9 @@ This module provides functions to register CMS routes
with module-based access control. with module-based access control.
NOTE: Routers are NOT auto-imported to avoid circular dependencies. NOTE: Routers are NOT auto-imported to avoid circular dependencies.
Import directly from admin.py or store.py as needed: Import directly from api/admin.py or api/store.py as needed:
from app.modules.cms.routes.admin import admin_router from app.modules.cms.routes.api.admin import admin_router
from app.modules.cms.routes.store import store_router, store_media_router from app.modules.cms.routes.api.store import store_router
""" """
# Routers are imported on-demand to avoid circular dependencies # Routers are imported on-demand to avoid circular dependencies
@@ -20,12 +20,12 @@ __all__ = ["admin_router", "store_router", "store_media_router"]
def __getattr__(name: str): def __getattr__(name: str):
"""Lazy import routers to avoid circular dependencies.""" """Lazy import routers to avoid circular dependencies."""
if name == "admin_router": if name == "admin_router":
from app.modules.cms.routes.admin import admin_router from app.modules.cms.routes.api.admin import admin_router
return admin_router return admin_router
if name == "store_router": if name == "store_router":
from app.modules.cms.routes.store import store_router from app.modules.cms.routes.api.store import store_router
return store_router return store_router
if name == "store_media_router": if name == "store_media_router":
from app.modules.cms.routes.store import store_media_router from app.modules.cms.routes.api.store_media import store_media_router
return store_media_router return store_media_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -8,8 +8,8 @@ Provides REST API endpoints for content page management:
- Storefront API: Public read-only access for storefronts - Storefront API: Public read-only access for storefronts
""" """
from app.modules.cms.routes.api.admin import router as admin_router from app.modules.cms.routes.api.admin import admin_router
from app.modules.cms.routes.api.store import router as store_router from app.modules.cms.routes.api.store import store_router
from app.modules.cms.routes.api.storefront import router as storefront_router from app.modules.cms.routes.api.storefront import router as storefront_router
__all__ = ["admin_router", "store_router", "storefront_router"] __all__ = ["admin_router", "store_router", "storefront_router"]

View File

@@ -23,9 +23,6 @@ admin_router = APIRouter(
dependencies=[Depends(require_module_access("cms", FrontendType.ADMIN))], dependencies=[Depends(require_module_access("cms", FrontendType.ADMIN))],
) )
# For backwards compatibility with existing imports
router = admin_router
# Aggregate all CMS admin routes # Aggregate all CMS admin routes
admin_router.include_router(admin_content_pages_router, tags=["admin-content-pages"]) admin_router.include_router(admin_content_pages_router, tags=["admin-content-pages"])
admin_router.include_router(admin_images_router, tags=["admin-images"]) admin_router.include_router(admin_images_router, tags=["admin-images"])

View File

@@ -18,7 +18,6 @@ ROUTE_CONFIG = {
} }
store_router = APIRouter() store_router = APIRouter()
router = store_router # Alias for discovery compatibility
# Aggregate all CMS store routes # Aggregate all CMS store routes
store_router.include_router(store_content_pages_router, tags=["store-content-pages"]) store_router.include_router(store_content_pages_router, tags=["store-content-pages"])

View File

@@ -80,7 +80,7 @@ async def homepage(
URL routing: URL routing:
- localhost:9999/ -> Main marketing site ('main' platform) - localhost:9999/ -> Main marketing site ('main' platform)
- localhost:9999/platforms/oms/ -> OMS platform (middleware rewrites to /) - localhost:9999/platforms/oms/ -> OMS platform (middleware rewrites to /)
- oms.lu/ -> OMS platform (domain-based) - omsflow.lu/ -> OMS platform (domain-based)
- shop.mymerchant.com/ -> Store landing page (custom domain) - shop.mymerchant.com/ -> Store landing page (custom domain)
""" """
# Get platform and store from middleware # Get platform and store from middleware

View File

@@ -1,16 +0,0 @@
# app/modules/cms/routes/store.py
"""
CMS module store routes.
Re-exports routes from the API routes for backwards compatibility
with the lazy router attachment pattern.
Includes:
- /content-pages/* - Content page management
- /media/* - Media library
"""
# Re-export store_router from API routes
from app.modules.cms.routes.api.store import store_router
__all__ = ["store_router"]

View File

@@ -7,9 +7,6 @@ enabled modules. Each module provides its own metrics via the MetricsProvider pr
Dashboard widgets are collected via the WidgetAggregator service, which discovers Dashboard widgets are collected via the WidgetAggregator service, which discovers
DashboardWidgetProvider implementations from all enabled modules. DashboardWidgetProvider implementations from all enabled modules.
For backward compatibility, this also falls back to the analytics stats_service
for comprehensive statistics that haven't been migrated to the provider pattern yet.
""" """
import logging import logging
@@ -20,7 +17,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api from app.api.deps import get_current_admin_api
from app.core.database import get_db from app.core.database import get_db
from app.modules.contracts.widgets import BreakdownWidget, ListWidget from app.modules.contracts.widgets import BreakdownWidget, ListWidget, WidgetListItem
from app.modules.core.schemas.dashboard import ( from app.modules.core.schemas.dashboard import (
AdminDashboardResponse, AdminDashboardResponse,
ImportStatsResponse, ImportStatsResponse,
@@ -70,19 +67,7 @@ def _get_platform_id(request: Request, current_admin: UserContext) -> int:
return 1 return 1
def _extract_metric_value( def _widget_list_item_to_dict(item: WidgetListItem) -> dict[str, Any]:
metrics: dict[str, list], category: str, key: str, default: int | float = 0
) -> int | float:
"""Extract a specific metric value from categorized metrics."""
if category not in metrics:
return default
for metric in metrics[category]:
if metric.key == key:
return metric.value
return default
def _widget_list_item_to_dict(item) -> dict[str, Any]:
"""Convert a WidgetListItem to a dictionary for API response.""" """Convert a WidgetListItem to a dictionary for API response."""
return { return {
"id": item.id, "id": item.id,
@@ -95,13 +80,12 @@ def _widget_list_item_to_dict(item) -> dict[str, Any]:
} }
def _extract_widget_items( def _get_list_widget_items(
widgets: dict[str, list], category: str, key: str widgets: dict[str, list], key: str
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Extract items from a list widget for backward compatibility.""" """Extract items from a list widget by key, searching all categories."""
if category not in widgets: for category_widgets in widgets.values():
return [] for widget in category_widgets:
for widget in widgets[category]:
if widget.key == key and isinstance(widget.data, ListWidget): if widget.key == key and isinstance(widget.data, ListWidget):
return [_widget_list_item_to_dict(item) for item in widget.data.items] return [_widget_list_item_to_dict(item) for item in widget.data.items]
return [] return []
@@ -116,59 +100,32 @@ def get_admin_dashboard(
"""Get admin dashboard with platform statistics (Admin only).""" """Get admin dashboard with platform statistics (Admin only)."""
platform_id = _get_platform_id(request, current_admin) platform_id = _get_platform_id(request, current_admin)
# Get aggregated metrics from all enabled modules # Get flat metrics from all enabled modules
metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) metrics = stats_aggregator.get_admin_stats_flat(db=db, platform_id=platform_id)
# Get aggregated widgets from all enabled modules # Get aggregated widgets from all enabled modules
widgets = widget_aggregator.get_admin_dashboard_widgets(db=db, platform_id=platform_id) widgets = widget_aggregator.get_admin_dashboard_widgets(db=db, platform_id=platform_id)
# Extract user stats from tenancy module
total_users = _extract_metric_value(metrics, "tenancy", "tenancy.total_users", 0)
active_users = _extract_metric_value(metrics, "tenancy", "tenancy.active_users", 0)
inactive_users = _extract_metric_value(metrics, "tenancy", "tenancy.inactive_users", 0)
admin_users = _extract_metric_value(metrics, "tenancy", "tenancy.admin_users", 0)
activation_rate = _extract_metric_value(
metrics, "tenancy", "tenancy.user_activation_rate", 0
)
# Extract store stats from tenancy module
total_stores = _extract_metric_value(metrics, "tenancy", "tenancy.total_stores", 0)
verified_stores = _extract_metric_value(
metrics, "tenancy", "tenancy.verified_stores", 0
)
pending_stores = _extract_metric_value(
metrics, "tenancy", "tenancy.pending_stores", 0
)
inactive_stores = _extract_metric_value(
metrics, "tenancy", "tenancy.inactive_stores", 0
)
# Extract recent_stores from tenancy widget (backward compatibility)
recent_stores = _extract_widget_items(widgets, "tenancy", "tenancy.recent_stores")
# Extract recent_imports from marketplace widget (backward compatibility)
recent_imports = _extract_widget_items(widgets, "marketplace", "marketplace.recent_imports")
return AdminDashboardResponse( return AdminDashboardResponse(
platform={ platform={
"name": "Multi-Tenant Ecommerce Platform", "name": "Multi-Tenant Ecommerce Platform",
"version": "1.0.0", "version": "1.0.0",
}, },
users=UserStatsResponse( users=UserStatsResponse(
total_users=int(total_users), total_users=int(metrics.get("tenancy.total_users", 0)),
active_users=int(active_users), active_users=int(metrics.get("tenancy.active_users", 0)),
inactive_users=int(inactive_users), inactive_users=int(metrics.get("tenancy.inactive_users", 0)),
admin_users=int(admin_users), admin_users=int(metrics.get("tenancy.admin_users", 0)),
activation_rate=float(activation_rate), activation_rate=float(metrics.get("tenancy.user_activation_rate", 0)),
), ),
stores=StoreStatsResponse( stores=StoreStatsResponse(
total=int(total_stores), total=int(metrics.get("tenancy.total_stores", 0)),
verified=int(verified_stores), verified=int(metrics.get("tenancy.verified_stores", 0)),
pending=int(pending_stores), pending=int(metrics.get("tenancy.pending_stores", 0)),
inactive=int(inactive_stores), inactive=int(metrics.get("tenancy.inactive_stores", 0)),
), ),
recent_stores=recent_stores, recent_stores=_get_list_widget_items(widgets, "tenancy.recent_stores"),
recent_imports=recent_imports, recent_imports=_get_list_widget_items(widgets, "marketplace.recent_imports"),
) )
@@ -181,37 +138,17 @@ def get_comprehensive_stats(
"""Get comprehensive platform statistics (Admin only).""" """Get comprehensive platform statistics (Admin only)."""
platform_id = _get_platform_id(request, current_admin) platform_id = _get_platform_id(request, current_admin)
# Get aggregated metrics # Get flat metrics from all enabled modules
metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) metrics = stats_aggregator.get_admin_stats_flat(db=db, platform_id=platform_id)
# Extract product stats from catalog module
total_products = _extract_metric_value(metrics, "catalog", "catalog.total_products", 0)
# Extract marketplace stats
unique_marketplaces = _extract_metric_value(
metrics, "marketplace", "marketplace.unique_marketplaces", 0
)
unique_brands = _extract_metric_value(
metrics, "marketplace", "marketplace.unique_brands", 0
)
# Extract store stats
unique_stores = _extract_metric_value(metrics, "tenancy", "tenancy.total_stores", 0)
# Extract inventory stats
inventory_entries = _extract_metric_value(metrics, "inventory", "inventory.entries", 0)
inventory_quantity = _extract_metric_value(
metrics, "inventory", "inventory.total_quantity", 0
)
return StatsResponse( return StatsResponse(
total_products=int(total_products), total_products=int(metrics.get("catalog.total_products", 0)),
unique_brands=int(unique_brands), unique_brands=int(metrics.get("marketplace.unique_brands", 0)),
unique_categories=0, # TODO: Add category tracking unique_categories=0, # TODO: Add category tracking
unique_marketplaces=int(unique_marketplaces), unique_marketplaces=int(metrics.get("marketplace.unique_marketplaces", 0)),
unique_stores=int(unique_stores), unique_stores=int(metrics.get("tenancy.total_stores", 0)),
total_inventory_entries=int(inventory_entries), total_inventory_entries=int(metrics.get("inventory.entries", 0)),
total_inventory_quantity=int(inventory_quantity), total_inventory_quantity=int(metrics.get("inventory.total_quantity", 0)),
) )
@@ -261,89 +198,39 @@ def get_platform_statistics(
"""Get comprehensive platform statistics (Admin only).""" """Get comprehensive platform statistics (Admin only)."""
platform_id = _get_platform_id(request, current_admin) platform_id = _get_platform_id(request, current_admin)
# Get aggregated metrics from all enabled modules # Get flat metrics from all enabled modules
metrics = stats_aggregator.get_admin_dashboard_stats(db=db, platform_id=platform_id) metrics = stats_aggregator.get_admin_stats_flat(db=db, platform_id=platform_id)
# User stats from tenancy
total_users = _extract_metric_value(metrics, "tenancy", "tenancy.total_users", 0)
active_users = _extract_metric_value(metrics, "tenancy", "tenancy.active_users", 0)
inactive_users = _extract_metric_value(metrics, "tenancy", "tenancy.inactive_users", 0)
admin_users = _extract_metric_value(metrics, "tenancy", "tenancy.admin_users", 0)
activation_rate = _extract_metric_value(
metrics, "tenancy", "tenancy.user_activation_rate", 0
)
# Store stats from tenancy
total_stores = _extract_metric_value(metrics, "tenancy", "tenancy.total_stores", 0)
verified_stores = _extract_metric_value(
metrics, "tenancy", "tenancy.verified_stores", 0
)
pending_stores = _extract_metric_value(
metrics, "tenancy", "tenancy.pending_stores", 0
)
inactive_stores = _extract_metric_value(
metrics, "tenancy", "tenancy.inactive_stores", 0
)
# Product stats from catalog
total_products = _extract_metric_value(metrics, "catalog", "catalog.total_products", 0)
active_products = _extract_metric_value(
metrics, "catalog", "catalog.active_products", 0
)
# Order stats from orders
total_orders = _extract_metric_value(metrics, "orders", "orders.total", 0)
# Import stats from marketplace
total_imports = _extract_metric_value(
metrics, "marketplace", "marketplace.total_imports", 0
)
pending_imports = _extract_metric_value(
metrics, "marketplace", "marketplace.pending_imports", 0
)
processing_imports = _extract_metric_value(
metrics, "marketplace", "marketplace.processing_imports", 0
)
completed_imports = _extract_metric_value(
metrics, "marketplace", "marketplace.successful_imports", 0
)
failed_imports = _extract_metric_value(
metrics, "marketplace", "marketplace.failed_imports", 0
)
import_success_rate = _extract_metric_value(
metrics, "marketplace", "marketplace.success_rate", 0
)
return PlatformStatsResponse( return PlatformStatsResponse(
users=UserStatsResponse( users=UserStatsResponse(
total_users=int(total_users), total_users=int(metrics.get("tenancy.total_users", 0)),
active_users=int(active_users), active_users=int(metrics.get("tenancy.active_users", 0)),
inactive_users=int(inactive_users), inactive_users=int(metrics.get("tenancy.inactive_users", 0)),
admin_users=int(admin_users), admin_users=int(metrics.get("tenancy.admin_users", 0)),
activation_rate=float(activation_rate), activation_rate=float(metrics.get("tenancy.user_activation_rate", 0)),
), ),
stores=StoreStatsResponse( stores=StoreStatsResponse(
total=int(total_stores), total=int(metrics.get("tenancy.total_stores", 0)),
verified=int(verified_stores), verified=int(metrics.get("tenancy.verified_stores", 0)),
pending=int(pending_stores), pending=int(metrics.get("tenancy.pending_stores", 0)),
inactive=int(inactive_stores), inactive=int(metrics.get("tenancy.inactive_stores", 0)),
), ),
products=ProductStatsResponse( products=ProductStatsResponse(
total_products=int(total_products), total_products=int(metrics.get("catalog.total_products", 0)),
active_products=int(active_products), active_products=int(metrics.get("catalog.active_products", 0)),
out_of_stock=0, # TODO: Implement out_of_stock=0, # TODO: Implement
), ),
orders=OrderStatsBasicResponse( orders=OrderStatsBasicResponse(
total_orders=int(total_orders), total_orders=int(metrics.get("orders.total", 0)),
pending_orders=0, # TODO: Implement status tracking pending_orders=0, # TODO: Implement status tracking
completed_orders=0, # TODO: Implement status tracking completed_orders=0, # TODO: Implement status tracking
), ),
imports=ImportStatsResponse( imports=ImportStatsResponse(
total=int(total_imports), total=int(metrics.get("marketplace.total_imports", 0)),
pending=int(pending_imports), pending=int(metrics.get("marketplace.pending_imports", 0)),
processing=int(processing_imports), processing=int(metrics.get("marketplace.processing_imports", 0)),
completed=int(completed_imports), completed=int(metrics.get("marketplace.successful_imports", 0)),
failed=int(failed_imports), failed=int(metrics.get("marketplace.failed_imports", 0)),
success_rate=float(import_success_rate), success_rate=float(metrics.get("marketplace.success_rate", 0)),
), ),
) )

View File

@@ -205,7 +205,9 @@ async def get_platform_menu_config(
) )
# Use user's preferred language, falling back to middleware-resolved language # Use user's preferred language, falling back to middleware-resolved language
language = current_user.preferred_language or getattr(request.state, "language", "en") language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
return _build_menu_config_response( return _build_menu_config_response(
items, frontend_type, language=language, platform_id=platform_id items, frontend_type, language=language, platform_id=platform_id
@@ -279,7 +281,10 @@ async def bulk_update_platform_menu_visibility(
f"{len(update_data.visibility)} items for platform {platform.code} ({frontend_type.value})" f"{len(update_data.visibility)} items for platform {platform.code} ({frontend_type.value})"
) )
return {"success": True, "message": f"Updated {len(update_data.visibility)} menu items"} return {
"success": True,
"message": f"Updated {len(update_data.visibility)} menu items",
}
@router.post("/platforms/{platform_id}/reset") @router.post("/platforms/{platform_id}/reset")
@@ -334,7 +339,9 @@ async def get_user_menu_config(
) )
# Use user's preferred language, falling back to middleware-resolved language # Use user's preferred language, falling back to middleware-resolved language
language = current_user.preferred_language or getattr(request.state, "language", "en") language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
return _build_menu_config_response( return _build_menu_config_response(
items, FrontendType.ADMIN, language=language, user_id=current_user.id items, FrontendType.ADMIN, language=language, user_id=current_user.id
@@ -386,7 +393,9 @@ async def reset_user_menu_config(
f"[MENU_CONFIG] Super admin {current_user.email} reset their personal menu config (hide all)" f"[MENU_CONFIG] Super admin {current_user.email} reset their personal menu config (hide all)"
) )
return MenuActionResponse(success=True, message="Menu configuration reset - all items hidden") return MenuActionResponse(
success=True, message="Menu configuration reset - all items hidden"
)
@router.post("/user/show-all", response_model=MenuActionResponse) @router.post("/user/show-all", response_model=MenuActionResponse)
@@ -486,22 +495,31 @@ async def get_rendered_admin_menu(
) )
# Use user's preferred language, falling back to middleware-resolved language # Use user's preferred language, falling back to middleware-resolved language
language = current_user.preferred_language or getattr(request.state, "language", "en") language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
# Translate section and item labels # Translate section and item labels
# menu is a list of DiscoveredMenuSection dataclasses
sections = [] sections = []
for section in menu.get("sections", []): for section in menu:
# Translate item labels # Translate item labels
translated_items = [] translated_items = []
for item in section.get("items", []): for item in section.items:
translated_item = item.copy() translated_items.append(
translated_item["label"] = _translate_label(item.get("label"), language) {
translated_items.append(translated_item) "id": item.id,
"label": _translate_label(item.label_key, language),
"icon": item.icon,
"url": item.route,
"super_admin_only": item.is_super_admin_only,
}
)
sections.append( sections.append(
MenuSectionResponse( MenuSectionResponse(
id=section["id"], id=section.id,
label=_translate_label(section.get("label"), language), label=_translate_label(section.label_key, language),
items=translated_items, items=translated_items,
) )
) )

View File

@@ -492,42 +492,6 @@ class MenuDiscoveryService:
return None return None
def menu_to_legacy_format(
self,
sections: list[DiscoveredMenuSection],
) -> dict:
"""
Convert discovered menu sections to legacy registry format.
This allows gradual migration by using new discovery with old rendering.
Args:
sections: List of DiscoveredMenuSection
Returns:
Dict in ADMIN_MENU_REGISTRY/STORE_MENU_REGISTRY format
"""
return {
"sections": [
{
"id": section.id,
"label": section.label_key, # Note: key not resolved
"super_admin_only": section.is_super_admin_only,
"items": [
{
"id": item.id,
"label": item.label_key, # Note: key not resolved
"icon": item.icon,
"url": item.route,
"super_admin_only": item.is_super_admin_only,
}
for item in section.items
],
}
for section in sections
]
}
# Singleton instance # Singleton instance
menu_discovery_service = MenuDiscoveryService() menu_discovery_service = MenuDiscoveryService()

View File

@@ -102,7 +102,9 @@ class MenuService:
# Validate menu item exists in registry # Validate menu item exists in registry
all_items = menu_discovery_service.get_all_menu_item_ids(frontend_type) all_items = menu_discovery_service.get_all_menu_item_ids(frontend_type)
if menu_item_id not in all_items: if menu_item_id not in all_items:
logger.warning(f"Unknown menu item: {menu_item_id} for {frontend_type.value}") logger.warning(
f"Unknown menu item: {menu_item_id} for {frontend_type.value}"
)
return False return False
# Check module enablement if platform is specified # Check module enablement if platform is specified
@@ -159,9 +161,7 @@ class MenuService:
# Filter by module enablement if platform is specified # Filter by module enablement if platform is specified
if platform_id: if platform_id:
module_service.get_module_menu_items( module_service.get_module_menu_items(db, platform_id, frontend_type)
db, platform_id, frontend_type
)
# Only keep items from enabled modules (or items not associated with any module) # Only keep items from enabled modules (or items not associated with any module)
all_items = module_service.filter_menu_items_by_modules( all_items = module_service.filter_menu_items_by_modules(
db, platform_id, all_items, frontend_type db, platform_id, all_items, frontend_type
@@ -228,7 +228,7 @@ class MenuService:
user_id: int | None = None, user_id: int | None = None,
is_super_admin: bool = False, is_super_admin: bool = False,
store_code: str | None = None, store_code: str | None = None,
) -> dict: ) -> list:
""" """
Get filtered menu structure for frontend rendering. Get filtered menu structure for frontend rendering.
@@ -248,10 +248,9 @@ class MenuService:
store_code: Store code for URL placeholder replacement (store frontend) store_code: Store code for URL placeholder replacement (store frontend)
Returns: Returns:
Filtered menu structure ready for rendering List of DiscoveredMenuSection ready for rendering
""" """
# Use the module-driven discovery service to get filtered menu return menu_discovery_service.get_menu_for_frontend(
sections = menu_discovery_service.get_menu_for_frontend(
db=db, db=db,
frontend_type=frontend_type, frontend_type=frontend_type,
platform_id=platform_id, platform_id=platform_id,
@@ -260,9 +259,6 @@ class MenuService:
store_code=store_code, store_code=store_code,
) )
# Convert to legacy format for backwards compatibility with existing templates
return menu_discovery_service.menu_to_legacy_format(sections)
# ========================================================================= # =========================================================================
# Menu Configuration (Super Admin) # Menu Configuration (Super Admin)
# ========================================================================= # =========================================================================
@@ -349,10 +345,10 @@ class MenuService:
Returns: Returns:
List of MenuItemConfig with current visibility state List of MenuItemConfig with current visibility state
""" """
shown_items = self._get_shown_items( shown_items = self._get_shown_items(db, FrontendType.ADMIN, user_id=user_id)
db, FrontendType.ADMIN, user_id=user_id mandatory_items = menu_discovery_service.get_mandatory_item_ids(
FrontendType.ADMIN
) )
mandatory_items = menu_discovery_service.get_mandatory_item_ids(FrontendType.ADMIN)
# Get all menu items from discovery service # Get all menu items from discovery service
all_items = menu_discovery_service.get_all_menu_items(FrontendType.ADMIN) all_items = menu_discovery_service.get_all_menu_items(FrontendType.ADMIN)
@@ -576,7 +572,9 @@ class MenuService:
# Create records with is_visible=False for all non-mandatory items # Create records with is_visible=False for all non-mandatory items
all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN) all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN)
mandatory_items = menu_discovery_service.get_mandatory_item_ids(FrontendType.ADMIN) mandatory_items = menu_discovery_service.get_mandatory_item_ids(
FrontendType.ADMIN
)
for item_id in all_items: for item_id in all_items:
if item_id not in mandatory_items: if item_id not in mandatory_items:
@@ -665,7 +663,9 @@ class MenuService:
# Create records with is_visible=True for all non-mandatory items # Create records with is_visible=True for all non-mandatory items
all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN) all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN)
mandatory_items = menu_discovery_service.get_mandatory_item_ids(FrontendType.ADMIN) mandatory_items = menu_discovery_service.get_mandatory_item_ids(
FrontendType.ADMIN
)
for item_id in all_items: for item_id in all_items:
if item_id not in mandatory_items: if item_id not in mandatory_items:
@@ -717,11 +717,17 @@ class MenuService:
return q.filter(AdminMenuConfig.user_id == user_id) return q.filter(AdminMenuConfig.user_id == user_id)
# Check if any visible records exist (valid opt-in config) # Check if any visible records exist (valid opt-in config)
visible_count = scope_query().filter( visible_count = (
scope_query()
.filter(
AdminMenuConfig.is_visible == True # noqa: E712 AdminMenuConfig.is_visible == True # noqa: E712
).count() )
.count()
)
if visible_count > 0: if visible_count > 0:
logger.debug(f"Config already exists with {visible_count} visible items, skipping init") logger.debug(
f"Config already exists with {visible_count} visible items, skipping init"
)
return False # Already initialized return False # Already initialized
# Check if ANY records exist (even is_visible=False from old opt-out model) # Check if ANY records exist (even is_visible=False from old opt-out model)
@@ -730,7 +736,9 @@ class MenuService:
# Clean up old records first # Clean up old records first
deleted = scope_query().delete(synchronize_session="fetch") deleted = scope_query().delete(synchronize_session="fetch")
db.flush() # Ensure deletes are applied before inserts db.flush() # Ensure deletes are applied before inserts
logger.info(f"Cleaned up {deleted} old menu config records before initialization") logger.info(
f"Cleaned up {deleted} old menu config records before initialization"
)
# Get all menu items for this frontend # Get all menu items for this frontend
all_items = menu_discovery_service.get_all_menu_item_ids(frontend_type) all_items = menu_discovery_service.get_all_menu_item_ids(frontend_type)

View File

@@ -3,8 +3,6 @@
Architecture Scan Models Architecture Scan Models
Database models for tracking code quality scans and violations. Database models for tracking code quality scans and violations.
This is the canonical location - models are re-exported from the legacy location
for backward compatibility.
""" """
from sqlalchemy import ( from sqlalchemy import (

View File

@@ -3,8 +3,6 @@
Test Run Models Test Run Models
Database models for tracking pytest test runs and results. Database models for tracking pytest test runs and results.
This is the canonical location - models are re-exported from the legacy location
for backward compatibility.
""" """
from sqlalchemy import ( from sqlalchemy import (

View File

@@ -3,7 +3,6 @@
Dev-Tools module Pydantic schemas. Dev-Tools module Pydantic schemas.
Schemas for API request/response serialization. Schemas for API request/response serialization.
Currently re-exports from central location for backward compatibility.
""" """
# Note: Dev-tools schemas are mostly inline in the API routes # Note: Dev-tools schemas are mostly inline in the API routes

View File

@@ -3,8 +3,6 @@
Celery tasks for code quality scans. Celery tasks for code quality scans.
Wraps the existing execute_code_quality_scan function for Celery execution. Wraps the existing execute_code_quality_scan function for Celery execution.
This is the canonical location - task is re-exported from the legacy location
for backward compatibility.
""" """
import json import json

View File

@@ -3,8 +3,6 @@
Celery tasks for test execution. Celery tasks for test execution.
Wraps the existing execute_test_run function for Celery execution. Wraps the existing execute_test_run function for Celery execution.
This is the canonical location - task is re-exported from the legacy location
for backward compatibility.
""" """
import logging import logging

View File

@@ -23,7 +23,6 @@ def upgrade() -> None:
sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True),
sa.Column("warehouse", sa.String(), nullable=False, server_default="strassen", index=True), sa.Column("warehouse", sa.String(), nullable=False, server_default="strassen", index=True),
sa.Column("bin_location", sa.String(), nullable=False, index=True), sa.Column("bin_location", sa.String(), nullable=False, index=True),
sa.Column("location", sa.String(), nullable=True, index=True),
sa.Column("quantity", sa.Integer(), nullable=False, server_default="0"), sa.Column("quantity", sa.Integer(), nullable=False, server_default="0"),
sa.Column("reserved_quantity", sa.Integer(), nullable=True, server_default="0"), sa.Column("reserved_quantity", sa.Integer(), nullable=True, server_default="0"),
sa.Column("gtin", sa.String(), nullable=True, index=True), sa.Column("gtin", sa.String(), nullable=True, index=True),

View File

@@ -30,9 +30,6 @@ class Inventory(Base, TimestampMixin):
warehouse = Column(String, nullable=False, default="strassen", index=True) warehouse = Column(String, nullable=False, default="strassen", index=True)
bin_location = Column(String, nullable=False, index=True) # e.g., "SA-10-02" bin_location = Column(String, nullable=False, index=True) # e.g., "SA-10-02"
# Legacy field - kept for backward compatibility, will be removed
location = Column(String, index=True)
quantity = Column(Integer, nullable=False, default=0) quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0) reserved_quantity = Column(Integer, default=0)
@@ -53,7 +50,7 @@ class Inventory(Base, TimestampMixin):
) )
def __repr__(self): def __repr__(self):
return f"<Inventory(product_id={self.product_id}, location='{self.location}', quantity={self.quantity})>" return f"<Inventory(product_id={self.product_id}, warehouse='{self.warehouse}', bin='{self.bin_location}', quantity={self.quantity})>"
@property @property
def available_quantity(self): def available_quantity(self):

View File

@@ -283,7 +283,7 @@ def delete_inventory(
inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id) inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id)
store_id = inventory.store_id store_id = inventory.store_id
product_id = inventory.product_id product_id = inventory.product_id
location = inventory.location location = inventory.bin_location
inventory_service.delete_inventory( inventory_service.delete_inventory(
db=db, db=db,

View File

@@ -45,7 +45,7 @@ class InventoryResponse(BaseModel):
id: int id: int
product_id: int product_id: int
store_id: int store_id: int
location: str bin_location: str
quantity: int quantity: int
reserved_quantity: int reserved_quantity: int
gtin: str | None gtin: str | None

View File

@@ -176,7 +176,6 @@ class InventoryImportService:
store_id=store_id, store_id=store_id,
warehouse=warehouse, warehouse=warehouse,
bin_location=bin_loc, bin_location=bin_loc,
location=bin_loc, # Legacy field
quantity=quantity, quantity=quantity,
gtin=ean, gtin=ean,
) )

View File

@@ -81,7 +81,7 @@ class InventoryMetricsProvider:
# Unique locations # Unique locations
unique_locations = ( unique_locations = (
db.query(func.count(func.distinct(Inventory.location))) db.query(func.count(func.distinct(Inventory.bin_location)))
.filter(Inventory.store_id == store_id) .filter(Inventory.store_id == store_id)
.scalar() .scalar()
or 0 or 0

View File

@@ -86,8 +86,7 @@ class InventoryService:
product_id=inventory_data.product_id, product_id=inventory_data.product_id,
store_id=store_id, store_id=store_id,
warehouse="strassen", # Default warehouse warehouse="strassen", # Default warehouse
bin_location=location, # Use location as bin location bin_location=location,
location=location, # Keep for backward compatibility
quantity=inventory_data.quantity, quantity=inventory_data.quantity,
gtin=product.marketplace_product.gtin, # Optional reference gtin=product.marketplace_product.gtin, # Optional reference
) )
@@ -154,8 +153,7 @@ class InventoryService:
product_id=inventory_data.product_id, product_id=inventory_data.product_id,
store_id=store_id, store_id=store_id,
warehouse="strassen", # Default warehouse warehouse="strassen", # Default warehouse
bin_location=location, # Use location as bin location bin_location=location,
location=location, # Keep for backward compatibility
quantity=inventory_data.quantity, quantity=inventory_data.quantity,
gtin=product.marketplace_product.gtin, gtin=product.marketplace_product.gtin,
) )
@@ -445,7 +443,7 @@ class InventoryService:
locations = [ locations = [
InventoryLocationResponse( InventoryLocationResponse(
location=inv.location, location=inv.bin_location,
quantity=inv.quantity, quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity, reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity, available_quantity=inv.available_quantity,
@@ -500,7 +498,7 @@ class InventoryService:
query = db.query(Inventory).filter(Inventory.store_id == store_id) query = db.query(Inventory).filter(Inventory.store_id == store_id)
if location: if location:
query = query.filter(Inventory.location.ilike(f"%{location}%")) query = query.filter(Inventory.bin_location.ilike(f"%{location}%"))
if low_stock_threshold is not None: if low_stock_threshold is not None:
query = query.filter(Inventory.quantity <= low_stock_threshold) query = query.filter(Inventory.quantity <= low_stock_threshold)
@@ -541,7 +539,7 @@ class InventoryService:
inventory.reserved_quantity = inventory_update.reserved_quantity inventory.reserved_quantity = inventory_update.reserved_quantity
if inventory_update.location: if inventory_update.location:
inventory.location = self._validate_location(inventory_update.location) inventory.bin_location = self._validate_location(inventory_update.location)
inventory.updated_at = datetime.now(UTC) inventory.updated_at = datetime.now(UTC)
db.flush() db.flush()
@@ -624,7 +622,7 @@ class InventoryService:
query = query.filter(Inventory.store_id == store_id) query = query.filter(Inventory.store_id == store_id)
if location: if location:
query = query.filter(Inventory.location.ilike(f"%{location}%")) query = query.filter(Inventory.bin_location.ilike(f"%{location}%"))
if low_stock is not None: if low_stock is not None:
query = query.filter(Inventory.quantity <= low_stock) query = query.filter(Inventory.quantity <= low_stock)
@@ -668,7 +666,7 @@ class InventoryService:
store_code=store.store_code if store else None, store_code=store.store_code if store else None,
product_title=title, product_title=title,
product_sku=product.store_sku if product else None, product_sku=product.store_sku if product else None,
location=inv.location, location=inv.bin_location,
quantity=inv.quantity, quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity, reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity, available_quantity=inv.available_quantity,
@@ -717,7 +715,7 @@ class InventoryService:
# Unique locations # Unique locations
unique_locations = ( unique_locations = (
db.query(func.count(func.distinct(Inventory.location))).scalar() or 0 db.query(func.count(func.distinct(Inventory.bin_location))).scalar() or 0
) )
return AdminInventoryStats( return AdminInventoryStats(
@@ -768,7 +766,7 @@ class InventoryService:
store_id=inv.store_id, store_id=inv.store_id,
store_name=store.name if store else None, store_name=store.name if store else None,
product_title=title, product_title=title,
location=inv.location, location=inv.bin_location,
quantity=inv.quantity, quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity, reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity, available_quantity=inv.available_quantity,
@@ -808,7 +806,7 @@ class InventoryService:
self, db: Session, store_id: int | None = None self, db: Session, store_id: int | None = None
) -> AdminInventoryLocationsResponse: ) -> AdminInventoryLocationsResponse:
"""Get list of unique inventory locations (admin only).""" """Get list of unique inventory locations (admin only)."""
query = db.query(func.distinct(Inventory.location)) query = db.query(func.distinct(Inventory.bin_location))
if store_id is not None: if store_id is not None:
query = query.filter(Inventory.store_id == store_id) query = query.filter(Inventory.store_id == store_id)
@@ -859,7 +857,7 @@ class InventoryService:
store_code=store.store_code, store_code=store.store_code,
product_title=title, product_title=title,
product_sku=product.store_sku if product else None, product_sku=product.store_sku if product else None,
location=inv.location, location=inv.bin_location,
quantity=inv.quantity, quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity, reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity, available_quantity=inv.available_quantity,
@@ -874,7 +872,7 @@ class InventoryService:
Inventory.store_id == store_id Inventory.store_id == store_id
) )
if location: if location:
total_query = total_query.filter(Inventory.location.ilike(f"%{location}%")) total_query = total_query.filter(Inventory.bin_location.ilike(f"%{location}%"))
if low_stock is not None: if low_stock is not None:
total_query = total_query.filter(Inventory.quantity <= low_stock) total_query = total_query.filter(Inventory.quantity <= low_stock)
total = total_query.scalar() or 0 total = total_query.scalar() or 0
@@ -940,7 +938,7 @@ class InventoryService:
"""Get inventory entry by product and location.""" """Get inventory entry by product and location."""
return ( return (
db.query(Inventory) db.query(Inventory)
.filter(Inventory.product_id == product_id, Inventory.location == location) .filter(Inventory.product_id == product_id, Inventory.bin_location == location)
.first() .first()
) )

View File

@@ -19,7 +19,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="SA-10-01", bin_location="SA-10-01",
location="WAREHOUSE_A",
quantity=150, quantity=150,
reserved_quantity=10, reserved_quantity=10,
gtin=test_product.marketplace_product.gtin, gtin=test_product.marketplace_product.gtin,
@@ -32,7 +31,6 @@ class TestInventoryModel:
assert inventory.id is not None assert inventory.id is not None
assert inventory.product_id == test_product.id assert inventory.product_id == test_product.id
assert inventory.store_id == test_store.id assert inventory.store_id == test_store.id
assert inventory.location == "WAREHOUSE_A"
assert inventory.bin_location == "SA-10-01" assert inventory.bin_location == "SA-10-01"
assert inventory.quantity == 150 assert inventory.quantity == 150
assert inventory.reserved_quantity == 10 assert inventory.reserved_quantity == 10
@@ -45,7 +43,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="SA-10-01", bin_location="SA-10-01",
location="WAREHOUSE_A",
quantity=100, quantity=100,
) )
db.add(inventory1) db.add(inventory1)
@@ -58,7 +55,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="SA-10-01", bin_location="SA-10-01",
location="WAREHOUSE_A",
quantity=50, quantity=50,
) )
db.add(inventory2) db.add(inventory2)
@@ -73,7 +69,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="SA-10-01", bin_location="SA-10-01",
location="WAREHOUSE_A",
quantity=100, quantity=100,
) )
db.add(inventory1) db.add(inventory1)
@@ -85,7 +80,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="SA-10-02", bin_location="SA-10-02",
location="WAREHOUSE_B",
quantity=50, quantity=50,
) )
db.add(inventory2) db.add(inventory2)
@@ -102,7 +96,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="DEF-01-01", bin_location="DEF-01-01",
location="DEFAULT_LOC",
quantity=100, quantity=100,
) )
db.add(inventory) db.add(inventory)
@@ -119,7 +112,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="PROP-01-01", bin_location="PROP-01-01",
location="PROP_TEST",
quantity=200, quantity=200,
reserved_quantity=50, reserved_quantity=50,
) )
@@ -136,7 +128,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="REL-01-01", bin_location="REL-01-01",
location="REL_TEST",
quantity=100, quantity=100,
) )
db.add(inventory) db.add(inventory)
@@ -155,7 +146,6 @@ class TestInventoryModel:
store_id=test_store.id, store_id=test_store.id,
warehouse="strassen", warehouse="strassen",
bin_location="NOGTIN-01-01", bin_location="NOGTIN-01-01",
location="NO_GTIN",
quantity=100, quantity=100,
) )
db.add(inventory) db.add(inventory)

View File

@@ -183,7 +183,7 @@ class TestInventoryResponseSchema:
"id": 1, "id": 1,
"product_id": 1, "product_id": 1,
"store_id": 1, "store_id": 1,
"location": "Warehouse A", "bin_location": "Warehouse A",
"quantity": 100, "quantity": 100,
"reserved_quantity": 20, "reserved_quantity": 20,
"gtin": "1234567890123", "gtin": "1234567890123",
@@ -203,7 +203,7 @@ class TestInventoryResponseSchema:
"id": 1, "id": 1,
"product_id": 1, "product_id": 1,
"store_id": 1, "store_id": 1,
"location": "Warehouse A", "bin_location": "Warehouse A",
"quantity": 100, "quantity": 100,
"reserved_quantity": 30, "reserved_quantity": 30,
"gtin": None, "gtin": None,
@@ -221,7 +221,7 @@ class TestInventoryResponseSchema:
"id": 1, "id": 1,
"product_id": 1, "product_id": 1,
"store_id": 1, "store_id": 1,
"location": "Warehouse A", "bin_location": "Warehouse A",
"quantity": 10, "quantity": 10,
"reserved_quantity": 50, # Over-reserved "reserved_quantity": 50, # Over-reserved
"gtin": None, "gtin": None,

View File

@@ -12,8 +12,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access from app.api.deps import get_current_admin_api, require_module_access
from app.core.database import get_db from app.core.database import get_db
from app.modules.analytics.schemas import ImportStatsResponse # IMPORT-002
from app.modules.analytics.services.stats_service import stats_service # IMPORT-002 from app.modules.analytics.services.stats_service import stats_service # IMPORT-002
from app.modules.core.schemas.dashboard import ImportStatsResponse
from app.modules.enums import FrontendType from app.modules.enums import FrontendType
from app.modules.marketplace.schemas import ( from app.modules.marketplace.schemas import (
AdminMarketplaceImportJobListResponse, AdminMarketplaceImportJobListResponse,

View File

@@ -1,9 +1,10 @@
# app/modules/marketplace/schemas/marketplace_product.py # app/modules/marketplace/schemas/marketplace_product.py
"""Pydantic schemas for MarketplaceProduct API validation. """Pydantic schemas for MarketplaceProduct API validation.
Note: title and description are stored in MarketplaceProductTranslation table, Note: title and description are stored in MarketplaceProductTranslation table.
but we keep them in the API schemas for convenience. The service layer The service layer handles creating/updating translations separately via
handles creating/updating translations separately. dedicated title/description parameters. MarketplaceProductCreate includes
a required title field for API convenience.
""" """
from datetime import datetime from datetime import datetime
@@ -32,10 +33,6 @@ class MarketplaceProductBase(BaseModel):
marketplace_product_id: str | None = None marketplace_product_id: str | None = None
# Localized fields (passed to translations)
title: str | None = None
description: str | None = None
# Links and media # Links and media
link: str | None = None link: str | None = None
image_link: str | None = None image_link: str | None = None

View File

@@ -112,10 +112,7 @@ class MarketplaceProductService:
) )
# Create the product (without title/description - those go in translations) # Create the product (without title/description - those go in translations)
product_dict = product_data.model_dump() product_dict = product_data.model_dump(exclude={"title", "description"})
# Remove any title/description if present in schema (for backwards compatibility)
product_dict.pop("title", None)
product_dict.pop("description", None)
db_product = MarketplaceProduct(**product_dict) db_product = MarketplaceProduct(**product_dict)
db.add(db_product) db.add(db_product)
@@ -259,17 +256,21 @@ class MarketplaceProductService:
MarketplaceProduct.store_name.ilike(search_term), MarketplaceProduct.store_name.ilike(search_term),
MarketplaceProduct.brand.ilike(search_term), MarketplaceProduct.brand.ilike(search_term),
MarketplaceProduct.gtin.ilike(search_term), MarketplaceProduct.gtin.ilike(search_term),
MarketplaceProduct.marketplace_product_id.ilike(search_term), MarketplaceProduct.marketplace_product_id.ilike(
search_term
),
MarketplaceProductTranslation.title.ilike(search_term), MarketplaceProductTranslation.title.ilike(search_term),
MarketplaceProductTranslation.description.ilike(search_term), MarketplaceProductTranslation.description.ilike(
search_term
),
) )
) )
.distinct() .distinct()
.subquery() .subquery()
) )
query = query.filter(MarketplaceProduct.id.in_( query = query.filter(
db.query(id_subquery.c.id) MarketplaceProduct.id.in_(db.query(id_subquery.c.id))
)) )
total = query.count() total = query.count()
products = query.offset(skip).limit(limit).all() products = query.offset(skip).limit(limit).all()
@@ -305,12 +306,10 @@ class MarketplaceProductService:
try: try:
product = self.get_product_by_id_or_raise(db, marketplace_product_id) product = self.get_product_by_id_or_raise(db, marketplace_product_id)
# Update fields # Update fields (exclude title/description - handled separately via translations)
update_data = product_update.model_dump(exclude_unset=True) update_data = product_update.model_dump(
exclude_unset=True, exclude={"title", "description"}
# Remove title/description from update data (handled separately) )
update_data.pop("title", None)
update_data.pop("description", None)
# Validate GTIN if being updated # Validate GTIN if being updated
if "gtin" in update_data and update_data["gtin"]: if "gtin" in update_data and update_data["gtin"]:
@@ -447,14 +446,16 @@ class MarketplaceProductService:
""" """
try: try:
# SVC-005 - Admin/internal function for inventory lookup by GTIN # SVC-005 - Admin/internal function for inventory lookup by GTIN
inventory_entries = db.query(Inventory).filter(Inventory.gtin == gtin).all() # SVC-005 inventory_entries = (
db.query(Inventory).filter(Inventory.gtin == gtin).all()
) # SVC-005
if not inventory_entries: if not inventory_entries:
return None return None
total_quantity = sum(entry.quantity for entry in inventory_entries) total_quantity = sum(entry.quantity for entry in inventory_entries)
locations = [ locations = [
InventoryLocationResponse( InventoryLocationResponse(
location=entry.location, location=entry.bin_location,
quantity=entry.quantity, quantity=entry.quantity,
reserved_quantity=entry.reserved_quantity or 0, reserved_quantity=entry.reserved_quantity or 0,
available_quantity=entry.quantity - (entry.reserved_quantity or 0), available_quantity=entry.quantity - (entry.reserved_quantity or 0),
@@ -661,17 +662,13 @@ class MarketplaceProductService:
.distinct() .distinct()
.subquery() .subquery()
) )
query = query.filter(MarketplaceProduct.id.in_( query = query.filter(MarketplaceProduct.id.in_(db.query(id_subquery.c.id)))
db.query(id_subquery.c.id)
))
if marketplace: if marketplace:
query = query.filter(MarketplaceProduct.marketplace == marketplace) query = query.filter(MarketplaceProduct.marketplace == marketplace)
if store_name: if store_name:
query = query.filter( query = query.filter(MarketplaceProduct.store_name.ilike(f"%{store_name}%"))
MarketplaceProduct.store_name.ilike(f"%{store_name}%")
)
if availability: if availability:
query = query.filter(MarketplaceProduct.availability == availability) query = query.filter(MarketplaceProduct.availability == availability)
@@ -966,8 +963,12 @@ class MarketplaceProductService:
primary_image_url=mp.image_link, primary_image_url=mp.image_link,
additional_images=mp.additional_images, additional_images=mp.additional_images,
# === Digital product fields === # === Digital product fields ===
download_url=mp.download_url if hasattr(mp, "download_url") else None, download_url=mp.download_url
license_type=mp.license_type if hasattr(mp, "license_type") else None, if hasattr(mp, "download_url")
else None,
license_type=mp.license_type
if hasattr(mp, "license_type")
else None,
) )
db.add(product) db.add(product)
@@ -990,12 +991,14 @@ class MarketplaceProductService:
translations_copied += 1 translations_copied += 1
copied += 1 copied += 1
details.append({ details.append(
{
"id": mp.id, "id": mp.id,
"status": "copied", "status": "copied",
"gtin": mp.gtin, "gtin": mp.gtin,
"translations_copied": translations_copied, "translations_copied": translations_copied,
}) }
)
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"Failed to copy product {mp.id}: {str(e)}") logger.error(f"Failed to copy product {mp.id}: {str(e)}")

View File

@@ -204,7 +204,7 @@ class TestProductService:
def test_update_product_not_found(self, db): def test_update_product_not_found(self, db):
"""Test updating non-existent product raises MarketplaceProductNotFoundException""" """Test updating non-existent product raises MarketplaceProductNotFoundException"""
update_data = MarketplaceProductUpdate(title="Updated Title") update_data = MarketplaceProductUpdate(brand="Updated Brand")
with pytest.raises(MarketplaceProductNotFoundException) as exc_info: with pytest.raises(MarketplaceProductNotFoundException) as exc_info:
self.service.update_product(db, "NONEXISTENT", update_data) self.service.update_product(db, "NONEXISTENT", update_data)
@@ -230,10 +230,13 @@ class TestProductService:
): ):
"""Test updating product with empty title preserves existing title in translation""" """Test updating product with empty title preserves existing title in translation"""
original_title = test_marketplace_product.get_title() original_title = test_marketplace_product.get_title()
update_data = MarketplaceProductUpdate(title="") update_data = MarketplaceProductUpdate()
updated_product = self.service.update_product( updated_product = self.service.update_product(
db, test_marketplace_product.marketplace_product_id, update_data db,
test_marketplace_product.marketplace_product_id,
update_data,
title="",
) )
# Empty title update preserves existing translation title # Empty title update preserves existing translation title

View File

@@ -70,7 +70,7 @@ class OrderInventoryService:
) )
.first() .first()
) )
return inventory.location if inventory else None return inventory.bin_location if inventory else None
def _is_placeholder_product(self, order_item: OrderItem) -> bool: def _is_placeholder_product(self, order_item: OrderItem) -> bool:
"""Check if the order item uses a placeholder product.""" """Check if the order item uses a placeholder product."""
@@ -98,7 +98,7 @@ class OrderInventoryService:
quantity_change=quantity_change, quantity_change=quantity_change,
quantity_after=inventory.quantity if inventory else 0, quantity_after=inventory.quantity if inventory else 0,
reserved_after=inventory.reserved_quantity if inventory else 0, reserved_after=inventory.reserved_quantity if inventory else 0,
location=inventory.location if inventory else None, location=inventory.bin_location if inventory else None,
warehouse=inventory.warehouse if inventory else None, warehouse=inventory.warehouse if inventory else None,
order_id=order.id, order_id=order.id,
order_number=order.order_number, order_number=order.order_number,
@@ -229,7 +229,7 @@ class OrderInventoryService:
.first() .first()
) )
if inventory: if inventory:
location = inventory.location location = inventory.bin_location
if not location: if not location:
if skip_missing: if skip_missing:
@@ -358,7 +358,7 @@ class OrderInventoryService:
.first() .first()
) )
if inventory: if inventory:
location = inventory.location location = inventory.bin_location
if not location: if not location:
if skip_missing: if skip_missing:
@@ -467,7 +467,7 @@ class OrderInventoryService:
try: try:
reserve_data = InventoryReserve( reserve_data = InventoryReserve(
product_id=item.product_id, product_id=item.product_id,
location=inventory.location, location=inventory.bin_location,
quantity=item.quantity, quantity=item.quantity,
) )
updated_inventory = inventory_service.release_reservation( updated_inventory = inventory_service.release_reservation(

View File

@@ -2,10 +2,7 @@
""" """
Payments module routes. Payments module routes.
Re-exports routers from the api subdirectory for backwards compatibility. Import routers directly from their canonical locations:
from app.modules.payments.routes.api.admin import admin_router
from app.modules.payments.routes.api.store import store_router
""" """
from app.modules.payments.routes.api.admin import admin_router
from app.modules.payments.routes.api.store import store_router
__all__ = ["admin_router", "store_router"]

View File

@@ -64,10 +64,10 @@ def _get_modules_by_tier() -> dict[str, dict[str, ModuleDefinition]]:
return discover_modules_by_tier() return discover_modules_by_tier()
# Expose as module-level variables for backward compatibility # Expose as module-level variables via lazy loading to avoid circular imports
# These are computed lazily on first access # These are computed on first access using Python's module __getattr__
def __getattr__(name: str): def __getattr__(name: str):
"""Lazy module-level attribute access for backward compatibility.""" """Lazy module-level attribute access (avoids circular imports at import time)."""
if name == "MODULES": if name == "MODULES":
return _get_all_modules() return _get_all_modules()
if name == "CORE_MODULES": if name == "CORE_MODULES":

View File

@@ -5,11 +5,8 @@ Module service for platform module operations.
Provides methods to check module enablement, get enabled modules, Provides methods to check module enablement, get enabled modules,
and filter menu items based on module configuration. and filter menu items based on module configuration.
Module configuration can be stored in two places: Module configuration is stored in the PlatformModule junction table,
1. PlatformModule junction table (preferred, auditable) which provides auditability, per-module config, and explicit state tracking.
2. Platform.settings["enabled_modules"] (fallback, legacy)
If neither is configured, all modules are enabled (backwards compatibility).
""" """
import logging import logging
@@ -37,27 +34,16 @@ class ModuleService:
Handles module enablement checking, module listing, and menu item filtering Handles module enablement checking, module listing, and menu item filtering
based on enabled modules. based on enabled modules.
Module configuration is stored in two places (with fallback): Module configuration is stored in the PlatformModule junction table,
1. PlatformModule junction table (preferred, auditable) which provides auditability, per-module config, and explicit state tracking.
2. Platform.settings["enabled_modules"] (legacy fallback)
The service checks the junction table first. If no records exist, If no PlatformModule records exist for a platform, no optional modules are
it falls back to the JSON settings for backwards compatibility. enabled (only core modules). Use seed scripts or the admin API to configure
module enablement for each platform.
If neither is configured, all modules are enabled (backwards compatibility).
Example PlatformModule records: Example PlatformModule records:
PlatformModule(platform_id=1, module_code="billing", is_enabled=True, config={"stripe_mode": "live"}) PlatformModule(platform_id=1, module_code="billing", is_enabled=True, config={"stripe_mode": "live"})
PlatformModule(platform_id=1, module_code="inventory", is_enabled=True, config={"low_stock_threshold": 10}) PlatformModule(platform_id=1, module_code="inventory", is_enabled=True, config={"low_stock_threshold": 10})
Legacy Platform.settings (fallback):
{
"enabled_modules": ["core", "billing", "inventory", "orders"],
"module_config": {
"billing": {"stripe_mode": "live"},
"inventory": {"low_stock_threshold": 10}
}
}
""" """
# ========================================================================= # =========================================================================
@@ -133,11 +119,8 @@ class ModuleService:
""" """
Get enabled module codes for a platform. Get enabled module codes for a platform.
Checks two sources with fallback: Uses the PlatformModule junction table exclusively. If no records exist,
1. PlatformModule junction table (preferred, auditable) returns only core modules (empty set of optional modules).
2. Platform.settings["enabled_modules"] (legacy fallback)
If neither is configured, returns all module codes (backwards compatibility).
Always includes core modules. Always includes core modules.
Args: Args:
@@ -149,29 +132,17 @@ class ModuleService:
""" """
platform = db.query(Platform).filter(Platform.id == platform_id).first() platform = db.query(Platform).filter(Platform.id == platform_id).first()
if not platform: if not platform:
logger.warning(f"Platform {platform_id} not found, returning all modules") logger.warning(f"Platform {platform_id} not found, returning core modules only")
return set(MODULES.keys()) return get_core_module_codes()
# Try junction table first (preferred) # Query junction table for enabled modules
platform_modules = ( platform_modules = (
db.query(PlatformModule) db.query(PlatformModule)
.filter(PlatformModule.platform_id == platform_id) .filter(PlatformModule.platform_id == platform_id)
.all() .all()
) )
if platform_modules:
# Use junction table data
enabled_set = {pm.module_code for pm in platform_modules if pm.is_enabled} enabled_set = {pm.module_code for pm in platform_modules if pm.is_enabled}
else:
# Fallback to JSON settings (legacy)
settings = platform.settings or {}
enabled_modules = settings.get("enabled_modules")
# If not configured, enable all modules (backwards compatibility)
if enabled_modules is None:
return set(MODULES.keys())
enabled_set = set(enabled_modules)
# Always include core modules # Always include core modules
core_codes = get_core_module_codes() core_codes = get_core_module_codes()
@@ -187,72 +158,6 @@ class ModuleService:
return enabled_set return enabled_set
def _migrate_json_to_junction_table(
self,
db: Session,
platform_id: int,
user_id: int | None = None,
) -> None:
"""
Migrate JSON settings to junction table records.
Called when first creating a junction table record for a platform
that previously used JSON settings. This ensures consistency when
mixing junction table and JSON approaches.
Args:
db: Database session
platform_id: Platform ID
user_id: ID of user performing the migration (for audit)
"""
# Check if any junction table records exist
existing_count = (
db.query(PlatformModule)
.filter(PlatformModule.platform_id == platform_id)
.count()
)
if existing_count > 0:
# Already using junction table
return
platform = db.query(Platform).filter(Platform.id == platform_id).first()
if not platform:
return
settings = platform.settings or {}
enabled_modules = settings.get("enabled_modules")
if enabled_modules is None:
# No JSON settings, start fresh with all modules enabled
enabled_codes = set(MODULES.keys())
else:
enabled_codes = set(enabled_modules) | get_core_module_codes()
now = datetime.now(UTC)
# Create junction table records for all known modules
for code in MODULES:
is_enabled = code in enabled_codes
pm = PlatformModule(
platform_id=platform_id,
module_code=code,
is_enabled=is_enabled,
enabled_at=now if is_enabled else None,
enabled_by_user_id=user_id if is_enabled else None,
disabled_at=None if is_enabled else now,
disabled_by_user_id=None if is_enabled else user_id,
config={},
)
db.add(pm)
# Flush to ensure records are visible to subsequent queries
db.flush()
logger.info(
f"Migrated platform {platform_id} from JSON settings to junction table"
)
def _resolve_dependencies(self, enabled_codes: set[str]) -> set[str]: def _resolve_dependencies(self, enabled_codes: set[str]) -> set[str]:
""" """
Resolve module dependencies by adding required modules. Resolve module dependencies by adding required modules.
@@ -383,9 +288,7 @@ class ModuleService:
""" """
Get module-specific configuration for a platform. Get module-specific configuration for a platform.
Checks two sources with fallback: Uses the PlatformModule junction table for configuration storage.
1. PlatformModule.config (preferred, auditable)
2. Platform.settings["module_config"] (legacy fallback)
Args: Args:
db: Database session db: Database session
@@ -395,7 +298,6 @@ class ModuleService:
Returns: Returns:
Module configuration dict (empty if not configured) Module configuration dict (empty if not configured)
""" """
# Try junction table first (preferred)
platform_module = ( platform_module = (
db.query(PlatformModule) db.query(PlatformModule)
.filter( .filter(
@@ -408,15 +310,8 @@ class ModuleService:
if platform_module: if platform_module:
return platform_module.config or {} return platform_module.config or {}
# Fallback to JSON settings (legacy)
platform = db.query(Platform).filter(Platform.id == platform_id).first()
if not platform:
return {} return {}
settings = platform.settings or {}
module_configs = settings.get("module_config", {})
return module_configs.get(module_code, {})
def set_module_config( def set_module_config(
self, self,
db: Session, db: Session,
@@ -569,7 +464,7 @@ class ModuleService:
Enable a single module for a platform. Enable a single module for a platform.
Also enables required dependencies. Also enables required dependencies.
Uses junction table for auditability when available. Uses the PlatformModule junction table for auditability.
Args: Args:
db: Database session db: Database session
@@ -589,9 +484,6 @@ class ModuleService:
logger.error(f"Platform {platform_id} not found") logger.error(f"Platform {platform_id} not found")
return False return False
# Migrate JSON settings to junction table if needed
self._migrate_json_to_junction_table(db, platform_id, user_id)
now = datetime.now(UTC) now = datetime.now(UTC)
# Enable this module and its dependencies # Enable this module and its dependencies
@@ -644,7 +536,7 @@ class ModuleService:
Core modules cannot be disabled. Core modules cannot be disabled.
Also disables modules that depend on this one. Also disables modules that depend on this one.
Uses junction table for auditability when available. Uses the PlatformModule junction table for auditability.
Args: Args:
db: Database session db: Database session
@@ -669,9 +561,6 @@ class ModuleService:
logger.error(f"Platform {platform_id} not found") logger.error(f"Platform {platform_id} not found")
return False return False
# Migrate JSON settings to junction table if needed
self._migrate_json_to_junction_table(db, platform_id, user_id)
now = datetime.now(UTC) now = datetime.now(UTC)
# Get modules to disable (this one + dependents) # Get modules to disable (this one + dependents)
@@ -754,8 +643,9 @@ class ModuleService:
""" """
platform = db.query(Platform).filter(Platform.code == platform_code).first() platform = db.query(Platform).filter(Platform.code == platform_code).first()
if not platform: if not platform:
logger.warning(f"Platform '{platform_code}' not found, returning all modules") logger.warning(f"Platform '{platform_code}' not found, returning core modules only")
return list(MODULES.values()) core_codes = get_core_module_codes()
return [MODULES[code] for code in core_codes if code in MODULES]
return self.get_platform_modules(db, platform.id) return self.get_platform_modules(db, platform.id)

View File

@@ -166,7 +166,4 @@ class ModuleTask(Task):
) )
# Alias for backward compatibility and clarity __all__ = ["ModuleTask"]
DatabaseTask = ModuleTask
__all__ = ["ModuleTask", "DatabaseTask"]

View File

@@ -57,9 +57,6 @@ class AdminAuditLog(Base, TimestampMixin):
return f"<AdminAuditLog(id={self.id}, action='{self.action}', target={self.target_type}:{self.target_id})>" return f"<AdminAuditLog(id={self.id}, action='{self.action}', target={self.target_type}:{self.target_id})>"
# AdminNotification has been moved to app/modules/messaging/models/admin_notification.py
# It's re-exported via models/database/__init__.py for backwards compatibility
class AdminSetting(Base, TimestampMixin): class AdminSetting(Base, TimestampMixin):
""" """

View File

@@ -9,7 +9,7 @@ Individual stores can optionally override this with their own custom StoreDomain
Domain Resolution Priority: Domain Resolution Priority:
1. Store-specific custom domain (StoreDomain) -> highest priority 1. Store-specific custom domain (StoreDomain) -> highest priority
2. Merchant domain (MerchantDomain) -> inherited default 2. Merchant domain (MerchantDomain) -> inherited default
3. Store subdomain ({store.subdomain}.loyalty.lu) -> fallback 3. Store subdomain ({store.subdomain}.rewardflow.lu) -> fallback
""" """
from sqlalchemy import ( from sqlalchemy import (

View File

@@ -80,7 +80,7 @@ class Platform(Base, TimestampMixin):
unique=True, unique=True,
nullable=True, nullable=True,
index=True, index=True,
comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')", comment="Production domain (e.g., 'omsflow.lu', 'rewardflow.lu')",
) )
path_prefix = Column( path_prefix = Column(

View File

@@ -6,9 +6,6 @@ This junction table provides:
- Auditability: Track when modules were enabled/disabled and by whom - Auditability: Track when modules were enabled/disabled and by whom
- Configuration: Per-module settings specific to each platform - Configuration: Per-module settings specific to each platform
- State tracking: Explicit enabled/disabled states with timestamps - State tracking: Explicit enabled/disabled states with timestamps
Replaces the simpler Platform.settings["enabled_modules"] JSON approach
for better auditability and query capabilities.
""" """
from sqlalchemy import ( from sqlalchemy import (

View File

@@ -208,9 +208,8 @@ def update_store(
return _build_store_detail_response(store) return _build_store_detail_response(store)
# NOTE: Ownership transfer is now at the Merchant level. # NOTE: Ownership transfer is handled at the Merchant level.
# Use PUT /api/v1/admin/merchants/{id}/transfer-ownership instead. # Use PUT /api/v1/admin/merchants/{id}/transfer-ownership instead.
# This endpoint is kept for backwards compatibility but may be removed in future versions.
@admin_stores_router.put("/{store_identifier}/verification", response_model=StoreDetailResponse) @admin_stores_router.put("/{store_identifier}/verification", response_model=StoreDetailResponse)

View File

@@ -206,7 +206,7 @@ class StoreResponse(BaseModel):
is_active: bool is_active: bool
is_verified: bool is_verified: bool
# Language Settings (optional with defaults for backward compatibility) # Language Settings (optional with sensible defaults)
default_language: str = "fr" default_language: str = "fr"
dashboard_language: str = "fr" dashboard_language: str = "fr"
storefront_language: str = "fr" storefront_language: str = "fr"

View File

@@ -197,7 +197,7 @@
<input <input
type="text" type="text"
x-model="formData.domain" x-model="formData.domain"
placeholder="e.g., oms.lu" placeholder="e.g., omsflow.lu"
:disabled="saving" :disabled="saving"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input" class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
> >

View File

@@ -8,9 +8,9 @@ The application serves multiple frontends from a single codebase:
| Frontend | Description | Example URLs | | Frontend | Description | Example URLs |
|----------|-------------|--------------| |----------|-------------|--------------|
| **ADMIN** | Platform administration | `/admin/*`, `/api/v1/admin/*`, `admin.oms.lu/*` | | **ADMIN** | Platform administration | `/admin/*`, `/api/v1/admin/*`, `admin.omsflow.lu/*` |
| **STORE** | Store dashboard | `/store/*`, `/api/v1/store/*` | | **STORE** | Store dashboard | `/store/*`, `/api/v1/store/*` |
| **STOREFRONT** | Customer-facing shop | `/storefront/*`, `/stores/*`, `orion.oms.lu/*` | | **STOREFRONT** | Customer-facing shop | `/storefront/*`, `/stores/*`, `orion.omsflow.lu/*` |
| **PLATFORM** | Marketing pages | `/`, `/pricing`, `/about` | | **PLATFORM** | Marketing pages | `/`, `/pricing`, `/about` |
The `FrontendDetector` class provides centralized, consistent detection of which frontend a request targets. The `FrontendDetector` class provides centralized, consistent detection of which frontend a request targets.
@@ -64,13 +64,13 @@ class FrontendType(str, Enum):
The `FrontendDetector` uses the following priority order: The `FrontendDetector` uses the following priority order:
``` ```
1. Admin subdomain (admin.oms.lu) → ADMIN 1. Admin subdomain (admin.omsflow.lu) → ADMIN
2. Path-based detection: 2. Path-based detection:
- /admin/* or /api/v1/admin/* → ADMIN - /admin/* or /api/v1/admin/* → ADMIN
- /store/* or /api/v1/store/* → STORE - /store/* or /api/v1/store/* → STORE
- /storefront/*, /shop/*, /stores/* → STOREFRONT - /storefront/*, /shop/*, /stores/* → STOREFRONT
- /api/v1/platform/* → PLATFORM - /api/v1/platform/* → PLATFORM
3. Store subdomain (orion.oms.lu) → STOREFRONT 3. Store subdomain (orion.omsflow.lu) → STOREFRONT
4. Store context set by middleware → STOREFRONT 4. Store context set by middleware → STOREFRONT
5. Default → PLATFORM 5. Default → PLATFORM
``` ```
@@ -133,7 +133,7 @@ from app.modules.enums import FrontendType
# Full detection # Full detection
frontend_type = FrontendDetector.detect( frontend_type = FrontendDetector.detect(
host="orion.oms.lu", host="orion.omsflow.lu",
path="/products", path="/products",
has_store_context=True has_store_context=True
) )
@@ -167,10 +167,10 @@ if FrontendDetector.is_storefront(host, path, has_store_context=True):
| Request | Host | Path | Frontend | | Request | Host | Path | Frontend |
|---------|------|------|----------| |---------|------|------|----------|
| Admin subdomain | admin.oms.lu | /dashboard | ADMIN | | Admin subdomain | admin.omsflow.lu | /dashboard | ADMIN |
| Store subdomain | orion.oms.lu | /products | STOREFRONT | | Store subdomain | orion.omsflow.lu | /products | STOREFRONT |
| Custom domain | mybakery.lu | /products | STOREFRONT | | Custom domain | mybakery.lu | /products | STOREFRONT |
| Platform root | oms.lu | /pricing | PLATFORM | | Platform root | omsflow.lu | /pricing | PLATFORM |
## Request State ## Request State

View File

@@ -20,7 +20,7 @@ This middleware layer is **system-wide** and enables the multi-tenant architectu
**What it does**: **What it does**:
- Detects platform from: - Detects platform from:
- Custom domain (e.g., `oms.lu`, `loyalty.lu`) - Custom domain (e.g., `omsflow.lu`, `rewardflow.lu`)
- Path prefix in development (e.g., `/platforms/oms/`, `/platforms/loyalty/`) - Path prefix in development (e.g., `/platforms/oms/`, `/platforms/loyalty/`)
- Default to `main` platform for localhost without prefix - Default to `main` platform for localhost without prefix
- Rewrites path for platform-prefixed requests (strips `/platforms/{code}/`) - Rewrites path for platform-prefixed requests (strips `/platforms/{code}/`)
@@ -33,7 +33,7 @@ Request arrives
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ Production domain? (oms.lu, etc.) │ │ Production domain? (omsflow.lu, etc.) │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
│ YES → Use that platform │ YES → Use that platform
@@ -135,13 +135,13 @@ Injects: request.state.store = <Store object>
**Detection Priority** (handled by `FrontendDetector`): **Detection Priority** (handled by `FrontendDetector`):
```python ```python
1. Admin subdomain (admin.oms.lu) ADMIN 1. Admin subdomain (admin.omsflow.lu) ADMIN
2. Path-based detection: 2. Path-based detection:
- /admin/* or /api/v1/admin/* ADMIN - /admin/* or /api/v1/admin/* ADMIN
- /store/* or /api/v1/store/* STORE - /store/* or /api/v1/store/* STORE
- /storefront/*, /shop/*, /stores/* STOREFRONT - /storefront/*, /shop/*, /stores/* STOREFRONT
- /api/v1/platform/* PLATFORM - /api/v1/platform/* PLATFORM
3. Store subdomain (orion.oms.lu) STOREFRONT 3. Store subdomain (orion.omsflow.lu) STOREFRONT
4. Store context set by middleware STOREFRONT 4. Store context set by middleware STOREFRONT
5. Default PLATFORM 5. Default PLATFORM
``` ```

View File

@@ -123,7 +123,7 @@ The system uses different URL patterns for development vs production:
**Production (custom domains):** **Production (custom domains):**
- Main marketing site: `orion.lu/``main` platform - Main marketing site: `orion.lu/``main` platform
- Platform sites: `oms.lu/`, `loyalty.lu/` → specific platform - Platform sites: `omsflow.lu/`, `rewardflow.lu/` → specific platform
### Request Processing ### Request Processing
@@ -259,7 +259,7 @@ Request: GET /about
1. Insert platform record: 1. Insert platform record:
```sql ```sql
INSERT INTO platforms (code, name, description, domain, path_prefix) INSERT INTO platforms (code, name, description, domain, path_prefix)
VALUES ('loyalty', 'Loyalty Program', 'Customer loyalty and rewards', 'loyalty.lu', 'loyalty'); VALUES ('loyalty', 'Loyalty Program', 'Customer loyalty and rewards', 'rewardflow.lu', 'loyalty');
``` ```
2. Create platform-specific content pages: 2. Create platform-specific content pages:
@@ -270,7 +270,7 @@ Request: GET /about
3. Configure routing: 3. Configure routing:
- **Development:** Access at `localhost:9999/platforms/loyalty/` - **Development:** Access at `localhost:9999/platforms/loyalty/`
- **Production:** Access at `loyalty.lu/` - **Production:** Access at `rewardflow.lu/`
- Platform detected automatically by `PlatformContextMiddleware` - Platform detected automatically by `PlatformContextMiddleware`
- No additional route configuration needed - No additional route configuration needed
@@ -285,5 +285,5 @@ Request: GET /about
| Platform | Code | Dev URL | Prod URL | | Platform | Code | Dev URL | Prod URL |
|----------|------|---------|----------| |----------|------|---------|----------|
| Main Marketing | `main` | `localhost:9999/` | `orion.lu/` | | Main Marketing | `main` | `localhost:9999/` | `orion.lu/` |
| OMS | `oms` | `localhost:9999/platforms/oms/` | `oms.lu/` | | OMS | `oms` | `localhost:9999/platforms/oms/` | `omsflow.lu/` |
| Loyalty | `loyalty` | `localhost:9999/platforms/loyalty/` | `loyalty.lu/` | | Loyalty | `loyalty` | `localhost:9999/platforms/loyalty/` | `rewardflow.lu/` |

View File

@@ -63,14 +63,14 @@ Orion supports multiple platforms (OMS, Loyalty, Site Builder), each with its ow
|-----|----------------| |-----|----------------|
| `orion.lu/` | Main marketing site homepage | | `orion.lu/` | Main marketing site homepage |
| `orion.lu/about` | Main marketing site about page | | `orion.lu/about` | Main marketing site about page |
| `oms.lu/` | OMS platform homepage | | `omsflow.lu/` | OMS platform homepage |
| `oms.lu/pricing` | OMS platform pricing page | | `omsflow.lu/pricing` | OMS platform pricing page |
| `oms.lu/admin/` | Admin panel for OMS platform | | `omsflow.lu/admin/` | Admin panel for OMS platform |
| `oms.lu/store/{code}/` | Store dashboard on OMS | | `omsflow.lu/store/{code}/` | Store dashboard on OMS |
| `https://mybakery.lu/storefront/` | Store storefront (store's custom domain) | | `https://mybakery.lu/storefront/` | Store storefront (store's custom domain) |
| `loyalty.lu/` | Loyalty platform homepage | | `rewardflow.lu/` | Loyalty platform homepage |
**Note:** In production, stores configure their own custom domains for storefronts. The platform domain (e.g., `oms.lu`) is used for admin and store dashboards, while storefronts use store-owned domains. **Note:** In production, stores configure their own custom domains for storefronts. The platform domain (e.g., `omsflow.lu`) is used for admin and store dashboards, while storefronts use store-owned domains.
### Quick Reference by Platform ### Quick Reference by Platform
@@ -83,9 +83,9 @@ Dev:
Storefront: http://localhost:8000/platforms/oms/stores/{store_code}/storefront/ Storefront: http://localhost:8000/platforms/oms/stores/{store_code}/storefront/
Prod: Prod:
Platform: https://oms.lu/ Platform: https://omsflow.lu/
Admin: https://oms.lu/admin/ Admin: https://omsflow.lu/admin/
Store: https://oms.lu/store/{store_code}/ Store: https://omsflow.lu/store/{store_code}/
Storefront: https://mybakery.lu/storefront/ (store's custom domain) Storefront: https://mybakery.lu/storefront/ (store's custom domain)
``` ```
@@ -98,9 +98,9 @@ Dev:
Storefront: http://localhost:8000/platforms/loyalty/stores/{store_code}/storefront/ Storefront: http://localhost:8000/platforms/loyalty/stores/{store_code}/storefront/
Prod: Prod:
Platform: https://loyalty.lu/ Platform: https://rewardflow.lu/
Admin: https://loyalty.lu/admin/ Admin: https://rewardflow.lu/admin/
Store: https://loyalty.lu/store/{store_code}/ Store: https://rewardflow.lu/store/{store_code}/
Storefront: https://myrewards.lu/storefront/ (store's custom domain) Storefront: https://myrewards.lu/storefront/ (store's custom domain)
``` ```
@@ -112,7 +112,7 @@ Request arrives
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ Check: Is this production domain? │ │ Check: Is this production domain? │
│ (oms.lu, loyalty.lu, etc.) │ │ (omsflow.lu, rewardflow.lu, etc.) │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
├── YES → Route to that platform ├── YES → Route to that platform
@@ -139,8 +139,8 @@ Request arrives
| Platform | Code | Dev URL | Prod Domain | | Platform | Code | Dev URL | Prod Domain |
|----------|------|---------|-------------| |----------|------|---------|-------------|
| Main Marketing | `main` | `localhost:8000/` | `orion.lu` | | Main Marketing | `main` | `localhost:8000/` | `orion.lu` |
| OMS | `oms` | `localhost:8000/platforms/oms/` | `oms.lu` | | OMS | `oms` | `localhost:8000/platforms/oms/` | `omsflow.lu` |
| Loyalty | `loyalty` | `localhost:8000/platforms/loyalty/` | `loyalty.lu` | | Loyalty | `loyalty` | `localhost:8000/platforms/loyalty/` | `rewardflow.lu` |
| Site Builder | `site-builder` | `localhost:8000/platforms/site-builder/` | `sitebuilder.lu` | | Site Builder | `site-builder` | `localhost:8000/platforms/site-builder/` | `sitebuilder.lu` |
**See:** [Multi-Platform CMS Architecture](../multi-platform-cms.md) for content management details. **See:** [Multi-Platform CMS Architecture](../multi-platform-cms.md) for content management details.

View File

@@ -204,7 +204,7 @@ Migration: `alembic/versions/z5f6g7h8i9j0_add_loyalty_platform.py`
Inserts Loyalty platform with: Inserts Loyalty platform with:
- code: `loyalty` - code: `loyalty`
- name: `Loyalty+` - name: `Loyalty+`
- domain: `loyalty.lu` - domain: `rewardflow.lu`
- path_prefix: `loyalty` - path_prefix: `loyalty`
- theme_config: purple color scheme - theme_config: purple color scheme
@@ -256,8 +256,8 @@ Inserts Loyalty platform with:
| Platform | Code | Dev URL | Prod URL | | Platform | Code | Dev URL | Prod URL |
|----------|------|---------|----------| |----------|------|---------|----------|
| Main Marketing | `main` | `localhost:9999/` | `orion.lu/` | | Main Marketing | `main` | `localhost:9999/` | `orion.lu/` |
| OMS | `oms` | `localhost:9999/platforms/oms/` | `oms.lu/` | | OMS | `oms` | `localhost:9999/platforms/oms/` | `omsflow.lu/` |
| Loyalty | `loyalty` | `localhost:9999/platforms/loyalty/` | `loyalty.lu/` | | Loyalty | `loyalty` | `localhost:9999/platforms/loyalty/` | `rewardflow.lu/` |
### 7.4 Middleware Detection Logic ### 7.4 Middleware Detection Logic
@@ -266,7 +266,7 @@ Request arrives
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ Production domain? (oms.lu, etc.) │ │ Production domain? (omsflow.lu, etc.) │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
│ YES → Route to that platform │ YES → Route to that platform
@@ -324,7 +324,7 @@ Included in `docs/architecture/multi-platform-cms.md`:
### Three-Tier Content Resolution ### Three-Tier Content Resolution
``` ```
Customer visits: oms.lu/stores/orion/about Customer visits: omsflow.lu/stores/orion/about
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐

View File

@@ -54,7 +54,7 @@ Complete step-by-step guide for deploying Orion on a Hetzner Cloud VPS.
**Deferred (not urgent, do when all platforms ready):** **Deferred (not urgent, do when all platforms ready):**
- [ ] DNS A + AAAA records for platform domains (`oms.lu`, `rewardflow.lu`) - [ ] DNS A + AAAA records for platform domains (`omsflow.lu`, `rewardflow.lu`)
- [ ] Uncomment platform domains in Caddyfile after DNS propagation - [ ] Uncomment platform domains in Caddyfile after DNS propagation
!!! success "Progress — 2026-02-14" !!! success "Progress — 2026-02-14"
@@ -63,7 +63,7 @@ Complete step-by-step guide for deploying Orion on a Hetzner Cloud VPS.
- **Wizamart → Orion rename** — 1,086 occurrences replaced across 184 files (database identifiers, email addresses, domains, config, templates, docs, seed data) - **Wizamart → Orion rename** — 1,086 occurrences replaced across 184 files (database identifiers, email addresses, domains, config, templates, docs, seed data)
- Template renamed: `homepage-wizamart.html``homepage-orion.html` - Template renamed: `homepage-wizamart.html``homepage-orion.html`
- **Production DB rebuilt from scratch** with Orion naming (`orion_db`, `orion_user`) - **Production DB rebuilt from scratch** with Orion naming (`orion_db`, `orion_user`)
- Platform domains configured in seed data: wizard.lu (main), oms.lu, rewardflow.lu (loyalty) - Platform domains configured in seed data: wizard.lu (main), omsflow.lu, rewardflow.lu (loyalty)
- Docker volume explicitly named `orion_postgres_data` - Docker volume explicitly named `orion_postgres_data`
- `.dockerignore` added — prevents `.env` from being baked into Docker images - `.dockerignore` added — prevents `.env` from being baked into Docker images
- `env_file: .env` added to `docker-compose.yml` — containers load host env vars properly - `env_file: .env` added to `docker-compose.yml` — containers load host env vars properly
@@ -438,7 +438,7 @@ Before setting up Caddy, point your domain's DNS to the server.
| A | `git` | `91.99.65.229` | 300 | | A | `git` | `91.99.65.229` | 300 |
| A | `flower` | `91.99.65.229` | 300 | | A | `flower` | `91.99.65.229` | 300 |
### oms.lu (OMS Platform) — TODO ### omsflow.lu (OMS Platform) — TODO
| Type | Name | Value | TTL | | Type | Name | Value | TTL |
|---|---|---|---| |---|---|---|---|
@@ -470,7 +470,7 @@ It should match the value in the Hetzner Cloud Console (Networking tab). Then cr
| AAAA | `git` | `2a01:4f8:1c1a:b39c::1` | 300 | | AAAA | `git` | `2a01:4f8:1c1a:b39c::1` | 300 |
| AAAA | `flower` | `2a01:4f8:1c1a:b39c::1` | 300 | | AAAA | `flower` | `2a01:4f8:1c1a:b39c::1` | 300 |
Repeat for `oms.lu` and `rewardflow.lu`. Repeat for `omsflow.lu` and `rewardflow.lu`.
!!! tip "DNS propagation" !!! tip "DNS propagation"
Set TTL to 300 (5 minutes) initially. DNS changes can take up to 24 hours to propagate globally, but usually complete within 30 minutes. Verify with: `dig api.wizard.lu +short` Set TTL to 300 (5 minutes) initially. DNS changes can take up to 24 hours to propagate globally, but usually complete within 30 minutes. Verify with: `dig api.wizard.lu +short`
@@ -502,14 +502,14 @@ www.wizard.lu {
redir https://wizard.lu{uri} permanent redir https://wizard.lu{uri} permanent
} }
# ─── Platform 2: OMS (oms.lu) ─────────────────────────────── # ─── Platform 2: OMS (omsflow.lu) ───────────────────────────────
# Uncomment after DNS is configured for oms.lu # Uncomment after DNS is configured for omsflow.lu
# oms.lu { # omsflow.lu {
# reverse_proxy localhost:8001 # reverse_proxy localhost:8001
# } # }
# #
# www.oms.lu { # www.omsflow.lu {
# redir https://oms.lu{uri} permanent # redir https://omsflow.lu{uri} permanent
# } # }
# ─── Platform 3: Loyalty+ (rewardflow.lu) ────────────────── # ─── Platform 3: Loyalty+ (rewardflow.lu) ──────────────────
@@ -537,14 +537,14 @@ flower.wizard.lu {
``` ```
!!! info "How multi-platform routing works" !!! info "How multi-platform routing works"
All platform domains (`wizard.lu`, `oms.lu`, `rewardflow.lu`) point to the **same FastAPI backend** on port 8001. The `PlatformContextMiddleware` reads the `Host` header to detect which platform the request is for. Caddy preserves the Host header by default, so no extra configuration is needed. All platform domains (`wizard.lu`, `omsflow.lu`, `rewardflow.lu`) point to the **same FastAPI backend** on port 8001. The `PlatformContextMiddleware` reads the `Host` header to detect which platform the request is for. Caddy preserves the Host header by default, so no extra configuration is needed.
The `domain` column in the `platforms` database table must match: The `domain` column in the `platforms` database table must match:
| Platform | code | domain | | Platform | code | domain |
|---|---|---| |---|---|---|
| Main | `main` | `wizard.lu` | | Main | `main` | `wizard.lu` |
| OMS | `oms` | `oms.lu` | | OMS | `oms` | `omsflow.lu` |
| Loyalty+ | `loyalty` | `rewardflow.lu` | | Loyalty+ | `loyalty` | `rewardflow.lu` |
Start Caddy: Start Caddy:
@@ -588,17 +588,17 @@ cd ~/gitea && docker compose up -d gitea
Stores on each platform use two routing modes: Stores on each platform use two routing modes:
- **Standard (subdomain)**: `acme.oms.lu` — included in the base subscription - **Standard (subdomain)**: `acme.omsflow.lu` — included in the base subscription
- **Premium (custom domain)**: `acme.lu` — available with premium subscription tiers - **Premium (custom domain)**: `acme.lu` — available with premium subscription tiers
Both modes are handled by the `StoreContextMiddleware` which reads the `Host` header, so Caddy just needs to forward requests and preserve the header. Both modes are handled by the `StoreContextMiddleware` which reads the `Host` header, so Caddy just needs to forward requests and preserve the header.
#### Wildcard Subdomains (for store subdomains) #### Wildcard Subdomains (for store subdomains)
When stores start using subdomains like `acme.oms.lu`, add wildcard blocks: When stores start using subdomains like `acme.omsflow.lu`, add wildcard blocks:
```caddy ```caddy
*.oms.lu { *.omsflow.lu {
reverse_proxy localhost:8001 reverse_proxy localhost:8001
} }
@@ -1099,7 +1099,7 @@ docker stats --no-stream
|---|---|---|---| |---|---|---|---|
| Orion API | 8000 | 8001 | `api.wizard.lu` | | Orion API | 8000 | 8001 | `api.wizard.lu` |
| Main Platform | 8000 | 8001 | `wizard.lu` | | Main Platform | 8000 | 8001 | `wizard.lu` |
| OMS Platform | 8000 | 8001 | `oms.lu` (TODO) | | OMS Platform | 8000 | 8001 | `omsflow.lu` (TODO) |
| Loyalty+ Platform | 8000 | 8001 | `rewardflow.lu` (TODO) | | Loyalty+ Platform | 8000 | 8001 | `rewardflow.lu` (TODO) |
| PostgreSQL | 5432 | 5432 | (internal only) | | PostgreSQL | 5432 | 5432 | (internal only) |
| Redis | 6379 | 6380 | (internal only) | | Redis | 6379 | 6380 | (internal only) |
@@ -1250,7 +1250,7 @@ After Caddy is configured:
| Gitea | `https://git.wizard.lu` | | Gitea | `https://git.wizard.lu` |
| Flower | `https://flower.wizard.lu` | | Flower | `https://flower.wizard.lu` |
| Grafana | `https://grafana.wizard.lu` | | Grafana | `https://grafana.wizard.lu` |
| OMS Platform | `https://oms.lu` (after DNS) | | OMS Platform | `https://omsflow.lu` (after DNS) |
| Loyalty+ Platform | `https://rewardflow.lu` (after DNS) | | Loyalty+ Platform | `https://rewardflow.lu` (after DNS) |
Direct IP access (temporary, until firewall rules are removed): Direct IP access (temporary, until firewall rules are removed):

View File

@@ -173,20 +173,20 @@ Login as a customer (e.g., `customer1@orion.example.com`).
--- ---
## Production URLs (loyalty.lu) ## Production URLs (rewardflow.lu)
In production, the platform uses **domain-based routing** instead of the `/platforms/loyalty/` path prefix. In production, the platform uses **domain-based routing** instead of the `/platforms/loyalty/` path prefix.
Store context is detected via **custom domains** (registered in `store_domains` table) Store context is detected via **custom domains** (registered in `store_domains` table)
or **subdomains** of `loyalty.lu` (from `Store.subdomain`). or **subdomains** of `rewardflow.lu` (from `Store.subdomain`).
### URL Routing Summary ### URL Routing Summary
| Routing mode | Priority | Pattern | Example | | Routing mode | Priority | Pattern | Example |
|-------------|----------|---------|---------| |-------------|----------|---------|---------|
| Platform domain | — | `loyalty.lu/...` | Admin pages, public API | | Platform domain | — | `rewardflow.lu/...` | Admin pages, public API |
| Store custom domain | 1 (highest) | `{custom_domain}/...` | Store with its own domain (overrides merchant domain) | | Store custom domain | 1 (highest) | `{custom_domain}/...` | Store with its own domain (overrides merchant domain) |
| Merchant domain | 2 | `{merchant_domain}/...` | All stores inherit merchant's domain | | Merchant domain | 2 | `{merchant_domain}/...` | All stores inherit merchant's domain |
| Store subdomain | 3 (fallback) | `{store_code}.loyalty.lu/...` | Default when no custom/merchant domain | | Store subdomain | 3 (fallback) | `{store_code}.rewardflow.lu/...` | Default when no custom/merchant domain |
!!! info "Domain Resolution Priority" !!! info "Domain Resolution Priority"
When a request arrives, the middleware resolves the store in this order: When a request arrives, the middleware resolves the store in this order:
@@ -295,58 +295,58 @@ store when the URL includes `/store/{store_code}/...`.
### Case 3: Store without custom domain (uses platform subdomain) ### Case 3: Store without custom domain (uses platform subdomain)
The store has no entry in `store_domains` and the merchant has no registered domain. The store has no entry in `store_domains` and the merchant has no registered domain.
**All** store URLs are served via a subdomain of the platform domain: `{store_code}.loyalty.lu`. **All** store URLs are served via a subdomain of the platform domain: `{store_code}.rewardflow.lu`.
**Storefront (customer-facing):** **Storefront (customer-facing):**
| Page | Production URL | | Page | Production URL |
|------|----------------| |------|----------------|
| Loyalty Dashboard | `https://bookstore.loyalty.lu/account/loyalty` | | Loyalty Dashboard | `https://bookstore.rewardflow.lu/account/loyalty` |
| Transaction History | `https://bookstore.loyalty.lu/account/loyalty/history` | | Transaction History | `https://bookstore.rewardflow.lu/account/loyalty/history` |
| Self-Enrollment | `https://bookstore.loyalty.lu/loyalty/join` | | Self-Enrollment | `https://bookstore.rewardflow.lu/loyalty/join` |
| Enrollment Success | `https://bookstore.loyalty.lu/loyalty/join/success` | | Enrollment Success | `https://bookstore.rewardflow.lu/loyalty/join/success` |
**Storefront API:** **Storefront API:**
| Method | Production URL | | Method | Production URL |
|--------|----------------| |--------|----------------|
| GET card | `https://bookstore.loyalty.lu/api/storefront/loyalty/card` | | GET card | `https://bookstore.rewardflow.lu/api/storefront/loyalty/card` |
| GET transactions | `https://bookstore.loyalty.lu/api/storefront/loyalty/transactions` | | GET transactions | `https://bookstore.rewardflow.lu/api/storefront/loyalty/transactions` |
| POST enroll | `https://bookstore.loyalty.lu/api/storefront/loyalty/enroll` | | POST enroll | `https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` |
| GET program | `https://bookstore.loyalty.lu/api/storefront/loyalty/program` | | GET program | `https://bookstore.rewardflow.lu/api/storefront/loyalty/program` |
**Store backend (staff/owner):** **Store backend (staff/owner):**
| Page | Production URL | | Page | Production URL |
|------|----------------| |------|----------------|
| Store Login | `https://bookstore.loyalty.lu/store/BOOKSTORE/login` | | Store Login | `https://bookstore.rewardflow.lu/store/BOOKSTORE/login` |
| Terminal | `https://bookstore.loyalty.lu/store/BOOKSTORE/loyalty/terminal` | | Terminal | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/terminal` |
| Cards | `https://bookstore.loyalty.lu/store/BOOKSTORE/loyalty/cards` | | Cards | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/cards` |
| Settings | `https://bookstore.loyalty.lu/store/BOOKSTORE/loyalty/settings` | | Settings | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/settings` |
| Stats | `https://bookstore.loyalty.lu/store/BOOKSTORE/loyalty/stats` | | Stats | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/stats` |
**Store API:** **Store API:**
| Method | Production URL | | Method | Production URL |
|--------|----------------| |--------|----------------|
| GET program | `https://bookstore.loyalty.lu/api/store/loyalty/program` | | GET program | `https://bookstore.rewardflow.lu/api/store/loyalty/program` |
| POST stamp | `https://bookstore.loyalty.lu/api/store/loyalty/stamp` | | POST stamp | `https://bookstore.rewardflow.lu/api/store/loyalty/stamp` |
| POST points | `https://bookstore.loyalty.lu/api/store/loyalty/points` | | POST points | `https://bookstore.rewardflow.lu/api/store/loyalty/points` |
| POST enroll | `https://bookstore.loyalty.lu/api/store/loyalty/cards/enroll` | | POST enroll | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/enroll` |
| POST lookup | `https://bookstore.loyalty.lu/api/store/loyalty/cards/lookup` | | POST lookup | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/lookup` |
### Platform Admin & Public API (always on platform domain) ### Platform Admin & Public API (always on platform domain)
| Page / Endpoint | Production URL | | Page / Endpoint | Production URL |
|-----------------|----------------| |-----------------|----------------|
| Admin Programs | `https://loyalty.lu/admin/loyalty/programs` | | Admin Programs | `https://rewardflow.lu/admin/loyalty/programs` |
| Admin Analytics | `https://loyalty.lu/admin/loyalty/analytics` | | Admin Analytics | `https://rewardflow.lu/admin/loyalty/analytics` |
| Admin Merchant Detail | `https://loyalty.lu/admin/loyalty/merchants/{id}` | | Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` |
| Admin Merchant Settings | `https://loyalty.lu/admin/loyalty/merchants/{id}/settings` | | Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` |
| Admin API - Programs | `GET https://loyalty.lu/api/admin/loyalty/programs` | | Admin API - Programs | `GET https://rewardflow.lu/api/admin/loyalty/programs` |
| Admin API - Stats | `GET https://loyalty.lu/api/admin/loyalty/stats` | | Admin API - Stats | `GET https://rewardflow.lu/api/admin/loyalty/stats` |
| Public API - Program | `GET https://loyalty.lu/api/loyalty/programs/ORION` | | Public API - Program | `GET https://rewardflow.lu/api/loyalty/programs/ORION` |
| Apple Wallet Pass | `GET https://loyalty.lu/api/loyalty/passes/apple/{serial}.pkpass` | | Apple Wallet Pass | `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial}.pkpass` |
### Domain configuration per store (current DB state) ### Domain configuration per store (current DB state)
@@ -364,11 +364,11 @@ The store has no entry in `store_domains` and the merchant has no registered dom
|-------|----------|---------------------|------------------| |-------|----------|---------------------|------------------|
| ORION | WizaCorp | `orion.shop` | `orion.shop` (store override) | | ORION | WizaCorp | `orion.shop` | `orion.shop` (store override) |
| FASHIONHUB | Fashion Group | `fashionhub.store` | `fashionhub.store` (store override) | | FASHIONHUB | Fashion Group | `fashionhub.store` | `fashionhub.store` (store override) |
| WIZAGADGETS | WizaCorp | _(none)_ | `wizagadgets.loyalty.lu` (subdomain fallback) | | WIZAGADGETS | WizaCorp | _(none)_ | `wizagadgets.rewardflow.lu` (subdomain fallback) |
| WIZAHOME | WizaCorp | _(none)_ | `wizahome.loyalty.lu` (subdomain fallback) | | WIZAHOME | WizaCorp | _(none)_ | `wizahome.rewardflow.lu` (subdomain fallback) |
| FASHIONOUTLET | Fashion Group | _(none)_ | `fashionoutlet.loyalty.lu` (subdomain fallback) | | FASHIONOUTLET | Fashion Group | _(none)_ | `fashionoutlet.rewardflow.lu` (subdomain fallback) |
| BOOKSTORE | BookWorld | _(none)_ | `bookstore.loyalty.lu` (subdomain fallback) | | BOOKSTORE | BookWorld | _(none)_ | `bookstore.rewardflow.lu` (subdomain fallback) |
| BOOKDIGITAL | BookWorld | _(none)_ | `bookdigital.loyalty.lu` (subdomain fallback) | | BOOKDIGITAL | BookWorld | _(none)_ | `bookdigital.rewardflow.lu` (subdomain fallback) |
!!! example "After merchant domain registration" !!! example "After merchant domain registration"
If WizaCorp registers `myloyaltyprogram.lu` as their merchant domain, the table becomes: If WizaCorp registers `myloyaltyprogram.lu` as their merchant domain, the table becomes:
@@ -384,7 +384,7 @@ The store has no entry in `store_domains` and the merchant has no registered dom
1. **Store custom domain**: `orion.shop` (from `store_domains` table) — highest priority 1. **Store custom domain**: `orion.shop` (from `store_domains` table) — highest priority
2. **Merchant domain**: `myloyaltyprogram.lu` (from `merchant_domains` table) — inherited default 2. **Merchant domain**: `myloyaltyprogram.lu` (from `merchant_domains` table) — inherited default
3. **Subdomain fallback**: `orion.loyalty.lu` (from `Store.subdomain` + platform domain) 3. **Subdomain fallback**: `orion.rewardflow.lu` (from `Store.subdomain` + platform domain)
--- ---
@@ -420,7 +420,7 @@ flowchart TD
1. Login as `john.owner@wizacorp.com` and navigate to billing: 1. Login as `john.owner@wizacorp.com` and navigate to billing:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/billing` - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/billing`
- Prod (custom domain): `https://orion.shop/store/ORION/billing` - Prod (custom domain): `https://orion.shop/store/ORION/billing`
- Prod (subdomain): `https://orion.loyalty.lu/store/ORION/billing` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/billing`
2. View available subscription tiers: 2. View available subscription tiers:
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/tiers` - API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/tiers`
- API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers` - API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers`
@@ -441,19 +441,19 @@ flowchart TD
1. Platform admin registers a merchant domain: 1. Platform admin registers a merchant domain:
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains` - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains`
- API Prod: `POST https://loyalty.lu/api/v1/admin/merchants/{merchant_id}/domains` - API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/{merchant_id}/domains`
- Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}` - Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}`
2. The API returns a `verification_token` for DNS verification 2. The API returns a `verification_token` for DNS verification
3. Get DNS verification instructions: 3. Get DNS verification instructions:
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` - API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
- API Prod: `GET https://loyalty.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` - API Prod: `GET https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
4. Merchant adds a DNS TXT record: `_orion-verify.myloyaltyprogram.lu TXT {verification_token}` 4. Merchant adds a DNS TXT record: `_orion-verify.myloyaltyprogram.lu TXT {verification_token}`
5. Verify the domain: 5. Verify the domain:
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
- API Prod: `POST https://loyalty.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` - API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
6. Activate the domain: 6. Activate the domain:
- API Dev: `PUT http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}` - API Dev: `PUT http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}`
- API Prod: `PUT https://loyalty.lu/api/v1/admin/merchants/domains/merchant/{domain_id}` - API Prod: `PUT https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}`
- Body: `{"is_active": true}` - Body: `{"is_active": true}`
7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain 7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain
@@ -463,7 +463,7 @@ If a store needs its own domain (e.g., ORION is a major brand and wants `mysuper
1. Platform admin registers a store domain: 1. Platform admin registers a store domain:
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/stores/{store_id}/domains` - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/stores/{store_id}/domains`
- API Prod: `POST https://loyalty.lu/api/v1/admin/stores/{store_id}/domains` - API Prod: `POST https://rewardflow.lu/api/v1/admin/stores/{store_id}/domains`
- Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}` - Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}`
2. Follow the same DNS verification and activation flow as merchant domains 2. Follow the same DNS verification and activation flow as merchant domains
3. Once active, this store's effective domain becomes `mysuperloyaltyprogram.lu` (overrides merchant domain) 3. Once active, this store's effective domain becomes `mysuperloyaltyprogram.lu` (overrides merchant domain)
@@ -509,11 +509,11 @@ flowchart TD
1. Login as `john.owner@wizacorp.com` at: 1. Login as `john.owner@wizacorp.com` at:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/login` - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/login`
- Prod (custom domain): `https://orion.shop/store/ORION/login` - Prod (custom domain): `https://orion.shop/store/ORION/login`
- Prod (subdomain): `https://orion.loyalty.lu/store/ORION/login` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/login`
2. Navigate to loyalty settings: 2. Navigate to loyalty settings:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings`
- Prod (custom domain): `https://orion.shop/store/ORION/loyalty/settings` - Prod (custom domain): `https://orion.shop/store/ORION/loyalty/settings`
- Prod (subdomain): `https://orion.loyalty.lu/store/ORION/loyalty/settings` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/loyalty/settings`
3. Create a new loyalty program: 3. Create a new loyalty program:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/program` - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/program`
- Prod: `POST https://{store_domain}/api/store/loyalty/program` - Prod: `POST https://{store_domain}/api/store/loyalty/program`
@@ -526,7 +526,7 @@ flowchart TD
- Prod: `POST https://{store_domain}/api/store/loyalty/pins` - Prod: `POST https://{store_domain}/api/store/loyalty/pins`
9. Verify program is live - check from another store (same merchant): 9. Verify program is live - check from another store (same merchant):
- Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/settings` - Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/settings`
- Prod (subdomain): `https://wizagadgets.loyalty.lu/store/WIZAGADGETS/loyalty/settings` - Prod (subdomain): `https://wizagadgets.rewardflow.lu/store/WIZAGADGETS/loyalty/settings`
**Expected blockers in current state:** **Expected blockers in current state:**
@@ -649,19 +649,19 @@ flowchart TD
1. Visit the public enrollment page: 1. Visit the public enrollment page:
- Dev: `http://localhost:9999/platforms/loyalty/loyalty/join` - Dev: `http://localhost:9999/platforms/loyalty/loyalty/join`
- Prod (custom domain): `https://orion.shop/loyalty/join` - Prod (custom domain): `https://orion.shop/loyalty/join`
- Prod (subdomain): `https://bookstore.loyalty.lu/loyalty/join` - Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join`
2. Fill in enrollment form (email, name) 2. Fill in enrollment form (email, name)
3. Submit enrollment: 3. Submit enrollment:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` - Dev: `POST http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll`
- Prod (custom domain): `POST https://orion.shop/api/storefront/loyalty/enroll` - Prod (custom domain): `POST https://orion.shop/api/storefront/loyalty/enroll`
- Prod (subdomain): `POST https://bookstore.loyalty.lu/api/storefront/loyalty/enroll` - Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll`
4. Redirected to success page: 4. Redirected to success page:
- Dev: `http://localhost:9999/platforms/loyalty/loyalty/join/success?card=XXXX-XXXX-XXXX` - Dev: `http://localhost:9999/platforms/loyalty/loyalty/join/success?card=XXXX-XXXX-XXXX`
- Prod (custom domain): `https://orion.shop/loyalty/join/success?card=XXXX-XXXX-XXXX` - Prod (custom domain): `https://orion.shop/loyalty/join/success?card=XXXX-XXXX-XXXX`
- Prod (subdomain): `https://bookstore.loyalty.lu/loyalty/join/success?card=XXXX-XXXX-XXXX` - Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join/success?card=XXXX-XXXX-XXXX`
5. Optionally download Apple Wallet pass: 5. Optionally download Apple Wallet pass:
- Dev: `GET http://localhost:9999/platforms/loyalty/api/loyalty/passes/apple/{serial_number}.pkpass` - Dev: `GET http://localhost:9999/platforms/loyalty/api/loyalty/passes/apple/{serial_number}.pkpass`
- Prod: `GET https://loyalty.lu/api/loyalty/passes/apple/{serial_number}.pkpass` - Prod: `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial_number}.pkpass`
--- ---
@@ -676,13 +676,13 @@ flowchart TD
2. View loyalty dashboard (card balance, available rewards): 2. View loyalty dashboard (card balance, available rewards):
- Dev: `http://localhost:9999/platforms/loyalty/account/loyalty` - Dev: `http://localhost:9999/platforms/loyalty/account/loyalty`
- Prod (custom domain): `https://orion.shop/account/loyalty` - Prod (custom domain): `https://orion.shop/account/loyalty`
- Prod (subdomain): `https://bookstore.loyalty.lu/account/loyalty` - Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty`
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` - API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card`
- API Prod: `GET https://orion.shop/api/storefront/loyalty/card` - API Prod: `GET https://orion.shop/api/storefront/loyalty/card`
3. View full transaction history: 3. View full transaction history:
- Dev: `http://localhost:9999/platforms/loyalty/account/loyalty/history` - Dev: `http://localhost:9999/platforms/loyalty/account/loyalty/history`
- Prod (custom domain): `https://orion.shop/account/loyalty/history` - Prod (custom domain): `https://orion.shop/account/loyalty/history`
- Prod (subdomain): `https://bookstore.loyalty.lu/account/loyalty/history` - Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty/history`
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` - API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions`
- API Prod: `GET https://orion.shop/api/storefront/loyalty/transactions` - API Prod: `GET https://orion.shop/api/storefront/loyalty/transactions`
@@ -698,22 +698,22 @@ flowchart TD
1. Login as admin 1. Login as admin
2. View all programs: 2. View all programs:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/programs`
- Prod: `https://loyalty.lu/admin/loyalty/programs` - Prod: `https://rewardflow.lu/admin/loyalty/programs`
3. View platform-wide analytics: 3. View platform-wide analytics:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics`
- Prod: `https://loyalty.lu/admin/loyalty/analytics` - Prod: `https://rewardflow.lu/admin/loyalty/analytics`
4. Drill into WizaCorp's program: 4. Drill into WizaCorp's program:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1`
- Prod: `https://loyalty.lu/admin/loyalty/merchants/1` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1`
5. Manage WizaCorp's merchant-level settings: 5. Manage WizaCorp's merchant-level settings:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings`
- Prod: `https://loyalty.lu/admin/loyalty/merchants/1/settings` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
- API Dev: `PATCH http://localhost:9999/platforms/loyalty/api/admin/loyalty/merchants/1/settings` - API Dev: `PATCH http://localhost:9999/platforms/loyalty/api/admin/loyalty/merchants/1/settings`
- API Prod: `PATCH https://loyalty.lu/api/admin/loyalty/merchants/1/settings` - API Prod: `PATCH https://rewardflow.lu/api/admin/loyalty/merchants/1/settings`
6. Adjust settings: PIN policy, self-enrollment toggle, void permissions 6. Adjust settings: PIN policy, self-enrollment toggle, void permissions
7. Check other merchants: 7. Check other merchants:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2`
- Prod: `https://loyalty.lu/admin/loyalty/merchants/2` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/2`
--- ---
@@ -752,7 +752,7 @@ flowchart TD
**Precondition:** Cross-location redemption must be enabled in merchant settings: **Precondition:** Cross-location redemption must be enabled in merchant settings:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings`
- Prod: `https://loyalty.lu/admin/loyalty/merchants/1/settings` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
**Steps:** **Steps:**

View File

@@ -521,8 +521,8 @@ async def store_root_path(
# #
# URL Structure (Production - domain-based): # URL Structure (Production - domain-based):
# - orion.lu/ → Main marketing site # - orion.lu/ → Main marketing site
# - oms.lu/ → OMS platform homepage # - omsflow.lu/ → OMS platform homepage
# - loyalty.lu/ → Loyalty platform homepage # - rewardflow.lu/ → Loyalty platform homepage
# ============================================================================ # ============================================================================

View File

@@ -6,7 +6,7 @@ Detects platform from host/domain/path and injects into request.state.
This middleware runs BEFORE StoreContextMiddleware to establish platform context. This middleware runs BEFORE StoreContextMiddleware to establish platform context.
Handles two routing modes: Handles two routing modes:
1. Production: Domain-based (oms.lu, loyalty.lu → Platform detection) 1. Production: Domain-based (omsflow.lu, rewardflow.lu → Platform detection)
2. Development: Path-based (localhost:9999/platforms/oms/*, localhost:9999/platforms/loyalty/*) 2. Development: Path-based (localhost:9999/platforms/oms/*, localhost:9999/platforms/loyalty/*)
URL Structure: URL Structure:
@@ -42,7 +42,7 @@ class PlatformContextManager:
Detect platform context from request. Detect platform context from request.
Priority order: Priority order:
1. Domain-based (production): oms.lu → platform code "oms" 1. Domain-based (production): omsflow.lu → platform code "oms"
2. Path-based (development): localhost:9999/platforms/oms/* → platform code "oms" 2. Path-based (development): localhost:9999/platforms/oms/* → platform code "oms"
3. Default: localhost without /platforms/ prefix → 'main' platform (marketing site) 3. Default: localhost without /platforms/ prefix → 'main' platform (marketing site)
@@ -74,12 +74,12 @@ class PlatformContextManager:
# For now, assume non-localhost hosts that aren't subdomains are platform domains # For now, assume non-localhost hosts that aren't subdomains are platform domains
if "." in host_without_port: if "." in host_without_port:
# This could be: # This could be:
# - Platform domain: oms.lu, loyalty.lu # - Platform domain: omsflow.lu, rewardflow.lu
# - Store subdomain: store.oms.lu # - Store subdomain: store.omsflow.lu
# - Custom domain: shop.mymerchant.com # - Custom domain: shop.mymerchant.com
# We detect platform domain vs subdomain by checking if it's a root domain # We detect platform domain vs subdomain by checking if it's a root domain
parts = host_without_port.split(".") parts = host_without_port.split(".")
if len(parts) == 2: # e.g., oms.lu (root domain) if len(parts) == 2: # e.g., omsflow.lu (root domain)
return { return {
"domain": host_without_port, "domain": host_without_port,
"detection_method": "domain", "detection_method": "domain",
@@ -409,7 +409,7 @@ class PlatformContextMiddleware:
if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]: if host_without_port and host_without_port not in ["localhost", "127.0.0.1"]:
if "." in host_without_port: if "." in host_without_port:
parts = host_without_port.split(".") parts = host_without_port.split(".")
if len(parts) == 2: # Root domain like oms.lu if len(parts) == 2: # Root domain like omsflow.lu
return { return {
"domain": host_without_port, "domain": host_without_port,
"detection_method": "domain", "detection_method": "domain",

View File

@@ -37,7 +37,7 @@ from app.core.config import (
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.core.environment import is_production from app.core.environment import is_production
from app.modules.billing.models.subscription import SubscriptionTier from app.modules.billing.models.subscription import SubscriptionTier
from app.modules.tenancy.models import AdminSetting, Platform, User from app.modules.tenancy.models import AdminSetting, Platform, PlatformModule, User
from app.modules.tenancy.services.permission_discovery_service import ( from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service, permission_discovery_service,
) )
@@ -140,7 +140,7 @@ def create_default_platforms(db: Session) -> list[Platform]:
"code": "oms", "code": "oms",
"name": "Orion OMS", "name": "Orion OMS",
"description": "Order Management System for multi-store e-commerce", "description": "Order Management System for multi-store e-commerce",
"domain": "oms.lu", "domain": "omsflow.lu",
"path_prefix": "oms", "path_prefix": "oms",
"default_language": "fr", "default_language": "fr",
"supported_languages": ["fr", "de", "en"], "supported_languages": ["fr", "de", "en"],
@@ -447,6 +447,50 @@ def create_subscription_tiers(db: Session, platform: Platform) -> int:
return tiers_created return tiers_created
def create_platform_modules(db: Session, platforms: list[Platform]) -> int:
"""Create PlatformModule records for all platforms.
Enables all discovered modules for each platform so the app works
out of the box. Admins can disable optional modules later via the API.
"""
from app.modules.registry import MODULES
now = datetime.now(UTC)
records_created = 0
for platform in platforms:
for code in MODULES:
# Check if record already exists
existing = db.execute(
select(PlatformModule).where(
PlatformModule.platform_id == platform.id,
PlatformModule.module_code == code,
)
).scalar_one_or_none()
if existing:
continue
pm = PlatformModule(
platform_id=platform.id,
module_code=code,
is_enabled=True,
enabled_at=now,
config={},
)
db.add(pm)
records_created += 1
db.flush()
if records_created > 0:
print_success(f"Created {records_created} platform module records")
else:
print_warning("Platform module records already exist")
return records_created
def verify_rbac_schema(db: Session) -> bool: def verify_rbac_schema(db: Session) -> bool:
"""Verify that RBAC schema is in place.""" """Verify that RBAC schema is in place."""
@@ -531,6 +575,10 @@ def initialize_production(db: Session, auth_manager: AuthManager):
else: else:
print_warning("OMS platform not found, skipping tier seeding") print_warning("OMS platform not found, skipping tier seeding")
# Step 7: Create platform module records
print_step(7, "Creating platform module records...")
create_platform_modules(db, platforms)
# Commit all changes # Commit all changes
db.commit() db.commit()
print_success("All changes committed") print_success("All changes committed")
@@ -546,10 +594,12 @@ def print_summary(db: Session):
setting_count = db.query(AdminSetting).count() setting_count = db.query(AdminSetting).count()
platform_count = db.query(Platform).count() platform_count = db.query(Platform).count()
tier_count = db.query(SubscriptionTier).filter(SubscriptionTier.is_active.is_(True)).count() tier_count = db.query(SubscriptionTier).filter(SubscriptionTier.is_active.is_(True)).count()
module_count = db.query(PlatformModule).count()
print("\n📊 Database Status:") print("\n📊 Database Status:")
print(f" Admin users: {user_count}") print(f" Admin users: {user_count}")
print(f" Platforms: {platform_count}") print(f" Platforms: {platform_count}")
print(f" Platform mods: {module_count}")
print(f" Admin settings: {setting_count}") print(f" Admin settings: {setting_count}")
print(f" Sub. tiers: {tier_count}") print(f" Sub. tiers: {tier_count}")

View File

@@ -49,7 +49,7 @@ def test_admin(db, auth_manager):
hashed_password=hashed_password, hashed_password=hashed_password,
role="admin", role="admin",
is_active=True, is_active=True,
is_super_admin=True, # Default to super admin for backward compatibility is_super_admin=True, # Full platform access
) )
db.add(admin) db.add(admin)
db.commit() db.commit()
@@ -130,7 +130,7 @@ def another_admin(db, auth_manager):
hashed_password=hashed_password, hashed_password=hashed_password,
role="admin", role="admin",
is_active=True, is_active=True,
is_super_admin=True, # Super admin for backward compatibility is_super_admin=True, # Full platform access
) )
db.add(admin) db.add(admin)
db.commit() db.commit()

View File

@@ -192,7 +192,6 @@ def test_inventory(db, test_product):
store_id=test_product.store_id, store_id=test_product.store_id,
warehouse="strassen", warehouse="strassen",
bin_location=f"SA-10-{unique_id[:2]}", bin_location=f"SA-10-{unique_id[:2]}",
location=f"WAREHOUSE_A_{unique_id}",
quantity=100, quantity=100,
reserved_quantity=10, reserved_quantity=10,
gtin=test_product.marketplace_product.gtin, gtin=test_product.marketplace_product.gtin,
@@ -213,7 +212,6 @@ def multiple_inventory_entries(db, multiple_products, test_store):
gtin=product.gtin, gtin=product.gtin,
warehouse="strassen", warehouse="strassen",
bin_location=f"SA-{i:02d}-01", bin_location=f"SA-{i:02d}-01",
location=f"LOC_{i}",
quantity=10 + (i * 5), quantity=10 + (i * 5),
reserved_quantity=i, reserved_quantity=i,
store_id=test_store.id, store_id=test_store.id,

View File

@@ -7,7 +7,7 @@ requests, including:
- Merchant domain → platform resolved → store resolved - Merchant domain → platform resolved → store resolved
- Store-specific domain overrides merchant domain - Store-specific domain overrides merchant domain
- Merchant domain resolves to first active store - Merchant domain resolves to first active store
- Existing StoreDomain and subdomain routing still work (backward compatibility) - Existing StoreDomain and subdomain routing still work
""" """
import uuid import uuid
@@ -95,7 +95,7 @@ class TestMerchantDomainFlow:
assert data["store_code"] == store.store_code assert data["store_code"] == store.store_code
def test_subdomain_routing_still_works(self, client, store_with_subdomain): def test_subdomain_routing_still_works(self, client, store_with_subdomain):
"""Test backward compatibility: subdomain routing still works.""" """Test that subdomain routing still works."""
response = client.get( response = client.get(
"/middleware-test/subdomain-detection", "/middleware-test/subdomain-detection",
headers={ headers={

View File

@@ -12,7 +12,7 @@ Tests cover:
import pytest import pytest
from app.core.frontend_detector import FrontendDetector, get_frontend_type from app.core.frontend_detector import FrontendDetector
from app.modules.enums import FrontendType from app.modules.enums import FrontendType
@@ -22,7 +22,7 @@ class TestFrontendDetectorAdmin:
def test_detect_admin_from_subdomain(self): def test_detect_admin_from_subdomain(self):
"""Test admin detection from admin subdomain.""" """Test admin detection from admin subdomain."""
result = FrontendDetector.detect(host="admin.oms.lu", path="/dashboard") result = FrontendDetector.detect(host="admin.omsflow.lu", path="/dashboard")
assert result == FrontendType.ADMIN assert result == FrontendType.ADMIN
def test_detect_admin_from_subdomain_with_port(self): def test_detect_admin_from_subdomain_with_port(self):
@@ -42,7 +42,7 @@ class TestFrontendDetectorAdmin:
def test_detect_admin_nested_path(self): def test_detect_admin_nested_path(self):
"""Test admin detection with nested admin path.""" """Test admin detection with nested admin path."""
result = FrontendDetector.detect(host="oms.lu", path="/admin/stores/123/products") result = FrontendDetector.detect(host="omsflow.lu", path="/admin/stores/123/products")
assert result == FrontendType.ADMIN assert result == FrontendType.ADMIN
@@ -62,7 +62,7 @@ class TestFrontendDetectorStore:
def test_detect_store_nested_path(self): def test_detect_store_nested_path(self):
"""Test store detection with nested store path.""" """Test store detection with nested store path."""
result = FrontendDetector.detect(host="oms.lu", path="/store/dashboard/analytics") result = FrontendDetector.detect(host="omsflow.lu", path="/store/dashboard/analytics")
assert result == FrontendType.STORE assert result == FrontendType.STORE
def test_stores_plural_not_store_dashboard(self): def test_stores_plural_not_store_dashboard(self):
@@ -92,7 +92,7 @@ class TestFrontendDetectorStorefront:
def test_detect_storefront_from_store_subdomain(self): def test_detect_storefront_from_store_subdomain(self):
"""Test storefront detection from store subdomain.""" """Test storefront detection from store subdomain."""
result = FrontendDetector.detect(host="orion.oms.lu", path="/products") result = FrontendDetector.detect(host="orion.omsflow.lu", path="/products")
assert result == FrontendType.STOREFRONT assert result == FrontendType.STOREFRONT
def test_detect_storefront_from_store_context(self): def test_detect_storefront_from_store_context(self):
@@ -115,7 +115,7 @@ class TestFrontendDetectorPlatform:
def test_detect_platform_from_marketing_page(self): def test_detect_platform_from_marketing_page(self):
"""Test platform detection from marketing page.""" """Test platform detection from marketing page."""
result = FrontendDetector.detect(host="oms.lu", path="/pricing") result = FrontendDetector.detect(host="omsflow.lu", path="/pricing")
assert result == FrontendType.PLATFORM assert result == FrontendType.PLATFORM
def test_detect_platform_from_about(self): def test_detect_platform_from_about(self):
@@ -135,7 +135,7 @@ class TestFrontendDetectorPriority:
def test_admin_subdomain_priority_over_path(self): def test_admin_subdomain_priority_over_path(self):
"""Test that admin subdomain takes priority.""" """Test that admin subdomain takes priority."""
result = FrontendDetector.detect(host="admin.oms.lu", path="/storefront/products") result = FrontendDetector.detect(host="admin.omsflow.lu", path="/storefront/products")
assert result == FrontendType.ADMIN assert result == FrontendType.ADMIN
def test_admin_path_priority_over_store_context(self): def test_admin_path_priority_over_store_context(self):
@@ -148,7 +148,7 @@ class TestFrontendDetectorPriority:
def test_path_priority_over_subdomain(self): def test_path_priority_over_subdomain(self):
"""Test that explicit path takes priority for store/storefront.""" """Test that explicit path takes priority for store/storefront."""
# /store/ path on a store subdomain -> STORE (path wins) # /store/ path on a store subdomain -> STORE (path wins)
result = FrontendDetector.detect(host="orion.oms.lu", path="/store/settings") result = FrontendDetector.detect(host="orion.omsflow.lu", path="/store/settings")
assert result == FrontendType.STORE assert result == FrontendType.STORE
@@ -159,20 +159,20 @@ class TestFrontendDetectorHelpers:
def test_strip_port(self): def test_strip_port(self):
"""Test port stripping from host.""" """Test port stripping from host."""
assert FrontendDetector._strip_port("localhost:8000") == "localhost" assert FrontendDetector._strip_port("localhost:8000") == "localhost"
assert FrontendDetector._strip_port("oms.lu") == "oms.lu" assert FrontendDetector._strip_port("omsflow.lu") == "omsflow.lu"
assert FrontendDetector._strip_port("admin.localhost:9999") == "admin.localhost" assert FrontendDetector._strip_port("admin.localhost:9999") == "admin.localhost"
def test_get_subdomain(self): def test_get_subdomain(self):
"""Test subdomain extraction.""" """Test subdomain extraction."""
assert FrontendDetector._get_subdomain("orion.oms.lu") == "orion" assert FrontendDetector._get_subdomain("orion.omsflow.lu") == "orion"
assert FrontendDetector._get_subdomain("admin.oms.lu") == "admin" assert FrontendDetector._get_subdomain("admin.omsflow.lu") == "admin"
assert FrontendDetector._get_subdomain("oms.lu") is None assert FrontendDetector._get_subdomain("omsflow.lu") is None
assert FrontendDetector._get_subdomain("localhost") is None assert FrontendDetector._get_subdomain("localhost") is None
assert FrontendDetector._get_subdomain("127.0.0.1") is None assert FrontendDetector._get_subdomain("127.0.0.1") is None
def test_is_admin(self): def test_is_admin(self):
"""Test is_admin convenience method.""" """Test is_admin convenience method."""
assert FrontendDetector.is_admin("admin.oms.lu", "/dashboard") is True assert FrontendDetector.is_admin("admin.omsflow.lu", "/dashboard") is True
assert FrontendDetector.is_admin("localhost", "/admin/stores") is True assert FrontendDetector.is_admin("localhost", "/admin/stores") is True
assert FrontendDetector.is_admin("localhost", "/store/settings") is False assert FrontendDetector.is_admin("localhost", "/store/settings") is False
@@ -185,13 +185,13 @@ class TestFrontendDetectorHelpers:
def test_is_storefront(self): def test_is_storefront(self):
"""Test is_storefront convenience method.""" """Test is_storefront convenience method."""
assert FrontendDetector.is_storefront("localhost", "/storefront/products") is True assert FrontendDetector.is_storefront("localhost", "/storefront/products") is True
assert FrontendDetector.is_storefront("orion.oms.lu", "/products") is True assert FrontendDetector.is_storefront("orion.omsflow.lu", "/products") is True
assert FrontendDetector.is_storefront("localhost", "/admin/dashboard") is False assert FrontendDetector.is_storefront("localhost", "/admin/dashboard") is False
def test_is_platform(self): def test_is_platform(self):
"""Test is_platform convenience method.""" """Test is_platform convenience method."""
assert FrontendDetector.is_platform("localhost", "/") is True assert FrontendDetector.is_platform("localhost", "/") is True
assert FrontendDetector.is_platform("oms.lu", "/pricing") is True assert FrontendDetector.is_platform("omsflow.lu", "/pricing") is True
assert FrontendDetector.is_platform("localhost", "/admin/dashboard") is False assert FrontendDetector.is_platform("localhost", "/admin/dashboard") is False
def test_is_api_request(self): def test_is_api_request(self):
@@ -201,46 +201,21 @@ class TestFrontendDetectorHelpers:
assert FrontendDetector.is_api_request("/admin/dashboard") is False assert FrontendDetector.is_api_request("/admin/dashboard") is False
@pytest.mark.unit
class TestGetFrontendTypeFunction:
"""Test suite for get_frontend_type convenience function."""
def test_get_frontend_type_admin(self):
"""Test get_frontend_type returns admin."""
result = get_frontend_type("localhost", "/admin/dashboard")
assert result == FrontendType.ADMIN
def test_get_frontend_type_store(self):
"""Test get_frontend_type returns store."""
result = get_frontend_type("localhost", "/store/settings")
assert result == FrontendType.STORE
def test_get_frontend_type_storefront(self):
"""Test get_frontend_type returns storefront."""
result = get_frontend_type("localhost", "/storefront/products")
assert result == FrontendType.STOREFRONT
def test_get_frontend_type_platform(self):
"""Test get_frontend_type returns platform."""
result = get_frontend_type("localhost", "/pricing")
assert result == FrontendType.PLATFORM
@pytest.mark.unit @pytest.mark.unit
class TestReservedSubdomains: class TestReservedSubdomains:
"""Test suite for reserved subdomain handling.""" """Test suite for reserved subdomain handling."""
def test_www_subdomain_not_storefront(self): def test_www_subdomain_not_storefront(self):
"""Test that www subdomain is not treated as store storefront.""" """Test that www subdomain is not treated as store storefront."""
result = FrontendDetector.detect(host="www.oms.lu", path="/") result = FrontendDetector.detect(host="www.omsflow.lu", path="/")
assert result == FrontendType.PLATFORM assert result == FrontendType.PLATFORM
def test_api_subdomain_not_storefront(self): def test_api_subdomain_not_storefront(self):
"""Test that api subdomain is not treated as store storefront.""" """Test that api subdomain is not treated as store storefront."""
result = FrontendDetector.detect(host="api.oms.lu", path="/v1/products") result = FrontendDetector.detect(host="api.omsflow.lu", path="/v1/products")
assert result == FrontendType.PLATFORM assert result == FrontendType.PLATFORM
def test_portal_subdomain_not_storefront(self): def test_portal_subdomain_not_storefront(self):
"""Test that portal subdomain is not treated as store storefront.""" """Test that portal subdomain is not treated as store storefront."""
result = FrontendDetector.detect(host="portal.oms.lu", path="/") result = FrontendDetector.detect(host="portal.omsflow.lu", path="/")
assert result == FrontendType.PLATFORM assert result == FrontendType.PLATFORM

View File

@@ -80,7 +80,7 @@ class TestFrontendTypeMiddleware:
request = Mock(spec=Request) request = Mock(spec=Request)
request.url = Mock(path="/products") request.url = Mock(path="/products")
request.headers = {"host": "orion.oms.lu"} request.headers = {"host": "orion.omsflow.lu"}
mock_store = Mock() mock_store = Mock()
mock_store.name = "Test Store" mock_store.name = "Test Store"
request.state = Mock(clean_path="/products", store=mock_store) request.state = Mock(clean_path="/products", store=mock_store)

View File

@@ -13,7 +13,7 @@ Tests cover:
URL Structure: URL Structure:
- Main marketing site: localhost:9999/ (no prefix) -> 'main' platform - Main marketing site: localhost:9999/ (no prefix) -> 'main' platform
- Platform sites: localhost:9999/platforms/{code}/ -> specific platform - Platform sites: localhost:9999/platforms/{code}/ -> specific platform
- Production: domain-based (oms.lu, loyalty.lu) - Production: domain-based (omsflow.lu, rewardflow.lu)
""" """
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
@@ -42,35 +42,35 @@ class TestPlatformContextManager:
# ======================================================================== # ========================================================================
def test_detect_domain_based_platform(self): def test_detect_domain_based_platform(self):
"""Test domain-based platform detection for production (e.g., oms.lu).""" """Test domain-based platform detection for production (e.g., omsflow.lu)."""
request = Mock(spec=Request) request = Mock(spec=Request)
request.headers = {"host": "oms.lu"} request.headers = {"host": "omsflow.lu"}
request.url = Mock(path="/pricing") request.url = Mock(path="/pricing")
context = PlatformContextManager.detect_platform_context(request) context = PlatformContextManager.detect_platform_context(request)
assert context is not None assert context is not None
assert context["detection_method"] == "domain" assert context["detection_method"] == "domain"
assert context["domain"] == "oms.lu" assert context["domain"] == "omsflow.lu"
assert context["host"] == "oms.lu" assert context["host"] == "omsflow.lu"
assert context["original_path"] == "/pricing" assert context["original_path"] == "/pricing"
def test_detect_domain_with_port(self): def test_detect_domain_with_port(self):
"""Test domain detection with port number.""" """Test domain detection with port number."""
request = Mock(spec=Request) request = Mock(spec=Request)
request.headers = {"host": "loyalty.lu:8443"} request.headers = {"host": "rewardflow.lu:8443"}
request.url = Mock(path="/features") request.url = Mock(path="/features")
context = PlatformContextManager.detect_platform_context(request) context = PlatformContextManager.detect_platform_context(request)
assert context is not None assert context is not None
assert context["detection_method"] == "domain" assert context["detection_method"] == "domain"
assert context["domain"] == "loyalty.lu" assert context["domain"] == "rewardflow.lu"
def test_detect_domain_three_level_not_detected(self): def test_detect_domain_three_level_not_detected(self):
"""Test that three-level domains (subdomains) are not detected as platform domains.""" """Test that three-level domains (subdomains) are not detected as platform domains."""
request = Mock(spec=Request) request = Mock(spec=Request)
request.headers = {"host": "store.oms.lu"} request.headers = {"host": "store.omsflow.lu"}
request.url = Mock(path="/shop") request.url = Mock(path="/shop")
context = PlatformContextManager.detect_platform_context(request) context = PlatformContextManager.detect_platform_context(request)
@@ -295,7 +295,7 @@ class TestPlatformContextManager:
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_platform mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_platform
context = {"detection_method": "domain", "domain": "oms.lu"} context = {"detection_method": "domain", "domain": "omsflow.lu"}
platform = PlatformContextManager.get_platform_from_context(mock_db, context) platform = PlatformContextManager.get_platform_from_context(mock_db, context)
@@ -398,7 +398,7 @@ class TestPlatformContextManager:
request = Mock(spec=Request) request = Mock(spec=Request)
request.url = Mock(path="/pricing") request.url = Mock(path="/pricing")
context = {"detection_method": "domain", "domain": "oms.lu"} context = {"detection_method": "domain", "domain": "omsflow.lu"}
clean_path = PlatformContextManager.extract_clean_path(request, context) clean_path = PlatformContextManager.extract_clean_path(request, context)
@@ -598,7 +598,7 @@ class TestPlatformContextMiddleware:
scope = { scope = {
"type": "http", "type": "http",
"path": "/pricing", "path": "/pricing",
"headers": [(b"host", b"oms.lu")], "headers": [(b"host", b"omsflow.lu")],
} }
receive = AsyncMock() receive = AsyncMock()
@@ -918,7 +918,7 @@ class TestEdgeCases:
def test_admin_subdomain_with_production_domain(self): def test_admin_subdomain_with_production_domain(self):
"""Test admin subdomain detection for production domains.""" """Test admin subdomain detection for production domains."""
assert FrontendDetector.is_admin("admin.oms.lu", "/dashboard") is True assert FrontendDetector.is_admin("admin.omsflow.lu", "/dashboard") is True
def test_static_file_case_insensitive(self): def test_static_file_case_insensitive(self):
"""Test static file detection is case-insensitive.""" """Test static file detection is case-insensitive."""
@@ -945,7 +945,7 @@ class TestURLRoutingSummary:
- Main marketing: localhost:9999/ -> 'main' platform, path unchanged - Main marketing: localhost:9999/ -> 'main' platform, path unchanged
- OMS platform: localhost:9999/platforms/oms/pricing -> 'oms' platform, path=/pricing - OMS platform: localhost:9999/platforms/oms/pricing -> 'oms' platform, path=/pricing
- Loyalty platform: localhost:9999/platforms/loyalty/features -> 'loyalty' platform, path=/features - Loyalty platform: localhost:9999/platforms/loyalty/features -> 'loyalty' platform, path=/features
- Production OMS: oms.lu/pricing -> 'oms' platform, path=/pricing (no rewrite) - Production OMS: omsflow.lu/pricing -> 'oms' platform, path=/pricing (no rewrite)
""" """
def test_main_marketing_site_routing(self): def test_main_marketing_site_routing(self):
@@ -987,11 +987,11 @@ class TestURLRoutingSummary:
def test_production_domain_routing(self): def test_production_domain_routing(self):
"""Document: Production domains don't rewrite path.""" """Document: Production domains don't rewrite path."""
request = Mock(spec=Request) request = Mock(spec=Request)
request.headers = {"host": "oms.lu"} request.headers = {"host": "omsflow.lu"}
request.url = Mock(path="/pricing") request.url = Mock(path="/pricing")
context = PlatformContextManager.detect_platform_context(request) context = PlatformContextManager.detect_platform_context(request)
assert context["detection_method"] == "domain" assert context["detection_method"] == "domain"
assert context["domain"] == "oms.lu" assert context["domain"] == "omsflow.lu"
# clean_path not set for domain detection - uses original path # clean_path not set for domain detection - uses original path

View File

@@ -203,17 +203,17 @@ class TestAdminService:
"""Test getting store statistics""" """Test getting store statistics"""
stats = stats_service.get_store_statistics(db) stats = stats_service.get_store_statistics(db)
assert "total_stores" in stats assert "total" in stats
assert "active_stores" in stats assert "verified" in stats
assert "verified_stores" in stats assert "pending" in stats
assert "inactive" in stats
assert "verification_rate" in stats assert "verification_rate" in stats
assert isinstance(stats["total_stores"], int) assert isinstance(stats["total"], int)
assert isinstance(stats["active_stores"], int) assert isinstance(stats["verified"], int)
assert isinstance(stats["verified_stores"], int)
assert isinstance(stats["verification_rate"], int | float) assert isinstance(stats["verification_rate"], int | float)
assert stats["total_stores"] >= 1 assert stats["total"] >= 1
# Error Handling Tests # Error Handling Tests
def test_get_all_users_database_error(self, db_with_error, test_admin): def test_get_all_users_database_error(self, db_with_error, test_admin):
@@ -259,9 +259,9 @@ class TestAdminService:
"""Test store statistics when no stores exist""" """Test store statistics when no stores exist"""
stats = stats_service.get_store_statistics(empty_db) stats = stats_service.get_store_statistics(empty_db)
assert stats["total_stores"] == 0 assert stats["total"] == 0
assert stats["active_stores"] == 0 assert stats["verified"] == 0
assert stats["verified_stores"] == 0 assert stats["inactive"] == 0
assert stats["verification_rate"] == 0 assert stats["verification_rate"] == 0

View File

@@ -362,21 +362,20 @@ class TestStatsService:
"""Test getting store statistics for admin dashboard.""" """Test getting store statistics for admin dashboard."""
stats = self.service.get_store_statistics(db) stats = self.service.get_store_statistics(db)
assert "total_stores" in stats assert "total" in stats
assert "active_stores" in stats assert "verified" in stats
assert "inactive_stores" in stats assert "pending" in stats
assert "verified_stores" in stats assert "inactive" in stats
assert "verification_rate" in stats assert "verification_rate" in stats
assert stats["total_stores"] >= 1 assert stats["total"] >= 1
assert stats["active_stores"] >= 1
def test_get_store_statistics_calculates_rates(self, db, test_store): def test_get_store_statistics_calculates_rates(self, db, test_store):
"""Test store statistics calculates rates correctly.""" """Test store statistics calculates rates correctly."""
stats = self.service.get_store_statistics(db) stats = self.service.get_store_statistics(db)
if stats["total_stores"] > 0: if stats["total"] > 0:
expected_rate = stats["verified_stores"] / stats["total_stores"] * 100 expected_rate = stats["verified"] / stats["total"] * 100
assert abs(stats["verification_rate"] - expected_rate) < 0.01 assert abs(stats["verification_rate"] - expected_rate) < 0.01
def test_get_store_statistics_database_error(self, db): def test_get_store_statistics_database_error(self, db):
@@ -422,9 +421,9 @@ class TestStatsService:
"""Test getting import statistics.""" """Test getting import statistics."""
stats = self.service.get_import_statistics(db) stats = self.service.get_import_statistics(db)
assert "total_imports" in stats assert "total" in stats
assert "completed_imports" in stats assert "completed" in stats
assert "failed_imports" in stats assert "failed" in stats
assert "success_rate" in stats assert "success_rate" in stats
def test_get_import_statistics_with_jobs( def test_get_import_statistics_with_jobs(
@@ -433,15 +432,15 @@ class TestStatsService:
"""Test import statistics with existing jobs.""" """Test import statistics with existing jobs."""
stats = self.service.get_import_statistics(db) stats = self.service.get_import_statistics(db)
assert stats["total_imports"] >= 1 assert stats["total"] >= 1
assert stats["completed_imports"] >= 1 # test job has completed status assert stats["completed"] >= 1 # test job has completed status
def test_get_import_statistics_calculates_rate(self, db): def test_get_import_statistics_calculates_rate(self, db):
"""Test import statistics calculates success rate.""" """Test import statistics calculates success rate."""
stats = self.service.get_import_statistics(db) stats = self.service.get_import_statistics(db)
if stats["total_imports"] > 0: if stats["total"] > 0:
expected_rate = stats["completed_imports"] / stats["total_imports"] * 100 expected_rate = stats["completed"] / stats["total"] * 100
assert abs(stats["success_rate"] - expected_rate) < 0.01 assert abs(stats["success_rate"] - expected_rate) < 0.01
else: else:
assert stats["success_rate"] == 0 assert stats["success_rate"] == 0
@@ -452,9 +451,9 @@ class TestStatsService:
stats = self.service.get_import_statistics(db) stats = self.service.get_import_statistics(db)
# Should return default values, not raise exception # Should return default values, not raise exception
assert stats["total_imports"] == 0 assert stats["total"] == 0
assert stats["completed_imports"] == 0 assert stats["completed"] == 0
assert stats["failed_imports"] == 0 assert stats["failed"] == 0
assert stats["success_rate"] == 0 assert stats["success_rate"] == 0
# ==================== Private Helper Method Tests ==================== # ==================== Private Helper Method Tests ====================
@@ -538,7 +537,6 @@ class TestStatsService:
gtin=f"123456789{unique_id[:4]}", gtin=f"123456789{unique_id[:4]}",
warehouse="strassen", warehouse="strassen",
bin_location=f"ST-{unique_id[:2]}-01", bin_location=f"ST-{unique_id[:2]}-01",
location=f"LOCATION2_{unique_id}",
quantity=25, quantity=25,
reserved_quantity=5, reserved_quantity=5,
store_id=test_inventory.store_id, store_id=test_inventory.store_id,